mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(file-upload): normalize Android Capacitor rejection and surface attempt details in UI
This commit is contained in:
@@ -236,6 +236,53 @@ describe('useFileUpload', () => {
|
|||||||
expect(hook().isUploading).toBe(false);
|
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 () => {
|
it('returns early when uploadMode is none (no upload, no alert)', async () => {
|
||||||
uploadModeRef.value = 'none';
|
uploadModeRef.value = 'none';
|
||||||
vi.mocked(Capacitor.getPlatform).mockReturnValue('android');
|
vi.mocked(Capacitor.getPlatform).mockReturnValue('android');
|
||||||
|
|||||||
@@ -6,9 +6,66 @@ 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';
|
||||||
|
|
||||||
const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled';
|
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 {
|
function isElectronRuntime(): boolean {
|
||||||
return window.electronApi?.isElectron === true || window.isElectron === true;
|
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);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
if (errorMessage === FILE_SELECTION_CANCELLED_ERROR) return;
|
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) {
|
if (err.attempts && err.attempts.length > 0) {
|
||||||
window.alert(formatAggregatedError(err.attempts, t));
|
window.alert(formatAggregatedError(err.attempts, t));
|
||||||
} else if (uploadMode === 'preferred') {
|
} else if (uploadMode === 'preferred') {
|
||||||
|
|||||||
Reference in New Issue
Block a user