196 lines
5.5 KiB
TypeScript
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 };
|