Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
# It drives the installed Google Chrome, as CI drives its runner's; where there
Expand Down
16 changes: 3 additions & 13 deletions dashboard/src/pages/map/LiveAircraftMap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -50,7 +51,6 @@ class Socket {
beforeEach(() => {
owner.user = SIGNED_IN;
owner.syntheticFleet = false;
localStorage.clear();
window.history.replaceState(null, "", "/");
Socket.instances = [];
vi.stubGlobal("WebSocket", Socket);
Expand Down Expand Up @@ -204,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 <button onClick={() => setPreference("dark")}>flip</button>;
Expand All @@ -228,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(
<ThemeProvider>
<MapThemeProvider><LiveAircraftMap /></MapThemeProvider>
Expand Down
26 changes: 2 additions & 24 deletions dashboard/src/test/appearanceSwitch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,14 @@ 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 }));

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<string, string>();
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,
},
});
window.matchMedia = vi.fn().mockReturnValue({
matches: false,
media: "(prefers-color-scheme: dark)",
addEventListener: () => {},
removeEventListener: () => {},
}) as unknown as typeof window.matchMedia;
}

function renderHeader() {
render(
<MemoryRouter>
Expand All @@ -51,7 +29,7 @@ const CALLERS = [
];

beforeEach(() => {
stubBrowser();
stubMatchMedia();
document.documentElement.removeAttribute("data-theme");
});

Expand Down
7 changes: 2 additions & 5 deletions dashboard/src/test/errorBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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(
<ThemeProvider>
<MemoryRouter initialEntries={[at]}>
Expand Down
7 changes: 2 additions & 5 deletions dashboard/src/test/frontDoor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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(
<ThemeProvider>
<MemoryRouter initialEntries={[path]}>
Expand Down
30 changes: 3 additions & 27 deletions dashboard/src/test/mapShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -17,31 +18,6 @@ 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<string, string> = {}) {
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; },
},
});
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) {
return render(
<ThemeProvider>
Expand All @@ -53,7 +29,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");
Expand Down Expand Up @@ -81,7 +57,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
Expand Down
37 changes: 6 additions & 31 deletions dashboard/src/test/mapThemeBinding.test.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +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";

/** Stubbed rather than borrowed, as in theme.test.tsx. 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<string, string>, 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; },
},
});
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);
});
};
for (const [k, v] of Object.entries(seed)) window.localStorage.setItem(k, v);
return stubMatchMedia(prefersDark).set;
}

function Probe({ onPalette }: { onPalette: (n: string) => void }) {
Expand Down
29 changes: 29 additions & 0 deletions dashboard/src/test/matchMedia.ts
Original file line number Diff line number Diff line change
@@ -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<Listener>();
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,
};
}
7 changes: 2 additions & 5 deletions dashboard/src/test/physicsRoute.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions dashboard/src/test/setupStorage.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
Loading
Loading