feat(media-hosting): extend ProviderAttempt with stage/elapsedMs/matchedSelectors and parse plugin errors

This commit is contained in:
plebeius
2026-02-19 17:49:18 +08:00
parent 1d133b812d
commit f1f5789118
4 changed files with 192 additions and 18 deletions
@@ -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');
}
});
});
+13 -7
View File
@@ -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}`;
}
+15
View File
@@ -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 */
+59 -10
View File
@@ -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<stri
throw new Error(`Unsupported provider: ${provider}`);
}
/** Rejection shape for plugin errors that include structured metadata (future Electron/Android) */
interface PluginRejectionMeta {
stage?: UploadAttemptStage;
matchedSelectors?: string[];
}
/**
* Orchestrates Electron upload: tries each provider in order, returns URL on first success.
* Collects attempt errors. Throws with attempts if all fail.
* Collects attempt errors with deterministic metadata (provider, stage, elapsedMs, matchedSelectors).
* Throws with attempts if all fail.
*/
export async function orchestrateElectronUpload(file: File, providerOrder: ProviderId[]): Promise<string> {
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;
}