Files
siftlode/frontend/src/lib/stylesheetRetry.dom.test.ts
T
peter ae775b383c fix(boot): a stylesheet lost on the way is asked for again
A lost stylesheet left the page unstyled until a reload, and every e2e step that finds elements by
testid still passed on it. Only axe noticed: the one-off target-size failure on eight scattered
controls. Losing the main stylesheet on purpose gives those same eight.
2026-11-03 23:32:40 +01:00

106 lines
3.2 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clear, events } from "./log";
import { retryLostStylesheets } from "./stylesheetRetry";
const LOADED = { cssRules: [{}] };
// A lost stylesheet still has a `sheet`, and reading its rules throws (measured: Chromium, WebKit).
const LOST = {
get cssRules(): never {
throw new DOMException("Cannot access rules", "SecurityError");
},
};
function link(href: string, loaded: boolean, sheet: unknown = loaded ? LOADED : LOST) {
const el = document.createElement("link");
el.rel = "stylesheet";
el.href = href;
el.setAttribute("crossorigin", "");
Object.defineProperty(el, "sheet", { value: sheet, configurable: true });
document.head.append(el);
return el;
}
const links = () => [...document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
const logged = () => events().map((e) => e.event);
describe("retryLostStylesheets", () => {
beforeEach(() => {
vi.useFakeTimers();
clear();
});
afterEach(() => {
vi.useRealTimers();
document.head.innerHTML = "";
});
it("leaves a loaded stylesheet alone", () => {
const fine = link("/a.css", true);
retryLostStylesheets();
vi.runAllTimers();
expect(links()).toEqual([fine]);
expect(logged()).toEqual([]);
});
it("asks again for a lost one, in its place, keeping its attributes", () => {
const first = link("/a.css", true);
const lost = link("/b.css", false);
const last = link("/c.css", true);
retryLostStylesheets();
vi.advanceTimersByTime(0);
const [a, b, c] = links();
expect(a).toBe(first);
expect(b).not.toBe(lost);
expect(b!.getAttribute("href")).toBe("/b.css");
expect(b!.hasAttribute("crossorigin")).toBe(true);
expect(c).toBe(last);
expect(logged()).toEqual(["app.stylesheet_lost"]);
});
it.each([
["no sheet at all", null],
["an empty sheet", { cssRules: [] }],
])("takes a link with %s for lost too", (_, sheet) => {
const lost = link("/a.css", false, sheet);
retryLostStylesheets();
vi.advanceTimersByTime(0);
expect(links()[0]).not.toBe(lost);
});
it("says so once the new request has loaded", () => {
link("/a.css", false);
retryLostStylesheets();
vi.advanceTimersByTime(0);
links()[0]!.dispatchEvent(new Event("load"));
vi.runAllTimers();
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_recovered"]);
});
it("waits longer each time and then gives up, reporting it", () => {
link("/a.css", false);
retryLostStylesheets();
const fail = () => links()[0]!.dispatchEvent(new Event("error"));
vi.advanceTimersByTime(0);
const second = links()[0];
fail();
vi.advanceTimersByTime(999);
expect(links()[0]).toBe(second);
vi.advanceTimersByTime(1);
const third = links()[0];
expect(third).not.toBe(second);
fail();
vi.advanceTimersByTime(2999);
expect(links()[0]).toBe(third);
vi.advanceTimersByTime(1);
const fourth = links()[0];
expect(fourth).not.toBe(third);
fail();
vi.runAllTimers();
expect(links()[0]).toBe(fourth);
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_gave_up"]);
});
});