mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(media-hosting): extend ProviderAttempt with stage/elapsedMs/matchedSelectors and parse plugin errors
This commit is contained in:
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user