mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(upload-automation): harden android and electron failure handling
Updated Android/Electron upload paths to fail deterministically and report consistent stage details, while removing unsafe stage casting and path/package mismatches. This keeps postimages automation behavior intact and makes failures easier to diagnose.
This commit is contained in:
+2
-1
@@ -111,8 +111,9 @@ public class FileUploaderPlugin extends Plugin {
|
|||||||
tryProvidersSequentially(uri, providerOrder, call);
|
tryProvidersSequentially(uri, providerOrder, call);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Upload failed", e);
|
Log.e(TAG, "Upload failed", e);
|
||||||
if (!call.getData().has("_resolved")) {
|
try {
|
||||||
call.reject("Upload failed: " + e.getMessage());
|
call.reject("Upload failed: " + e.getMessage());
|
||||||
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
+6
-3
@@ -73,6 +73,9 @@ public class MediaUploadAutomationRunner {
|
|||||||
static final String STAGE_SUBMIT_CLICKED = "submit_clicked";
|
static final String STAGE_SUBMIT_CLICKED = "submit_clicked";
|
||||||
static final String STAGE_SUCCESS_SELECTOR_MATCHED = "success_selector_matched";
|
static final String STAGE_SUCCESS_SELECTOR_MATCHED = "success_selector_matched";
|
||||||
static final String STAGE_BLOCKED_DETECTED = "blocked_detected";
|
static final String STAGE_BLOCKED_DETECTED = "blocked_detected";
|
||||||
|
static final String STAGE_INPUT_NOT_FOUND = "input_not_found";
|
||||||
|
static final String STAGE_CHOOSER_NOT_TRIGGERED = "chooser_not_triggered";
|
||||||
|
static final String STAGE_UPLOAD_TIMED_OUT = "upload_timed_out";
|
||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
private final Uri fileUri;
|
private final Uri fileUri;
|
||||||
@@ -202,7 +205,7 @@ public class MediaUploadAutomationRunner {
|
|||||||
if (finished || fileChooserHandled) return;
|
if (finished || fileChooserHandled) return;
|
||||||
long elapsed = elapsedMs();
|
long elapsed = elapsedMs();
|
||||||
if (elapsed >= MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS) {
|
if (elapsed >= MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS) {
|
||||||
String stage = lastMatchedSelector != null ? "chooser_not_triggered" : "input_not_found";
|
String stage = lastMatchedSelector != null ? STAGE_CHOOSER_NOT_TRIGGERED : STAGE_INPUT_NOT_FOUND;
|
||||||
String error =
|
String error =
|
||||||
lastMatchedSelector != null
|
lastMatchedSelector != null
|
||||||
? "File chooser not triggered"
|
? "File chooser not triggered"
|
||||||
@@ -289,7 +292,7 @@ public class MediaUploadAutomationRunner {
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
"Upload timeout",
|
"Upload timeout",
|
||||||
"upload_timed_out",
|
STAGE_UPLOAD_TIMED_OUT,
|
||||||
elapsed,
|
elapsed,
|
||||||
lastMatchedSelector));
|
lastMatchedSelector));
|
||||||
return;
|
return;
|
||||||
@@ -302,7 +305,7 @@ public class MediaUploadAutomationRunner {
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
"File chooser not triggered",
|
"File chooser not triggered",
|
||||||
"chooser_not_triggered",
|
STAGE_CHOOSER_NOT_TRIGGERED,
|
||||||
elapsed,
|
elapsed,
|
||||||
lastMatchedSelector,
|
lastMatchedSelector,
|
||||||
triggerAttemptCount));
|
triggerAttemptCount));
|
||||||
@@ -113,12 +113,12 @@ public class MediaUploadRecipesTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void failureClassification_inputNotFound_stageConstant() {
|
public void failureClassification_inputNotFound_stageConstant() {
|
||||||
assertEquals("input_not_found", "input_not_found");
|
assertEquals(MediaUploadAutomationRunner.STAGE_INPUT_NOT_FOUND, "input_not_found");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void failureClassification_chooserNotTriggered_stageConstant() {
|
public void failureClassification_chooserNotTriggered_stageConstant() {
|
||||||
assertEquals("chooser_not_triggered", "chooser_not_triggered");
|
assertEquals(MediaUploadAutomationRunner.STAGE_CHOOSER_NOT_TRIGGERED, "chooser_not_triggered");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -128,7 +128,7 @@ public class MediaUploadRecipesTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void failureClassification_uploadTimedOut_stageConstant() {
|
public void failureClassification_uploadTimedOut_stageConstant() {
|
||||||
assertEquals("upload_timed_out", "upload_timed_out");
|
assertEquals(MediaUploadAutomationRunner.STAGE_UPLOAD_TIMED_OUT, "upload_timed_out");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.web
|
|||||||
/** Returns true if the URL appears to point to a direct media file. Exported for tests. */
|
/** Returns true if the URL appears to point to a direct media file. Exported for tests. */
|
||||||
export function isDirectMediaUrl(url) {
|
export function isDirectMediaUrl(url) {
|
||||||
try {
|
try {
|
||||||
const normalized = url.split('?')[0].toLowerCase();
|
const normalized = url.split('?')[0].split('#')[0].toLowerCase();
|
||||||
return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext));
|
return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext));
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -67,14 +67,23 @@ export async function automateUploadMedia(options) {
|
|||||||
await sendCommand('DOM.enable');
|
await sendCommand('DOM.enable');
|
||||||
await sendCommand('Page.enable');
|
await sendCommand('Page.enable');
|
||||||
|
|
||||||
|
const PAGE_LOAD_TIMEOUT_MS = 30_000;
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const settle = (fn, arg) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
win.webContents.removeListener('did-finish-load', onLoad);
|
||||||
|
win.webContents.removeListener('did-fail-load', onFail);
|
||||||
|
fn(arg);
|
||||||
|
};
|
||||||
|
const onLoad = () => settle(resolve);
|
||||||
|
const onFail = (_, code, desc) => settle(reject, new Error(`Page load failed: ${code} ${desc}`));
|
||||||
|
const timer = setTimeout(() => settle(reject, new Error(`Page load timed out after ${PAGE_LOAD_TIMEOUT_MS}ms for ${recipe.uploadUrl}`)), PAGE_LOAD_TIMEOUT_MS);
|
||||||
|
win.webContents.once('did-finish-load', onLoad);
|
||||||
|
win.webContents.once('did-fail-load', onFail);
|
||||||
win.loadURL(recipe.uploadUrl);
|
win.loadURL(recipe.uploadUrl);
|
||||||
win.webContents.once('did-finish-load', () => {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
win.webContents.once('did-fail-load', (_, code, desc) => {
|
|
||||||
reject(new Error(`Page load failed: ${code} ${desc}`));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Small delay for SPA/JS to settle
|
// Small delay for SPA/JS to settle
|
||||||
@@ -132,9 +141,10 @@ export async function automateUploadMedia(options) {
|
|||||||
|
|
||||||
const clickNode = async (nodeId) => {
|
const clickNode = async (nodeId) => {
|
||||||
const { model } = await sendCommand('DOM.getBoxModel', { nodeId });
|
const { model } = await sendCommand('DOM.getBoxModel', { nodeId });
|
||||||
if (!model?.content) return;
|
if (!model?.content || model.content.length < 8) {
|
||||||
|
throw new Error('Cannot click node: box model unavailable (element may be hidden or zero-size)');
|
||||||
|
}
|
||||||
const content = model.content;
|
const content = model.content;
|
||||||
if (content.length < 8) return;
|
|
||||||
const x = (content[0] + content[2] + content[4] + content[6]) / 4;
|
const x = (content[0] + content[2] + content[4] + content[6]) / 4;
|
||||||
const y = (content[1] + content[3] + content[5] + content[7]) / 4;
|
const y = (content[1] + content[3] + content[5] + content[7]) / 4;
|
||||||
await sendCommand('Input.dispatchMouseEvent', {
|
await sendCommand('Input.dispatchMouseEvent', {
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ describe('media-upload-automation', () => {
|
|||||||
expect(isDirectMediaUrl('https://example.com/video.mp4')).toBe(true);
|
expect(isDirectMediaUrl('https://example.com/video.mp4')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips query strings before checking', () => {
|
it('strips query strings and fragments before checking', () => {
|
||||||
expect(isDirectMediaUrl('https://i.imgur.com/abc.png?size=large')).toBe(true);
|
expect(isDirectMediaUrl('https://i.imgur.com/abc.png?size=large')).toBe(true);
|
||||||
expect(isDirectMediaUrl('https://imgur.com/page.html?img=photo.jpg')).toBe(false);
|
expect(isDirectMediaUrl('https://imgur.com/page.html?img=photo.jpg')).toBe(false);
|
||||||
|
expect(isDirectMediaUrl('https://i.imgur.com/abc.png#fragment')).toBe(true);
|
||||||
|
expect(isDirectMediaUrl('https://i.postimg.cc/xyz.webp#')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for non-direct URLs (guards against non-media pages)', () => {
|
it('returns false for non-direct URLs (guards against non-media pages)', () => {
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ function validateRecipes() {
|
|||||||
if (!Array.isArray(recipe.blockedIndicators) || recipe.blockedIndicators.length === 0) {
|
if (!Array.isArray(recipe.blockedIndicators) || recipe.blockedIndicators.length === 0) {
|
||||||
throw new Error(`Recipe validation failed: ${provider} blockedIndicators must be non-empty array`);
|
throw new Error(`Recipe validation failed: ${provider} blockedIndicators must be non-empty array`);
|
||||||
}
|
}
|
||||||
|
if (!Array.isArray(recipe.submitSelectorCandidates) || recipe.submitSelectorCandidates.length === 0) {
|
||||||
|
throw new Error(`Recipe validation failed: ${provider} submitSelectorCandidates must be non-empty array`);
|
||||||
|
}
|
||||||
if (typeof recipe.timeoutMs !== 'number' || recipe.timeoutMs <= 0) {
|
if (typeof recipe.timeoutMs !== 'number' || recipe.timeoutMs <= 0) {
|
||||||
throw new Error(`Recipe validation failed: ${provider} timeoutMs must be positive number`);
|
throw new Error(`Recipe validation failed: ${provider} timeoutMs must be positive number`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,23 @@ import { formatAggregatedError, formatPreferredModeError, type ProviderAttempt }
|
|||||||
import { getProviderOrder } from '../lib/media-hosting/provider-order';
|
import { getProviderOrder } from '../lib/media-hosting/provider-order';
|
||||||
import { orchestrateElectronUpload } from '../lib/media-hosting/upload-orchestrator';
|
import { orchestrateElectronUpload } from '../lib/media-hosting/upload-orchestrator';
|
||||||
import useMediaHostingStore from '../stores/use-media-hosting-store';
|
import useMediaHostingStore from '../stores/use-media-hosting-store';
|
||||||
import type { ProviderId } from '../lib/media-hosting/types';
|
import type { ProviderId, UploadAttemptStage } from '../lib/media-hosting/types';
|
||||||
|
|
||||||
|
/** Maps Android plugin stage strings to UploadAttemptStage (avoids unsafe cast). Includes pass-through for valid stages. */
|
||||||
|
const ANDROID_STAGE_MAP: Record<string, UploadAttemptStage> = {
|
||||||
|
input_not_found: 'file_input',
|
||||||
|
chooser_not_triggered: 'file_input',
|
||||||
|
upload_timed_out: 'timeout',
|
||||||
|
blocked_detected: 'blocked',
|
||||||
|
no_recipe: 'unknown',
|
||||||
|
page_loaded: 'page_load',
|
||||||
|
blocked: 'blocked',
|
||||||
|
file_input: 'file_input',
|
||||||
|
submit: 'submit',
|
||||||
|
timeout: 'timeout',
|
||||||
|
page_load: 'page_load',
|
||||||
|
unknown: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled';
|
const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled';
|
||||||
|
|
||||||
@@ -52,7 +68,7 @@ function normalizeAndroidRejection(error: unknown): Error & { attempts?: Provide
|
|||||||
provider: provider as ProviderId,
|
provider: provider as ProviderId,
|
||||||
success: Boolean(item?.success),
|
success: Boolean(item?.success),
|
||||||
error: typeof item?.error === 'string' ? item.error : undefined,
|
error: typeof item?.error === 'string' ? item.error : undefined,
|
||||||
stage: typeof item?.stage === 'string' && item.stage ? (item.stage as ProviderAttempt['stage']) : undefined,
|
stage: typeof item?.stage === 'string' && item.stage ? (ANDROID_STAGE_MAP[item.stage] ?? 'unknown') : undefined,
|
||||||
elapsedMs: ms,
|
elapsedMs: ms,
|
||||||
matchedSelectors: sel?.length ? sel : undefined,
|
matchedSelectors: sel?.length ? sel : undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ describe('direct-url', () => {
|
|||||||
expect(isDirectMediaUrl('https://example.com/video.gifv')).toBe(true);
|
expect(isDirectMediaUrl('https://example.com/video.gifv')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips query strings before checking', () => {
|
it('strips query strings and fragments before checking', () => {
|
||||||
expect(isDirectMediaUrl('https://example.com/photo.jpg?size=large')).toBe(true);
|
expect(isDirectMediaUrl('https://example.com/photo.jpg?size=large')).toBe(true);
|
||||||
expect(isDirectMediaUrl('https://example.com/page.html?img=photo.jpg')).toBe(false);
|
expect(isDirectMediaUrl('https://example.com/page.html?img=photo.jpg')).toBe(false);
|
||||||
|
expect(isDirectMediaUrl('https://example.com/photo.png#section')).toBe(true);
|
||||||
|
expect(isDirectMediaUrl('https://example.com/photo.gif#')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is case insensitive', () => {
|
it('is case insensitive', () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.web
|
|||||||
/** Returns true if the URL appears to point to a direct media file (image or video) */
|
/** Returns true if the URL appears to point to a direct media file (image or video) */
|
||||||
export function isDirectMediaUrl(url: string): boolean {
|
export function isDirectMediaUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const normalized = url.split('?')[0].toLowerCase();
|
const normalized = url.split('?')[0].split('#')[0].toLowerCase();
|
||||||
return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext));
|
return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext));
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user