Files
gerbeur/src/test/setup.ts
khannurien fac0e2db28 v4: an audio file's waveform is made once by the server, and the player seeks on one accessible scrubber — every player downloaded the whole file a second time and decoded all of it in the browser to draw 150 bars, so a long FLAC cost tens of megabytes of memory per page, and the waveform could only be clicked: no drag, no keyboard, no slider for a screen reader. GET /api/dumps/:id/waveform now answers an audio dump's peaks, which lib/ffmpeg.ts decodes with ffmpeg on first request, mono at 8 kHz and streamed, keeps beside the thumbnails as the response itself, and serves with an ETag (a ten-minute MP3 takes about a second, then a few hundred bytes). The peaks are taken by shared/waveform.ts's peaksOf, scaled to the loudest so a quiet recording fills the track, and the dump form's preview of a file not yet uploaded decodes it with the same function, at 8 kHz in an OfflineAudioContext. Scrubber replaces Waveform and SeekBar: a role="slider" that seeks where a pointer lets go, follows a drag (the clock shows where it is held), takes arrows, Home and End, and draws the bars as one path with the played part clipped by a nested svg, so it moves smoothly and a timeupdate redraws two attributes instead of 150 rects; audio without a waveform, and video, get a plain bar. MediaPlayer reads whether it plays from the element's events alone, so a click no longer reports the change twice, its handles for PlayerProvider are stable callbacks instead of refs to refs, the drag no longer reattaches every listener, and the <source> children that src made dead are gone, with the mime prop. The file, thumbnail and waveform routes resolve their dump, token and caching through lib/dump-media.ts, and what is generated from an upload is named and removed in lib/ffmpeg.ts, the custom thumbnail by dump-service's customThumbnailPath, instead of paths spelled out in each. The test setup's play() and pause() fire their events as a browser does. 277 backend tests and 684 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in the app with a real FLAC on its dump page and in the global player at 1280 and 390px, seeking by keyboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:34:22 +00:00

196 lines
5.5 KiB
TypeScript

// Runs before every test file: DOM matchers, the English catalog, and the
// browser APIs jsdom leaves out that the app reaches for at render time.
import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { afterEach, beforeEach, vi } from "vitest";
import { i18n } from "@lingui/core";
import { messages } from "../../locales/en.po";
import { FakeWebSocket } from "./ws.ts";
import { resetFetch } from "./fetch.ts";
// Lazy chunks and the app's own debounces (a link preview waits 600ms) make
// the default 1s too tight once every file runs in parallel.
configure({ asyncUtilTimeout: 4000 });
i18n.load("en", messages);
i18n.activate("en");
function rectAt(top: number): DOMRectReadOnly {
return {
x: 0,
y: top,
top,
right: 0,
bottom: top,
left: 0,
width: 0,
height: 0,
toJSON() {
return this;
},
};
}
class FakeIntersectionObserver implements IntersectionObserver {
static instances: FakeIntersectionObserver[] = [];
readonly root = null;
readonly rootMargin = "0px";
readonly scrollMargin = "0px";
readonly thresholds = [0];
/**
* Whether observed elements are on screen. When set, a new observer reports
* its element as intersecting right after observe(), as a browser's does —
* which is what re-triggers a sentinel that never left the view.
*/
static inView = false;
elements = new Set<Element>();
callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
FakeIntersectionObserver.instances.push(this);
}
observe(el: Element) {
this.elements.add(el);
if (!FakeIntersectionObserver.inView) return;
setTimeout(() => {
if (!this.elements.has(el)) return;
this.callback([this.entry(el, true, 0)], this);
});
}
/**
* Reports the observed elements in or out of view, `top` px down the
* viewport, as a browser would on a scroll.
*/
report(isIntersecting: boolean, top = 0) {
const targets = this.elements.size ? [...this.elements] : [document.body];
this.callback(
targets.map((el) => this.entry(el, isIntersecting, top)),
this,
);
}
private entry(
target: Element,
isIntersecting: boolean,
top: number,
): IntersectionObserverEntry {
return {
target,
isIntersecting,
intersectionRatio: isIntersecting ? 1 : 0,
boundingClientRect: rectAt(top),
intersectionRect: rectAt(top),
rootBounds: null,
time: performance.now(),
};
}
unobserve(el: Element) {
this.elements.delete(el);
}
disconnect() {
this.elements.clear();
}
takeRecords() {
return [];
}
}
class FakeResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
Object.assign(globalThis, {
IntersectionObserver: FakeIntersectionObserver,
ResizeObserver: FakeResizeObserver,
});
if (!globalThis.matchMedia) {
globalThis.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}) as MediaQueryList;
}
Element.prototype.scrollIntoView ??= function () {};
// An animation that has already finished: the app only cancels them or waits
// for them to end.
class FakeAnimation extends EventTarget implements Animation {
currentTime: CSSNumberish | null = null;
effect: AnimationEffect | null = null;
readonly finished: Promise<Animation> = Promise.resolve(this);
readonly ready: Promise<Animation> = Promise.resolve(this);
id = "";
oncancel = null;
onfinish = null;
onremove = null;
readonly overallProgress = 1;
readonly pending = false;
readonly playState = "finished";
playbackRate = 1;
readonly replaceState = "active";
startTime: CSSNumberish | null = null;
timeline: AnimationTimeline | null = null;
cancel() {}
commitStyles() {}
finish() {}
pause() {}
persist() {}
play() {}
reverse() {}
updatePlaybackRate() {}
}
Element.prototype.animate ??= () => new FakeAnimation();
Element.prototype.getAnimations ??= () => [];
globalThis.scrollTo = () => {};
// jsdom plays nothing. These keep `paused` and fire `play` and `pause` as a
// browser does, which the players' state follows.
const playingMedia = new WeakSet<HTMLMediaElement>();
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
configurable: true,
get(this: HTMLMediaElement) {
return !playingMedia.has(this);
},
});
HTMLMediaElement.prototype.play = function (this: HTMLMediaElement) {
if (!playingMedia.has(this)) {
playingMedia.add(this);
this.dispatchEvent(new Event("play"));
}
return Promise.resolve();
};
HTMLMediaElement.prototype.pause = function (this: HTMLMediaElement) {
if (playingMedia.delete(this)) this.dispatchEvent(new Event("pause"));
};
HTMLMediaElement.prototype.load = function () {};
// The emoji picker probes emoji support on a canvas; jsdom has none.
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
value: () => null,
});
// jsdom has no layout; execCommand("insertText") is how the editor types.
document.execCommand ??= () => false;
beforeEach(() => {
resetFetch();
FakeWebSocket.reset();
vi.stubGlobal("WebSocket", FakeWebSocket);
FakeIntersectionObserver.instances = [];
FakeIntersectionObserver.inView = false;
});
afterEach(() => {
cleanup();
localStorage.clear();
sessionStorage.clear();
document.documentElement.removeAttribute("style");
});
export { FakeIntersectionObserver };