feat(file-upload): normalize Android Capacitor rejection and surface attempt details in UI

This commit is contained in:
plebeius
2026-02-19 17:49:23 +08:00
parent f1f5789118
commit bbddb9ce17
2 changed files with 105 additions and 1 deletions
@@ -236,6 +236,53 @@ describe('useFileUpload', () => {
expect(hook().isUploading).toBe(false);
});
it('surfaces Android Capacitor rejection attempts in user-visible alert (preferred mode)', async () => {
uploadModeRef.value = 'preferred';
preferredProviderRef.value = 'catbox';
vi.mocked(Capacitor.getPlatform).mockReturnValue('android');
const capacitorRejection = new Error('All providers failed: catbox: timeout') as Error & {
data?: { attempts?: { provider: string; error: string; stage?: string; elapsedMs?: number }[] };
};
capacitorRejection.data = {
attempts: [{ provider: 'catbox', error: 'timeout', stage: 'timeout', elapsedMs: 5000 }],
};
vi.mocked(FileUploader.pickAndUploadMedia).mockRejectedValue(capacitorRejection);
const { onUploadComplete, hook } = mountHook();
await act(async () => {
await hook().handleUpload();
});
expect(window.alert).toHaveBeenCalledWith('upload_failed. upload_failed_all_providers: catbox: timeout (stage=timeout, 5000ms)');
expect(onUploadComplete).not.toHaveBeenCalled();
expect(hook().isUploading).toBe(false);
});
it('surfaces Android rejection with multiple provider attempts in alert', async () => {
uploadModeRef.value = 'random';
preferredProviderRef.value = 'catbox';
vi.mocked(Capacitor.getPlatform).mockReturnValue('android');
const capacitorRejection = new Error('All providers failed') as Error & {
data?: { attempts?: { provider: string; error: string }[] };
};
capacitorRejection.data = {
attempts: [
{ provider: 'catbox', error: 'timeout' },
{ provider: 'imgur', error: 'rate limit' },
],
};
vi.mocked(FileUploader.pickAndUploadMedia).mockRejectedValue(capacitorRejection);
const { onUploadComplete, hook } = mountHook();
await act(async () => {
await hook().handleUpload();
});
expect(window.alert).toHaveBeenCalledWith('upload_failed. upload_failed_all_providers: catbox: timeout; imgur: rate limit');
expect(onUploadComplete).not.toHaveBeenCalled();
expect(hook().isUploading).toBe(false);
});
it('returns early when uploadMode is none (no upload, no alert)', async () => {
uploadModeRef.value = 'none';
vi.mocked(Capacitor.getPlatform).mockReturnValue('android');
+58 -1
View File
@@ -6,9 +6,66 @@ import { formatAggregatedError, formatPreferredModeError, type ProviderAttempt }
import { getProviderOrder } from '../lib/media-hosting/provider-order';
import { orchestrateElectronUpload } from '../lib/media-hosting/upload-orchestrator';
import useMediaHostingStore from '../stores/use-media-hosting-store';
import type { ProviderId } from '../lib/media-hosting/types';
const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled';
const VALID_PROVIDERS: ProviderId[] = ['catbox', 'imgur', 'postimages'];
/** Raw attempt shape from Android plugin rejection payload */
interface RawAttempt {
provider?: unknown;
success?: unknown;
error?: unknown;
stage?: unknown;
elapsedMs?: unknown;
matchedSelectors?: unknown;
}
/**
* Normalizes Android Capacitor rejection payload. The plugin may reject with
* `{ message, code, data: { attempts: [...] } }` where attempts contain
* provider, error, stage, elapsedMs, matchedSelectors. We extract and attach
* attempts to the error so formatAggregatedError can surface them.
*/
function normalizeAndroidRejection(error: unknown): Error & { attempts?: ProviderAttempt[] } {
const err = error as Error & { data?: { attempts?: unknown[] } };
const raw = err?.data?.attempts;
if (!Array.isArray(raw) || raw.length === 0) return err;
const attempts: ProviderAttempt[] = raw
.map((a: unknown): ProviderAttempt | null => {
const item = a as RawAttempt;
const provider = item?.provider;
if (typeof provider !== 'string' || !VALID_PROVIDERS.includes(provider as ProviderId)) return null;
const ms = typeof item?.elapsedMs === 'number' ? item.elapsedMs : undefined;
let sel: string[] | undefined;
if (Array.isArray(item?.matchedSelectors)) {
sel = (item.matchedSelectors as unknown[]).filter((s): s is string => typeof s === 'string');
} else if (typeof item?.matchedSelectors === 'string' && item.matchedSelectors.trim()) {
sel = item.matchedSelectors
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
}
const attempt: ProviderAttempt = {
provider: provider as ProviderId,
success: Boolean(item?.success),
error: typeof item?.error === 'string' ? item.error : undefined,
stage: typeof item?.stage === 'string' && item.stage ? (item.stage as ProviderAttempt['stage']) : undefined,
elapsedMs: ms,
matchedSelectors: sel?.length ? sel : undefined,
};
return attempt;
})
.filter((a): a is ProviderAttempt => a !== null);
if (attempts.length > 0) {
(err as Error & { attempts?: ProviderAttempt[] }).attempts = attempts;
}
return err as Error & { attempts?: ProviderAttempt[] };
}
function isElectronRuntime(): boolean {
return window.electronApi?.isElectron === true || window.isElectron === true;
}
@@ -122,7 +179,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage === FILE_SELECTION_CANCELLED_ERROR) return;
const err = error as Error & { attempts?: ProviderAttempt[] };
const err = normalizeAndroidRejection(error) as Error & { attempts?: ProviderAttempt[] };
if (err.attempts && err.attempts.length > 0) {
window.alert(formatAggregatedError(err.attempts, t));
} else if (uploadMode === 'preferred') {