Upload failures keep name, size and file readability #34
6 changed files with 920 additions and 30 deletions
|
|
@ -1,9 +1,14 @@
|
|||
import { atom, useAtom } from 'jotai';
|
||||
import { atomFamily } from 'jotai/utils';
|
||||
import { MatrixClient, UploadResponse, UploadProgress, MatrixError } from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useThrottle } from '../hooks/useThrottle';
|
||||
import { uploadContent, TUploadContent } from '../utils/matrix';
|
||||
import {
|
||||
ComponentLifecycle,
|
||||
describeComponentLifecycle,
|
||||
uploadContent,
|
||||
TUploadContent,
|
||||
} from '../utils/matrix';
|
||||
|
||||
export enum UploadStatus {
|
||||
Idle = 'idle',
|
||||
|
|
@ -105,22 +110,34 @@ export const useBindUploadAtom = (
|
|||
const [upload, setUpload] = useAtom(uploadAtom);
|
||||
const { file } = upload;
|
||||
|
||||
// For the failure text: renders and unmount of the component that owns this upload.
|
||||
const life = useRef<ComponentLifecycle>({ attachedAt: 0, rendersAtAttach: 0, renders: [] });
|
||||
if (life.current.renders.length < 50) life.current.renders.push(Date.now());
|
||||
useEffect(() => {
|
||||
const tracked = life.current;
|
||||
tracked.unmountedAt = undefined;
|
||||
return () => {
|
||||
tracked.unmountedAt = Date.now();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleProgress = useThrottle(
|
||||
useCallback((progress: UploadProgress) => setUpload({ progress }), [setUpload]),
|
||||
{ immediate: true, wait: 200 }
|
||||
);
|
||||
|
||||
const startUpload = useCallback(
|
||||
() =>
|
||||
uploadContent(mx, file, {
|
||||
hideFilename,
|
||||
onPromise: (promise: Promise<UploadResponse>) => setUpload({ promise }),
|
||||
onProgress: handleProgress,
|
||||
onSuccess: (mxc) => setUpload({ mxc }),
|
||||
onError: (error) => setUpload({ error }),
|
||||
}),
|
||||
[mx, file, hideFilename, setUpload, handleProgress]
|
||||
);
|
||||
const startUpload = useCallback(() => {
|
||||
life.current.attachedAt = Date.now();
|
||||
life.current.rendersAtAttach = life.current.renders.length;
|
||||
return uploadContent(mx, file, {
|
||||
hideFilename,
|
||||
describeLifecycle: () => describeComponentLifecycle(life.current),
|
||||
onPromise: (promise: Promise<UploadResponse>) => setUpload({ promise }),
|
||||
onProgress: handleProgress,
|
||||
onSuccess: (mxc) => setUpload({ mxc }),
|
||||
onError: (error) => setUpload({ error }),
|
||||
});
|
||||
}, [mx, file, hideFilename, setUpload, handleProgress]);
|
||||
|
||||
const cancelUpload = useCallback(async () => {
|
||||
if (upload.status === UploadStatus.Loading) {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,270 @@ export type ContentUploadOptions = {
|
|||
onProgress?: (progress: UploadProgress) => void;
|
||||
onSuccess: (mxc: string) => void;
|
||||
onError: (error: MatrixError) => void;
|
||||
/** Read at failure time: what the owning component did since the upload attached. */
|
||||
describeLifecycle?: () => string;
|
||||
};
|
||||
|
||||
let lastUserActivity = Date.now();
|
||||
export const noteUserActivity = () => {
|
||||
lastUserActivity = Date.now();
|
||||
};
|
||||
if (typeof window !== 'undefined') {
|
||||
['pointerdown', 'keydown', 'touchstart'].forEach((type) =>
|
||||
window.addEventListener(type, noteUserActivity, { capture: true, passive: true })
|
||||
);
|
||||
}
|
||||
|
||||
export type AbortCall = { api: string; frames: string };
|
||||
|
||||
/** What the progress handler saw of one upload, for the failure text. */
|
||||
export type UploadTrace = {
|
||||
startedAt: number;
|
||||
firstProgressAt?: number;
|
||||
loaded?: number;
|
||||
total?: number;
|
||||
/** Summary of an earlier attempt of the same upload, when this one is a retry. */
|
||||
previous?: string;
|
||||
/** abort() calls the page made while this attempt ran; absent when not probed. */
|
||||
aborts?: AbortCall[];
|
||||
/** What the owning component did since the upload attached. */
|
||||
lifecycle?: string;
|
||||
/** Upload XHR events and end state, and whether our own signal was aborted. */
|
||||
xhr?: string;
|
||||
signalAborted?: boolean;
|
||||
};
|
||||
|
||||
const MAX_FRAMES = 6;
|
||||
const MAX_ABORT_CALLS = 4;
|
||||
|
||||
/**
|
||||
* An Error stack as `file:line:col < file:line:col`, at most six frames. The error line and the
|
||||
* probe's own frame are dropped; origins and function names are not kept.
|
||||
*/
|
||||
export const formatAbortStack = (stack: string): string => {
|
||||
const frames = stack
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith('at ') || l.includes('@'))
|
||||
.map((l) => {
|
||||
const m = /([^/\s()@]+):(\d+):(\d+)\)?$/.exec(l);
|
||||
return m ? `${m[1]}:${m[2]}:${m[3]}` : undefined;
|
||||
})
|
||||
.filter((f): f is string => f !== undefined)
|
||||
.slice(1, 1 + MAX_FRAMES);
|
||||
return frames.length > 0 ? frames.join(' < ') : 'no stack';
|
||||
};
|
||||
|
||||
export type ComponentLifecycle = {
|
||||
attachedAt: number;
|
||||
rendersAtAttach: number;
|
||||
renders: number[];
|
||||
unmountedAt?: number;
|
||||
};
|
||||
|
||||
/** What the component that owns an upload did after the upload attached to it. */
|
||||
export const describeComponentLifecycle = (life: ComponentLifecycle): string => {
|
||||
const since = life.renders.slice(life.rendersAtAttach);
|
||||
const quick = since.filter((t) => t - life.attachedAt <= 300).length;
|
||||
const unmount =
|
||||
life.unmountedAt === undefined
|
||||
? 'not unmounted'
|
||||
: `unmounted +${life.unmountedAt - life.attachedAt}ms`;
|
||||
return `component: ${since.length} renders since attach (${quick} within 300ms), ${unmount}`;
|
||||
};
|
||||
|
||||
export type XhrRecord = {
|
||||
xhr: XMLHttpRequest;
|
||||
method: string;
|
||||
path: string;
|
||||
startedAt: number;
|
||||
events: string[];
|
||||
};
|
||||
|
||||
type Probe = { calls: AbortCall[]; xhrs: XhrRecord[] };
|
||||
|
||||
const probes = new Set<Probe>();
|
||||
let restoreProbe: (() => void) | undefined;
|
||||
const xhrTargets = new WeakMap<object, { method: string; path: string }>();
|
||||
|
||||
const XHR_EVENTS = ['loadstart', 'error', 'abort', 'timeout', 'load', 'loadend'];
|
||||
const MAX_XHR_EVENTS = 12;
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
const patchAbort = (proto: { abort: (...a: any[]) => any }, api: string) => {
|
||||
const original = proto.abort;
|
||||
const wrapper = function abortProbe(this: unknown, ...args: any[]) {
|
||||
const frames = formatAbortStack(new Error().stack ?? '');
|
||||
probes.forEach(({ calls }) => {
|
||||
if (calls.length < MAX_ABORT_CALLS) calls.push({ api, frames });
|
||||
});
|
||||
return original.apply(this, args);
|
||||
};
|
||||
proto.abort = wrapper;
|
||||
return () => {
|
||||
if (proto.abort === wrapper) proto.abort = original;
|
||||
};
|
||||
};
|
||||
|
||||
// Upload XHRs only; the path is kept and the query (it holds the access token) is dropped.
|
||||
const patchXhr = (proto: XMLHttpRequest) => {
|
||||
const { open, send } = proto;
|
||||
const openWrapper = function openProbe(this: XMLHttpRequest, ...args: any[]) {
|
||||
try {
|
||||
const url = new URL(String(args[1]), 'http://x');
|
||||
if (url.pathname.includes('/upload')) {
|
||||
xhrTargets.set(this, { method: String(args[0]).toUpperCase(), path: url.pathname });
|
||||
}
|
||||
} catch {
|
||||
// not a URL we can read; the request is not recorded
|
||||
}
|
||||
return (open as any).apply(this, args);
|
||||
};
|
||||
const sendWrapper = function sendProbe(this: XMLHttpRequest, ...args: any[]) {
|
||||
const target = xhrTargets.get(this);
|
||||
if (target && probes.size > 0) {
|
||||
const startedAt = Date.now();
|
||||
const events: string[] = [];
|
||||
const note = (name: string) => () => {
|
||||
if (events.length < MAX_XHR_EVENTS) events.push(`${name} +${Date.now() - startedAt}ms`);
|
||||
};
|
||||
XHR_EVENTS.forEach((ev) => this.addEventListener(ev, note(ev)));
|
||||
XHR_EVENTS.forEach((ev) => this.upload?.addEventListener(ev, note(`upload.${ev}`)));
|
||||
this.upload?.addEventListener(
|
||||
'progress',
|
||||
(() => {
|
||||
let first = true;
|
||||
return () => {
|
||||
if (first) note('upload.progress')();
|
||||
first = false;
|
||||
};
|
||||
})()
|
||||
);
|
||||
const record: XhrRecord = { xhr: this, ...target, startedAt, events };
|
||||
probes.forEach((probe) => probe.xhrs.push(record));
|
||||
}
|
||||
return (send as any).apply(this, args);
|
||||
};
|
||||
(proto as any).open = openWrapper;
|
||||
(proto as any).send = sendWrapper;
|
||||
return () => {
|
||||
if ((proto as any).open === openWrapper) (proto as any).open = open;
|
||||
if ((proto as any).send === sendWrapper) (proto as any).send = send;
|
||||
};
|
||||
};
|
||||
/* eslint-enable no-param-reassign */
|
||||
|
||||
const describeXhr = (r: XhrRecord): string =>
|
||||
`${r.method} ${r.path} readyState ${r.xhr.readyState} status ${
|
||||
r.xhr.status
|
||||
} events [${r.events.join(', ')}]`;
|
||||
|
||||
/**
|
||||
* Records, until stopped, every XMLHttpRequest.abort and AbortController.abort the page makes and
|
||||
* the lifecycle events and end state of upload XHRs. The prototypes are wrapped while at least one
|
||||
* probe runs and restored when the last one stops; the wrappers change no behaviour.
|
||||
*/
|
||||
export const startAbortProbe = () => {
|
||||
const probe: Probe = { calls: [], xhrs: [] };
|
||||
if (probes.size === 0) {
|
||||
const restores: (() => void)[] = [];
|
||||
if (typeof XMLHttpRequest !== 'undefined') {
|
||||
restores.push(patchAbort(XMLHttpRequest.prototype, 'XHR.abort'));
|
||||
restores.push(patchXhr(XMLHttpRequest.prototype));
|
||||
}
|
||||
if (typeof AbortController !== 'undefined') {
|
||||
restores.push(patchAbort(AbortController.prototype, 'AbortController.abort'));
|
||||
}
|
||||
restoreProbe = () => restores.forEach((r) => r());
|
||||
}
|
||||
probes.add(probe);
|
||||
return {
|
||||
calls: probe.calls,
|
||||
xhrs: probe.xhrs,
|
||||
describeXhrs: () =>
|
||||
probe.xhrs.length === 0 ? 'xhr: none seen' : `xhr: ${probe.xhrs.map(describeXhr).join('; ')}`,
|
||||
stop: () => {
|
||||
probes.delete(probe);
|
||||
if (probes.size === 0) {
|
||||
restoreProbe?.();
|
||||
restoreProbe = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const describeNetwork = (): string => {
|
||||
const online =
|
||||
typeof navigator !== 'undefined' && navigator.onLine === false ? 'offline' : 'online';
|
||||
const c = typeof navigator !== 'undefined' ? (navigator as any).connection : undefined;
|
||||
const net = c
|
||||
? `${c.effectiveType ?? '?'} rtt ${c.rtt ?? '?'}ms down ${c.downlink ?? '?'}Mbps saveData ${
|
||||
c.saveData ? 'on' : 'off'
|
||||
}`
|
||||
: 'n/a';
|
||||
const tab = typeof document !== 'undefined' ? document.visibilityState : 'n/a';
|
||||
return `${online}, net ${net}, tab ${tab}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describe why an upload died. The SDK reports an XHR with status 0 as an AbortError
|
||||
* DOMException with an empty message, so the text carries what the client can observe: file
|
||||
* size, type and readability, connectivity, tab visibility, idle time, progress, time to fail.
|
||||
*/
|
||||
export const uploadFailure = async (
|
||||
e: any,
|
||||
file: TUploadContent,
|
||||
trace?: UploadTrace
|
||||
): Promise<MatrixError> => {
|
||||
const now = Date.now();
|
||||
const name = typeof e?.name === 'string' && e.name ? e.name : 'Error';
|
||||
const message = typeof e?.message === 'string' ? e.message : '';
|
||||
let readable: string;
|
||||
try {
|
||||
await file.arrayBuffer();
|
||||
readable = 'file readable';
|
||||
} catch (readErr: any) {
|
||||
readable = `file unreadable (${readErr?.name ?? 'unknown'})`;
|
||||
}
|
||||
const parts = [`${file.size} bytes`, file.type || 'unknown type', readable, describeNetwork()];
|
||||
parts.push(`idle ${now - lastUserActivity}ms`);
|
||||
if (trace) {
|
||||
parts.push(
|
||||
trace.firstProgressAt === undefined
|
||||
? 'no progress'
|
||||
: `first progress ${trace.firstProgressAt - trace.startedAt}ms ${trace.loaded ?? 0}/${
|
||||
trace.total ?? '?'
|
||||
}B`
|
||||
);
|
||||
parts.push(`failed after ${now - trace.startedAt}ms`);
|
||||
if (trace.aborts) {
|
||||
parts.push(
|
||||
trace.aborts.length === 0
|
||||
? 'abort calls: none'
|
||||
: `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}`
|
||||
);
|
||||
}
|
||||
if (trace.xhr) parts.push(trace.xhr);
|
||||
if (trace.signalAborted !== undefined) parts.push(`signal.aborted ${trace.signalAborted}`);
|
||||
if (trace.lifecycle) parts.push(trace.lifecycle);
|
||||
if (trace.previous) parts.push(`retried (attempt 1: ${trace.previous})`);
|
||||
}
|
||||
const error = `Upload failed: ${message ? `${name}: ${message}` : name} (${parts.join(', ')})`;
|
||||
const err = new MatrixError({ error, errcode: name });
|
||||
(err as any).cause = e;
|
||||
return err;
|
||||
};
|
||||
|
||||
// An abort before any progress, within this long, is retried once: the request most likely died on
|
||||
// a reused connection before a byte left, and the failed attempt discards that connection. Nothing
|
||||
// here has shown that is the cause; the failure text records both attempts so it can be checked.
|
||||
const RETRY_WINDOW_MS = 3000;
|
||||
|
||||
const copyInMemory = async (file: TUploadContent): Promise<TUploadContent> => {
|
||||
const bytes = await file.arrayBuffer();
|
||||
return typeof File !== 'undefined' && file instanceof File
|
||||
? new File([bytes], file.name, { type: file.type, lastModified: file.lastModified })
|
||||
: new Blob([bytes], { type: file.type });
|
||||
};
|
||||
|
||||
export const uploadContent = async (
|
||||
|
|
@ -150,24 +414,86 @@ export const uploadContent = async (
|
|||
file: TUploadContent,
|
||||
options: ContentUploadOptions
|
||||
) => {
|
||||
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
|
||||
|
||||
const uploadPromise = mx.uploadContent(file, {
|
||||
const {
|
||||
name,
|
||||
type: fileType,
|
||||
includeFilename: !hideFilename,
|
||||
progressHandler: onProgress,
|
||||
});
|
||||
onPromise?.(uploadPromise);
|
||||
try {
|
||||
const data = await uploadPromise;
|
||||
const mxc = data.content_uri;
|
||||
if (mxc) onSuccess(mxc);
|
||||
else onError(new MatrixError(data));
|
||||
} catch (e: any) {
|
||||
const error = typeof e?.message === 'string' ? e.message : undefined;
|
||||
const errcode = typeof e?.name === 'string' ? e.message : undefined;
|
||||
onError(new MatrixError({ error, errcode }));
|
||||
fileType,
|
||||
hideFilename,
|
||||
onProgress,
|
||||
onPromise,
|
||||
onSuccess,
|
||||
onError,
|
||||
describeLifecycle,
|
||||
} = options;
|
||||
|
||||
let previous: string | undefined;
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
// The browser may hold a picked file as a handle it cannot always re-read (e.g. content://), so
|
||||
// each attempt uploads a copy held in memory; a file that cannot be read fails here, named.
|
||||
let body: TUploadContent;
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
body = await copyInMemory(file);
|
||||
} catch (readErr: any) {
|
||||
const errcode = readErr?.name || 'Error';
|
||||
onError(
|
||||
new MatrixError({
|
||||
error: `Upload failed: file unreadable before upload (${errcode}) (${file.size} bytes, ${
|
||||
file.type || 'unknown type'
|
||||
})`,
|
||||
errcode,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
const probe = startAbortProbe();
|
||||
const trace: UploadTrace = { startedAt: Date.now(), previous, aborts: probe.calls };
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
const uploadPromise = mx.uploadContent(body, {
|
||||
name,
|
||||
type: fileType,
|
||||
includeFilename: !hideFilename,
|
||||
abortController,
|
||||
progressHandler: (progress) => {
|
||||
if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now();
|
||||
trace.loaded = progress.loaded;
|
||||
trace.total = progress.total;
|
||||
onProgress?.(progress);
|
||||
},
|
||||
});
|
||||
onPromise?.(uploadPromise);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const data = await uploadPromise;
|
||||
const mxc = data.content_uri;
|
||||
if (mxc) onSuccess(mxc);
|
||||
else onError(new MatrixError(data));
|
||||
return;
|
||||
} catch (e: any) {
|
||||
const elapsed = Date.now() - trace.startedAt;
|
||||
trace.xhr = probe.describeXhrs();
|
||||
trace.signalAborted = abortController.signal.aborted;
|
||||
const retry =
|
||||
attempt === 1 &&
|
||||
e?.name === 'AbortError' &&
|
||||
!abortController.signal.aborted &&
|
||||
trace.firstProgressAt === undefined &&
|
||||
elapsed < RETRY_WINDOW_MS;
|
||||
if (!retry) {
|
||||
trace.lifecycle = describeLifecycle?.();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
onError(await uploadFailure(e, file, trace));
|
||||
return;
|
||||
}
|
||||
previous = `AbortError after ${elapsed}ms, no progress, ${
|
||||
trace.aborts?.length
|
||||
? `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}`
|
||||
: 'abort calls: none'
|
||||
}, ${trace.xhr}`;
|
||||
}
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
171
src/app/utils/uploadAbortProbe.test.ts
Normal file
171
src/app/utils/uploadAbortProbe.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
describeComponentLifecycle,
|
||||
formatAbortStack,
|
||||
startAbortProbe,
|
||||
uploadContent,
|
||||
uploadFailure,
|
||||
} from './matrix';
|
||||
|
||||
const chromeStack = [
|
||||
'Error',
|
||||
' at wrapper (https://chat.example/assets/index-abc.js:1:100)',
|
||||
' at https://chat.example/assets/index-abc.js:1:200',
|
||||
' at Object.cb (https://chat.example/assets/vendor-def.js:7:8)',
|
||||
' at https://chat.example/assets/a.js:2:3',
|
||||
' at https://chat.example/assets/b.js:4:5',
|
||||
' at https://chat.example/assets/c.js:6:7',
|
||||
' at https://chat.example/assets/d.js:8:9',
|
||||
' at https://chat.example/assets/e.js:10:11',
|
||||
].join('\n');
|
||||
|
||||
describe('formatAbortStack', () => {
|
||||
it('keeps at most six frames as file:line:col, skipping the probe wrapper and the error line', () => {
|
||||
const out = formatAbortStack(chromeStack);
|
||||
const frames = out.split(' < ');
|
||||
expect(frames).toHaveLength(6);
|
||||
expect(frames[0]).toBe('index-abc.js:1:200');
|
||||
expect(out).not.toContain('https://');
|
||||
expect(out).not.toContain('wrapper');
|
||||
});
|
||||
|
||||
it('copes with an empty stack', () => {
|
||||
expect(formatAbortStack('')).toBe('no stack');
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort probe', () => {
|
||||
const original = AbortController.prototype.abort;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
AbortController.prototype.abort = original;
|
||||
});
|
||||
|
||||
it('records an AbortController.abort made while it runs, still aborts, and restores afterwards', () => {
|
||||
const probe = startAbortProbe();
|
||||
expect(AbortController.prototype.abort).not.toBe(original);
|
||||
const c = new AbortController();
|
||||
c.abort();
|
||||
expect(c.signal.aborted).toBe(true);
|
||||
probe.stop();
|
||||
expect(AbortController.prototype.abort).toBe(original);
|
||||
expect(probe.calls).toHaveLength(1);
|
||||
expect(probe.calls[0].api).toBe('AbortController.abort');
|
||||
});
|
||||
|
||||
it('records XMLHttpRequest.prototype.abort and restores it', () => {
|
||||
const xhrAbort = vi.fn();
|
||||
class FakeXhr {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
abort() {
|
||||
xhrAbort();
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr);
|
||||
const probe = startAbortProbe();
|
||||
new FakeXhr().abort();
|
||||
probe.stop();
|
||||
expect(xhrAbort).toHaveBeenCalledTimes(1);
|
||||
expect(probe.calls.map((c) => c.api)).toContain('XHR.abort');
|
||||
expect(FakeXhr.prototype.abort.toString()).not.toContain('collectors');
|
||||
const after = new FakeXhr();
|
||||
after.abort();
|
||||
expect(xhrAbort).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('records nothing after stop', () => {
|
||||
const probe = startAbortProbe();
|
||||
probe.stop();
|
||||
new AbortController().abort();
|
||||
expect(probe.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the wrapper until the last overlapping probe stops, then restores the original', () => {
|
||||
const a = startAbortProbe();
|
||||
const b = startAbortProbe();
|
||||
a.stop();
|
||||
new AbortController().abort();
|
||||
expect(b.calls).toHaveLength(1);
|
||||
expect(a.calls).toHaveLength(0);
|
||||
b.stop();
|
||||
expect(AbortController.prototype.abort).toBe(original);
|
||||
});
|
||||
|
||||
it('restores the original when the upload attempt throws', async () => {
|
||||
const mx = { uploadContent: () => Promise.reject(new Error('boom')) } as any;
|
||||
await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError: vi.fn() });
|
||||
expect(AbortController.prototype.abort).toBe(original);
|
||||
});
|
||||
|
||||
it('restores the original when uploadContent itself throws synchronously', async () => {
|
||||
const mx = {
|
||||
uploadContent: () => {
|
||||
throw new Error('sync');
|
||||
},
|
||||
} as any;
|
||||
await expect(
|
||||
uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError: vi.fn() })
|
||||
).rejects.toThrow('sync');
|
||||
expect(AbortController.prototype.abort).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('failure text', () => {
|
||||
const abort = () => new DOMException('', 'AbortError');
|
||||
|
||||
it('lists abort calls seen during the attempt', async () => {
|
||||
const mx = {
|
||||
uploadContent: () => {
|
||||
new AbortController().abort();
|
||||
return Promise.reject(abort());
|
||||
},
|
||||
} as any;
|
||||
const onError = vi.fn();
|
||||
await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError });
|
||||
// retried once: the final message carries the second attempt and attempt 1's summary
|
||||
const msg = onError.mock.calls[0][0].message;
|
||||
expect(msg).toContain('abort calls: AbortController.abort');
|
||||
expect(msg).toContain('retried (attempt 1: AbortError');
|
||||
});
|
||||
|
||||
it('says none when nothing in the page called abort', async () => {
|
||||
const err = await uploadFailure(abort(), new Blob(['x']), {
|
||||
startedAt: Date.now(),
|
||||
aborts: [],
|
||||
});
|
||||
expect(err.message).toContain('abort calls: none');
|
||||
});
|
||||
|
||||
it('reports the component lifecycle when given', async () => {
|
||||
const mx = { uploadContent: () => Promise.reject(new Error('Timeout')) } as any;
|
||||
const onError = vi.fn();
|
||||
await uploadContent(mx, new Blob(['x']), {
|
||||
onSuccess: vi.fn(),
|
||||
onError,
|
||||
describeLifecycle: () => 'component remounted 0x, renders 3 (2 within 300ms)',
|
||||
});
|
||||
expect(onError.mock.calls[0][0].message).toContain('renders 3 (2 within 300ms)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeComponentLifecycle', () => {
|
||||
it('counts renders after attach and those within 300ms, and reports an unmount', () => {
|
||||
const text = describeComponentLifecycle({
|
||||
attachedAt: 1000,
|
||||
rendersAtAttach: 1,
|
||||
renders: [990, 1100, 1250, 1400],
|
||||
unmountedAt: 1500,
|
||||
});
|
||||
expect(text).toBe('component: 3 renders since attach (2 within 300ms), unmounted +500ms');
|
||||
});
|
||||
|
||||
it('says not unmounted when it was not', () => {
|
||||
const text = describeComponentLifecycle({
|
||||
attachedAt: 1000,
|
||||
rendersAtAttach: 1,
|
||||
renders: [990],
|
||||
});
|
||||
expect(text).toBe('component: 0 renders since attach (0 within 300ms), not unmounted');
|
||||
});
|
||||
});
|
||||
100
src/app/utils/uploadFailure.test.ts
Normal file
100
src/app/utils/uploadFailure.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { noteUserActivity, uploadFailure } from './matrix';
|
||||
|
||||
const abort = () => new DOMException('', 'AbortError');
|
||||
|
||||
describe('uploadFailure', () => {
|
||||
it('keeps the error name as errcode, never the message', async () => {
|
||||
const err = await uploadFailure(abort(), new Blob(['abc'], { type: 'image/png' }));
|
||||
expect(err.errcode).toBe('AbortError');
|
||||
});
|
||||
|
||||
it('never reduces an empty-message error to "Unknown message"', async () => {
|
||||
const err = await uploadFailure(abort(), new Blob(['abc']));
|
||||
expect(err.message).not.toContain('Unknown message');
|
||||
expect(err.message).toContain('AbortError');
|
||||
});
|
||||
|
||||
it('reports size and type of the file', async () => {
|
||||
const err = await uploadFailure(abort(), new Blob(['abcd'], { type: 'image/png' }));
|
||||
expect(err.message).toContain('4 bytes');
|
||||
expect(err.message).toContain('image/png');
|
||||
});
|
||||
|
||||
it('keeps the original error as cause', async () => {
|
||||
const e = abort();
|
||||
const err = await uploadFailure(e, new Blob(['x']));
|
||||
expect((err as any).cause).toBe(e);
|
||||
});
|
||||
|
||||
it('says whether the browser could still read the file', async () => {
|
||||
const ok = await uploadFailure(abort(), new Blob(['x']));
|
||||
expect(ok.message).toContain('file readable');
|
||||
const bad = new Blob(['x']);
|
||||
(bad as any).arrayBuffer = () => Promise.reject(new DOMException('gone', 'NotReadableError'));
|
||||
const err = await uploadFailure(abort(), bad);
|
||||
expect(err.message).toContain('file unreadable (NotReadableError)');
|
||||
});
|
||||
|
||||
it('keeps a real message when the error has one', async () => {
|
||||
const err = await uploadFailure(new Error('Timeout'), new Blob(['x']));
|
||||
expect(err.message).toContain('Timeout');
|
||||
expect(err.errcode).toBe('Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadFailure client trace', () => {
|
||||
const blob = new Blob(['abcd'], { type: 'image/jpeg' });
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('reports connectivity, visibility and idle time at the moment of failure', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(10_000);
|
||||
vi.stubGlobal('navigator', {
|
||||
onLine: false,
|
||||
connection: { effectiveType: '3g', rtt: 300, downlink: 1.5, saveData: true },
|
||||
});
|
||||
vi.stubGlobal('document', { visibilityState: 'hidden' });
|
||||
noteUserActivity();
|
||||
vi.setSystemTime(13_000);
|
||||
const err = await uploadFailure(abort(), blob, { startedAt: 12_000 });
|
||||
expect(err.message).toContain('offline');
|
||||
expect(err.message).toContain('3g rtt 300ms down 1.5Mbps saveData on');
|
||||
expect(err.message).toContain('tab hidden');
|
||||
expect(err.message).toContain('idle 3000ms');
|
||||
expect(err.message).toContain('failed after 1000ms');
|
||||
});
|
||||
|
||||
it('says so when no progress event ever arrived', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(5_000);
|
||||
const err = await uploadFailure(abort(), blob, { startedAt: 4_000 });
|
||||
expect(err.message).toContain('no progress');
|
||||
});
|
||||
|
||||
it('reports time to first progress and bytes loaded when some arrived', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(9_000);
|
||||
const err = await uploadFailure(abort(), blob, {
|
||||
startedAt: 5_000,
|
||||
firstProgressAt: 5_120,
|
||||
loaded: 65536,
|
||||
total: 700000,
|
||||
});
|
||||
expect(err.message).toContain('first progress 120ms');
|
||||
expect(err.message).toContain('65536/700000B');
|
||||
expect(err.message).toContain('failed after 4000ms');
|
||||
});
|
||||
|
||||
it('copes with a browser that has no connection info or document', async () => {
|
||||
vi.stubGlobal('navigator', { onLine: true });
|
||||
vi.stubGlobal('document', undefined);
|
||||
const err = await uploadFailure(abort(), blob, { startedAt: Date.now() });
|
||||
expect(err.message).toContain('online');
|
||||
expect(err.message).toContain('net n/a');
|
||||
});
|
||||
});
|
||||
113
src/app/utils/uploadRetry.test.ts
Normal file
113
src/app/utils/uploadRetry.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { uploadContent } from './matrix';
|
||||
|
||||
const abort = () => new DOMException('', 'AbortError');
|
||||
const blob = new Blob(['abcd'], { type: 'image/jpeg' });
|
||||
|
||||
type Step = (opts: any) => Promise<any>;
|
||||
const fakeMx = (steps: Step[]) => {
|
||||
const calls: any[] = [];
|
||||
const mx = {
|
||||
uploadContent: vi.fn((_file: unknown, opts: any) => {
|
||||
calls.push(opts);
|
||||
return steps[calls.length - 1](opts);
|
||||
}),
|
||||
};
|
||||
return { mx: mx as any, calls };
|
||||
};
|
||||
const run = async (mx: any) => {
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const onPromise = vi.fn();
|
||||
await uploadContent(mx, blob, { onSuccess, onError, onPromise });
|
||||
return { onSuccess, onError, onPromise };
|
||||
};
|
||||
|
||||
describe('uploadContent retry', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('retries once when the first attempt aborts at once with no progress', async () => {
|
||||
const { mx } = fakeMx([
|
||||
() => Promise.reject(abort()),
|
||||
async () => ({ content_uri: 'mxc://a/b' }),
|
||||
]);
|
||||
const r = await run(mx);
|
||||
expect(mx.uploadContent).toHaveBeenCalledTimes(2);
|
||||
expect(r.onSuccess).toHaveBeenCalledWith('mxc://a/b');
|
||||
expect(r.onError).not.toHaveBeenCalled();
|
||||
expect(r.onPromise).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries at most once and reports both attempts when the retry fails too', async () => {
|
||||
const { mx } = fakeMx([() => Promise.reject(abort()), () => Promise.reject(abort())]);
|
||||
const r = await run(mx);
|
||||
expect(mx.uploadContent).toHaveBeenCalledTimes(2);
|
||||
const msg = r.onError.mock.calls[0][0].message;
|
||||
expect(msg).toContain('retried');
|
||||
expect(msg).toContain('attempt 1: AbortError');
|
||||
expect(msg).toContain('no progress');
|
||||
});
|
||||
|
||||
it('never retries after a progress event', async () => {
|
||||
const { mx } = fakeMx([
|
||||
(opts) => {
|
||||
opts.progressHandler({ loaded: 10, total: 100 });
|
||||
return Promise.reject(abort());
|
||||
},
|
||||
async () => ({ content_uri: 'mxc://a/b' }),
|
||||
]);
|
||||
const r = await run(mx);
|
||||
expect(mx.uploadContent).toHaveBeenCalledTimes(1);
|
||||
expect(r.onError).toHaveBeenCalledTimes(1);
|
||||
expect(r.onError.mock.calls[0][0].message).not.toContain('retried');
|
||||
});
|
||||
|
||||
it('never retries after the user cancelled', async () => {
|
||||
const { mx } = fakeMx([
|
||||
(opts) => {
|
||||
opts.abortController.abort();
|
||||
return Promise.reject(abort());
|
||||
},
|
||||
async () => ({ content_uri: 'mxc://a/b' }),
|
||||
]);
|
||||
const r = await run(mx);
|
||||
expect(mx.uploadContent).toHaveBeenCalledTimes(1);
|
||||
expect(r.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never retries an error that is not an abort', async () => {
|
||||
const { mx } = fakeMx([() => Promise.reject(new Error('Timeout')), async () => ({})]);
|
||||
const r = await run(mx);
|
||||
expect(mx.uploadContent).toHaveBeenCalledTimes(1);
|
||||
expect(r.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never retries an abort that took three seconds or more', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
const { mx } = fakeMx([
|
||||
() => {
|
||||
vi.setSystemTime(3000);
|
||||
return Promise.reject(abort());
|
||||
},
|
||||
async () => ({ content_uri: 'mxc://a/b' }),
|
||||
]);
|
||||
const r = await run(mx);
|
||||
expect(mx.uploadContent).toHaveBeenCalledTimes(1);
|
||||
expect(r.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries the same abort at 2999ms', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
const { mx } = fakeMx([
|
||||
() => {
|
||||
vi.setSystemTime(2999);
|
||||
return Promise.reject(abort());
|
||||
},
|
||||
async () => ({ content_uri: 'mxc://a/b' }),
|
||||
]);
|
||||
await run(mx);
|
||||
expect(mx.uploadContent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
163
src/app/utils/uploadXhrProbe.test.ts
Normal file
163
src/app/utils/uploadXhrProbe.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/* eslint-disable class-methods-use-this, @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, no-unused-vars */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { startAbortProbe, uploadContent } from './matrix';
|
||||
|
||||
class FakeXhr extends EventTarget {
|
||||
upload = new EventTarget();
|
||||
|
||||
readyState = 0;
|
||||
|
||||
status = 0;
|
||||
|
||||
open(_method: string, _url: string) {
|
||||
this.readyState = 1;
|
||||
}
|
||||
|
||||
send() {
|
||||
this.readyState = 2;
|
||||
}
|
||||
|
||||
abort() {}
|
||||
}
|
||||
|
||||
const origOpen = FakeXhr.prototype.open;
|
||||
const origSend = FakeXhr.prototype.send;
|
||||
const uploadUrl =
|
||||
'https://matrix.example/_matrix/media/v3/upload?filename=a.jpg&access_token=SECRET';
|
||||
|
||||
describe('XHR probe', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
FakeXhr.prototype.open = origOpen;
|
||||
FakeXhr.prototype.send = origSend;
|
||||
});
|
||||
|
||||
it('records which events an upload XHR fired and how it ended, without the query string', () => {
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr);
|
||||
const probe = startAbortProbe();
|
||||
const x = new FakeXhr();
|
||||
x.open('POST', uploadUrl);
|
||||
x.send();
|
||||
x.upload.dispatchEvent(new Event('loadstart'));
|
||||
x.upload.dispatchEvent(new Event('error'));
|
||||
x.dispatchEvent(new Event('error'));
|
||||
x.readyState = 4;
|
||||
x.status = 0;
|
||||
probe.stop();
|
||||
expect(probe.xhrs).toHaveLength(1);
|
||||
const text = probe.describeXhrs();
|
||||
expect(text).toContain('POST /_matrix/media/v3/upload');
|
||||
expect(text).toContain('readyState 4 status 0');
|
||||
expect(text).toContain('upload.loadstart');
|
||||
expect(text).toContain('upload.error');
|
||||
expect(text).toContain('error');
|
||||
expect(text).not.toContain('SECRET');
|
||||
expect(text).not.toContain('access_token');
|
||||
});
|
||||
|
||||
it('ignores XHRs that are not uploads', () => {
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr);
|
||||
const probe = startAbortProbe();
|
||||
const x = new FakeXhr();
|
||||
x.open('GET', 'https://matrix.example/_matrix/client/v3/sync');
|
||||
x.send();
|
||||
probe.stop();
|
||||
expect(probe.xhrs).toHaveLength(0);
|
||||
expect(probe.describeXhrs()).toBe('xhr: none seen');
|
||||
});
|
||||
|
||||
it('still calls the real open and send and restores both afterwards', () => {
|
||||
const open = vi.fn();
|
||||
const send = vi.fn();
|
||||
FakeXhr.prototype.open = open;
|
||||
FakeXhr.prototype.send = send;
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr);
|
||||
const probe = startAbortProbe();
|
||||
const x = new FakeXhr();
|
||||
x.open('POST', uploadUrl);
|
||||
x.send();
|
||||
expect(open).toHaveBeenCalledWith('POST', uploadUrl);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
probe.stop();
|
||||
expect(FakeXhr.prototype.open).toBe(open);
|
||||
expect(FakeXhr.prototype.send).toBe(send);
|
||||
});
|
||||
|
||||
it('records nothing for requests made after stop', () => {
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr);
|
||||
const probe = startAbortProbe();
|
||||
probe.stop();
|
||||
const x = new FakeXhr();
|
||||
x.open('POST', uploadUrl);
|
||||
x.send();
|
||||
expect(probe.xhrs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadContent in-memory copy and failure line', () => {
|
||||
const abort = () => new DOMException('', 'AbortError');
|
||||
|
||||
it('uploads an in-memory copy with the same bytes, name and type, not the original', async () => {
|
||||
const file = new File(['abcdef'], 'a.jpg', { type: 'image/jpeg' });
|
||||
let sent: any;
|
||||
const mx = {
|
||||
uploadContent: vi.fn(async (f: any) => {
|
||||
sent = f;
|
||||
return { content_uri: 'mxc://a/b' };
|
||||
}),
|
||||
} as any;
|
||||
await uploadContent(mx, file, { onSuccess: vi.fn(), onError: vi.fn() });
|
||||
expect(sent).not.toBe(file);
|
||||
expect(sent.type).toBe('image/jpeg');
|
||||
expect(sent.name).toBe('a.jpg');
|
||||
expect(await sent.text()).toBe('abcdef');
|
||||
});
|
||||
|
||||
it('copies again before the retry', async () => {
|
||||
const seen: any[] = [];
|
||||
const mx = {
|
||||
uploadContent: vi.fn((f: any) => {
|
||||
seen.push(f);
|
||||
return seen.length === 1
|
||||
? Promise.reject(abort())
|
||||
: Promise.resolve({ content_uri: 'mxc://a/b' });
|
||||
}),
|
||||
} as any;
|
||||
await uploadContent(mx, new Blob(['xy']), { onSuccess: vi.fn(), onError: vi.fn() });
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen[0]).not.toBe(seen[1]);
|
||||
});
|
||||
|
||||
it('reports an unreadable file and never starts the upload', async () => {
|
||||
const bad = new Blob(['x']);
|
||||
(bad as any).arrayBuffer = () => Promise.reject(new DOMException('gone', 'NotReadableError'));
|
||||
const mx = { uploadContent: vi.fn() } as any;
|
||||
const onError = vi.fn();
|
||||
await uploadContent(mx, bad, { onSuccess: vi.fn(), onError });
|
||||
expect(mx.uploadContent).not.toHaveBeenCalled();
|
||||
expect(onError.mock.calls[0][0].message).toContain(
|
||||
'unreadable before upload (NotReadableError)'
|
||||
);
|
||||
});
|
||||
|
||||
it('puts the XHR end state and signal.aborted in the failure line', async () => {
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr);
|
||||
const mx = {
|
||||
uploadContent: () => {
|
||||
const x = new FakeXhr();
|
||||
x.open('POST', uploadUrl);
|
||||
x.send();
|
||||
x.upload.dispatchEvent(new Event('error'));
|
||||
x.readyState = 4;
|
||||
return Promise.reject(new Error('Timeout'));
|
||||
},
|
||||
} as any;
|
||||
const onError = vi.fn();
|
||||
await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError });
|
||||
const msg = onError.mock.calls[0][0].message;
|
||||
expect(msg).toContain('readyState 4 status 0');
|
||||
expect(msg).toContain('upload.error');
|
||||
expect(msg).toContain('signal.aborted false');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue