diff --git a/components/email/__tests__/composer-upload-timeout.test.tsx b/components/email/__tests__/composer-upload-timeout.test.tsx new file mode 100644 index 000000000..0afc1df55 --- /dev/null +++ b/components/email/__tests__/composer-upload-timeout.test.tsx @@ -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) => 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) => 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) => 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) => 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) => 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) => 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) => 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(); + 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(); + 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; + // 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(); + }); +}); diff --git a/lib/__tests__/helpers/upload-xhr.ts b/lib/__tests__/helpers/upload-xhr.ts new file mode 100644 index 000000000..5ded4faac --- /dev/null +++ b/lib/__tests__/helpers/upload-xhr.ts @@ -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 = {}; + 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; +} diff --git a/lib/__tests__/jmap-upload-inactivity.test.ts b/lib/__tests__/jmap-upload-inactivity.test.ts new file mode 100644 index 000000000..38457ddeb --- /dev/null +++ b/lib/__tests__/jmap-upload-inactivity.test.ts @@ -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' }); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index e7a42f77c..a3e4e4294 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 | 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)); + } }); }