// 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(); 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 = Promise.resolve(this); readonly ready: Promise = 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(); 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 };