From aa67e90dec890779eec53abb72c0dbf53853ddfe Mon Sep 17 00:00:00 2001 From: Babissimo Date: Wed, 23 Sep 2026 17:58:52 +0100 Subject: [PATCH 1/3] Give every dashboard test jsdom's storage, whatever the Node Node 25 and later define localStorage and sessionStorage globals of their own (localStorage is undefined without --localstorage-file), and in vitest's jsdom environment they hide jsdom's. Eight test files worked around it with their own in-memory Storage, each a copy of the last, and LiveAircraftMap.test.tsx used the ambient one and failed its beforeEach on any local Node 25+ while CI's Node 20 passed. One setup file now installs jsdom's own storage on window before collection and again before every test, emptying it each time, so every test sees the same implementation on every Node, and a test that swaps in a throwing storage cannot leak it into the next. The per-file copies go, and the tests seed and read window.localStorage directly. jsdom's storage rather than a hand-written Map keeps the Storage semantics real. Closes the Node 25+ half of ClickUp 123zgec36re; part of 2.4 (123zgec4mxr). Co-Authored-By: Claude Opus 5.5 --- .../src/pages/map/LiveAircraftMap.test.tsx | 1 - dashboard/src/test/appearanceSwitch.test.tsx | 19 ++------ dashboard/src/test/mapShell.test.tsx | 23 ++-------- dashboard/src/test/mapThemeBinding.test.tsx | 16 +------ dashboard/src/test/setupStorage.ts | 42 +++++++++++++++++ dashboard/src/test/sidebarCollapse.test.tsx | 29 +++--------- dashboard/src/test/signOutLanding.test.tsx | 19 ++------ dashboard/src/test/signedOutChrome.test.tsx | 25 +++-------- dashboard/src/test/theme.test.tsx | 41 +++-------------- dashboard/src/test/usePersistedState.test.tsx | 45 +++++-------------- dashboard/vite.config.js | 2 +- 11 files changed, 84 insertions(+), 178 deletions(-) create mode 100644 dashboard/src/test/setupStorage.ts diff --git a/dashboard/src/pages/map/LiveAircraftMap.test.tsx b/dashboard/src/pages/map/LiveAircraftMap.test.tsx index ee18994a7..5354c6ee8 100644 --- a/dashboard/src/pages/map/LiveAircraftMap.test.tsx +++ b/dashboard/src/pages/map/LiveAircraftMap.test.tsx @@ -50,7 +50,6 @@ class Socket { beforeEach(() => { owner.user = SIGNED_IN; owner.syntheticFleet = false; - localStorage.clear(); window.history.replaceState(null, "", "/"); Socket.instances = []; vi.stubGlobal("WebSocket", Socket); diff --git a/dashboard/src/test/appearanceSwitch.test.tsx b/dashboard/src/test/appearanceSwitch.test.tsx index 4978fe26a..231ae944c 100644 --- a/dashboard/src/test/appearanceSwitch.test.tsx +++ b/dashboard/src/test/appearanceSwitch.test.tsx @@ -10,21 +10,8 @@ vi.mock("../context/AuthContext", () => ({ useAuth: () => ({ user: state.user, loading: false, logout: vi.fn(async () => ({ redirected: false })) }), })); -/** As in theme.test.tsx: stubbed rather than borrowed, because jsdom has no - * matchMedia and Node 20 and 26 disagree about window.localStorage. */ -function stubBrowser() { - const store = new Map(); - Object.defineProperty(window, "localStorage", { - configurable: true, - value: { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, String(v)), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - key: () => null, - length: 0, - }, - }); +/** jsdom has no matchMedia. */ +function stubMatchMedia() { window.matchMedia = vi.fn().mockReturnValue({ matches: false, media: "(prefers-color-scheme: dark)", @@ -51,7 +38,7 @@ const CALLERS = [ ]; beforeEach(() => { - stubBrowser(); + stubMatchMedia(); document.documentElement.removeAttribute("data-theme"); }); diff --git a/dashboard/src/test/mapShell.test.tsx b/dashboard/src/test/mapShell.test.tsx index e35b7ad83..acf048176 100644 --- a/dashboard/src/test/mapShell.test.tsx +++ b/dashboard/src/test/mapShell.test.tsx @@ -17,29 +17,14 @@ const state = vi.hoisted(() => ({ // house pattern (signedOutChrome.test.tsx) mocks it rather than mounting one. vi.mock("../context/AuthContext", () => ({ useAuth: () => state.auth })); -/** Stubbed rather than borrowed, as in theme.test.tsx: jsdom has no matchMedia, - * and Node 20 and 26 disagree about whose window.localStorage is reached. */ -function stubBrowser(seed: Record = {}) { - const store = new Map(Object.entries(seed)); - Object.defineProperty(window, "localStorage", { - configurable: true, - writable: true, - value: { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, String(v)), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - key: (i: number) => [...store.keys()][i] ?? null, - get length() { return store.size; }, - }, - }); +/** jsdom has no matchMedia. */ +function stubMatchMedia() { window.matchMedia = vi.fn().mockReturnValue({ matches: false, media: "(prefers-color-scheme: dark)", addEventListener: () => {}, removeEventListener: () => {}, }) as unknown as typeof window.matchMedia; - return store; } function renderAt(path: string, isAdmin = false) { @@ -53,7 +38,7 @@ function renderAt(path: string, isAdmin = false) { } describe("the map's content pane", () => { - beforeEach(() => stubBrowser()); + beforeEach(() => stubMatchMedia()); it("is flush on the map route", () => { const { container } = renderAt("/map"); @@ -81,7 +66,7 @@ describe("the map's content pane", () => { }); describe("the header's name for a page", () => { - beforeEach(() => stubBrowser()); + beforeEach(() => stubMatchMedia()); // Every other page is named by its first path segment and owns whatever // nests under it. /sim is the exception: the page beneath it configures the diff --git a/dashboard/src/test/mapThemeBinding.test.tsx b/dashboard/src/test/mapThemeBinding.test.tsx index a89ee8b6d..0b3835261 100644 --- a/dashboard/src/test/mapThemeBinding.test.tsx +++ b/dashboard/src/test/mapThemeBinding.test.tsx @@ -5,22 +5,10 @@ import { MapThemeProvider, usePalette } from "../pages/map/useMapTheme"; import MapSurface from "../pages/map/MapSurface"; import { PALETTES } from "../pages/map/mapPalette"; -/** Stubbed rather than borrowed, as in theme.test.tsx. The media query keeps +/** Seeds storage and stubs matchMedia, which jsdom lacks. The media query keeps * its listeners, so the returned function can flip the OS preference. */ function stubBrowser(seed: Record, prefersDark: boolean) { - const store = new Map(Object.entries(seed)); - Object.defineProperty(window, "localStorage", { - configurable: true, - writable: true, - value: { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, String(v)), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - key: (i: number) => [...store.keys()][i] ?? null, - get length() { return store.size; }, - }, - }); + for (const [k, v] of Object.entries(seed)) window.localStorage.setItem(k, v); const listeners = new Set<(e: MediaQueryListEvent) => void>(); const mql = { matches: prefersDark, diff --git a/dashboard/src/test/setupStorage.ts b/dashboard/src/test/setupStorage.ts new file mode 100644 index 000000000..f2ad94a14 --- /dev/null +++ b/dashboard/src/test/setupStorage.ts @@ -0,0 +1,42 @@ +import { beforeEach } from "vitest"; + +// Node 25 and later define storage globals of their own (localStorage is +// undefined without --localstorage-file), and they hide jsdom's. Installing +// jsdom's explicitly gives every Node the same storage, from module scope on. +// Every test starts with it empty and unstubbed, so seed or stub it in the test +// or a beforeEach, never at module or describe scope or in beforeAll. + +// Read off globalThis rather than declared, so no other file sees a `jsdom` global. +const dom = (globalThis as { jsdom?: { window: Window } }).jsdom; + +// jsdom names itself in its user agent, which tells a jsdom environment that no +// longer exposes its instance apart from a file that opted into another one. +if (!dom && typeof navigator !== "undefined" && /\bjsdom\//.test(navigator.userAgent)) { + throw new Error( + "Vitest's jsdom environment no longer sets globalThis.jsdom, so setupStorage.ts cannot install jsdom's storage", + ); +} + +// A file that opts into another environment has no jsdom to borrow from. +if (dom) { + // Held here because under a VM pool `window` is jsdom's own, so a test's stub + // would otherwise replace the only reference to the real storage. + const stores = [ + ["localStorage", dom.window.localStorage], + ["sessionStorage", dom.window.sessionStorage], + ] as const; + + const install = () => { + // Not writable, as in a browser, where assigning to either throws. + for (const [name, storage] of stores) { + Object.defineProperty(window, name, { value: storage, configurable: true, writable: false }); + } + }; + + install(); + beforeEach(() => { + // Reinstalled as well as emptied, since a test may swap in a storage that throws. + install(); + for (const [, storage] of stores) storage.clear(); + }); +} diff --git a/dashboard/src/test/sidebarCollapse.test.tsx b/dashboard/src/test/sidebarCollapse.test.tsx index 454e1fa7c..2feb273b0 100644 --- a/dashboard/src/test/sidebarCollapse.test.tsx +++ b/dashboard/src/test/sidebarCollapse.test.tsx @@ -20,29 +20,14 @@ const state = vi.hoisted(() => ({ // house pattern (signedOutChrome.test.tsx) mocks it rather than mounting one. vi.mock("../context/AuthContext", () => ({ useAuth: () => state.auth })); -/** Stubbed rather than borrowed, as in theme.test.tsx: jsdom has no matchMedia, - * and Node 20 and 26 disagree about whose window.localStorage is reached. */ -function stubBrowser(seed: Record = {}) { - const store = new Map(Object.entries(seed)); - Object.defineProperty(window, "localStorage", { - configurable: true, - writable: true, - value: { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, String(v)), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - key: (i: number) => [...store.keys()][i] ?? null, - get length() { return store.size; }, - }, - }); +/** jsdom has no matchMedia. */ +function stubMatchMedia() { window.matchMedia = vi.fn().mockReturnValue({ matches: false, media: "(prefers-color-scheme: dark)", addEventListener: () => {}, removeEventListener: () => {}, }) as unknown as typeof window.matchMedia; - return store; } function renderAt(path: string, page: React.ReactNode =
page
) { @@ -57,7 +42,7 @@ function renderAt(path: string, page: React.ReactNode =
page
) { } describe("sidebar collapse", () => { - beforeEach(() => stubBrowser()); + beforeEach(() => stubMatchMedia()); it("starts expanded on an ordinary page", () => { const { container } = renderAt("/"); @@ -70,21 +55,19 @@ describe("sidebar collapse", () => { }); it("toggles and remembers the choice", () => { - const store = stubBrowser(); const { container } = renderAt("/"); fireEvent.click(screen.getByRole("button", { name: /collapse sidebar/i })); expect(container.querySelector(".dashboard.sidebar-collapsed")).not.toBeNull(); - expect(store.get("retina.sidebarCollapsed")).toBe("true"); + expect(window.localStorage.getItem("retina.sidebarCollapsed")).toBe("true"); }); it("an explicit choice beats the map route's default", () => { - stubBrowser({ "retina.sidebarCollapsed": "false" }); + window.localStorage.setItem("retina.sidebarCollapsed", "false"); const { container } = renderAt("/map"); expect(container.querySelector(".dashboard.sidebar-collapsed")).toBeNull(); }); it("survives storage that throws", () => { - stubBrowser(); const boom = () => { throw new Error("storage unavailable"); }; Object.defineProperty(window, "localStorage", { value: { getItem: boom, setItem: boom, removeItem: boom, clear: boom, key: boom, length: 0 }, @@ -99,7 +82,7 @@ describe("sidebar collapse", () => { * the header's Menu button brings it over the page. jsdom applies no media * query, so what is asserted here is the state the stylesheet keys off. */ describe("the sidebar as a drawer on a narrow screen", () => { - beforeEach(() => stubBrowser()); + beforeEach(() => stubMatchMedia()); const menuButton = () => screen.getByRole("button", { name: "Menu" }); const isOpen = (container: HTMLElement) => diff --git a/dashboard/src/test/signOutLanding.test.tsx b/dashboard/src/test/signOutLanding.test.tsx index cf597286a..b9bf07a89 100644 --- a/dashboard/src/test/signOutLanding.test.tsx +++ b/dashboard/src/test/signOutLanding.test.tsx @@ -16,21 +16,8 @@ import { ThemeProvider } from "../context/ThemeContext"; * the question is how the cleared identity and the route change meet the guard. */ -/** As in signedOutChrome.test.tsx: jsdom has no matchMedia, and Node 20 and 26 - * disagree about window.localStorage. */ -function stubBrowser() { - const store = new Map(); - Object.defineProperty(window, "localStorage", { - configurable: true, - value: { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, String(v)), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - key: () => null, - length: 0, - }, - }); +/** jsdom has no matchMedia. */ +function stubMatchMedia() { window.matchMedia = vi.fn().mockReturnValue({ matches: false, media: "(prefers-color-scheme: dark)", @@ -101,7 +88,7 @@ async function signOut() { describe("signing out", () => { beforeEach(() => { - stubBrowser(); + stubMatchMedia(); stubServer(); signInCard.mockClear(); }); diff --git a/dashboard/src/test/signedOutChrome.test.tsx b/dashboard/src/test/signedOutChrome.test.tsx index 7044df3f0..e2debeb9d 100644 --- a/dashboard/src/test/signedOutChrome.test.tsx +++ b/dashboard/src/test/signedOutChrome.test.tsx @@ -18,21 +18,8 @@ const state = vi.hoisted(() => ({ vi.mock("../context/AuthContext", () => ({ useAuth: () => state.auth })); -/** As in theme.test.tsx: stubbed rather than borrowed, because jsdom has no - * matchMedia and Node 20 and 26 disagree about window.localStorage. */ -function stubBrowser() { - const store = new Map(); - Object.defineProperty(window, "localStorage", { - configurable: true, - value: { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, String(v)), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - key: () => null, - length: 0, - }, - }); +/** jsdom has no matchMedia. */ +function stubMatchMedia() { window.matchMedia = vi.fn().mockReturnValue({ matches: false, media: "(prefers-color-scheme: dark)", @@ -151,7 +138,7 @@ describe("the sidebar shown to a caller with no session", () => { describe("the header shown to a caller with no session", () => { beforeEach(() => { - stubBrowser(); + stubMatchMedia(); document.documentElement.removeAttribute("data-theme"); }); @@ -195,7 +182,7 @@ describe("signing in from an open page and changing one's mind", () => { } it("returns to the page the sign-in link was on, as it was left", () => { - stubBrowser(); + stubMatchMedia(); render( @@ -217,7 +204,7 @@ describe("signing in from an open page and changing one's mind", () => { }); it("returns there from a greyed-out nav entry too", () => { - stubBrowser(); + stubMatchMedia(); render( @@ -249,7 +236,7 @@ describe("signing in from an open page and going through with it", () => { /** Open `/leaderboard` drawing `chrome`, click `link`, and ask for a sign-in * link; returns what the request asked the server to mail. */ async function requestFrom(chrome: React.ReactNode, link: string) { - stubBrowser(); + stubMatchMedia(); vi.stubGlobal( "fetch", vi.fn(async () => new Response(JSON.stringify({ status: "accepted" }), { status: 202 })) diff --git a/dashboard/src/test/theme.test.tsx b/dashboard/src/test/theme.test.tsx index 2efade545..4ee868de3 100644 --- a/dashboard/src/test/theme.test.tsx +++ b/dashboard/src/test/theme.test.tsx @@ -3,32 +3,8 @@ import { render, screen, act } from "@testing-library/react"; import { ThemeProvider, useTheme, THEME_KEY } from "../context/ThemeContext"; import themeBoot from "../../public/theme-boot.js?raw"; -/** - * Both browser APIs under test are stubbed rather than borrowed from the - * environment. matchMedia because jsdom has none; localStorage because Node 20 - * and Node 26 disagree about whose implementation `window.localStorage` reaches - * (26 shadows jsdom's with its own, which throws without `--localstorage-file`), - * and a suite that passes on CI's Node and fails on a developer's is worse than - * no suite. - */ -function stubStorage(seed: Record = {}) { - const store = new Map(Object.entries(seed)); - const storage = { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, String(v)), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - key: (i: number) => [...store.keys()][i] ?? null, - get length() { - return store.size; - }, - }; - Object.defineProperty(window, "localStorage", { value: storage, configurable: true, writable: true }); - return storage; -} - -/** Replace it with one that throws on every access, as private browsing and a - * full quota both do. */ +/** Replace localStorage with one that throws on every access, as private + * browsing and a full quota both do. */ function stubBrokenStorage() { const boom = () => { throw new Error("storage unavailable"); @@ -40,8 +16,8 @@ function stubBrokenStorage() { }); } -/** Install a matchMedia whose match state the test controls, keeping the - * listeners so a test can fire an OS-level theme change. */ +/** jsdom has no matchMedia. Install one whose match state the test controls, + * keeping the listeners so a test can fire an OS-level theme change. */ function stubMatchMedia(prefersDark: boolean) { const listeners = new Set<(e: MediaQueryListEvent) => void>(); const mql = { @@ -78,10 +54,7 @@ function Probe() { const renderProbe = () => render(); const attr = () => document.documentElement.getAttribute("data-theme"); -let storage: ReturnType; - beforeEach(() => { - storage = stubStorage(); document.documentElement.removeAttribute("data-theme"); stubMatchMedia(false); }); @@ -97,7 +70,7 @@ describe("theme preference", () => { }); it("restores a stored preference", () => { - storage.setItem(THEME_KEY, "dark"); + window.localStorage.setItem(THEME_KEY, "dark"); renderProbe(); expect(screen.getByTestId("preference")).toHaveTextContent("dark"); expect(screen.getByTestId("resolved")).toHaveTextContent("dark"); @@ -106,13 +79,13 @@ describe("theme preference", () => { it("persists a change", () => { renderProbe(); act(() => screen.getByText("dark").click()); - expect(storage.getItem(THEME_KEY)).toBe("dark"); + expect(window.localStorage.getItem(THEME_KEY)).toBe("dark"); }); // A value from a future version, a hand-edited one, or another app sharing // the origin must not leave the console rendering a theme that has no tokens. it("falls back to system on a value it does not recognise", () => { - storage.setItem(THEME_KEY, "solarized"); + window.localStorage.setItem(THEME_KEY, "solarized"); renderProbe(); expect(screen.getByTestId("preference")).toHaveTextContent("system"); }); diff --git a/dashboard/src/test/usePersistedState.test.tsx b/dashboard/src/test/usePersistedState.test.tsx index c9d858a7c..445c097d9 100644 --- a/dashboard/src/test/usePersistedState.test.tsx +++ b/dashboard/src/test/usePersistedState.test.tsx @@ -1,27 +1,8 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { act, renderHook } from "@testing-library/react"; import { usePersistedState } from "../hooks/usePersistedState"; import { readStored, writeStored } from "../utils/storage"; -/** Stubbed as in sidebarCollapse.test.tsx: Node 20 and 26 disagree about whose - * window.localStorage is reached. */ -function stubStorage(seed: Record = {}) { - const store = new Map(Object.entries(seed)); - Object.defineProperty(window, "localStorage", { - configurable: true, - writable: true, - value: { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, String(v)), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - key: (i: number) => [...store.keys()][i] ?? null, - get length() { return store.size; }, - }, - }); - return store; -} - function stubBrokenStorage() { const refuse = () => { throw new Error("SecurityError"); @@ -34,41 +15,36 @@ function stubBrokenStorage() { } describe("usePersistedState", () => { - let store: Map; - beforeEach(() => { - store = stubStorage(); - }); - it("starts from the initial value and keeps nothing until it changes", () => { const { result } = renderHook(() => usePersistedState("retina.map.x", 1)); expect(result.current[0]).toBe(1); - expect(store.has("retina.map.x")).toBe(false); + expect(window.localStorage.getItem("retina.map.x")).toBeNull(); act(() => result.current[1](2)); - expect(store.get("retina.map.x")).toBe("2"); + expect(window.localStorage.getItem("retina.map.x")).toBe("2"); }); it("reads what it stored", () => { - store.set("retina.map.x", JSON.stringify({ a: 1 })); + window.localStorage.setItem("retina.map.x", JSON.stringify({ a: 1 })); const { result } = renderHook(() => usePersistedState("retina.map.x", {})); expect(result.current[0]).toEqual({ a: 1 }); }); it("falls back to the legacy key and copies its value across", () => { - store.set("tf.x", "false"); + window.localStorage.setItem("tf.x", "false"); const { result } = renderHook(() => usePersistedState("retina.map.x", true, "tf.x")); expect(result.current[0]).toBe(false); - expect(store.get("retina.map.x")).toBe("false"); + expect(window.localStorage.getItem("retina.map.x")).toBe("false"); }); it("prefers the current key over the legacy one", () => { - store.set("retina.map.x", "true"); - store.set("tf.x", "false"); + window.localStorage.setItem("retina.map.x", "true"); + window.localStorage.setItem("tf.x", "false"); const { result } = renderHook(() => usePersistedState("retina.map.x", false, "tf.x")); expect(result.current[0]).toBe(true); }); it("uses the initial value for a stored value it cannot parse", () => { - store.set("retina.map.x", "{not json"); + window.localStorage.setItem("retina.map.x", "{not json"); const { result } = renderHook(() => usePersistedState("retina.map.x", 7)); expect(result.current[0]).toBe(7); }); @@ -84,9 +60,8 @@ describe("usePersistedState", () => { describe("readStored and writeStored", () => { it("round-trip a raw string", () => { - const store = stubStorage(); writeStored("retina.theme", "dark"); - expect(store.get("retina.theme")).toBe("dark"); + expect(window.localStorage.getItem("retina.theme")).toBe("dark"); expect(readStored("retina.theme")).toBe("dark"); }); diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js index 3c698b637..bc4974df4 100644 --- a/dashboard/vite.config.js +++ b/dashboard/vite.config.js @@ -20,7 +20,7 @@ export default defineConfig({ // So `App.css?raw` reaches the theme tests as its text. Vitest stubs every // CSS import with an empty string by default, the raw query included. css: true, - setupFiles: ["@testing-library/jest-dom/vitest"], + setupFiles: ["@testing-library/jest-dom/vitest", "./src/test/setupStorage.ts"], alias: { "@edsc/timeline": fileURLToPath(new URL("./src/test/stubs/edscTimeline.tsx", import.meta.url)), }, From 07c0b6d07fb7c1bb55e4b9847ec0443e70b5374e Mon Sep 17 00:00:00 2001 From: Babissimo Date: Wed, 23 Sep 2026 17:59:27 +0100 Subject: [PATCH 2/3] Run every front-end unit suite, and the whole front-end check, from the repo root There was no root Vitest config and no root test script. A bare vitest from the root collected e2e's Playwright specs and ran the dashboard tests without dashboard/vite.config.js (no jsdom, no globals, no jest-dom, no @edsc/timeline alias), which reads as about eleven failing files. The only documented way to run everything was a chained one-liner in ONBOARDING. A root vitest.config.js now runs every workspace with a test script as a project under its own config, derived from package.json so a new workspace joins without an edit here. The root package.json gains test, typecheck, lint, build and check; check runs the four in CI's order, so a local pass covers what web-build runs. It is the npm half of the just check recipe 2.2 adds. CI keeps running each workspace's scripts from its matrix, so nothing there changes. ClickUp 2.4 (123zgec4mxr). Co-Authored-By: Claude Opus 5.5 --- ONBOARDING.md | 8 ++++++-- package.json | 7 +++++++ vitest.config.js | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 vitest.config.js diff --git a/ONBOARDING.md b/ONBOARDING.md index 93d4ae7f2..3e25a8ab4 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -200,8 +200,12 @@ libs are editable installs of that checkout's submodules. just test # or one file: just test tests/test_health_routes.py just test-ci -# every workspace; -w dashboard (or -w packages/shared, -w e2e) for one -npm run test --workspaces --if-present && npm run typecheck --workspaces && npm run lint --workspaces --if-present +# every workspace's lint, typecheck, unit tests and build, from the repo root +npm run check +# the unit tests alone, each workspace's under its own config +npm test +# one workspace's own script, as CI runs it +npm run typecheck -w e2e # the browser suite, against staging (local and prod are the other two targets) npm run test:e2e:staging -w e2e diff --git a/package.json b/package.json index 2f1ae758d..2a9ca37c7 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,13 @@ "packages/shared", "e2e" ], + "scripts": { + "test": "vitest run", + "typecheck": "npm run typecheck --workspaces", + "lint": "npm run lint --workspaces --if-present", + "build": "npm run build --workspaces --if-present", + "check": "npm run lint && npm run typecheck && npm test && npm run build" + }, "dependencies": { "classnames": "^2.5.1", "leaflet": "^1.9.4", diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 000000000..3eb020599 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const root = fileURLToPath(new URL(".", import.meta.url)); +const manifest = (dir) => JSON.parse(readFileSync(`${root}${dir}/package.json`, "utf8")); + +// Every workspace whose own `test` script runs Vitest, each under its own +// config (environment, setup files, aliases). e2e has none: its specs are +// Playwright's. +export default defineConfig({ + test: { + // Project paths resolve against this, which is otherwise the cwd. + root, + projects: manifest(".").workspaces.filter((dir) => /\bvitest\b/.test(manifest(dir).scripts?.test ?? "")), + }, +}); From 128adabe896501c7a716b04573cea9dca102776a Mon Sep 17 00:00:00 2001 From: Babissimo Date: Fri, 25 Sep 2026 12:13:47 +0100 Subject: [PATCH 3/3] Share one matchMedia stub across the dashboard tests Ten test files carried their own matchMedia stub, each a near copy of the last: five identical, two keeping listeners so a test could flip the OS preference, three inline in a render helper, and two in LiveAircraftMap installed through vi.stubGlobal. A fix to one (a listener that is never removed, a missing media field) reached none of the others. dashboard/src/test/matchMedia.ts now holds the one stub, with the listener-keeping behaviour the theme tests need, and every file imports it. It installs through vi.stubGlobal so LiveAircraftMap's unstubAllGlobals teardown still removes it, and its implementation is given to vi.fn directly so a vi.resetAllMocks cannot empty it. It stays a helper the tests call rather than a default in the shared setup file. ThemeContext treats a missing matchMedia as a real case (jsdom, any non-browser render), and a test that does not stub one exercises that path; a setup-file default would take it away from every test at once. Raised by the review on #596. ClickUp 2.4 (123zgec4mxr). Co-Authored-By: Claude Opus 5.5 --- .../src/pages/map/LiveAircraftMap.test.tsx | 15 ++-------- dashboard/src/test/appearanceSwitch.test.tsx | 11 +------ dashboard/src/test/errorBoundary.test.tsx | 7 ++--- dashboard/src/test/frontDoor.test.tsx | 7 ++--- dashboard/src/test/mapShell.test.tsx | 11 +------ dashboard/src/test/mapThemeBinding.test.tsx | 23 ++++----------- dashboard/src/test/matchMedia.ts | 29 +++++++++++++++++++ dashboard/src/test/physicsRoute.test.tsx | 7 ++--- dashboard/src/test/sidebarCollapse.test.tsx | 11 +------ dashboard/src/test/signOutLanding.test.tsx | 11 +------ dashboard/src/test/signedOutChrome.test.tsx | 11 +------ dashboard/src/test/theme.test.tsx | 24 +-------------- 12 files changed, 49 insertions(+), 118 deletions(-) create mode 100644 dashboard/src/test/matchMedia.ts diff --git a/dashboard/src/pages/map/LiveAircraftMap.test.tsx b/dashboard/src/pages/map/LiveAircraftMap.test.tsx index 5354c6ee8..09d1fdd95 100644 --- a/dashboard/src/pages/map/LiveAircraftMap.test.tsx +++ b/dashboard/src/pages/map/LiveAircraftMap.test.tsx @@ -4,6 +4,7 @@ import { Children, isValidElement, type ReactElement } from "react"; import LiveAircraftMap from "./LiveAircraftMap"; import { MapThemeProvider } from "./useMapTheme"; import { ThemeProvider, useTheme } from "../../context/ThemeContext"; +import { stubMatchMedia } from "../../test/matchMedia"; // Leaflet layers need a browser layout. Keep the real map controller, toolbar, // playback and feed lifecycle; geometry and layer helpers have their own tests. @@ -203,12 +204,7 @@ it("draws its live stats in the aircraft list, not floating over the map", () => it("moves its default basemap with the console's theme", () => { // Console starts light, so the default basemap is Positron. localStorage.setItem("retina.theme", "light"); - vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ - matches: false, - media: "(prefers-color-scheme: dark)", - addEventListener: () => {}, - removeEventListener: () => {}, - })); + stubMatchMedia(); function Flip() { const { setPreference } = useTheme(); return ; @@ -227,12 +223,7 @@ function mountUnderStoredTheme(theme: string, tile: string, chosenBy: string, pr localStorage.setItem("retina.theme", theme); localStorage.setItem(`${prefix}tile.theme`, JSON.stringify(tile)); localStorage.setItem(`${prefix}tile.chosenBy`, JSON.stringify(chosenBy)); - vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ - matches: false, - media: "(prefers-color-scheme: dark)", - addEventListener: () => {}, - removeEventListener: () => {}, - })); + stubMatchMedia(); render( diff --git a/dashboard/src/test/appearanceSwitch.test.tsx b/dashboard/src/test/appearanceSwitch.test.tsx index 231ae944c..eab3f9685 100644 --- a/dashboard/src/test/appearanceSwitch.test.tsx +++ b/dashboard/src/test/appearanceSwitch.test.tsx @@ -3,6 +3,7 @@ import { render, screen, act, fireEvent } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import Header from "../components/Header"; import { ThemeProvider } from "../context/ThemeContext"; +import { stubMatchMedia } from "./matchMedia"; const state = vi.hoisted(() => ({ user: null as { name: string; email: string } | null })); @@ -10,16 +11,6 @@ vi.mock("../context/AuthContext", () => ({ useAuth: () => ({ user: state.user, loading: false, logout: vi.fn(async () => ({ redirected: false })) }), })); -/** jsdom has no matchMedia. */ -function stubMatchMedia() { - window.matchMedia = vi.fn().mockReturnValue({ - matches: false, - media: "(prefers-color-scheme: dark)", - addEventListener: () => {}, - removeEventListener: () => {}, - }) as unknown as typeof window.matchMedia; -} - function renderHeader() { render( diff --git a/dashboard/src/test/errorBoundary.test.tsx b/dashboard/src/test/errorBoundary.test.tsx index 7e9cc3f70..0744467af 100644 --- a/dashboard/src/test/errorBoundary.test.tsx +++ b/dashboard/src/test/errorBoundary.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from "react-router-dom"; import App from "../App"; import ErrorBoundary from "../components/ErrorBoundary"; import { ThemeProvider } from "../context/ThemeContext"; +import { stubMatchMedia } from "./matchMedia"; const state = vi.hoisted(() => ({ auth: { @@ -82,11 +83,7 @@ describe("the error fallback", () => { /** The whole console at `at`, under an OS that prefers dark or light. */ function visit(at: string, { prefersDark = false } = {}) { - window.matchMedia = vi.fn().mockReturnValue({ - matches: prefersDark, - addEventListener: () => {}, - removeEventListener: () => {}, - }) as unknown as typeof window.matchMedia; + stubMatchMedia(prefersDark); return render( diff --git a/dashboard/src/test/frontDoor.test.tsx b/dashboard/src/test/frontDoor.test.tsx index a35590803..9ac971a79 100644 --- a/dashboard/src/test/frontDoor.test.tsx +++ b/dashboard/src/test/frontDoor.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import App from "../App"; import { ThemeProvider } from "../context/ThemeContext"; +import { stubMatchMedia } from "./matchMedia"; const state = vi.hoisted(() => ({ auth: { @@ -25,11 +26,7 @@ function Where() { function visit(path: string, user: { name: string; email: string } | null) { state.auth.user = user; - window.matchMedia = vi.fn().mockReturnValue({ - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, - }) as unknown as typeof window.matchMedia; + stubMatchMedia(); render( diff --git a/dashboard/src/test/mapShell.test.tsx b/dashboard/src/test/mapShell.test.tsx index acf048176..89dbcf08c 100644 --- a/dashboard/src/test/mapShell.test.tsx +++ b/dashboard/src/test/mapShell.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from "react-router-dom"; import appSource from "../App.tsx?raw"; import DashboardLayout, { pageTitles } from "../components/DashboardLayout"; import { ThemeProvider } from "../context/ThemeContext"; +import { stubMatchMedia } from "./matchMedia"; const state = vi.hoisted(() => ({ auth: { @@ -17,16 +18,6 @@ const state = vi.hoisted(() => ({ // house pattern (signedOutChrome.test.tsx) mocks it rather than mounting one. vi.mock("../context/AuthContext", () => ({ useAuth: () => state.auth })); -/** jsdom has no matchMedia. */ -function stubMatchMedia() { - window.matchMedia = vi.fn().mockReturnValue({ - matches: false, - media: "(prefers-color-scheme: dark)", - addEventListener: () => {}, - removeEventListener: () => {}, - }) as unknown as typeof window.matchMedia; -} - function renderAt(path: string, isAdmin = false) { return render( diff --git a/dashboard/src/test/mapThemeBinding.test.tsx b/dashboard/src/test/mapThemeBinding.test.tsx index 0b3835261..7658cfa6b 100644 --- a/dashboard/src/test/mapThemeBinding.test.tsx +++ b/dashboard/src/test/mapThemeBinding.test.tsx @@ -1,28 +1,15 @@ -import { describe, it, expect, vi } from "vitest"; -import { act, render } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; import { ThemeProvider } from "../context/ThemeContext"; import { MapThemeProvider, usePalette } from "../pages/map/useMapTheme"; import MapSurface from "../pages/map/MapSurface"; import { PALETTES } from "../pages/map/mapPalette"; +import { stubMatchMedia } from "./matchMedia"; -/** Seeds storage and stubs matchMedia, which jsdom lacks. The media query keeps - * its listeners, so the returned function can flip the OS preference. */ +/** Seeds storage and stubs matchMedia; the returned function flips the OS preference. */ function stubBrowser(seed: Record, prefersDark: boolean) { for (const [k, v] of Object.entries(seed)) window.localStorage.setItem(k, v); - const listeners = new Set<(e: MediaQueryListEvent) => void>(); - const mql = { - matches: prefersDark, - media: "(prefers-color-scheme: dark)", - addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn), - removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn), - }; - window.matchMedia = vi.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia; - return (matches: boolean) => { - mql.matches = matches; - act(() => { - for (const fn of listeners) fn({ matches } as MediaQueryListEvent); - }); - }; + return stubMatchMedia(prefersDark).set; } function Probe({ onPalette }: { onPalette: (n: string) => void }) { diff --git a/dashboard/src/test/matchMedia.ts b/dashboard/src/test/matchMedia.ts new file mode 100644 index 000000000..0088b4dfe --- /dev/null +++ b/dashboard/src/test/matchMedia.ts @@ -0,0 +1,29 @@ +import { act } from "@testing-library/react"; +import { vi } from "vitest"; + +type Listener = (e: MediaQueryListEvent) => void; + +/** jsdom has no matchMedia. Installs one answering every query with the OS + * colour-scheme preference given, until the file ends or its globals are + * unstubbed. Deliberately not in the shared setup: ThemeContext copes with + * there being no matchMedia, and a test that does not stub one exercises that. */ +export function stubMatchMedia(prefersDark = false) { + const listeners = new Set(); + const mql = { + matches: prefersDark, + media: "(prefers-color-scheme: dark)", + addEventListener: (_: string, fn: Listener) => listeners.add(fn), + removeEventListener: (_: string, fn: Listener) => listeners.delete(fn), + }; + vi.stubGlobal("matchMedia", vi.fn(() => mql)); + return { + /** Flip the OS preference the way a real media query would. */ + set(matches: boolean) { + mql.matches = matches; + act(() => { + for (const fn of listeners) fn({ matches } as MediaQueryListEvent); + }); + }, + listenerCount: () => listeners.size, + }; +} diff --git a/dashboard/src/test/physicsRoute.test.tsx b/dashboard/src/test/physicsRoute.test.tsx index 3f0b9a5f2..cfe56614d 100644 --- a/dashboard/src/test/physicsRoute.test.tsx +++ b/dashboard/src/test/physicsRoute.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import Sidebar from "../components/Sidebar"; +import { stubMatchMedia } from "./matchMedia"; type User = { name: string; email: string; role: string; synthetic_fleet?: boolean }; @@ -88,11 +89,7 @@ describe("the app's sidebar", () => { describe("the /sim addresses", () => { async function visit(at: string, opts: { admin: boolean; fleet: boolean; signedIn?: boolean }) { signIn(opts); - window.matchMedia = vi.fn().mockReturnValue({ - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, - }) as unknown as typeof window.matchMedia; + stubMatchMedia(); vi.resetModules(); // All three from the one fresh registry, so App's router and theme // contexts are the ones these providers supply. diff --git a/dashboard/src/test/sidebarCollapse.test.tsx b/dashboard/src/test/sidebarCollapse.test.tsx index 2feb273b0..ea5971d45 100644 --- a/dashboard/src/test/sidebarCollapse.test.tsx +++ b/dashboard/src/test/sidebarCollapse.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter, useNavigate } from "react-router-dom"; import DashboardLayout from "../components/DashboardLayout"; import { ThemeProvider } from "../context/ThemeContext"; import rules from "../App.css?raw"; +import { stubMatchMedia } from "./matchMedia"; // Comments name selectors in prose, and the sweeps below would read them. const sheet = rules.replace(/\/\*[\s\S]*?\*\//g, ""); @@ -20,16 +21,6 @@ const state = vi.hoisted(() => ({ // house pattern (signedOutChrome.test.tsx) mocks it rather than mounting one. vi.mock("../context/AuthContext", () => ({ useAuth: () => state.auth })); -/** jsdom has no matchMedia. */ -function stubMatchMedia() { - window.matchMedia = vi.fn().mockReturnValue({ - matches: false, - media: "(prefers-color-scheme: dark)", - addEventListener: () => {}, - removeEventListener: () => {}, - }) as unknown as typeof window.matchMedia; -} - function renderAt(path: string, page: React.ReactNode =
page
) { // ThemeProvider because DashboardLayout renders Header, which calls useTheme. return render( diff --git a/dashboard/src/test/signOutLanding.test.tsx b/dashboard/src/test/signOutLanding.test.tsx index b9bf07a89..8bb5ed11f 100644 --- a/dashboard/src/test/signOutLanding.test.tsx +++ b/dashboard/src/test/signOutLanding.test.tsx @@ -6,6 +6,7 @@ import Header from "../components/Header"; import RequireAuth from "../components/RequireAuth"; import { AuthProvider } from "../context/AuthContext"; import { ThemeProvider } from "../context/ThemeContext"; +import { stubMatchMedia } from "./matchMedia"; /** * Where signing out leaves a caller. @@ -16,16 +17,6 @@ import { ThemeProvider } from "../context/ThemeContext"; * the question is how the cleared identity and the route change meet the guard. */ -/** jsdom has no matchMedia. */ -function stubMatchMedia() { - window.matchMedia = vi.fn().mockReturnValue({ - matches: false, - media: "(prefers-color-scheme: dark)", - addEventListener: () => {}, - removeEventListener: () => {}, - }) as unknown as typeof window.matchMedia; -} - /** `signOutAnswered` holds the sign-out response back until it settles. */ function stubServer(signOutAnswered: Promise = Promise.resolve()) { const json = (body: unknown) => diff --git a/dashboard/src/test/signedOutChrome.test.tsx b/dashboard/src/test/signedOutChrome.test.tsx index e2debeb9d..65f848fbc 100644 --- a/dashboard/src/test/signedOutChrome.test.tsx +++ b/dashboard/src/test/signedOutChrome.test.tsx @@ -7,6 +7,7 @@ import Header from "../components/Header"; import LoginPage from "../pages/LoginPage"; import { ThemeProvider } from "../context/ThemeContext"; import { PUBLIC_PATHS, isPublicRoute } from "../utils/publicRoutes"; +import { stubMatchMedia } from "./matchMedia"; const state = vi.hoisted(() => ({ auth: { @@ -18,16 +19,6 @@ const state = vi.hoisted(() => ({ vi.mock("../context/AuthContext", () => ({ useAuth: () => state.auth })); -/** jsdom has no matchMedia. */ -function stubMatchMedia() { - window.matchMedia = vi.fn().mockReturnValue({ - matches: false, - media: "(prefers-color-scheme: dark)", - addEventListener: () => {}, - removeEventListener: () => {}, - }) as unknown as typeof window.matchMedia; -} - const signedIn = { name: "Ada", email: "ada@example.com" }; beforeEach(() => { diff --git a/dashboard/src/test/theme.test.tsx b/dashboard/src/test/theme.test.tsx index 4ee868de3..60e153570 100644 --- a/dashboard/src/test/theme.test.tsx +++ b/dashboard/src/test/theme.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, screen, act } from "@testing-library/react"; import { ThemeProvider, useTheme, THEME_KEY } from "../context/ThemeContext"; import themeBoot from "../../public/theme-boot.js?raw"; +import { stubMatchMedia } from "./matchMedia"; /** Replace localStorage with one that throws on every access, as private * browsing and a full quota both do. */ @@ -16,29 +17,6 @@ function stubBrokenStorage() { }); } -/** jsdom has no matchMedia. Install one whose match state the test controls, - * keeping the listeners so a test can fire an OS-level theme change. */ -function stubMatchMedia(prefersDark: boolean) { - const listeners = new Set<(e: MediaQueryListEvent) => void>(); - const mql = { - matches: prefersDark, - media: "(prefers-color-scheme: dark)", - addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn), - removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn), - }; - window.matchMedia = vi.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia; - return { - /** Flip the OS preference the way a real media query would. */ - set(matches: boolean) { - mql.matches = matches; - act(() => { - for (const fn of listeners) fn({ matches } as MediaQueryListEvent); - }); - }, - listenerCount: () => listeners.size, - }; -} - function Probe() { const { preference, resolved, setPreference } = useTheme(); return (