// The pre-paint theme script, run for real: buildThemeScript's string executed // in node:vm against a fake , localStorage, matchMedia and theme-color // metas, over the whole legacy-migration matrix. The expected values come from // the pure helpers (migrateLegacy, storedBase, resolveBase), so the inline // script and the runtime provider cannot drift apart. import { test } from "node:test"; import assert from "node:assert/strict"; import vm from "node:vm"; import { ACCENT_IDS, BASE_GROUNDS } from "../lib/brand"; import { ACCENT_KEY, BASE_KEY, LEGACY_MODE_KEY, LEGACY_THEME_KEY, RETIRED_BASE, THEME_BASES, buildThemeScript, migrateLegacy, nextBase, resolveBase, storedBase, type ThemeBase, } from "./themeConfig"; type Env = { storage: Record; prefersDark: boolean; serverAccent?: string; storageThrows?: boolean; // Reads work, writes throw (a full or read-only storage). writeThrows?: boolean; matchMediaThrows?: boolean; }; type Result = { attrs: Map; dark: boolean; store: Map; metas: string[]; // Every DOM mutation, in order: the ready marker must be the last. log: string[]; }; const scripts = new Map(); function compiled(defaultBase: ThemeBase): vm.Script { let s = scripts.get(defaultBase); if (!s) { s = new vm.Script(buildThemeScript({ defaultBase })); scripts.set(defaultBase, s); } return s; } const ctx = vm.createContext({}); function run(defaultBase: ThemeBase, env: Env): Result { const attrs = new Map(); if (env.serverAccent) attrs.set("data-accent", env.serverAccent); const classes = new Set(); const log: string[] = []; const store = new Map(); for (const [k, v] of Object.entries(env.storage)) if (v !== null) store.set(k, v); const metaEls = ["#123456", "#654321"].map((content) => ({ content, setAttribute(k: string, v: string) { if (k === "content") this.content = v; log.push("meta"); }, })); const fail = () => { throw new Error("SecurityError: storage disabled"); }; const localStorage = env.storageThrows ? { getItem: fail, setItem: fail, removeItem: fail } : { getItem: (k: string) => (store.has(k) ? store.get(k)! : null), setItem: (k: string, v: string) => (env.writeThrows ? fail() : void store.set(k, String(v))), removeItem: (k: string) => (env.writeThrows ? fail() : void store.delete(k)), }; ctx.window = { get localStorage() { if (env.storageThrows) fail(); return localStorage; }, matchMedia: (q: string) => { if (env.matchMediaThrows) throw new Error("no matchMedia"); return { matches: q === "(prefers-color-scheme: dark)" && env.prefersDark }; }, }; ctx.document = { documentElement: { setAttribute: (k: string, v: string) => { attrs.set(k, String(v)); log.push(k); }, getAttribute: (k: string) => attrs.get(k) ?? null, classList: { toggle: (c: string, on: boolean) => { if (on) classes.add(c); else classes.delete(c); log.push(`class:${c}`); }, }, }, querySelectorAll: (sel: string) => (sel === 'meta[name="theme-color"]' ? metaEls : []), }; compiled(defaultBase).runInContext(ctx); return { attrs, dark: classes.has("dark"), store, metas: metaEls.map((m) => m.content), log }; } const THEMES = [null, "base", "archive", "selenized", "swiss", "archilyzer", "terminal"]; const MODES = [null, "light", "dark", "system", "bogus"]; const BASES = [null, "light", RETIRED_BASE, "dark", "system", "bogus"]; const DEFAULTS: ThemeBase[] = ["system", "light", "dark"]; test("migrateLegacy follows the plan's table", () => { assert.equal(migrateLegacy({ theme: null, mode: "light" }), "light"); // The old "archive" paper theme was the retired third ground: light now. assert.equal(migrateLegacy({ theme: "archive", mode: "light" }), "light"); assert.equal(migrateLegacy({ theme: "selenized", mode: "light" }), "light"); assert.equal(migrateLegacy({ theme: "archive", mode: "dark" }), "dark"); assert.equal(migrateLegacy({ theme: "selenized", mode: "dark" }), "dark"); assert.equal(migrateLegacy({ theme: "archive", mode: "system" }), "system"); assert.equal(migrateLegacy({ theme: "archive", mode: null }), null); assert.equal(migrateLegacy({ theme: null, mode: null }), null); assert.equal(migrateLegacy({ theme: null, mode: "bogus" }), null); }); test("the pre-paint script over the whole legacy × stored-base × default × OS matrix", () => { let cases = 0; for (const theme of THEMES) for (const mode of MODES) for (const base of BASES) for (const defaultBase of DEFAULTS) for (const prefersDark of [false, true]) { const storage = { [LEGACY_THEME_KEY]: theme, [LEGACY_MODE_KEY]: mode, [BASE_KEY]: base, }; const r = run(defaultBase, { storage, prefersDark }); const label = JSON.stringify({ theme, mode, base, defaultBase, prefersDark }); // 1. Migration: only when no base is stored; legacy keys always go. // 2. The retired base is rewritten to light, in storage too. const migrated = base === null ? migrateLegacy({ theme, mode }) : null; const storedAfter = base === RETIRED_BASE ? "light" : (base ?? migrated); assert.equal(r.store.get(BASE_KEY) ?? null, storedAfter, `${label}: stored base`); assert.ok(!r.store.has(LEGACY_THEME_KEY), `${label}: legacy theme key kept`); assert.ok(!r.store.has(LEGACY_MODE_KEY), `${label}: legacy mode key kept`); // 3–4. Validated base, resolved against the OS. const effective: ThemeBase = storedBase(storedAfter) ?? defaultBase; const resolved = resolveBase(effective, prefersDark); assert.equal(r.attrs.get("data-base"), resolved, `${label}: data-base`); assert.equal(r.dark, resolved === "dark", `${label}: .dark`); assert.ok(resolved === "light" || resolved === "dark", `${label}: a third ground`); // 5. Browser chrome follows the resolved ground. assert.deepEqual(r.metas, [BASE_GROUNDS[resolved], BASE_GROUNDS[resolved]], `${label}: theme-color`); // 6. The marker is set, and set last. assert.equal(r.attrs.get("data-theme-ready"), "1", `${label}: ready`); assert.equal(r.log.at(-1), "data-theme-ready", `${label}: ready is not last`); cases++; } assert.equal(cases, THEMES.length * MODES.length * BASES.length * DEFAULTS.length * 2); }); test("a stored accent is ignored: the server's data-accent stays, and the key is left in place", () => { for (const stored of [null, ...ACCENT_IDS, "custom", "#cc3366", "bogus", ""]) for (const serverAccent of [undefined, "brass", "custom"]) { const r = run("system", { storage: { [ACCENT_KEY]: stored }, prefersDark: false, serverAccent }); const label = JSON.stringify({ stored, serverAccent }); assert.equal(r.attrs.get("data-accent"), serverAccent, label); assert.ok(!r.log.includes("data-accent"), `${label}: the script wrote data-accent`); assert.equal(r.store.get(ACCENT_KEY) ?? null, stored, `${label}: the stored accent moved`); assert.equal(r.log.at(-1), "data-theme-ready"); } assert.ok(!buildThemeScript().includes(ACCENT_KEY)); }); test("the retired base paints light even when storage will not take the rewrite", () => { for (const defaultBase of DEFAULTS) for (const prefersDark of [false, true]) { const r = run(defaultBase, { storage: { [BASE_KEY]: RETIRED_BASE }, prefersDark, writeThrows: true }); assert.equal(r.attrs.get("data-base"), "light"); assert.equal(r.dark, false); assert.equal(r.log.at(-1), "data-theme-ready"); } }); test("storedBase: light, dark and system, the retired one as light, anything else null", () => { assert.equal(storedBase("light"), "light"); assert.equal(storedBase("dark"), "dark"); assert.equal(storedBase("system"), "system"); assert.equal(storedBase(RETIRED_BASE), "light"); for (const v of [null, "", "bogus", "Light", 1]) assert.equal(storedBase(v), null); }); test("storage that throws still paints the default base and sets the marker", () => { for (const defaultBase of DEFAULTS) for (const prefersDark of [false, true]) { const r = run(defaultBase, { storage: {}, prefersDark, storageThrows: true, serverAccent: "violet" }); const resolved = resolveBase(defaultBase, prefersDark); assert.equal(r.attrs.get("data-base"), resolved); assert.equal(r.dark, resolved === "dark"); assert.equal(r.attrs.get("data-accent"), "violet"); assert.equal(r.log.at(-1), "data-theme-ready"); } }); test("a matchMedia that throws resolves system to light and still sets the marker", () => { const r = run("system", { storage: {}, prefersDark: true, matchMediaThrows: true }); assert.equal(r.attrs.get("data-base"), "light"); assert.equal(r.dark, false); assert.equal(r.attrs.get("data-theme-ready"), "1"); }); test("an invalid defaultBase falls back to system", () => { const r = run("bogus" as ThemeBase, { storage: {}, prefersDark: true }); assert.equal(r.attrs.get("data-base"), "dark"); }); test("the toggle cycles system → light → dark → system, in THEME_BASES order", () => { const seen: ThemeBase[] = []; let b: ThemeBase = "system"; for (let i = 0; i < 3; i++) { seen.push(b); b = nextBase(b); } assert.equal(b, "system"); assert.deepEqual(seen, THEME_BASES.map((x) => x.id)); assert.deepEqual(THEME_BASES.map((x) => x.label), ["System", "Light", "Dark"]); });