// Stream S4 (PLAN C5): the theme kind. A theme module is data; theme.mjs lets nothing else through. import test from "node:test"; import assert from "node:assert/strict"; import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { PluginError } from "../src/kinds.mjs"; import { CONTRAST, MODES, TOKEN_NAMES, checkTheme, contrastRatio, installedThemes } from "../src/theme.mjs"; import { discoverModules, loadInstalled } from "../src/loader.mjs"; import { tree } from "./helpers.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, "..", "..", ".."); const IDS = ["harbor", "carmine", "atlantic", "terracotta", "aubergine", "mineral", "cobalt", "rosewood", "graphite", "grove"]; const tokensJson = JSON.parse(readFileSync(join(REPO, "docs", "design", "tokens.json"), "utf8")); const bad = (message, name) => e => e instanceof PluginError && e.code === "bad-theme" && (name === undefined || e.message.includes(name)) && (message === undefined || e.message.includes(message)); const clone = v => JSON.parse(JSON.stringify(v)); const harbor = async () => (await import("../theme/harbor/index.mjs")).theme(); const body = value => `export const interfaceVersion = 1;\nexport function theme() { return ${JSON.stringify(value)}; }\n`; const themeUrl = t => pathToFileURL(join(t.pkg, "src", "theme.mjs")).href; // The Console renders Harbor through a hand-written overlay in tokens.css ("Dashboard reference", "Cool Harbor light // equivalents"), so its light and dark differ from docs/design/tokens.json in exactly these twelve values. The other // nine palettes, and Harbor's dim, are tokens.json to the letter. packages/webui/tests checks the module against the page. const HARBOR_OVERLAY = { light: { "--canvas": "#f1f5fa", "--raised": "#e4edf8", "--line": "#c8d4e3", "--text": "#34445a", "--muted": "#53677e" }, dark: { "--canvas": "#141821", "--surface": "#1d222b", "--raised": "#293446", "--line": "#444f60", "--text": "#d8e0ef", "--muted": "#b5c2d6", "--action": "#64b5ff" }, }; test("S4 the ten shipped palettes are ten theme modules whose tokens equal docs/design/tokens.json, plus Harbor's twelve overlay values", async () => { const { themes, refused } = await installedThemes(); assert.deepEqual(refused, []); assert.deepEqual(themes.map(x => x.id), [...IDS].sort()); assert.deepEqual(tokensJson.palettes.map(p => p.id).sort(), [...IDS].sort()); let overlaid = 0; for (const palette of tokensJson.palettes) { const found = themes.find(x => x.id === palette.id); assert.equal(found.displayName, palette.name, palette.id); for (const mode of MODES) { const expected = Object.fromEntries(TOKEN_NAMES.map(token => [token, palette.modes[mode][token.slice(2)].toLowerCase()])); const overlay = palette.id === "harbor" ? HARBOR_OVERLAY[mode] ?? {} : {}; for (const [token, value] of Object.entries(overlay)) { assert.notEqual(expected[token], value, `${palette.id} ${mode} ${token}: the overlay value must really differ from tokens.json`); expected[token] = value; overlaid++; } assert.deepEqual({ ...found.tokens[mode] }, expected, `${palette.id} ${mode}`); } } assert.equal(overlaid, 12); }); test("S4 every palette clears the contrast floor in every mode, measured pair by pair", async () => { const { themes } = await installedThemes(); const seen = []; for (const { id, tokens } of themes) { for (const mode of MODES) { for (const [fg, bg, minimum] of CONTRAST) { const ratio = contrastRatio(tokens[mode][fg], tokens[mode][bg]); seen.push(ratio); assert.ok(ratio >= minimum, `${id} ${mode} ${fg} on ${bg}: ${ratio.toFixed(2)} < ${minimum}`); } } } assert.equal(seen.length, IDS.length * MODES.length * CONTRAST.length); // The measure itself: black on white is 21, a colour on itself is 1. assert.equal(Math.round(contrastRatio("#000000", "#ffffff")), 21); assert.equal(contrastRatio("#336699", "#336699"), 1); }); test("S4 checkTheme returns a frozen copy and lowercases colours", async () => { const input = clone(await harbor()); input.tokens.light["--canvas"] = input.tokens.light["--canvas"].toUpperCase(); const checked = checkTheme(input, "harbor"); assert.equal(checked.tokens.light["--canvas"], input.tokens.light["--canvas"].toLowerCase()); assert.ok(Object.isFrozen(checked) && Object.isFrozen(checked.tokens) && Object.isFrozen(checked.tokens.light)); input.tokens.light["--text"] = "#000001"; assert.notEqual(checked.tokens.light["--text"], "#000001", "a later change to the module's object can't reach the copy"); }); test("S4 malformed tokens are refused with bad-theme, one reason each", async () => { const good = await harbor(); const mutate = fn => { const v = clone(good); fn(v); return v; }; const cases = { "not an object": [null, "must be an object"], "an array": [[], "must be an object"], "extra top-level key": [mutate(v => { v.css = "body{}"; }), "unexpected key"], "no displayName": [mutate(v => { delete v.displayName; }), "lacks displayName"], "displayName with markup": [mutate(v => { v.displayName = "x"; }), "displayName"], "displayName too long": [mutate(v => { v.displayName = "x".repeat(41); }), "displayName"], "empty displayName": [mutate(v => { v.displayName = ""; }), "displayName"], "displayName not a string": [mutate(v => { v.displayName = 5; }), "displayName"], "tokens missing a mode": [mutate(v => { delete v.tokens.dim; }), "lacks dim"], "tokens with an extra mode": [mutate(v => { v.tokens.contrast = v.tokens.dark; }), "unexpected key"], "mode not an object": [mutate(v => { v.tokens.dark = "#000000"; }), "must be an object"], "token missing": [mutate(v => { delete v.tokens.dark["--focus"]; }), "lacks --focus"], "extra token": [mutate(v => { v.tokens.dark["--glow"] = "#ffffff"; }), "isn't a theme token"], "token name not a custom property": [mutate(v => { v.tokens.dark["canvas"] = "#ffffff"; }), "isn't a CSS custom property name"], "token name with a brace": [mutate(v => { v.tokens.dark["--a}body{x"] = "#ffffff"; }), "isn't a CSS custom property name"], "token name with a space": [mutate(v => { v.tokens.dark["--a b"] = "#ffffff"; }), "isn't a CSS custom property name"], "colour is url()": [mutate(v => { v.tokens.light["--canvas"] = "url(https://example.test/x.png)"; }), "#rrggbb"], "colour is var()": [mutate(v => { v.tokens.light["--canvas"] = "var(--surface)"; }), "#rrggbb"], "colour is a 3-digit hex": [mutate(v => { v.tokens.light["--canvas"] = "#fff"; }), "#rrggbb"], "colour is an 8-digit hex": [mutate(v => { v.tokens.light["--canvas"] = "#ffffffff"; }), "#rrggbb"], "colour is a name": [mutate(v => { v.tokens.light["--canvas"] = "white"; }), "#rrggbb"], "colour is rgb()": [mutate(v => { v.tokens.light["--canvas"] = "rgb(255,255,255)"; }), "#rrggbb"], "colour carries CSS after it": [mutate(v => { v.tokens.light["--canvas"] = "#ffffff;}body{display:none"; }), "#rrggbb"], "colour carries a newline": [mutate(v => { v.tokens.light["--canvas"] = "#ffffff\n"; }), "#rrggbb"], "colour is a number": [mutate(v => { v.tokens.light["--canvas"] = 0xffffff; }), "#rrggbb"], "colour is null": [mutate(v => { v.tokens.light["--canvas"] = null; }), "#rrggbb"], "text on canvas under the floor": [mutate(v => { v.tokens.light["--text"] = v.tokens.light["--canvas"]; }), "--text on --canvas"], "accent on canvas under the floor": [mutate(v => { v.tokens.dark["--accent"] = v.tokens.dark["--canvas"]; }), "--accent on --canvas"], "label on the action colour under the floor": [mutate(v => { v.tokens.dark["--onAction"] = v.tokens.dark["--action"]; }), "--onAction on --action"], "focus ring under 3:1": [mutate(v => { v.tokens.dim["--focus"] = v.tokens.dim["--surface"]; }), "--focus on --canvas"], }; for (const [label, [value, message]] of Object.entries(cases)) { assert.throws(() => checkTheme(value, "x"), bad(message, "theme x"), label); } }); test("S4 a module's answer can only be plain data: no getter, symbol key, class instance or prototype trick", async () => { const good = await harbor(); const withGetter = clone(good); Object.defineProperty(withGetter.tokens.light, "--canvas", { enumerable: true, get() { throw new Error("ran"); } }); assert.throws(() => checkTheme(withGetter, "x"), bad("plain data")); const hidden = clone(good); Object.defineProperty(hidden.tokens.light, "--canvas", { enumerable: false, value: "#ffffff" }); assert.throws(() => checkTheme(hidden, "x"), bad("plain data")); const symbol = clone(good); symbol.tokens.dark[Symbol("x")] = "#ffffff"; assert.throws(() => checkTheme(symbol, "x"), bad("symbol key")); class Tokens { constructor(o) { Object.assign(this, o); } } const klass = clone(good); klass.tokens.dark = new Tokens(klass.tokens.dark); assert.throws(() => checkTheme(klass, "x"), bad("plain object")); const proto = clone(good); Object.setPrototypeOf(proto.tokens.dark, { extra: "x" }); assert.throws(() => checkTheme(proto, "x"), bad("plain object")); // A null-prototype map is plain data and is accepted. const bare = clone(good); bare.tokens.dark = Object.assign(Object.create(null), bare.tokens.dark); assert.equal(checkTheme(bare, "x").tokens.dark["--canvas"], good.tokens.dark["--canvas"]); }); test("S4 installedThemes: a bad module is refused and logged, never listed; the good ones still list", async t => { const x = tree(t); const good = await harbor(); x.write("theme", "fine", body(good)); x.write("theme", "no-tokens", body({ displayName: "No Tokens" })); const url = x.write("theme", "css", body({ ...clone(good), tokens: { ...clone(good).tokens, light: { ...clone(good).tokens.light, "--canvas": "url(//e.test/a)" } } })); x.write("theme", "no-export", "export const interfaceVersion = 1;\n"); x.write("theme", "old", "export const interfaceVersion = 2;\nexport function theme() { return null; }\n"); x.write("theme", "throws", "export const interfaceVersion = 1;\nexport function theme() { throw new Error('boom'); }\n"); x.write("theme", "promise", "export const interfaceVersion = 1;\nexport async function theme() { return null; }\n"); x.write("theme", "syntax", "export const interfaceVersion = 1;\nexport function theme( {\n"); assert.ok(url); const { installedThemes: list } = await import(themeUrl(x)); const log = []; const { themes, refused } = await list({ log: e => log.push(e) }); assert.deepEqual(themes.map(v => v.id), ["fine"]); assert.equal(themes[0].displayName, good.displayName); const by = Object.fromEntries(refused.map(r => [r.name, r.code])); assert.deepEqual(by, { "no-tokens": "bad-theme", css: "bad-theme", "no-export": "missing-export", old: "interface-version", throws: "theme-failed", promise: "bad-theme", syntax: "load-failed", }); assert.deepEqual(log, refused, "every refusal is logged, in the order it is returned"); assert.ok(log.every(e => e.event === "plugin-refused" && e.kind === "theme" && typeof e.message === "string")); assert.match(refused.find(r => r.name === "throws").message, /theme\(\) threw/); // A refusal quotes what it refused, but short and JSON-escaped: a long or multi-line value can't // flood or forge a log line. const long = clone(good); long.tokens.light["--canvas"] = `#ffffff\n${"x".repeat(200)}`; const message = (() => { try { checkTheme(long, "long"); } catch (e) { return e.message; } return ""; })(); assert.match(message, /#rrggbb colour/); assert.ok(!message.includes("\n") && message.length < 200, message); }); test("S4 uninstall and reinstall: a removed theme leaves the list at once and comes back when its directory does", async t => { const x = tree(t); const good = await harbor(); x.write("theme", "alpha", body({ ...clone(good), displayName: "Alpha" })); x.write("theme", "beta", body({ ...clone(good), displayName: "Beta" })); const { installedThemes: list } = await import(themeUrl(x)); assert.deepEqual((await list()).themes.map(v => v.id), ["alpha", "beta"]); rmSync(join(x.pkg, "theme", "beta"), { recursive: true }); const after = await list(); assert.deepEqual(after.themes.map(v => v.id), ["alpha"]); assert.deepEqual(after.refused, [], "an uninstalled theme is absent, not an error"); x.write("theme", "beta", body({ ...clone(good), displayName: "Beta" })); assert.deepEqual((await list()).themes.map(v => `${v.id}:${v.displayName}`), ["alpha:Alpha", "beta:Beta"]); rmSync(join(x.pkg, "theme"), { recursive: true }); assert.deepEqual(await list(), { themes: [], refused: [] }, "no theme directory at all is an empty list"); }); test("S4 discoverModules lists directories by name, reports bad names, ignores files, refuses a symlinked kind directory", async t => { const x = tree(t); const { discoverModules: discover, loadInstalled: loadAll } = await import(x.loaderUrl); assert.deepEqual(discover("theme"), { names: [], invalid: [] }); assert.throws(() => discover("made-up"), e => e.code === "unknown-kind"); const good = await harbor(); x.write("theme", "zeta", body(good)); x.write("theme", "alpha", body(good)); x.write("theme", "Bad_Name", body(good)); x.write("theme", "9lives", body(good)); writeFileSync(join(x.pkg, "theme", "README.md"), "not a theme\n"); assert.deepEqual(discover("theme"), { names: ["alpha", "zeta"], invalid: ["9lives", "Bad_Name"] }); const { modules, refused } = await loadAll("theme"); assert.deepEqual(modules.map(m => m.name), ["alpha", "zeta"]); assert.deepEqual(refused.map(r => [r.name, r.code]), [["9lives", "bad-name"], ["Bad_Name", "bad-name"]]); // A symlinked module directory is listed so the load refuses it by name. mkdirSync(join(x.root, "elsewhere"), { recursive: true }); writeFileSync(join(x.root, "elsewhere", "index.mjs"), body(good)); symlinkSync(join(x.root, "elsewhere"), join(x.pkg, "theme", "linked")); assert.deepEqual(discover("theme").names, ["alpha", "linked", "zeta"]); const again = await loadAll("theme"); assert.deepEqual(again.modules.map(m => m.name), ["alpha", "zeta"]); assert.ok(again.refused.some(r => r.name === "linked" && r.code === "symlink")); // A symlinked kind directory is refused outright. rmSync(join(x.pkg, "theme"), { recursive: true }); symlinkSync(join(x.root, "elsewhere"), join(x.pkg, "theme")); // The scratch tree has its own copy of kinds.mjs, so its PluginError is not this file's class. assert.throws(() => discover("theme"), e => e.name === "PluginError" && e.code === "symlink"); const refusedKind = await loadAll("theme"); assert.deepEqual(refusedKind.modules, []); assert.equal(refusedKind.refused[0].code, "symlink"); }); test("S4 loadInstalled leaves createLoader's switched-on modules alone", async t => { const x = tree(t); x.write("usage-source", "meter", "export const interfaceVersion = 1;\nexport function readTurn() { return 'meter'; }\n"); x.write("theme", "alpha", body(await harbor())); const loader = await x.loader(); await loader.load({ "usage-source": ["meter"] }); const { loadInstalled: loadAll } = await import(x.loaderUrl); await loadAll("theme"); assert.deepEqual(loader.loaded()["usage-source"], ["meter"]); assert.deepEqual(loader.loaded().theme, []); assert.deepEqual(loader.logged, []); }); test("S4 the shipped modules are data only: no import, no network, no filesystem, no URL", () => { for (const id of IDS) { const text = readFileSync(join(HERE, "..", "theme", id, "index.mjs"), "utf8"); const code = text.split("\n").filter(line => !line.trimStart().startsWith("//")).join("\n"); assert.doesNotMatch(code, /\bimport\b|\brequire\b|\bfetch\b|\bprocess\b|\beval\b|https?:|url\(|<\w/i, id); assert.match(text, /^export const interfaceVersion = 1;$/m, id); } assert.ok(discoverModules("theme").names.length >= IDS.length); assert.equal(typeof loadInstalled, "function"); });