From f1f5789118154f0271cff695c4f96662bdd4888d Mon Sep 17 00:00:00 2001 From: plebeius Date: Thu, 19 Feb 2026 17:49:18 +0800 Subject: [PATCH] feat(media-hosting): extend ProviderAttempt with stage/elapsedMs/matchedSelectors and parse plugin errors --- .../__tests__/upload-orchestrator.test.ts | 106 +++++++++++++++++- src/lib/media-hosting/error-format.ts | 20 ++-- src/lib/media-hosting/types.ts | 15 +++ src/lib/media-hosting/upload-orchestrator.ts | 69 ++++++++++-- 4 files changed, 192 insertions(+), 18 deletions(-) diff --git a/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts b/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts index 9d015d6b..f3ece1a5 100644 --- a/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts +++ b/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts @@ -60,11 +60,115 @@ describe('orchestrateElectronUpload', () => { throw new Error('Expected orchestrateElectronUpload to throw'); } catch (error) { const typedError = error as Error & { - attempts?: Array<{ provider: string; error?: string }>; + attempts?: Array<{ provider: string; error?: string; elapsedMs?: number; stage?: string }>; }; expect(typedError.message).toBe('All providers failed'); expect(typedError.attempts?.[0]?.provider).toBe('postimages'); expect(typedError.attempts?.[0]?.error).toContain('File path required for Electron automation'); + expect(typedError.attempts?.[0]?.elapsedMs).toBeGreaterThanOrEqual(0); + expect(typedError.attempts?.[0]?.stage).toBeDefined(); + } + }); + + it('includes stage and matchedSelectors when provider throws block/file-input errors', async () => { + const electronApi = createElectronApiMock(); + electronApi.automateUploadMedia = vi.fn().mockRejectedValue(new Error('No file input found for imgur. Tried: input[type="file"], #upload')); + window.electronApi = electronApi; + + const file = new File(['x'], 'x.png', { type: 'image/png' }); + + try { + await orchestrateElectronUpload(file, ['imgur']); + throw new Error('Expected orchestrateElectronUpload to throw'); + } catch (error) { + const typedError = error as Error & { + attempts?: Array<{ provider: string; error?: string; stage?: string; elapsedMs?: number; matchedSelectors?: string[] }>; + }; + expect(typedError.attempts?.[0]?.provider).toBe('imgur'); + expect(typedError.attempts?.[0]?.stage).toBe('file_input'); + expect(typedError.attempts?.[0]?.matchedSelectors).toEqual(['input[type="file"]', '#upload']); + expect(typedError.attempts?.[0]?.elapsedMs).toBeGreaterThanOrEqual(0); + } + }); + + it('parses submit selector failure with matchedSelectors', async () => { + const electronApi = createElectronApiMock(); + electronApi.automateUploadMedia = vi + .fn() + .mockRejectedValue(new Error('No submit button found for imgur. Tried: button[type="submit"], [data-action="upload"], .upload-btn')); + window.electronApi = electronApi; + + const file = new File(['x'], 'x.png', { type: 'image/png' }); + + try { + await orchestrateElectronUpload(file, ['imgur']); + throw new Error('Expected orchestrateElectronUpload to throw'); + } catch (error) { + const typedError = error as Error & { + attempts?: Array<{ provider: string; stage?: string; matchedSelectors?: string[] }>; + }; + expect(typedError.attempts?.[0]?.provider).toBe('imgur'); + expect(typedError.attempts?.[0]?.stage).toBe('submit'); + expect(typedError.attempts?.[0]?.matchedSelectors).toEqual(['button[type="submit"]', '[data-action="upload"]', '.upload-btn']); + } + }); + + it('parses timeout stage when upload or URL extraction times out', async () => { + const electronApi = createElectronApiMock(); + electronApi.automateUploadMedia = vi + .fn() + .mockRejectedValue(new Error('Upload timeout or no direct URL extracted for postimages (elapsed: 45000ms, timeout: 45000ms)')); + window.electronApi = electronApi; + + const file = new File(['x'], 'x.png', { type: 'image/png' }); + + try { + await orchestrateElectronUpload(file, ['postimages']); + throw new Error('Expected orchestrateElectronUpload to throw'); + } catch (error) { + const typedError = error as Error & { + attempts?: Array<{ provider: string; stage?: string }>; + }; + expect(typedError.attempts?.[0]?.provider).toBe('postimages'); + expect(typedError.attempts?.[0]?.stage).toBe('timeout'); + } + }); + + it('parses page_load stage when page fails to load', async () => { + const electronApi = createElectronApiMock(); + electronApi.automateUploadMedia = vi.fn().mockRejectedValue(new Error('Page load failed: -3 net::ERR_ABORTED')); + window.electronApi = electronApi; + + const file = new File(['x'], 'x.png', { type: 'image/png' }); + + try { + await orchestrateElectronUpload(file, ['imgur']); + throw new Error('Expected orchestrateElectronUpload to throw'); + } catch (error) { + const typedError = error as Error & { + attempts?: Array<{ provider: string; stage?: string }>; + }; + expect(typedError.attempts?.[0]?.provider).toBe('imgur'); + expect(typedError.attempts?.[0]?.stage).toBe('page_load'); + } + }); + + it('parses blocked stage when captcha or challenge detected', async () => { + const electronApi = createElectronApiMock(); + electronApi.automateUploadMedia = vi.fn().mockRejectedValue(new Error('Provider blocked: captcha, login, or challenge detected (imgur), selector: .g-recaptcha')); + window.electronApi = electronApi; + + const file = new File(['x'], 'x.png', { type: 'image/png' }); + + try { + await orchestrateElectronUpload(file, ['imgur']); + throw new Error('Expected orchestrateElectronUpload to throw'); + } catch (error) { + const typedError = error as Error & { + attempts?: Array<{ provider: string; stage?: string }>; + }; + expect(typedError.attempts?.[0]?.provider).toBe('imgur'); + expect(typedError.attempts?.[0]?.stage).toBe('blocked'); } }); }); diff --git a/src/lib/media-hosting/error-format.ts b/src/lib/media-hosting/error-format.ts index 545f16ae..5f159486 100644 --- a/src/lib/media-hosting/error-format.ts +++ b/src/lib/media-hosting/error-format.ts @@ -1,18 +1,24 @@ -import type { ProviderId } from './types'; +import type { ProviderAttempt } from './types'; -export interface ProviderAttempt { - provider: ProviderId; - success: boolean; - error?: string; -} +export type { ProviderAttempt } from './types'; export type TranslateFn = (key: string) => string; +function formatSingleAttempt(a: ProviderAttempt): string { + const base = `${a.provider}: ${a.error ?? 'unknown'}`; + const parts: string[] = [base]; + if (a.stage) parts.push(`stage=${a.stage}`); + if (a.elapsedMs != null) parts.push(`${a.elapsedMs}ms`); + if (a.matchedSelectors?.length) parts.push(`tried=[${a.matchedSelectors.join(', ')}]`); + return parts.length > 1 ? `${base} (${parts.slice(1).join(', ')})` : base; +} + /** * Formats aggregated error message when all providers fail (random mode). + * Includes stage, elapsed time, and matched selectors when available for actionable UI logs. */ export function formatAggregatedError(attempts: ProviderAttempt[], t: TranslateFn): string { - const details = attempts.map((a) => `${a.provider}: ${a.error ?? 'unknown'}`).join('; '); + const details = attempts.map(formatSingleAttempt).join('; '); return `${t('upload_failed')}. ${t('upload_failed_all_providers')}: ${details}`; } diff --git a/src/lib/media-hosting/types.ts b/src/lib/media-hosting/types.ts index fb068ed8..0bde849b 100644 --- a/src/lib/media-hosting/types.ts +++ b/src/lib/media-hosting/types.ts @@ -7,12 +7,27 @@ export type UploadMode = 'random' | 'preferred' | 'none'; /** Runtime environment for automation support */ export type MediaHostingRuntime = 'web' | 'electron' | 'android'; +/** Stage at which an upload attempt failed (enables comparable errors across Electron/Android) */ +export type UploadAttemptStage = + | 'blocked' /** captcha/login/challenge detected */ + | 'file_input' /** file input not found */ + | 'submit' /** submit button not found */ + | 'timeout' /** upload or extraction timeout */ + | 'page_load' /** page load failed */ + | 'unknown'; + /** Result of a single provider upload attempt */ export interface ProviderAttempt { provider: ProviderId; success: boolean; url?: string; error?: string; + /** Stage at which the attempt failed; inferred or reported by plugin */ + stage?: UploadAttemptStage; + /** Elapsed time in ms for the attempt */ + elapsedMs?: number; + /** Selectors tried (e.g. when file input / submit not found); from plugin or parsed from error */ + matchedSelectors?: string[]; } /** Successful upload result */ diff --git a/src/lib/media-hosting/upload-orchestrator.ts b/src/lib/media-hosting/upload-orchestrator.ts index 4caa762a..5c6da209 100644 --- a/src/lib/media-hosting/upload-orchestrator.ts +++ b/src/lib/media-hosting/upload-orchestrator.ts @@ -1,11 +1,42 @@ -import type { ProviderId } from './types'; +import type { ProviderAttempt, ProviderId, UploadAttemptStage } from './types'; import { uploadToCatbox } from '../utils/catbox-utils'; -export interface OrchestratorAttempt { - provider: ProviderId; - success: boolean; - url?: string; - error?: string; +/** Attempt metadata inferred or parsed from plugin rejection. Used when plugins throw plain errors. */ +function parseAttemptMetadata(errorMessage: string): { + stage: UploadAttemptStage; + matchedSelectors?: string[]; +} { + const msg = errorMessage.toLowerCase(); + if (msg.includes('blocked') || msg.includes('captcha') || msg.includes('challenge')) { + return { stage: 'blocked' }; + } + if (msg.includes('no file input') || msg.includes('file input')) { + const tried = errorMessage.match(/Tried:\s*(.+)$/)?.[1]; + const selectors = tried + ? tried + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + return { stage: 'file_input', matchedSelectors: selectors }; + } + if (msg.includes('no submit') || msg.includes('submit button')) { + const tried = errorMessage.match(/Tried:\s*(.+)$/)?.[1]; + const selectors = tried + ? tried + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + return { stage: 'submit', matchedSelectors: selectors }; + } + if (msg.includes('timeout') || msg.includes('upload timeout')) { + return { stage: 'timeout' }; + } + if (msg.includes('page load failed')) { + return { stage: 'page_load' }; + } + return { stage: 'unknown' }; } function resolveElectronFilePath(file: File): string | null { @@ -44,24 +75,42 @@ async function uploadViaProvider(provider: ProviderId, file: File): Promise { - const attempts: OrchestratorAttempt[] = []; + const attempts: ProviderAttempt[] = []; for (const provider of providerOrder) { + const start = Date.now(); try { const url = await uploadViaProvider(provider, file); return url; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - attempts.push({ provider, success: false, error: msg }); + const elapsedMs = Date.now() - start; + const meta = err && typeof err === 'object' && 'stage' in err ? (err as PluginRejectionMeta) : null; + const parsed = parseAttemptMetadata(msg); + attempts.push({ + provider, + success: false, + error: msg, + stage: meta?.stage ?? parsed.stage, + elapsedMs, + matchedSelectors: meta?.matchedSelectors ?? parsed.matchedSelectors, + }); } } - const err = new Error('All providers failed') as Error & { attempts: OrchestratorAttempt[] }; + const err = new Error('All providers failed') as Error & { attempts: ProviderAttempt[] }; err.attempts = attempts; throw err; }