mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Merge pull request #1464 from VasilisNtovolos/fix/xhr-upload-inactivity-timeout
fix: time out stalled XHR uploads after inactivity
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
import { render, screen, within, fireEvent, act, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// Keep the real composer queue and JMAP client; stub unrelated UI and stores.
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
// The selected identity is namespaced with the local account that owns it.
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => {
|
||||
const idx = id.indexOf('::');
|
||||
return idx === -1
|
||||
? { localAccountId: null, rawId: id }
|
||||
: { localAccountId: id.slice(0, idx), rawId: id.slice(idx + 2) };
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'acct-2::id-other', email: 'other@example.com', name: 'Other' }],
|
||||
defaultIdentityId: 'acct-2::id-other',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
onBeforeAttachmentUpload: { intercept: async () => true },
|
||||
onBeforeBlobUpload: { transform: async (fileId: unknown) => fileId },
|
||||
onAfterAttachmentUpload: { emit: vi.fn() },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
isExternalAttachmentResult: () => false,
|
||||
}));
|
||||
|
||||
const { deleteStagedFile } = vi.hoisted(() => ({
|
||||
deleteStagedFile: vi.fn(async (_id: string) => {}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-storage', () => ({
|
||||
fileStorage: {
|
||||
saveFile: async () => {},
|
||||
getFile: async () => null,
|
||||
deleteFile: deleteStagedFile,
|
||||
},
|
||||
}));
|
||||
|
||||
const progressCleanup = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/upload-progress', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/lib/upload-progress')>();
|
||||
return { ...original, onUploadProgress: (id: string, listener: (loaded: number, total: number) => void) => {
|
||||
const unsubscribe = original.onUploadProgress(id, listener);
|
||||
return () => { unsubscribe(); progressCleanup(); };
|
||||
} };
|
||||
});
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeSignatureHtmlForDisplay: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
sanitizePluginBodyHtml: (v: string) => v,
|
||||
escapeHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
plainTextBodyHasSignature: () => false,
|
||||
plainTextBodyWithoutSignature: (body: string) => body,
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
|
||||
import { RequestTimeoutError } from '@/lib/jmap/client';
|
||||
import { UploadXHR, uploadClient } from '@/lib/__tests__/helpers/upload-xhr';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { emailHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
const INACTIVITY_MS = 300_000;
|
||||
const file = (name: string) => new File(['0123456789'], name, { type: 'text/plain' });
|
||||
|
||||
async function advance(ms = 0) {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(ms); });
|
||||
}
|
||||
|
||||
async function select(input: HTMLInputElement, files: File[]) {
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files } });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
}
|
||||
|
||||
function chip(name: string) {
|
||||
const progress = screen.queryByRole('progressbar', { name });
|
||||
// Each attachment chip contains its label and cancel/remove button.
|
||||
return progress?.parentElement ?? screen.getByText(name).parentElement!.parentElement!.parentElement!;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
UploadXHR.instances = [];
|
||||
vi.stubGlobal('XMLHttpRequest', UploadXHR);
|
||||
vi.stubGlobal('fetch', vi.fn(() => { throw new Error('Unexpected networking'); }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup();
|
||||
for (const xhr of UploadXHR.instances) {
|
||||
xhr.abort();
|
||||
// Drain any queued requests before restoring the synthetic transport.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
describe('composer XHR inactivity recovery', () => {
|
||||
it.each([0, 10])('releases stalled A after %i uploaded bytes, starts B, and permits removing/re-adding A', async loaded => {
|
||||
const client = uploadClient();
|
||||
const upload = vi.spyOn(client, 'uploadBlob');
|
||||
useAuthStore.setState({ client: client as never });
|
||||
render(<EmailComposer
|
||||
initialData={{ to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false,
|
||||
selectedIdentityId: 'acct-2::id-other', subAddressTag: '', mode: 'compose', draftId: null }}
|
||||
onClose={vi.fn()}
|
||||
/>);
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const a = file('A.txt');
|
||||
await select(input, [a, file('B.txt')]);
|
||||
expect(upload).toHaveBeenCalledTimes(1);
|
||||
const aXHR = UploadXHR.instances[0];
|
||||
if (loaded) {
|
||||
await advance(120_000);
|
||||
act(() => aXHR.progress(loaded));
|
||||
}
|
||||
const aOutcome = upload.mock.results[0].value as ReturnType<typeof client.uploadBlob>;
|
||||
// Attach the rejection assertion before the fake clock expires the request.
|
||||
const result = aOutcome.catch((error: unknown) => error);
|
||||
await advance(INACTIVITY_MS - 1);
|
||||
expect(screen.getByRole('progressbar', { name: 'A.txt' })).toHaveAttribute('aria-valuenow', String(loaded * 10));
|
||||
expect(screen.getByRole('progressbar', { name: 'B.txt' })).not.toHaveAttribute('aria-valuenow');
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(deleteStagedFile).not.toHaveBeenCalled();
|
||||
await advance(1);
|
||||
|
||||
// Timeout must let the existing serial loop reach B.
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(await result).toBeInstanceOf(RequestTimeoutError);
|
||||
expect(aXHR.abort).toHaveBeenCalledOnce();
|
||||
expect(within(chip('A.txt')).getByText('attachment_upload_failed')).toBeTruthy();
|
||||
expect(within(chip('A.txt')).queryByRole('progressbar')).toBeNull();
|
||||
expect(screen.getByRole('progressbar', { name: 'B.txt' })).toHaveAttribute('aria-valuenow', '0');
|
||||
expect(toast.error).toHaveBeenCalledExactlyOnceWith('upload_failed');
|
||||
expect(deleteStagedFile).toHaveBeenCalledTimes(1);
|
||||
expect(progressCleanup).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => UploadXHR.instances[1].load());
|
||||
await advance();
|
||||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||||
expect(input.value).toBe('');
|
||||
expect(progressCleanup).toHaveBeenCalledTimes(2);
|
||||
fireEvent.click(within(chip('A.txt')).getByRole('button', { name: 'recipient_remove A.txt' }));
|
||||
expect(screen.queryByText('A.txt')).toBeNull();
|
||||
|
||||
await select(input, [a]);
|
||||
expect(upload).toHaveBeenCalledTimes(3);
|
||||
expect(upload.mock.calls[2][0]).toBe(a);
|
||||
act(() => UploadXHR.instances[2].load());
|
||||
await advance();
|
||||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||||
expect(screen.queryByText('attachment_upload_failed')).toBeNull();
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
expect(deleteStagedFile).toHaveBeenCalledTimes(3);
|
||||
expect(progressCleanup).toHaveBeenCalledTimes(3);
|
||||
expect(emailHooks.onAfterAttachmentUpload.emit).toHaveBeenCalledTimes(2);
|
||||
expect(UploadXHR.instances).toHaveLength(3);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { vi } from 'vitest';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
|
||||
// No native networking: tests explicitly deliver progress and terminal events.
|
||||
export class UploadXHR {
|
||||
static instances: UploadXHR[] = [];
|
||||
method = '';
|
||||
url = '';
|
||||
headers: Record<string, string> = {};
|
||||
responseType = '';
|
||||
responseText = '';
|
||||
status = 0;
|
||||
body: File | null = null;
|
||||
upload = { onprogress: null as ((event: ProgressEvent) => void) | null };
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onabort: (() => void) | null = null;
|
||||
ontimeout: (() => void) | null = null;
|
||||
private done = false;
|
||||
|
||||
constructor() { UploadXHR.instances.push(this); }
|
||||
open(method: string, url: string) { this.method = method; this.url = url; }
|
||||
setRequestHeader(name: string, value: string) { this.headers[name] = value; }
|
||||
send(body: File) { this.body = body; }
|
||||
abort = vi.fn(() => {
|
||||
// Native abort emits no abort event before send or after DONE.
|
||||
if (this.body && !this.done) { this.done = true; this.onabort?.(); }
|
||||
});
|
||||
progress(loaded: number, total = this.body?.size ?? 0, lengthComputable = true) {
|
||||
this.upload.onprogress?.(new ProgressEvent('progress', { loaded, total, lengthComputable }));
|
||||
}
|
||||
load(status = 201, text = JSON.stringify({ blobId: `blob-${this.body?.name}`, size: this.body?.size, type: this.body?.type })) {
|
||||
this.done = true;
|
||||
this.status = status;
|
||||
this.responseText = text;
|
||||
this.onload?.();
|
||||
}
|
||||
error() { this.done = true; this.onerror?.(); }
|
||||
timeout() { this.done = true; this.ontimeout?.(); }
|
||||
}
|
||||
|
||||
export function uploadClient() {
|
||||
const client = new JMAPClient('https://upload.invalid', 'synthetic', 'synthetic');
|
||||
// Seed only a synthetic connected session; avoid keep-alive/reconnect traffic.
|
||||
Object.assign(client, {
|
||||
accountId: 'synthetic',
|
||||
session: {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
accounts: { synthetic: { accountCapabilities: {} } },
|
||||
primaryAccounts: {},
|
||||
uploadUrl: 'https://upload.invalid/upload/{accountId}',
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RequestTimeoutError } from '../jmap/client';
|
||||
import { UploadXHR, uploadClient } from './helpers/upload-xhr';
|
||||
|
||||
const INACTIVITY_MS = 300_000;
|
||||
const file = () => new File(['0123456789'], 'A.txt', { type: 'text/plain' });
|
||||
|
||||
async function start(options: { onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal; accountId?: string } = { onProgress: vi.fn() }) {
|
||||
const client = uploadClient();
|
||||
const body = file();
|
||||
const completed = vi.fn();
|
||||
const promise = client.uploadBlob(body, options);
|
||||
void promise.then(completed, completed);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
return { promise, completed, body, xhr: UploadXHR.instances[0] };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
UploadXHR.instances = [];
|
||||
vi.stubGlobal('XMLHttpRequest', UploadXHR);
|
||||
vi.stubGlobal('fetch', vi.fn(() => { throw new Error('Unexpected networking'); }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const xhr of UploadXHR.instances) xhr.abort();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('JMAP XHR upload inactivity recovery', () => {
|
||||
it('rejects a silent upload at five minutes, aborts it and does not retry', async () => {
|
||||
const { promise, completed, xhr } = await start();
|
||||
await vi.advanceTimersByTimeAsync(INACTIVITY_MS - 1);
|
||||
expect(completed).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(completed).toHaveBeenCalledExactlyOnceWith(expect.any(RequestTimeoutError));
|
||||
await expect(promise).rejects.toThrow('Request timed out after 300s');
|
||||
expect(xhr.abort).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(INACTIVITY_MS);
|
||||
expect(UploadXHR.instances).toHaveLength(1);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('resets the threshold when additional bytes have transferred', async () => {
|
||||
const { completed, xhr } = await start();
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
xhr.progress(1);
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
expect(completed).not.toHaveBeenCalled();
|
||||
xhr.progress(2);
|
||||
await vi.advanceTimersByTimeAsync(INACTIVITY_MS - 1);
|
||||
expect(completed).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(completed).toHaveBeenCalledExactlyOnceWith(expect.any(RequestTimeoutError));
|
||||
});
|
||||
|
||||
it('does not renew for zero, duplicate or decreasing byte counts, or a changed total', async () => {
|
||||
const { completed, xhr } = await start();
|
||||
await vi.advanceTimersByTimeAsync(100_000);
|
||||
xhr.progress(0);
|
||||
await vi.advanceTimersByTimeAsync(100_000);
|
||||
xhr.progress(2);
|
||||
await vi.advanceTimersByTimeAsync(100_000);
|
||||
xhr.progress(2, 20);
|
||||
await vi.advanceTimersByTimeAsync(100_000);
|
||||
xhr.progress(1);
|
||||
await vi.advanceTimersByTimeAsync(99_999);
|
||||
expect(completed).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(completed).toHaveBeenCalledExactlyOnceWith(expect.any(RequestTimeoutError));
|
||||
});
|
||||
|
||||
it('allows a successful sixteen-minute transfer while bytes keep increasing', async () => {
|
||||
const progress = vi.fn();
|
||||
const { promise, completed, xhr, body } = await start({ onProgress: progress });
|
||||
expect(progress).toHaveBeenCalledExactlyOnceWith(0, body.size);
|
||||
for (let loaded = 1; loaded <= 4; loaded++) {
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
xhr.progress(loaded);
|
||||
expect(completed).not.toHaveBeenCalled();
|
||||
}
|
||||
xhr.load();
|
||||
await expect(promise).resolves.toEqual({ blobId: 'blob-A.txt', size: 10, type: 'text/plain' });
|
||||
expect(progress).toHaveBeenCalledTimes(5);
|
||||
expect(xhr.abort).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([5, 10])('expires after progress to %i bytes followed by five silent minutes', async loaded => {
|
||||
const { completed, xhr } = await start();
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
xhr.progress(loaded);
|
||||
await vi.advanceTimersByTimeAsync(INACTIVITY_MS - 1);
|
||||
expect(completed).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(completed).toHaveBeenCalledExactlyOnceWith(expect.any(RequestTimeoutError));
|
||||
expect(xhr.abort).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('observes byte activity for signal-only callers and keeps the file-size progress fallback', async () => {
|
||||
const controller = new AbortController();
|
||||
const { promise, completed, xhr } = await start({ signal: controller.signal });
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
xhr.progress(2, 0, false);
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
expect(completed).not.toHaveBeenCalled();
|
||||
xhr.load();
|
||||
await promise;
|
||||
|
||||
const progress = vi.fn();
|
||||
const next = uploadClient().uploadBlob(file(), { onProgress: progress });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
UploadXHR.instances[1].progress(3, 0, false);
|
||||
expect(progress).toHaveBeenLastCalledWith(3, 10);
|
||||
UploadXHR.instances[1].load();
|
||||
await next;
|
||||
});
|
||||
|
||||
it.each(['success', 'http', 'network', 'cancel', 'native abort', 'native timeout', 'invalid JSON'])('cleans the watchdog and signal listener after %s', async terminal => {
|
||||
const controller = new AbortController();
|
||||
const add = vi.spyOn(controller.signal, 'addEventListener');
|
||||
const remove = vi.spyOn(controller.signal, 'removeEventListener');
|
||||
const { promise, completed, xhr } = await start({ signal: controller.signal });
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
if (terminal === 'success') xhr.load();
|
||||
else if (terminal === 'http') xhr.load(503, 'synthetic failure');
|
||||
else if (terminal === 'network') xhr.error();
|
||||
else if (terminal === 'cancel') controller.abort();
|
||||
else if (terminal === 'native abort') xhr.abort();
|
||||
else if (terminal === 'native timeout') xhr.timeout();
|
||||
else xhr.load(201, 'invalid JSON');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(completed).toHaveBeenCalledOnce();
|
||||
if (terminal === 'success') await expect(promise).resolves.toHaveProperty('blobId');
|
||||
else if (terminal === 'cancel' || terminal === 'native abort') await expect(promise).rejects.toHaveProperty('name', 'AbortError');
|
||||
else if (terminal === 'native timeout') await expect(promise).rejects.toBeInstanceOf(RequestTimeoutError);
|
||||
else await expect(promise).rejects.toBeInstanceOf(Error);
|
||||
expect(remove).toHaveBeenCalledExactlyOnceWith('abort', add.mock.calls[0][1]);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(INACTIVITY_MS * 2);
|
||||
expect(completed).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(['timeout', 'cancel', 'load', 'error'])('%s wins the terminal race; retained late callbacks cannot renew or report', async first => {
|
||||
const controller = new AbortController();
|
||||
const progress = vi.fn();
|
||||
const remove = vi.spyOn(controller.signal, 'removeEventListener');
|
||||
const { completed, xhr } = await start({ signal: controller.signal, onProgress: progress });
|
||||
// Retain callbacks even if cleanup detaches them from the XHR.
|
||||
const late = { progress: xhr.upload.onprogress, load: xhr.onload, error: xhr.onerror, abort: xhr.onabort, timeout: xhr.ontimeout };
|
||||
await vi.advanceTimersByTimeAsync(INACTIVITY_MS - 1);
|
||||
if (first === 'cancel') controller.abort();
|
||||
else if (first === 'load') xhr.load();
|
||||
else if (first === 'error') xhr.error();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
controller.abort();
|
||||
if (first === 'timeout') expect(completed).toHaveBeenCalledExactlyOnceWith(expect.any(RequestTimeoutError));
|
||||
else if (first === 'cancel') expect(completed).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ name: 'AbortError' }));
|
||||
else if (first === 'load') expect(completed).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ blobId: 'blob-A.txt' }));
|
||||
else expect(completed).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ message: 'Upload network error' }));
|
||||
const progressCalls = progress.mock.calls.length;
|
||||
late.progress?.(new ProgressEvent('progress', { loaded: 9, total: 10, lengthComputable: true }));
|
||||
late.load?.(); late.error?.(); late.abort?.(); late.timeout?.();
|
||||
await vi.advanceTimersByTimeAsync(INACTIVITY_MS * 2);
|
||||
expect(completed).toHaveBeenCalledOnce();
|
||||
expect(progress).toHaveBeenCalledTimes(progressCalls);
|
||||
expect(remove).toHaveBeenCalledOnce();
|
||||
expect(xhr.abort).toHaveBeenCalledTimes(first === 'timeout' || first === 'cancel' ? 1 : 0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a pre-aborted signal without starting a request or watchdog', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(uploadClient().uploadBlob(file(), { signal: controller.signal })).rejects.toHaveProperty('name', 'AbortError');
|
||||
expect(UploadXHR.instances).toHaveLength(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('handles cancellation from the initial progress callback before send', async () => {
|
||||
const controller = new AbortController();
|
||||
const { promise, completed, xhr } = await start({ signal: controller.signal, onProgress: () => controller.abort() });
|
||||
expect(completed).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ name: 'AbortError' }));
|
||||
await expect(promise).rejects.toHaveProperty('name', 'AbortError');
|
||||
expect(xhr.body).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('cleans up a synchronous send failure', async () => {
|
||||
const controller = new AbortController();
|
||||
const remove = vi.spyOn(controller.signal, 'removeEventListener');
|
||||
vi.spyOn(UploadXHR.prototype, 'send').mockImplementationOnce(() => { throw new Error('synthetic send failure'); });
|
||||
const { promise } = await start({ signal: controller.signal });
|
||||
await expect(promise).rejects.toThrow('synthetic send failure');
|
||||
expect(remove).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves request construction and nested upload results', async () => {
|
||||
const { promise, xhr, body } = await start({ accountId: 'group/a b', onProgress: vi.fn() });
|
||||
expect(xhr.method).toBe('POST');
|
||||
expect(xhr.url).toBe('https://upload.invalid/upload/group%2Fa%20b');
|
||||
expect(xhr.headers).toEqual({ 'Content-Type': 'text/plain', Authorization: uploadClient().getAuthHeader() });
|
||||
expect(xhr.body).toBe(body);
|
||||
expect(xhr.responseType).toBe('text');
|
||||
xhr.load(200, JSON.stringify({ 'group/a b': { blobId: 'nested', size: 10, type: 'text/plain' } }));
|
||||
await expect(promise).resolves.toEqual({ blobId: 'nested', size: 10, type: 'text/plain' });
|
||||
});
|
||||
});
|
||||
+55
-21
@@ -5806,34 +5806,68 @@ export class JMAPClient implements IJMAPClient {
|
||||
xhr.setRequestHeader('Authorization', this.authHeader);
|
||||
xhr.responseType = 'text';
|
||||
|
||||
const onAbort = () => xhr.abort();
|
||||
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||
const cleanup = () => signal?.removeEventListener('abort', onAbort);
|
||||
|
||||
if (onProgress) {
|
||||
// Fire 0% immediately so the UI leaves its initial state even
|
||||
// before the first network packet flushes.
|
||||
onProgress(0, file.size);
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
// ev.total is only meaningful when lengthComputable; fall back
|
||||
// to file.size so callers always get a usable denominator.
|
||||
const total = ev.lengthComputable ? ev.total : file.size;
|
||||
onProgress(ev.loaded, total);
|
||||
};
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let uploaded = 0;
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
xhr.onload = xhr.onerror = xhr.onabort = xhr.ontimeout = null;
|
||||
xhr.upload.onprogress = null;
|
||||
};
|
||||
const settle = (complete: () => void): boolean => {
|
||||
if (settled) return false;
|
||||
settled = true;
|
||||
cleanup();
|
||||
complete();
|
||||
return true;
|
||||
};
|
||||
const abortWith = (error: Error) => {
|
||||
// Settle first: abort() may synchronously dispatch an abort event.
|
||||
if (settle(() => reject(error))) xhr.abort();
|
||||
};
|
||||
const onAbort = () => abortWith(new DOMException('Upload aborted', 'AbortError'));
|
||||
const onTimeout = () => abortWith(new RequestTimeoutError(JMAPClient.TRANSFER_TIMEOUT_MS));
|
||||
const resetWatchdog = () => {
|
||||
clearTimeout(timer);
|
||||
// XHR uses this budget for inactivity, unlike fetch's deadline until
|
||||
// response headers. Additional upload bytes renew it; after 100% it
|
||||
// stays armed while waiting for the terminal response.
|
||||
timer = setTimeout(onTimeout, JMAPClient.TRANSFER_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (settled) return;
|
||||
if (ev.loaded > uploaded) {
|
||||
uploaded = ev.loaded;
|
||||
resetWatchdog();
|
||||
}
|
||||
// ev.total is only meaningful when lengthComputable; fall back
|
||||
// to file.size so callers always get a usable denominator.
|
||||
onProgress?.(ev.loaded, ev.lengthComputable ? ev.total : file.size);
|
||||
};
|
||||
xhr.onload = () => settle(() => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.responseText);
|
||||
} else {
|
||||
reject(new Error(`Failed to upload file: ${xhr.status} - ${xhr.responseText}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => { cleanup(); reject(new Error('Upload network error')); };
|
||||
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload aborted', 'AbortError')); };
|
||||
});
|
||||
xhr.onerror = () => settle(() => reject(new Error('Upload network error')));
|
||||
xhr.onabort = () => settle(() => reject(new DOMException('Upload aborted', 'AbortError')));
|
||||
xhr.ontimeout = onTimeout;
|
||||
|
||||
xhr.send(file);
|
||||
try {
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
// Fire 0% immediately so the UI leaves its initial state even
|
||||
// before the first network packet flushes.
|
||||
onProgress?.(0, file.size);
|
||||
if (settled) return;
|
||||
resetWatchdog();
|
||||
xhr.send(file);
|
||||
} catch (error) {
|
||||
settle(() => reject(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user