mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-07 12:44:15 +08:00
update 2026-10-21 10:58:41
This commit is contained in:
1 parent
f144cc6010
commit
4570f1512e
495 files changed
+129503
-31
No files matched your search
@@ -0,0 +1,176 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, "../..");
|
||||
const output = resolve(projectRoot, "htdocs/luci-static");
|
||||
|
||||
const asset = (path) => resolve(output, path);
|
||||
const bytes = (path) => statSync(asset(path)).size;
|
||||
|
||||
test("production assets stay within raw-transfer budgets", () => {
|
||||
const main = bytes("aurora/main.css");
|
||||
const login = bytes("aurora/login.css");
|
||||
const menu = bytes("resources/menu-aurora.js");
|
||||
const router = bytes("resources/router-aurora.js");
|
||||
const font = bytes("aurora/fonts/lato-v24-latin-regular.woff2");
|
||||
const logo = bytes("aurora/images/logo.svg");
|
||||
|
||||
// 187K: pseudo-elements are nested rules, one content declaration each.
|
||||
assert.ok(main <= 187_000, "main.css exceeds 187 KB");
|
||||
assert.ok(login <= 12_000, "login.css exceeds 12 KB");
|
||||
// 22K: palette recents (record on pick, pure-LRU browse order, storage
|
||||
// validation) added ~0.8 KB and the ">" logout command ~0.2 KB. The total
|
||||
// budget below moved by the same amount.
|
||||
// 23K: palette tabs (third-level nodes, redirect-parent folding, legacy
|
||||
// recents mapping, parent-aware scoring, per-segment path words) added
|
||||
// ~1.3 KB; the total budget below still holds.
|
||||
assert.ok(menu <= 23_000, "menu-aurora.js exceeds 23 KB");
|
||||
// 15K: the expiry gate, readonly folding, menu.d node css, wildcard
|
||||
// actions, progress bar, visibility gate and contract check added ~3 KB
|
||||
// over the first cut; the total-transfer budget below moved by the same
|
||||
// amount. Mirrored in the perf skill's aurora-budgets.md.
|
||||
// 16K: the router now ships from @eamonxg/luci-theme-devkit — theme hooks
|
||||
// as document events, the title format read off the document, and the
|
||||
// inner-scroller restoration shared with shadcn (+0.8 KB raw, +0.3 KB gz).
|
||||
assert.ok(router <= 16_000, "router-aurora.js exceeds 16 KB");
|
||||
assert.ok(logo <= 16_000, "logo.svg exceeds 16 KB");
|
||||
assert.ok(
|
||||
main + menu + router + font + logo <= 264_000,
|
||||
"admin assets exceed 264 KB",
|
||||
);
|
||||
assert.ok(login + font + logo <= 55_000, "login assets exceed 55 KB");
|
||||
});
|
||||
|
||||
test("compressed LuCI JS preserves its loader directives", () => {
|
||||
const js = readFileSync(asset("resources/menu-aurora.js"), "utf8");
|
||||
assert.match(js, /["']require baseclass["'];["']require ui["'];/);
|
||||
|
||||
const module = new Function("baseclass", "ui", js)(
|
||||
{ extend: (value) => value },
|
||||
{},
|
||||
);
|
||||
assert.equal(typeof module.__init__, "function");
|
||||
});
|
||||
|
||||
test("compiled CSS does not duplicate SVG payloads for every mask property", () => {
|
||||
const css = readFileSync(asset("aurora/main.css"), "utf8");
|
||||
const dataUrls = css.match(/data:image\/svg\+xml,[^"]+/g) ?? [];
|
||||
const encodedBytes = dataUrls.reduce((sum, value) => sum + value.length, 0);
|
||||
|
||||
assert.equal(
|
||||
dataUrls.length,
|
||||
new Set(dataUrls).size,
|
||||
"duplicate SVG data URL",
|
||||
);
|
||||
assert.ok(encodedBytes <= 17_000, `SVG data URLs occupy ${encodedBytes} B`);
|
||||
});
|
||||
|
||||
test("pruned login.css keeps every consumed variable resolvable", () => {
|
||||
const css = readFileSync(asset("aurora/login.css"), "utf8");
|
||||
const declared = new Set(
|
||||
[...css.matchAll(/[{;](--[\w-]+):/g)].map((m) => m[1]),
|
||||
);
|
||||
const registered = new Set(
|
||||
[...css.matchAll(/@property\s+(--[\w-]+)/g)].map((m) => m[1]),
|
||||
);
|
||||
// header.ut injects these from UCI at render time — consumed here, never
|
||||
// declared here.
|
||||
const injected = new Set(["--login-bg", "--login-bg-lqip"]);
|
||||
|
||||
const unresolvable = [];
|
||||
for (const [, name, delim] of css.matchAll(/var\(\s*(--[\w-]+)\s*([,)])/g)) {
|
||||
const hasFallback = delim === ",";
|
||||
if (
|
||||
!declared.has(name) &&
|
||||
!registered.has(name) &&
|
||||
!injected.has(name) &&
|
||||
!hasFallback
|
||||
) {
|
||||
unresolvable.push(name);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(unresolvable, []);
|
||||
|
||||
// The prune must actually strip admin-only tokens from the shared sheet.
|
||||
for (const adminOnly of ["--mega-menu-bg", "--icon-", "--sidebar"]) {
|
||||
assert.ok(!css.includes(adminOnly), `${adminOnly} should be pruned`);
|
||||
}
|
||||
// And the login page's own consumed tokens must survive, light and dark.
|
||||
for (const kept of ["--surface:", "--brand:", "--control-bg:"]) {
|
||||
const count = css.split(kept).length - 1;
|
||||
assert.ok(count >= 2, `${kept} should be declared for both modes`);
|
||||
}
|
||||
});
|
||||
|
||||
test("package roots contain no macOS metadata", () => {
|
||||
const offenders = [];
|
||||
const visit = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.name === ".DS_Store") offenders.push(path);
|
||||
else if (entry.isDirectory()) visit(path);
|
||||
}
|
||||
};
|
||||
|
||||
visit(resolve(projectRoot, "htdocs"));
|
||||
visit(resolve(projectRoot, "ucode"));
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
|
||||
test("the web manifest's icons exist in the package", () => {
|
||||
const manifest = JSON.parse(readFileSync(asset("aurora/images/app.webmanifest"), "utf8"));
|
||||
assert.ok(manifest.icons.length > 0);
|
||||
for (const { src } of manifest.icons) {
|
||||
assert.match(src, /^\/luci-static\//);
|
||||
assert.ok(statSync(asset(src.replace(/^\/luci-static\//, ""))).isFile(), src);
|
||||
}
|
||||
});
|
||||
|
||||
test("login.css keeps the Safari 16.4 backdrop-filter prefix", () => {
|
||||
const css = readFileSync(asset("aurora/login.css"), "utf8");
|
||||
const count = (needle) => css.split(needle).length - 1;
|
||||
const prefixed = count("-webkit-backdrop-filter:");
|
||||
assert.ok(prefixed > 0);
|
||||
assert.equal(count("backdrop-filter:") - prefixed, prefixed);
|
||||
});
|
||||
|
||||
test("no rule repeats a declaration, vendor-prefixed ones aside", () => {
|
||||
for (const sheet of ["aurora/main.css", "aurora/login.css"]) {
|
||||
const css = readFileSync(asset(sheet), "utf8");
|
||||
const repeated = [];
|
||||
const blocks = [[]];
|
||||
let quote = "";
|
||||
let parens = 0;
|
||||
let start = 0;
|
||||
const close = (end) => {
|
||||
const declaration = css.slice(start, end).trim();
|
||||
const seen = blocks.at(-1);
|
||||
if (declaration && !declaration.startsWith("-webkit-")) {
|
||||
if (seen.includes(declaration)) repeated.push(declaration);
|
||||
seen.push(declaration);
|
||||
}
|
||||
start = end + 1;
|
||||
};
|
||||
for (let i = 0; i < css.length; i++) {
|
||||
const char = css[i];
|
||||
if (quote) {
|
||||
if (char === "\\") i++;
|
||||
else if (char === quote) quote = "";
|
||||
} else if (char === '"' || char === "'") quote = char;
|
||||
else if (char === "(") parens++;
|
||||
else if (char === ")") parens--;
|
||||
else if (parens) continue;
|
||||
else if (char === ";") close(i);
|
||||
else if (char === "{") {
|
||||
blocks.push([]);
|
||||
start = i + 1;
|
||||
} else if (char === "}") {
|
||||
close(i);
|
||||
blocks.pop();
|
||||
}
|
||||
}
|
||||
assert.deepEqual(repeated, [], sheet);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,792 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
|
||||
const source = await readFile(
|
||||
new URL("../src/resource/menu-aurora.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const E = (tagName, attributes, children) => ({
|
||||
tagName,
|
||||
attributes,
|
||||
children,
|
||||
});
|
||||
|
||||
const loadMenuModule = (
|
||||
localStorage = {},
|
||||
{
|
||||
document = {},
|
||||
navigator = { platform: "" },
|
||||
ui = { menu: { getChildren: () => [] } },
|
||||
} = {},
|
||||
) => {
|
||||
const baseclass = {
|
||||
extend(module) {
|
||||
return module;
|
||||
},
|
||||
};
|
||||
const L = {
|
||||
env: { dispatchpath: [], requestpath: [] },
|
||||
url: (...segments) => `/${segments.join("/")}`,
|
||||
};
|
||||
|
||||
return new Function(
|
||||
"baseclass",
|
||||
"ui",
|
||||
"E",
|
||||
"L",
|
||||
"_",
|
||||
"document",
|
||||
"window",
|
||||
"localStorage",
|
||||
"navigator",
|
||||
source,
|
||||
)(baseclass, ui, E, L, (value) => value, document, {}, localStorage, navigator);
|
||||
};
|
||||
|
||||
const menu = loadMenuModule();
|
||||
|
||||
const score = (query, text) => menu.fuzzyMatch(query, text)?.score ?? null;
|
||||
|
||||
const sliced = (text, ranges) =>
|
||||
ranges.map(([from, to]) => text.slice(from, to));
|
||||
|
||||
test("matches a scattered subsequence and reports its ranges", () => {
|
||||
const match = menu.fuzzyMatch("dns", "DHCP and DNS");
|
||||
|
||||
assert.ok(match);
|
||||
assert.deepEqual(sliced("DHCP and DNS", match.ranges), ["DNS"]);
|
||||
});
|
||||
|
||||
test("returns null when the query is not a subsequence", () => {
|
||||
assert.equal(menu.fuzzyMatch("zzz", "Firewall"), null);
|
||||
});
|
||||
|
||||
test("retries from every first-character occurrence instead of staying greedy", () => {
|
||||
// A single greedy pass anchors "dns" to the leading D of "DHCP and DNS" and
|
||||
// scatters, scoring the real hit below shorter near-misses.
|
||||
assert.ok(score("dns", "DHCP and DNS") > score("dns", "Diagnostics"));
|
||||
});
|
||||
|
||||
test("scores an adjacency run above the same characters scattered", () => {
|
||||
assert.ok(score("fir", "Firewall") > score("fir", "Failover in Routing"));
|
||||
});
|
||||
|
||||
test("rewards word starts", () => {
|
||||
assert.ok(score("s", "Status") > score("s", "Wireless"));
|
||||
});
|
||||
|
||||
test("penalises longer targets so the tighter title wins", () => {
|
||||
assert.ok(score("log", "Logout") > score("log", "Logging and reporting"));
|
||||
});
|
||||
|
||||
test("matches CJK titles", () => {
|
||||
const match = menu.fuzzyMatch("防火墙", "防火墙设置");
|
||||
|
||||
assert.ok(match);
|
||||
assert.deepEqual(sliced("防火墙设置", match.ranges), ["防火墙"]);
|
||||
});
|
||||
|
||||
test("keeps astral characters whole in the reported ranges", () => {
|
||||
// `at` is a UTF-16 offset while for...of yields code points: advancing by 1
|
||||
// would end the range inside the surrogate pair and slice it into mojibake.
|
||||
const title = "🚀 Passwall";
|
||||
const match = menu.fuzzyMatch("🚀", title);
|
||||
|
||||
assert.ok(match);
|
||||
assert.deepEqual(match.ranges, [[0, 2]]);
|
||||
assert.deepEqual(sliced(title, match.ranges), ["🚀"]);
|
||||
});
|
||||
|
||||
test("merges adjacent astral matches into one range", () => {
|
||||
const title = "𠀀𠀁 Extension";
|
||||
const match = menu.fuzzyMatch("𠀀𠀁", title);
|
||||
|
||||
assert.ok(match);
|
||||
assert.deepEqual(match.ranges, [[0, 4]]);
|
||||
assert.deepEqual(sliced(title, match.ranges), ["𠀀𠀁"]);
|
||||
});
|
||||
|
||||
test("an empty query matches every entry unranked and unhighlighted", () => {
|
||||
assert.deepEqual(menu.matchPaletteEntry("", { title: "Status" }), {
|
||||
score: 0,
|
||||
ranges: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("title hits outrank name hits and are the only highlighted ones", () => {
|
||||
const title = menu.matchPaletteEntry("dns", {
|
||||
title: "DNS",
|
||||
name: "dns",
|
||||
group: "Network",
|
||||
});
|
||||
const viaName = menu.matchPaletteEntry("dhcp", {
|
||||
title: "DNS",
|
||||
name: "dhcp",
|
||||
group: "Network",
|
||||
});
|
||||
|
||||
assert.ok(title.ranges);
|
||||
assert.equal(viaName.ranges, null);
|
||||
assert.ok(title.score > viaName.score);
|
||||
});
|
||||
|
||||
test("falls back to the group when neither title nor name matches", () => {
|
||||
const match = menu.matchPaletteEntry("netw", {
|
||||
title: "DNS",
|
||||
name: "dns",
|
||||
group: "Network",
|
||||
});
|
||||
|
||||
assert.ok(match);
|
||||
assert.equal(match.ranges, null);
|
||||
});
|
||||
|
||||
test("returns falsy when nothing on the entry matches", () => {
|
||||
assert.ok(
|
||||
!menu.matchPaletteEntry("zzz", {
|
||||
title: "DNS",
|
||||
name: "dns",
|
||||
group: "Network",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("wraps matched ranges in <mark> and keeps the surrounding text", () => {
|
||||
const parts = menu.highlightPaletteMatch("DHCP and DNS", [[9, 12]]);
|
||||
|
||||
assert.deepEqual(
|
||||
parts.map((part) => (typeof part === "string" ? part : part.children[0])),
|
||||
["DHCP and ", "DNS", ""],
|
||||
);
|
||||
assert.equal(parts[1].tagName, "mark");
|
||||
});
|
||||
|
||||
test("highlights astral characters without splitting the surrogate pair", () => {
|
||||
const parts = menu.highlightPaletteMatch("🚀 Passwall", [[0, 2]]);
|
||||
|
||||
assert.equal(parts[1].children[0], "🚀");
|
||||
assert.ok(!parts.some((part) => String(part).includes("�")));
|
||||
});
|
||||
|
||||
test("skips highlighting when case folding would skew the offsets", () => {
|
||||
// "İ".toLowerCase() is two code units, so ranges measured on the lowercased
|
||||
// copy no longer index the original.
|
||||
assert.deepEqual(menu.highlightPaletteMatch("İnterface", [[0, 1]]), [
|
||||
"İnterface",
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders the plain title when there are no ranges", () => {
|
||||
assert.deepEqual(menu.highlightPaletteMatch("Status", null), ["Status"]);
|
||||
});
|
||||
|
||||
// ---- Recents: recording, storage hygiene, and browse-order floating ----
|
||||
|
||||
const RECENTS_KEY = "aurora.paletteRecents";
|
||||
|
||||
const fakeStorage = (initial = {}) => {
|
||||
const map = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
map,
|
||||
};
|
||||
};
|
||||
|
||||
const navPage = (name, title, group = "Group") => ({
|
||||
title,
|
||||
name,
|
||||
group,
|
||||
href: `/${name}`,
|
||||
});
|
||||
|
||||
const paletteWith = (storage, index) => {
|
||||
const module = loadMenuModule(storage);
|
||||
module.paletteIndex = index;
|
||||
return module;
|
||||
};
|
||||
|
||||
const browseIndex = () => [
|
||||
navPage("status/overview", "概览", "状态"),
|
||||
navPage("status/syslog", "系统日志", "状态"),
|
||||
navPage("network/iface", "接口", "网络"),
|
||||
navPage("network/firewall", "防火墙", "网络"),
|
||||
{ title: "浅色模式", name: "theme light", group: "主题", mode: "light" },
|
||||
];
|
||||
|
||||
const stored = (recents) => ({ [RECENTS_KEY]: JSON.stringify(recents) });
|
||||
|
||||
const names = (matches) => matches.map((match) => match.page.name);
|
||||
|
||||
test("a recorded selection lands at the head of the stored recents", () => {
|
||||
const storage = fakeStorage();
|
||||
const palette = paletteWith(storage, browseIndex());
|
||||
|
||||
palette.recordPaletteRecent("status/syslog");
|
||||
palette.recordPaletteRecent("network/iface");
|
||||
|
||||
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
|
||||
"network/iface",
|
||||
"status/syslog",
|
||||
]);
|
||||
});
|
||||
|
||||
test("re-recording an entry moves it up instead of duplicating it", () => {
|
||||
const storage = fakeStorage(stored(["network/iface", "status/syslog"]));
|
||||
const palette = paletteWith(storage, browseIndex());
|
||||
|
||||
palette.recordPaletteRecent("status/syslog");
|
||||
|
||||
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
|
||||
"status/syslog",
|
||||
"network/iface",
|
||||
]);
|
||||
});
|
||||
|
||||
test("the store has no artificial cap — dedupe is the only bound", () => {
|
||||
const storage = fakeStorage(stored(["h", "g", "f", "e", "d", "c", "b", "a"]));
|
||||
const palette = paletteWith(storage, browseIndex());
|
||||
|
||||
palette.recordPaletteRecent("i");
|
||||
|
||||
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
|
||||
"i",
|
||||
"h",
|
||||
"g",
|
||||
"f",
|
||||
"e",
|
||||
"d",
|
||||
"c",
|
||||
"b",
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
test("corrupt or foreign stored values read as no history", () => {
|
||||
for (const value of ["not json", '"just a string"', "[1,2,3]", "{}"]) {
|
||||
const palette = paletteWith(
|
||||
fakeStorage({ [RECENTS_KEY]: value }),
|
||||
browseIndex(),
|
||||
);
|
||||
assert.deepEqual(palette.readPaletteRecents(), [], value);
|
||||
}
|
||||
});
|
||||
|
||||
test("a throwing storage neither breaks reads nor records", () => {
|
||||
const hostile = {
|
||||
getItem: () => {
|
||||
throw new Error("privacy mode");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("quota");
|
||||
},
|
||||
};
|
||||
const palette = paletteWith(hostile, browseIndex());
|
||||
|
||||
assert.deepEqual(palette.readPaletteRecents(), []);
|
||||
assert.doesNotThrow(() => palette.recordPaletteRecent("status/overview"));
|
||||
});
|
||||
|
||||
test("browsing floats stored recents in recency order, menu order behind", () => {
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["network/iface", "status/syslog"])),
|
||||
browseIndex(),
|
||||
);
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")), [
|
||||
"network/iface",
|
||||
"status/syslog",
|
||||
"status/overview",
|
||||
"network/firewall",
|
||||
"theme light",
|
||||
]);
|
||||
});
|
||||
|
||||
test("every visited page floats in recency order; vanished names drop", () => {
|
||||
const wide = [
|
||||
navPage("p1", "One"),
|
||||
navPage("p2", "Two"),
|
||||
navPage("p3", "Three"),
|
||||
navPage("p4", "Four"),
|
||||
navPage("p5", "Five"),
|
||||
navPage("p6", "Six"),
|
||||
navPage("p7", "Seven"),
|
||||
];
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["removed/page", "p7", "p6", "p5", "p4", "p3", "p2"])),
|
||||
wide,
|
||||
);
|
||||
|
||||
// Pure LRU: all six visited pages float, only p1 keeps its menu slot.
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")), [
|
||||
"p7",
|
||||
"p6",
|
||||
"p5",
|
||||
"p4",
|
||||
"p3",
|
||||
"p2",
|
||||
"p1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a stored name never floats a command row", () => {
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["theme light"])),
|
||||
browseIndex(),
|
||||
);
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")), [
|
||||
"status/overview",
|
||||
"status/syslog",
|
||||
"network/iface",
|
||||
"network/firewall",
|
||||
"theme light",
|
||||
]);
|
||||
});
|
||||
|
||||
test("typed queries rank by fuzzy score alone", () => {
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["diag"])),
|
||||
[navPage("dns", "DNS Settings"), navPage("diag", "Diagnostics")],
|
||||
);
|
||||
|
||||
// "Diagnostics" is the recent entry, but the tight title hit must win.
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("dns")), [
|
||||
"dns",
|
||||
"diag",
|
||||
]);
|
||||
});
|
||||
|
||||
test("'>' browsing lists command rows only, unaffected by recents", () => {
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["network/iface"])),
|
||||
browseIndex(),
|
||||
);
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches(">")), ["theme light"]);
|
||||
});
|
||||
|
||||
// ---- Logout as a ">"-only command ----
|
||||
|
||||
const logoutIndex = () => [
|
||||
navPage("p1", "Alpha"),
|
||||
navPage("p2", "Beta"),
|
||||
navPage("p3", "Gamma"),
|
||||
navPage("p4", "Delta"),
|
||||
navPage("p5", "Epsilon"),
|
||||
navPage("p6", "Zeta"),
|
||||
{ title: "浅色模式", name: "theme light", group: "主题", mode: "light" },
|
||||
{ title: "Logout", name: "logout", group: null, href: "/logout", isLogout: true },
|
||||
];
|
||||
|
||||
test("'>' browsing includes the logout command", () => {
|
||||
const palette = paletteWith(fakeStorage(), logoutIndex());
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches(">")), [
|
||||
"theme light",
|
||||
"logout",
|
||||
]);
|
||||
});
|
||||
|
||||
test("plain browsing hides the logout command", () => {
|
||||
const palette = paletteWith(fakeStorage(), logoutIndex());
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")), [
|
||||
"p1",
|
||||
"p2",
|
||||
"p3",
|
||||
"p4",
|
||||
"p5",
|
||||
"p6",
|
||||
"theme light",
|
||||
]);
|
||||
});
|
||||
|
||||
test("typed queries still reach logout", () => {
|
||||
const palette = paletteWith(fakeStorage(), logoutIndex());
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("logout")), ["logout"]);
|
||||
});
|
||||
|
||||
test("a stored logout name never floats", () => {
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["logout", "p6", "p5", "p4", "p3", "p2"])),
|
||||
logoutIndex(),
|
||||
);
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")), [
|
||||
"p6",
|
||||
"p5",
|
||||
"p4",
|
||||
"p3",
|
||||
"p2",
|
||||
"p1",
|
||||
"theme light",
|
||||
]);
|
||||
});
|
||||
|
||||
test("initPalette appends logout as the last command row", () => {
|
||||
const toggle = {
|
||||
setAttribute() {},
|
||||
addEventListener() {},
|
||||
};
|
||||
const doc = {
|
||||
querySelector: (sel) => (sel === "#cmdk-trigger" ? toggle : null),
|
||||
addEventListener() {},
|
||||
};
|
||||
const palette = loadMenuModule(fakeStorage(), { document: doc });
|
||||
|
||||
palette.initPalette([
|
||||
{
|
||||
name: "status",
|
||||
title: "Status",
|
||||
hasChildren: true,
|
||||
pages: [{ name: "overview", title: "Overview", href: "/overview" }],
|
||||
},
|
||||
{
|
||||
name: "logout",
|
||||
title: "Logout",
|
||||
href: "/logout",
|
||||
hasChildren: false,
|
||||
isLogout: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const last = palette.paletteIndex.at(-1);
|
||||
assert.equal(last.isLogout, true);
|
||||
assert.equal(last.href, "/logout");
|
||||
assert.equal(last.title, "Logout");
|
||||
// After the theme commands, so ">" lists modes first, logout last.
|
||||
assert.ok(palette.paletteIndex.findIndex((page) => page.mode) >= 0);
|
||||
assert.ok(
|
||||
palette.paletteIndex.findIndex((page) => page.mode) <
|
||||
palette.paletteIndex.indexOf(last),
|
||||
);
|
||||
});
|
||||
|
||||
// ---- Tabs: third-level menu nodes ----
|
||||
|
||||
// ui.menu.getChildren() as luci-base ships it, down to handing out alias
|
||||
// nodes with their target's children (none, for a tab target).
|
||||
const menuUi = {
|
||||
menu: {
|
||||
getChildren: (node) =>
|
||||
Object.entries(node?.children ?? {})
|
||||
.filter(([, child]) => child.satisfied && "title" in child)
|
||||
.map(([name, child]) =>
|
||||
child.action?.type === "alias"
|
||||
? { ...child, name, children: undefined }
|
||||
: Object.assign(child, { name }),
|
||||
)
|
||||
.sort((a, b) => (a.order ?? 1000) - (b.order ?? 1000)),
|
||||
},
|
||||
};
|
||||
|
||||
const node = (title, order, action, children) => ({
|
||||
satisfied: true,
|
||||
title,
|
||||
order,
|
||||
action,
|
||||
children,
|
||||
});
|
||||
const view = { type: "view" };
|
||||
|
||||
const menuRoot = () => ({
|
||||
name: "admin",
|
||||
children: {
|
||||
status: node("Status", 1, view, {
|
||||
overview: node("Overview", 1, view),
|
||||
logs: node(
|
||||
"System Log",
|
||||
2,
|
||||
{ type: "alias", path: "admin/status/logs/syslog" },
|
||||
{
|
||||
dmesg: node("Kernel Log", 2, view),
|
||||
syslog: node("System Log", 1, view),
|
||||
},
|
||||
),
|
||||
}),
|
||||
system: node("System", 2, view, {
|
||||
admin: node(
|
||||
"Administration",
|
||||
1,
|
||||
{ type: "firstchild" },
|
||||
{
|
||||
dropbear: node("SSH Access", 2, view),
|
||||
hidden: { satisfied: true, order: 0, action: view },
|
||||
password: node("Router Password", 1, view),
|
||||
},
|
||||
),
|
||||
bmx: node("BMX", 2, view, { nodes: node("Nodes", 1, view) }),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const menuItems = () => [
|
||||
{
|
||||
name: "status",
|
||||
title: "Status",
|
||||
hasChildren: true,
|
||||
pages: [
|
||||
{ name: "overview", title: "Overview", href: "/admin/status/overview" },
|
||||
{ name: "logs", title: "System Log", href: "/admin/status/logs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "system",
|
||||
title: "System",
|
||||
hasChildren: true,
|
||||
pages: [
|
||||
{ name: "admin", title: "Administration", href: "/admin/system/admin" },
|
||||
{ name: "bmx", title: "BMX", href: "/admin/system/bmx" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const initTabbedPalette = (storage = fakeStorage()) => {
|
||||
const doc = {
|
||||
querySelector: (sel) =>
|
||||
sel === "#cmdk-trigger"
|
||||
? { setAttribute() {}, addEventListener() {} }
|
||||
: null,
|
||||
addEventListener() {},
|
||||
};
|
||||
const palette = loadMenuModule(storage, { document: doc, ui: menuUi });
|
||||
palette.initPalette(menuItems(), menuRoot());
|
||||
return palette;
|
||||
};
|
||||
|
||||
const navRows = (palette) =>
|
||||
palette.paletteIndex.filter((page) => !page.mode && !page.isLogout);
|
||||
|
||||
test("tabs join the index; a redirecting parent gives way to them", () => {
|
||||
const rows = navRows(initTabbedPalette());
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((page) => [page.name, page.parent ?? null]),
|
||||
[
|
||||
["status/overview", null],
|
||||
["status/logs/syslog", "System Log"],
|
||||
["status/logs/dmesg", "System Log"],
|
||||
["system/admin/password", "Administration"],
|
||||
["system/admin/dropbear", "Administration"],
|
||||
["system/bmx", null],
|
||||
["system/bmx/nodes", "BMX"],
|
||||
],
|
||||
);
|
||||
assert.equal(rows[2].href, "/admin/status/logs/dmesg");
|
||||
assert.equal(rows[2].group, "Status");
|
||||
});
|
||||
|
||||
test("redirecting parents map to the tab they open", () => {
|
||||
assert.deepEqual(initTabbedPalette().paletteAliases, {
|
||||
"status/logs": "status/logs/syslog",
|
||||
"system/admin": "system/admin/password",
|
||||
});
|
||||
});
|
||||
|
||||
test("a stored parent name reads as its tab, deduplicated", () => {
|
||||
const palette = initTabbedPalette(
|
||||
fakeStorage(
|
||||
stored(["system/admin", "system/admin/password", "status/logs"]),
|
||||
),
|
||||
);
|
||||
|
||||
assert.deepEqual(palette.readPaletteRecents(), [
|
||||
"system/admin/password",
|
||||
"status/logs/syslog",
|
||||
]);
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")).slice(0, 3), [
|
||||
"system/admin/password",
|
||||
"status/logs/syslog",
|
||||
"status/overview",
|
||||
]);
|
||||
});
|
||||
|
||||
const tab = (name, title, parent = "Firewall") => ({
|
||||
title,
|
||||
parent,
|
||||
name: `network/firewall/${name}`,
|
||||
group: "Network",
|
||||
href: `/${name}`,
|
||||
});
|
||||
|
||||
const firewallIndex = () => [
|
||||
navPage("status/nftables", "Firewall", "Status"),
|
||||
tab("zones", "General Settings"),
|
||||
tab("forwards", "Port Forwards"),
|
||||
tab("rules", "Traffic Rules"),
|
||||
tab("ipsets", "IP Sets"),
|
||||
];
|
||||
|
||||
test("a parent-only query keeps its tabs in menu order", () => {
|
||||
const palette = paletteWith(fakeStorage(), firewallIndex());
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("firewall")), [
|
||||
"status/nftables",
|
||||
"network/firewall/zones",
|
||||
"network/firewall/forwards",
|
||||
"network/firewall/rules",
|
||||
"network/firewall/ipsets",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a parent hit highlights the parent, not the title", () => {
|
||||
const match = menu.matchPaletteEntry(
|
||||
"fire",
|
||||
tab("forwards", "Port Forwards"),
|
||||
);
|
||||
|
||||
assert.equal(match.ranges, null);
|
||||
assert.deepEqual(sliced("Firewall", match.parentRanges), ["Fire"]);
|
||||
});
|
||||
|
||||
test("a spaced query pairs parent words with title words", () => {
|
||||
const palette = paletteWith(fakeStorage(), firewallIndex());
|
||||
const [hit, ...rest] = palette.collectPaletteMatches("firewall port");
|
||||
|
||||
assert.equal(hit.page.name, "network/firewall/forwards");
|
||||
assert.deepEqual(rest, []);
|
||||
assert.deepEqual(sliced("Port Forwards", hit.ranges), ["Port"]);
|
||||
assert.deepEqual(sliced("Firewall", hit.parentRanges), ["Firewall"]);
|
||||
});
|
||||
|
||||
test("a query no longer scatters across path segments", () => {
|
||||
const other = {
|
||||
title: "Other Settings",
|
||||
parent: "PassWall 2",
|
||||
name: "services/passwall2/other",
|
||||
group: "Services",
|
||||
};
|
||||
|
||||
assert.ok(!menu.matchPaletteEntry("ssh", other));
|
||||
assert.ok(
|
||||
menu.matchPaletteEntry("sshkeys", {
|
||||
title: "SSH 密钥",
|
||||
parent: "管理权",
|
||||
name: "system/admin/sshkeys",
|
||||
group: "系统",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("path words land in segments in order, never scattered", () => {
|
||||
const wireless = { title: "无线", name: "network/wireless", group: "网络" };
|
||||
const forwards = tab("forwards", "端口转发", "防火墙");
|
||||
|
||||
assert.ok(menu.matchPaletteEntry("network wireless", wireless));
|
||||
assert.ok(
|
||||
menu.matchPaletteEntry(
|
||||
"status/overview",
|
||||
navPage("status/overview", "概览"),
|
||||
),
|
||||
);
|
||||
assert.ok(menu.matchPaletteEntry("network firewall", forwards));
|
||||
assert.ok(menu.matchPaletteEntry("netw forw", forwards));
|
||||
assert.ok(!menu.matchPaletteEntry("wireless network", wireless));
|
||||
assert.ok(!menu.matchPaletteEntry("netwire", wireless));
|
||||
assert.ok(!menu.matchPaletteEntry("/", wireless));
|
||||
});
|
||||
|
||||
test("a parent keeps its row unless it redirects to one of its own tabs", () => {
|
||||
const doc = {
|
||||
querySelector: (sel) =>
|
||||
sel === "#cmdk-trigger"
|
||||
? { setAttribute() {}, addEventListener() {} }
|
||||
: null,
|
||||
addEventListener() {},
|
||||
};
|
||||
const palette = loadMenuModule(fakeStorage(), { document: doc, ui: menuUi });
|
||||
const root = {
|
||||
name: "admin",
|
||||
children: {
|
||||
services: node("Services", 1, view, {
|
||||
// Redirects to an untitled page, so no tab stands in for it.
|
||||
foo: node(
|
||||
"Foo",
|
||||
1,
|
||||
{ type: "alias", path: "admin/services/foo/main" },
|
||||
{
|
||||
main: { satisfied: true, order: 1, action: view },
|
||||
log: node("Log", 2, view),
|
||||
},
|
||||
),
|
||||
bar: node(
|
||||
"Bar",
|
||||
2,
|
||||
{ type: "firstchild" },
|
||||
{ only: { ...node("Only", 1, view), firstchild_ineligible: true } },
|
||||
),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
palette.initPalette(
|
||||
[
|
||||
{
|
||||
name: "services",
|
||||
title: "Services",
|
||||
hasChildren: true,
|
||||
pages: [
|
||||
{ name: "foo", title: "Foo", href: "/admin/services/foo" },
|
||||
{ name: "bar", title: "Bar", href: "/admin/services/bar" },
|
||||
],
|
||||
},
|
||||
],
|
||||
root,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
navRows(palette).map((page) => page.name),
|
||||
["services/foo", "services/foo/log", "services/bar", "services/bar/only"],
|
||||
);
|
||||
assert.deepEqual(palette.paletteAliases, {});
|
||||
});
|
||||
|
||||
test("recording after a legacy read writes the mapped names back", () => {
|
||||
const storage = fakeStorage(stored(["status/logs", "status/overview"]));
|
||||
const palette = initTabbedPalette(storage);
|
||||
|
||||
palette.recordPaletteRecent("system/admin/dropbear");
|
||||
|
||||
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
|
||||
"system/admin/dropbear",
|
||||
"status/logs/syslog",
|
||||
"status/overview",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a tab row renders its parent before the title unless they share a name", () => {
|
||||
const rows = [];
|
||||
const palette = paletteWith(fakeStorage(), [
|
||||
tab("forwards", "Port Forwards"),
|
||||
{ ...tab("firewall", "Firewall"), name: "network/firewall/firewall" },
|
||||
]);
|
||||
palette.paletteInput = { removeAttribute() {}, setAttribute() {} };
|
||||
palette.paletteList = {
|
||||
replaceChildren() {},
|
||||
appendChild: (row) => rows.push(row),
|
||||
};
|
||||
palette.setPaletteSelection = () => {};
|
||||
|
||||
palette.renderPaletteResults("fire");
|
||||
|
||||
const title = (name) =>
|
||||
rows.find((row) => row.attributes["data-name"] === name).children[0]
|
||||
.children;
|
||||
const forwards = title("network/firewall/forwards");
|
||||
const same = title("network/firewall/firewall");
|
||||
assert.deepEqual(
|
||||
forwards.map((part) => part.attributes.class),
|
||||
["cmdk-parent", "cmdk-label"],
|
||||
);
|
||||
const marked = (parts) => parts.some((part) => part.tagName === "mark");
|
||||
// The parent hit is highlighted inside the parent, the title stays plain.
|
||||
assert.ok(marked(forwards[0].children));
|
||||
assert.deepEqual(forwards[1].children, ["Port Forwards"]);
|
||||
// Same name: a plain highlighted title, no prefix.
|
||||
assert.ok(marked(same));
|
||||
assert.ok(!same.some((part) => part.attributes?.class === "cmdk-parent"));
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
// Geometry checks for the DOM compat fixtures (see ../compat/README.md).
|
||||
// Each fixture is a trimmed page that links the built main.css and declares
|
||||
// its checks in <script type="application/json" id="compat">. They run in
|
||||
// headless Chrome over raw CDP — no npm dependency, same approach as the
|
||||
// performance skill's bench scripts.
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
const fixtureDir = resolve(import.meta.dirname, "../compat/fixtures");
|
||||
const output = resolve(import.meta.dirname, "../../htdocs/luci-static/aurora");
|
||||
// header.ut inlines fonts/aurora-font.css; its absolute /luci-static/ URL
|
||||
// cannot resolve under file://, so the runner injects it with a file URL.
|
||||
// Without the theme font the fallback metrics differ enough to flip a wrap.
|
||||
const fontCss = existsSync(join(output, "fonts/aurora-font.css"))
|
||||
? readFileSync(join(output, "fonts/aurora-font.css"), "utf8")
|
||||
.replaceAll("/luci-static/aurora/", pathToFileURL(output).href + "/")
|
||||
: "";
|
||||
const fixtures = existsSync(fixtureDir)
|
||||
? readdirSync(fixtureDir).filter((f) => f.endsWith(".html")).sort()
|
||||
: [];
|
||||
|
||||
function findChrome() {
|
||||
const candidates = [
|
||||
process.env.CHROME_BIN,
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
].filter(Boolean);
|
||||
for (const c of candidates) if (existsSync(c)) return c;
|
||||
for (const name of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]) {
|
||||
try {
|
||||
return execFileSync("which", [name], { encoding: "utf8" }).trim() || null;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const chromeBin = findChrome();
|
||||
const skipReason =
|
||||
fixtures.length === 0
|
||||
? "no fixtures in compat/fixtures"
|
||||
: chromeBin
|
||||
? null
|
||||
: "no Chrome found (set CHROME_BIN); required when CI is set";
|
||||
|
||||
if (skipReason && process.env.CI && fixtures.length > 0) {
|
||||
test("compat fixtures need a browser on CI", () => assert.fail(skipReason));
|
||||
}
|
||||
|
||||
// The checks run inside the page; `sel` is the selector from the fixture.
|
||||
const CHECKS = {
|
||||
// Every matched element's text sits on one line, and no text starts below
|
||||
// an inline checkbox/indicator inside it (the "label under the icon" wrap).
|
||||
nowrap: (sel) => `(() => {
|
||||
const bad = [];
|
||||
for (const el of document.querySelectorAll(${JSON.stringify(sel)})) {
|
||||
const tops = [];
|
||||
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
for (let n; (n = w.nextNode());) {
|
||||
if (!n.textContent.trim()) continue;
|
||||
const r = document.createRange(); r.selectNodeContents(n);
|
||||
for (const x of r.getClientRects()) if (x.width > 0) tops.push(x.top);
|
||||
}
|
||||
const ind = el.querySelector('input, .partition-color-indicator, img, svg');
|
||||
const wrapped = tops.some((t) => Math.abs(t - tops[0]) > 3) ||
|
||||
(ind && tops.length && tops[0] >= ind.getBoundingClientRect().bottom - 1);
|
||||
if (wrapped) bad.push(el.textContent.trim().slice(0, 40));
|
||||
}
|
||||
return bad;
|
||||
})()`,
|
||||
// No horizontal overflow inside, and the box stays inside its parent.
|
||||
fits: (sel) => `(() => {
|
||||
const bad = [];
|
||||
for (const el of document.querySelectorAll(${JSON.stringify(sel)})) {
|
||||
const r = el.getBoundingClientRect(), p = el.parentElement.getBoundingClientRect();
|
||||
if (el.scrollWidth > el.clientWidth + 1 || r.right > p.right + 1)
|
||||
bad.push((el.id || el.className || el.tagName) + ' ' + Math.round(el.scrollWidth) + '/' + Math.round(el.clientWidth) + ' right ' + Math.round(r.right) + '>' + Math.round(p.right));
|
||||
}
|
||||
return bad;
|
||||
})()`,
|
||||
js: (expr) => `(() => { const v = (${expr}); return v ? [] : ['expression was ' + JSON.stringify(v)]; })()`,
|
||||
};
|
||||
|
||||
function readSpec(file) {
|
||||
const html = readFileSync(join(fixtureDir, file), "utf8");
|
||||
const m = html.match(/<script[^>]*id="compat"[^>]*>([\s\S]*?)<\/script>/);
|
||||
assert.ok(m, `${file}: missing <script id="compat"> block`);
|
||||
const spec = JSON.parse(m[1]);
|
||||
assert.ok(Array.isArray(spec.checks) && spec.checks.length, `${file}: no checks`);
|
||||
return spec;
|
||||
}
|
||||
|
||||
test("compat fixtures keep their geometry", { skip: skipReason ?? false }, async (t) => {
|
||||
const profile = mkdtempSync(join(tmpdir(), "cdp-compat-"));
|
||||
const chrome = spawn(
|
||||
chromeBin,
|
||||
["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`,
|
||||
"--no-first-run", "--no-default-browser-check", "--allow-file-access-from-files", "about:blank"],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
let port = null;
|
||||
for (let i = 0; i < 100 && !port; i++) {
|
||||
await sleep(100);
|
||||
const f = join(profile, "DevToolsActivePort");
|
||||
if (existsSync(f)) port = +readFileSync(f, "utf8").split("\n")[0];
|
||||
}
|
||||
assert.ok(port, "chrome did not expose a DevTools port");
|
||||
const { webSocketDebuggerUrl } = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
|
||||
const ws = new WebSocket(webSocketDebuggerUrl);
|
||||
await new Promise((res, rej) => {
|
||||
ws.addEventListener("open", res, { once: true });
|
||||
ws.addEventListener("error", () => rej(new Error("CDP connect failed")), { once: true });
|
||||
});
|
||||
let mid = 0;
|
||||
const pending = new Map();
|
||||
const handlers = new Set();
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && pending.has(m.id)) {
|
||||
const { res, rej } = pending.get(m.id);
|
||||
pending.delete(m.id);
|
||||
m.error ? rej(new Error(m.error.message)) : res(m.result);
|
||||
} else for (const h of handlers) h(m);
|
||||
});
|
||||
const send = (method, params = {}, sessionId) =>
|
||||
new Promise((res, rej) => {
|
||||
const id = ++mid;
|
||||
pending.set(id, { res, rej });
|
||||
ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
});
|
||||
|
||||
try {
|
||||
for (const file of fixtures) {
|
||||
const spec = readSpec(file);
|
||||
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
|
||||
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
|
||||
await send("Page.enable", {}, sessionId);
|
||||
if (fontCss)
|
||||
await send("Page.addScriptToEvaluateOnNewDocument", { source: `
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const s = document.createElement('style');
|
||||
s.textContent = ${JSON.stringify(fontCss)};
|
||||
document.head.prepend(s);
|
||||
});` }, sessionId);
|
||||
const evaljs = async (expression) => {
|
||||
const r = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }, sessionId);
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
for (const width of spec.viewports ?? [1280]) {
|
||||
await send("Emulation.setDeviceMetricsOverride",
|
||||
{ width, height: 900, deviceScaleFactor: 1, mobile: width < 768 }, sessionId);
|
||||
const loaded = new Promise((res) => {
|
||||
const h = (m) => { if (m.method === "Page.loadEventFired" && m.sessionId === sessionId) { handlers.delete(h); res(); } };
|
||||
handlers.add(h);
|
||||
});
|
||||
await send("Page.navigate", { url: pathToFileURL(join(fixtureDir, file)).href }, sessionId);
|
||||
await loaded;
|
||||
// Force the theme font to load before measuring (font-display: swap
|
||||
// would otherwise measure the fallback), then give layout one frame.
|
||||
await evaljs(`document.fonts.load('12px "Lato"').then(() => document.fonts.ready)
|
||||
.then(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))))`);
|
||||
const fontOk = await evaljs(`!${JSON.stringify(!!fontCss)} || document.fonts.check('12px "Lato"')`);
|
||||
assert.ok(fontOk, `${file}: theme font did not load from ${output}/fonts`);
|
||||
const sheets = await evaljs("[...document.styleSheets].map(s => { try { return s.cssRules.length } catch { return -1 } })");
|
||||
assert.ok(sheets.some((n) => n > 100), `${file}: main.css did not load (rules per sheet: ${sheets}) — run pnpm build`);
|
||||
for (const check of spec.checks) {
|
||||
if (check.viewports && !check.viewports.includes(width)) continue;
|
||||
const [kind, arg] = Object.entries(check).find(([k]) => k in CHECKS) ?? [];
|
||||
assert.ok(kind, `${file}: unknown check ${JSON.stringify(check)}`);
|
||||
const bad = await evaljs(CHECKS[kind](arg));
|
||||
assert.deepEqual(bad, [], `${file} @${width} ${kind} ${JSON.stringify(arg)} (ledger ${spec.ledger}): ${bad.join("; ")}`);
|
||||
}
|
||||
}
|
||||
await send("Target.closeTarget", { targetId });
|
||||
}
|
||||
} finally {
|
||||
ws.close();
|
||||
chrome.kill();
|
||||
await sleep(200);
|
||||
rmSync(profile, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
/* The patch ships as a plain deferred script, not a module. Evaluate it with
|
||||
an injected `module` to reach its parsing exports; `document` stays
|
||||
undefined so the DOM bootstrap is skipped. */
|
||||
const source = await readFile(
|
||||
new URL("../src/resource/patches/admin-status-logs.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const mod = { exports: {} };
|
||||
new Function("module", "exports", "document", "window", source)(
|
||||
mod,
|
||||
mod.exports,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
const { parseSyslogLine, parseDmesgLine, evaluate, PARSE_GATE } = mod.exports;
|
||||
|
||||
test("classic logread format (23.05 syslog, 24.10 syslog-wrapper) parses", () => {
|
||||
const r = parseSyslogLine(
|
||||
"Sat Jul 26 03:14:15 2026 daemon.info dnsmasq-dhcp[1]: DHCPACK(br-lan) 192.168.1.140",
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ time: r.time, fac: r.fac, sev: r.sev, sevTok: r.sevTok, tag: r.tag },
|
||||
{
|
||||
time: "Sat Jul 26 03:14:15 2026",
|
||||
fac: "daemon",
|
||||
sev: "info",
|
||||
sevTok: "daemon.info",
|
||||
tag: "dnsmasq-dhcp[1]",
|
||||
},
|
||||
);
|
||||
assert.equal(r.msg, "DHCPACK(br-lan) 192.168.1.140");
|
||||
|
||||
// ctime() pads single-digit days with a space — kept verbatim.
|
||||
const padded = parseSyslogLine(
|
||||
"Sun Jul 6 09:05:07 2026 daemon.warn odhcpd[2029]: No default route present",
|
||||
);
|
||||
assert.equal(padded.time, "Sun Jul 6 09:05:07 2026");
|
||||
|
||||
// Kernel lines routed through logread; hostapd logs without a pid.
|
||||
assert.equal(
|
||||
parseSyslogLine(
|
||||
"Sat Jul 26 03:14:16 2026 kern.info kernel: [73412.882110] br-lan: port 3(lan3) entered forwarding state",
|
||||
).tag,
|
||||
"kernel",
|
||||
);
|
||||
assert.equal(
|
||||
parseSyslogLine(
|
||||
"Sat Jul 26 03:14:17 2026 daemon.notice hostapd: wlan1: AP-STA-CONNECTED 3c:22:fb:8a:12:9d",
|
||||
).tag,
|
||||
"hostapd",
|
||||
);
|
||||
});
|
||||
|
||||
test("master RPC bracket format parses across browser locales", () => {
|
||||
// The datestr renders verbatim, brackets and timezone included — the
|
||||
// stamp is UTC-formatted upstream and hiding the zone read as local time.
|
||||
const en = parseSyslogLine(
|
||||
"[Jul 24, 2026, 10:59:50 PM UTC] daemon.info: dnsmasq-dhcp[1]: DHCPREQUEST(br-lan) 192.168.1.140",
|
||||
);
|
||||
assert.equal(en.time, "[Jul 24, 2026, 10:59:50 PM UTC]");
|
||||
assert.equal(en.sevTok, "daemon.info:");
|
||||
|
||||
assert.equal(
|
||||
parseSyslogLine(
|
||||
"[Jul 24, 2026, 10:59:50 PM Coordinated Universal Time] daemon.warn: odhcpd[2029]: No default route present",
|
||||
).time,
|
||||
"[Jul 24, 2026, 10:59:50 PM Coordinated Universal Time]",
|
||||
);
|
||||
|
||||
// Non-en datestrs parse fine and display untouched — never guessed at.
|
||||
const zh = parseSyslogLine(
|
||||
"[2026年7月24日 GMT 22:59:50] daemon.err: odhcpd[2029]: Failed to send RA",
|
||||
);
|
||||
assert.equal(zh.sev, "err");
|
||||
assert.equal(zh.time, "[2026年7月24日 GMT 22:59:50]");
|
||||
});
|
||||
|
||||
test("rendered tokens rejoin into the source line byte for byte", () => {
|
||||
const lines = [
|
||||
"[Jul 24, 2026, 10:59:50 PM UTC] daemon.info: dnsmasq-dhcp[1]: DHCPREQUEST(br-lan) 192.168.1.140",
|
||||
"[Jul 25, 2026, 4:30:55 PM UTC] kern.info: [ 0.000000] Booting Linux on physical CPU 0x0000000000 [0x51af8014]",
|
||||
"Sun Jul 6 09:05:07 2026 daemon.warn odhcpd[2029]: No default route present",
|
||||
"Sat Jul 26 03:14:16 2026 kern.info kernel: [73412.882110] br-lan: port 3(lan3) entered forwarding state",
|
||||
];
|
||||
for (const line of lines) {
|
||||
const p = parseSyslogLine(line);
|
||||
const parts = [p.time, p.sevTok];
|
||||
if (p.tag) parts.push(p.tag + ":");
|
||||
parts.push(p.msg);
|
||||
assert.equal(parts.join(" "), line, line);
|
||||
}
|
||||
const dm = "[ 12.345678] br-lan: port 3(lan3) entered forwarding state";
|
||||
const d = parseDmesgLine(dm);
|
||||
assert.equal([d.time, d.msg].join(" "), dm);
|
||||
});
|
||||
|
||||
test("severity comes from the field, never from message content", () => {
|
||||
const r = parseSyslogLine(
|
||||
"[Jul 24, 2026, 11:00:00 PM UTC] daemon.info: logger: user said daemon.err: fake",
|
||||
);
|
||||
assert.equal(r.sev, "info");
|
||||
assert.equal(r.msg, "user said daemon.err: fake");
|
||||
});
|
||||
|
||||
test("unknown facility/severity and foreign formats are rejected", () => {
|
||||
assert.equal(
|
||||
parseSyslogLine("[Jul 24, 2026, 11:00:00 PM UTC] bogus.info: x: y"),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
parseSyslogLine("[Jul 24, 2026, 11:00:00 PM UTC] daemon.bogus: x: y"),
|
||||
null,
|
||||
);
|
||||
// syslog-ng's classic template has no facility.severity field at all.
|
||||
assert.equal(
|
||||
parseSyslogLine("Jul 26 10:00:00 OpenWrt dnsmasq[123]: query answered"),
|
||||
null,
|
||||
);
|
||||
assert.equal(parseSyslogLine(""), null);
|
||||
});
|
||||
|
||||
test("dmesg lines: uptime stamp only, message verbatim, no severity", () => {
|
||||
// Kernel lines have no uniform tag grammar (upstream renders them as plain
|
||||
// text for the same reason) — a partial split styled only some lines and
|
||||
// ate only their colons, so no tag is ever extracted and the message keeps
|
||||
// its punctuation untouched.
|
||||
const r = parseDmesgLine(
|
||||
"[ 12.345678] br-lan: port 3(lan3) entered forwarding state",
|
||||
);
|
||||
assert.equal(r.time, "[ 12.345678]");
|
||||
assert.equal(r.tag, "");
|
||||
assert.equal(r.sev, "");
|
||||
assert.equal(r.msg, "br-lan: port 3(lan3) entered forwarding state");
|
||||
|
||||
const pci = parseDmesgLine(
|
||||
"[75821.004518] mt7921e 0000:01:00.0: Message 00000010 timeout",
|
||||
);
|
||||
assert.equal(pci.tag, "");
|
||||
assert.equal(pci.msg, "mt7921e 0000:01:00.0: Message 00000010 timeout");
|
||||
|
||||
assert.equal(parseDmesgLine("no bracket prefix here"), null);
|
||||
});
|
||||
|
||||
test("parse gate: mixed content falls back, empty value stays calm", () => {
|
||||
const good = "Sat Jul 26 03:14:15 2026 daemon.info dnsmasq[1]: ok";
|
||||
const bad = "Jul 26 10:00:00 OpenWrt dnsmasq[123]: syslog-ng style";
|
||||
|
||||
const healthy = evaluate(
|
||||
[good, good, good, good, bad].join("\n"),
|
||||
parseSyslogLine,
|
||||
);
|
||||
assert.ok(healthy.ratio >= PARSE_GATE, "4/5 parsed must stay enhanced");
|
||||
|
||||
const foreign = evaluate([bad, bad, bad].join("\n"), parseSyslogLine);
|
||||
assert.ok(foreign.ratio < PARSE_GATE, "foreign log must fall back");
|
||||
|
||||
const empty = evaluate("", parseSyslogLine);
|
||||
assert.equal(empty.rows.length, 0);
|
||||
assert.equal(empty.ratio, 1, "empty log must not trip the gate");
|
||||
});
|
||||
|
||||
test("built log payloads land in their homes", () => {
|
||||
const out = resolve(import.meta.dirname, "../../htdocs/luci-static/aurora");
|
||||
// The JS ships on-demand under one name — every supported release
|
||||
// (23.05/24.10/master) mounts both log pages under admin/status/logs/*.
|
||||
const js = resolve(out, "patches/admin-status-logs.js");
|
||||
assert.ok(existsSync(js), `${js} missing — run pnpm build first`);
|
||||
assert.ok(statSync(js).size <= 6_000, `${js} exceeds 6 KB`);
|
||||
// The CSS is core-page styling and lives in the main bundle, where the
|
||||
// Tailwind token pipeline (radius-base chain, app shadows) applies — a
|
||||
// stray patches/ copy would mean the component migration regressed.
|
||||
const main = readFileSync(resolve(out, "main.css"), "utf8");
|
||||
assert.ok(main.includes(".syslog-view"), "main.css lost the log viewer");
|
||||
assert.ok(
|
||||
main.includes("calc(var(--radius-base)"),
|
||||
"radius utilities no longer resolve through the reactive base chain",
|
||||
);
|
||||
assert.ok(!existsSync(resolve(out, "patches/admin-status-logs.css")));
|
||||
assert.ok(!existsSync(resolve(out, "patches/_log-shared.css")));
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const header = readFileSync(
|
||||
resolve(import.meta.dirname, "../../ucode/template/themes/aurora/header.ut"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Just enough ucode for these scripts: comments and the nav-type branches.
|
||||
const renderFor = (source, navType) =>
|
||||
source
|
||||
.replace(/\{#[\s\S]*?#\}/g, "")
|
||||
.replace(
|
||||
/\{% if \(nav_type == 'sidebar'\): %\}([\s\S]*?)\{% endif %\}/g,
|
||||
(_, body) => (navType === "sidebar" ? body : ""),
|
||||
);
|
||||
|
||||
const replaySource = header.match(
|
||||
/<script>\s*(\{% if \(nav_type == 'sidebar'\): %\}\s*\{#[^#]*#\}\s*function auroraCrumb[\s\S]*?)<\/script>/,
|
||||
)?.[1];
|
||||
const loginSource = header.match(
|
||||
/\{% if \(blank_page\): %\}\s*<script>([\s\S]*?)<\/script>/,
|
||||
)?.[1];
|
||||
|
||||
const classes = (...initial) => {
|
||||
const set = new Set(initial);
|
||||
return {
|
||||
set,
|
||||
contains: (name) => set.has(name),
|
||||
toggle: (name, on) => (on ? set.add(name) : set.delete(name), on),
|
||||
};
|
||||
};
|
||||
|
||||
const surface = (extra = {}) => {
|
||||
const el = { style: { display: "none" }, dataset: {}, html: null, ...extra };
|
||||
Object.defineProperty(el, "innerHTML", {
|
||||
set(value) {
|
||||
el.html = value;
|
||||
},
|
||||
});
|
||||
return el;
|
||||
};
|
||||
|
||||
// A sidebar as the snapshot left it: System was the active group then.
|
||||
const sidebarFixture = (activePath) => {
|
||||
const group = (name, label, links) => {
|
||||
const item = { classList: classes(), lastChild: {} };
|
||||
if (name === "system")
|
||||
item.classList.set.add("is-active-group").add("is-expanded");
|
||||
item.toggle = {
|
||||
tagName: "BUTTON",
|
||||
dataset: { section: name },
|
||||
parentNode: item,
|
||||
};
|
||||
item.label = { textContent: label };
|
||||
item.links = links.map(([page, text]) => ({
|
||||
pathname: `/cgi-bin/luci/admin/${name}/${page}`,
|
||||
textContent: text,
|
||||
classList: classes(
|
||||
...(name === "system" && page === "system" ? ["is-active-page"] : []),
|
||||
),
|
||||
}));
|
||||
item.querySelector = (sel) =>
|
||||
sel === ".nav-category-label" ? item.label : null;
|
||||
return item;
|
||||
};
|
||||
const direct = {
|
||||
tagName: "A",
|
||||
dataset: { section: "status" },
|
||||
textContent: "Status",
|
||||
classList: classes(),
|
||||
};
|
||||
const groups = [
|
||||
group("network", "Network", [
|
||||
["wireless", "Wireless"],
|
||||
["network", "Interfaces"],
|
||||
]),
|
||||
group("system", "System", [
|
||||
["system", "System"],
|
||||
["admin", "Administration"],
|
||||
]),
|
||||
];
|
||||
const links = [direct, ...groups.flatMap((g) => g.links)];
|
||||
const list = surface({
|
||||
dataset: { activePath },
|
||||
querySelectorAll: (sel) =>
|
||||
sel === "[data-section]"
|
||||
? [direct, ...groups.map((g) => g.toggle)]
|
||||
: sel === ".navigation-sublink"
|
||||
? groups.flatMap((g) => g.links)
|
||||
: [],
|
||||
querySelector: (sel) =>
|
||||
sel === ".is-active-group"
|
||||
? (groups.find((g) => g.classList.contains("is-active-group")) ?? null)
|
||||
: sel === ".is-active-page"
|
||||
? (links.find((l) => l.classList.contains("is-active-page")) ?? null)
|
||||
: null,
|
||||
});
|
||||
return { list, direct, groups };
|
||||
};
|
||||
|
||||
const run = ({
|
||||
navType,
|
||||
stamp = "s",
|
||||
cached = "s",
|
||||
luciMenu = true,
|
||||
elements,
|
||||
}) => {
|
||||
const store = {
|
||||
"aurora.nav": JSON.stringify([
|
||||
cached,
|
||||
Object.fromEntries(
|
||||
Object.keys(elements)
|
||||
.filter((id) => id !== "header-crumb")
|
||||
.map((id) => [id, `<${id}>`]),
|
||||
),
|
||||
]),
|
||||
"luci-session-store": JSON.stringify({ sid: luciMenu ? { menu: {} } : {} }),
|
||||
};
|
||||
const document = {
|
||||
body: { dataset: { navStamp: stamp } },
|
||||
getElementById: (id) => elements[id] ?? null,
|
||||
createElement: () => ({}),
|
||||
};
|
||||
new Function("document", "sessionStorage", renderFor(replaySource, navType))(
|
||||
document,
|
||||
{
|
||||
getItem: (key) => store[key] ?? null,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
test("header.ut carries the replay and the login-page clear", () => {
|
||||
assert.ok(replaySource, "replay script not found");
|
||||
assert.ok(loginSource, "login-page script not found");
|
||||
});
|
||||
|
||||
test("a sidebar replay paints inert and re-marks the current page", () => {
|
||||
const { list, direct, groups } = sidebarFixture("network/wireless");
|
||||
const footer = surface();
|
||||
const crumb = { replaceChildren: (...items) => (crumb.items = items) };
|
||||
|
||||
run({
|
||||
navType: "sidebar",
|
||||
elements: {
|
||||
"sidebar-list": list,
|
||||
"sidebar-footer": footer,
|
||||
"header-crumb": crumb,
|
||||
},
|
||||
});
|
||||
|
||||
for (const el of [list, footer]) {
|
||||
assert.equal(
|
||||
el.html,
|
||||
`<${el === list ? "sidebar-list" : "sidebar-footer"}>`,
|
||||
);
|
||||
assert.equal(el.inert, true);
|
||||
assert.equal(el.dataset.restored, "");
|
||||
}
|
||||
const [network, system] = groups;
|
||||
assert.deepEqual(
|
||||
[...network.classList.set],
|
||||
["is-active-group", "is-expanded"],
|
||||
);
|
||||
assert.deepEqual([...system.classList.set], []);
|
||||
assert.equal(direct.classList.contains("is-active-page"), false);
|
||||
assert.deepEqual(
|
||||
groups
|
||||
.flatMap((g) => g.links)
|
||||
.filter((l) => l.classList.contains("is-active-page"))
|
||||
.map((l) => l.textContent),
|
||||
["Wireless"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
crumb.items.map((li) => [li.className, li.textContent]),
|
||||
[
|
||||
["", "Network"],
|
||||
["crumb-sep", "/"],
|
||||
["current", "Wireless"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("a direct row is marked on its own section, with no sublink", () => {
|
||||
const { list, direct, groups } = sidebarFixture("status");
|
||||
const crumb = { replaceChildren: (...items) => (crumb.items = items) };
|
||||
|
||||
run({
|
||||
navType: "sidebar",
|
||||
elements: { "sidebar-list": list, "header-crumb": crumb },
|
||||
});
|
||||
|
||||
assert.equal(direct.classList.contains("is-active-page"), true);
|
||||
assert.ok(groups.every((g) => g.classList.set.size === 0));
|
||||
assert.ok(
|
||||
groups
|
||||
.flatMap((g) => g.links)
|
||||
.every((l) => !l.classList.contains("is-active-page")),
|
||||
);
|
||||
assert.deepEqual(
|
||||
crumb.items.map((li) => [li.className, li.textContent]),
|
||||
[["current", "Status"]],
|
||||
);
|
||||
});
|
||||
|
||||
test("a top menu replay only paints, inert", () => {
|
||||
const topmenu = surface();
|
||||
|
||||
run({ navType: "mega-menu", elements: { topmenu } });
|
||||
|
||||
assert.equal(topmenu.html, "<topmenu>");
|
||||
assert.equal(topmenu.style.display, "");
|
||||
assert.equal(topmenu.inert, true);
|
||||
});
|
||||
|
||||
test("nothing is replayed for another stamp or after luci-base flushed its menu", () => {
|
||||
for (const options of [{ cached: "other user" }, { luciMenu: false }]) {
|
||||
const topmenu = surface();
|
||||
run({ navType: "mega-menu", elements: { topmenu }, ...options });
|
||||
assert.equal(topmenu.html, null, JSON.stringify(options));
|
||||
assert.equal(topmenu.inert, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("the login page drops the cached nav", () => {
|
||||
const removed = [];
|
||||
new Function(
|
||||
"localStorage",
|
||||
"sessionStorage",
|
||||
"window",
|
||||
"document",
|
||||
renderFor(loginSource),
|
||||
)(
|
||||
{ getItem: () => null },
|
||||
{ removeItem: (key) => removed.push(key) },
|
||||
{ matchMedia: () => ({ matches: false }) },
|
||||
{ documentElement: { setAttribute() {} } },
|
||||
);
|
||||
assert.deepEqual(removed, ["aurora.nav"]);
|
||||
});
|
||||
@@ -0,0 +1,569 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
|
||||
const source = await readFile(
|
||||
new URL("../src/resource/menu-aurora.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const menuOrder = (item) => (Number.isFinite(item.order) ? item.order : 1000);
|
||||
|
||||
const compareMenuItems = (a, b) => {
|
||||
const orderDifference = menuOrder(a) - menuOrder(b);
|
||||
|
||||
if (orderDifference) return orderDifference;
|
||||
if (a.name === b.name) return 0;
|
||||
|
||||
return a.name < b.name ? -1 : 1;
|
||||
};
|
||||
|
||||
const getMenuChildren = (children = {}) =>
|
||||
Object.entries(children)
|
||||
.filter(([, child]) => child?.satisfied !== false && child?.title)
|
||||
.map(([name, child]) => ({ ...child, name }))
|
||||
.sort(compareMenuItems);
|
||||
|
||||
const loadMenuModule = (
|
||||
dispatchpath,
|
||||
getChildren = (item) => getMenuChildren(item?.children),
|
||||
) => {
|
||||
const baseclass = {
|
||||
extend(module) {
|
||||
return module;
|
||||
},
|
||||
};
|
||||
const ui = { menu: { getChildren } };
|
||||
const E = () => ({});
|
||||
const L = {
|
||||
env: { dispatchpath },
|
||||
url: (...segments) => `/${segments.join("/")}`,
|
||||
};
|
||||
const translate = (title) => `translated:${title}`;
|
||||
const document = {};
|
||||
const window = {};
|
||||
const localStorage = {};
|
||||
|
||||
return new Function(
|
||||
"baseclass",
|
||||
"ui",
|
||||
"E",
|
||||
"L",
|
||||
"_",
|
||||
"document",
|
||||
"window",
|
||||
"localStorage",
|
||||
source,
|
||||
)(baseclass, ui, E, L, translate, document, window, localStorage);
|
||||
};
|
||||
|
||||
test("normalizes, orders, and filters a mixed navigation model", () => {
|
||||
const menu = loadMenuModule(["admin", "network", "wireless"]);
|
||||
const tree = {
|
||||
children: {
|
||||
logout: { title: "Logout", order: 30 },
|
||||
titleless: { order: 5 },
|
||||
status: { title: "Status", order: 20 },
|
||||
unavailable: { title: "Unavailable", order: 4, satisfied: false },
|
||||
network: {
|
||||
title: "Network",
|
||||
order: 10,
|
||||
children: {
|
||||
wireless: { title: "Wireless", order: 10 },
|
||||
hidden: { title: "Hidden", order: 5, satisfied: false },
|
||||
titleless: { order: 6 },
|
||||
interfaces: { title: "Interfaces", order: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
menu.buildNavigationModel(getMenuChildren(tree.children), "admin"),
|
||||
[
|
||||
{
|
||||
name: "network",
|
||||
title: "translated:Network",
|
||||
href: "/admin/network",
|
||||
hasChildren: true,
|
||||
isLogout: false,
|
||||
isActiveGroup: true,
|
||||
isActivePage: false,
|
||||
activePage: {
|
||||
name: "wireless",
|
||||
title: "translated:Wireless",
|
||||
href: "/admin/network/wireless",
|
||||
isActivePage: true,
|
||||
},
|
||||
pages: [
|
||||
{
|
||||
name: "interfaces",
|
||||
title: "translated:Interfaces",
|
||||
href: "/admin/network/interfaces",
|
||||
isActivePage: false,
|
||||
},
|
||||
{
|
||||
name: "wireless",
|
||||
title: "translated:Wireless",
|
||||
href: "/admin/network/wireless",
|
||||
isActivePage: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
title: "translated:Status",
|
||||
href: "/admin/status",
|
||||
hasChildren: false,
|
||||
isLogout: false,
|
||||
isActiveGroup: false,
|
||||
isActivePage: false,
|
||||
activePage: null,
|
||||
pages: [],
|
||||
},
|
||||
{
|
||||
name: "logout",
|
||||
title: "translated:Logout",
|
||||
href: "/admin/logout",
|
||||
hasChildren: false,
|
||||
isLogout: true,
|
||||
isActiveGroup: false,
|
||||
isActivePage: false,
|
||||
activePage: null,
|
||||
pages: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("marks a direct current destination as an active page, not a group", () => {
|
||||
const menu = loadMenuModule(["admin", "status"]);
|
||||
const tree = {
|
||||
children: {
|
||||
status: { title: "Status" },
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
menu.buildNavigationModel(getMenuChildren(tree.children), "admin"),
|
||||
[
|
||||
{
|
||||
name: "status",
|
||||
title: "translated:Status",
|
||||
href: "/admin/status",
|
||||
hasChildren: false,
|
||||
isLogout: false,
|
||||
isActiveGroup: false,
|
||||
isActivePage: true,
|
||||
activePage: null,
|
||||
pages: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("omits invalid top-level items without a name", () => {
|
||||
const menu = loadMenuModule(["admin", "status"]);
|
||||
|
||||
assert.deepEqual(
|
||||
menu.buildNavigationModel(
|
||||
[
|
||||
{ title: "Invalid", children: {} },
|
||||
...getMenuChildren({
|
||||
status: { title: "Status" },
|
||||
}),
|
||||
],
|
||||
"admin",
|
||||
),
|
||||
[
|
||||
{
|
||||
name: "status",
|
||||
title: "translated:Status",
|
||||
href: "/admin/status",
|
||||
hasChildren: false,
|
||||
isLogout: false,
|
||||
isActiveGroup: false,
|
||||
isActivePage: true,
|
||||
activePage: null,
|
||||
pages: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("omits pages without a name returned by the menu API", () => {
|
||||
const getChildren = () => [
|
||||
{ title: "Invalid" },
|
||||
{ name: "wireless", title: "Wireless" },
|
||||
];
|
||||
const menu = loadMenuModule(["admin", "network", "wireless"], getChildren);
|
||||
|
||||
assert.deepEqual(
|
||||
menu.buildNavigationModel(
|
||||
[{ name: "network", title: "Network" }],
|
||||
"admin",
|
||||
)[0].pages,
|
||||
[
|
||||
{
|
||||
name: "wireless",
|
||||
title: "translated:Wireless",
|
||||
href: "/admin/network/wireless",
|
||||
isActivePage: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns an empty navigation model for empty input", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
|
||||
assert.deepEqual(menu.buildNavigationModel([], "admin"), []);
|
||||
});
|
||||
|
||||
const createClassList = (...classes) => {
|
||||
const values = new Set(classes);
|
||||
|
||||
return {
|
||||
contains(className) {
|
||||
return values.has(className);
|
||||
},
|
||||
toggle(className, force) {
|
||||
const enabled = force ?? !values.has(className);
|
||||
|
||||
if (enabled) values.add(className);
|
||||
else values.delete(className);
|
||||
|
||||
return enabled;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createFakeElement = (...classes) => {
|
||||
const attributes = new Map();
|
||||
|
||||
const element = {
|
||||
classList: createClassList(...classes),
|
||||
parentElement: null,
|
||||
closest(selector) {
|
||||
if (!selector.startsWith(".")) return null;
|
||||
|
||||
const className = selector.slice(1);
|
||||
let current = element;
|
||||
|
||||
while (current) {
|
||||
if (current.classList?.contains(className)) return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
getAttribute(name) {
|
||||
return attributes.get(name) ?? null;
|
||||
},
|
||||
hasAttribute(name) {
|
||||
return attributes.has(name);
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
removeAttribute(name) {
|
||||
attributes.delete(name);
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
attributes.set(name, String(value));
|
||||
},
|
||||
};
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
const createNavigationGroup = (...classes) => {
|
||||
const item = createFakeElement("navigation-group", ...classes);
|
||||
const toggle = createFakeElement("navigation-group-toggle");
|
||||
const region = createFakeElement("navigation-group-region");
|
||||
|
||||
toggle.parentElement = item;
|
||||
region.parentElement = item;
|
||||
item.querySelector = (selector) => {
|
||||
if (selector === ".navigation-group-toggle") return toggle;
|
||||
if (selector === ".navigation-group-region") return region;
|
||||
return null;
|
||||
};
|
||||
|
||||
return { item, region, toggle };
|
||||
};
|
||||
|
||||
const createNavigationSurface = (groups) => {
|
||||
const items = groups.map((group) => group.item);
|
||||
const listeners = new Map();
|
||||
|
||||
return {
|
||||
dataset: {},
|
||||
addEventListener(type, listener) {
|
||||
const typeListeners = listeners.get(type) ?? [];
|
||||
|
||||
typeListeners.push(listener);
|
||||
listeners.set(type, typeListeners);
|
||||
},
|
||||
contains(item) {
|
||||
return items.includes(item);
|
||||
},
|
||||
dispatch(type, event) {
|
||||
for (const listener of listeners.get(type) ?? []) listener(event);
|
||||
},
|
||||
listenerCount(type) {
|
||||
return listeners.get(type)?.length ?? 0;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === ".navigation-group.is-active-group") {
|
||||
return (
|
||||
items.find((item) => item.classList.contains("is-active-group")) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === ".navigation-group.is-expanded") {
|
||||
return items.filter((item) => item.classList.contains("is-expanded"));
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test("sets one navigation group expanded while preserving active state", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const { item, region, toggle } = createNavigationGroup(
|
||||
"is-active-group",
|
||||
"custom-state",
|
||||
);
|
||||
|
||||
region.setAttribute("aria-hidden", "true");
|
||||
region.setAttribute("inert", "");
|
||||
|
||||
menu.setNavigationGroupExpanded(item, true);
|
||||
|
||||
assert.equal(item.classList.contains("is-expanded"), true);
|
||||
assert.equal(item.classList.contains("is-active-group"), true);
|
||||
assert.equal(item.classList.contains("custom-state"), true);
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.equal(region.getAttribute("aria-hidden"), "false");
|
||||
assert.equal(region.hasAttribute("inert"), false);
|
||||
|
||||
menu.setNavigationGroupExpanded(item, false);
|
||||
|
||||
assert.equal(item.classList.contains("is-expanded"), false);
|
||||
assert.equal(item.classList.contains("is-active-group"), true);
|
||||
assert.equal(item.classList.contains("custom-state"), true);
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "false");
|
||||
assert.equal(region.getAttribute("aria-hidden"), "true");
|
||||
assert.equal(region.getAttribute("inert"), "");
|
||||
});
|
||||
|
||||
test("sets an expanded navigation group when controls or regions are missing", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const item = createFakeElement("navigation-group");
|
||||
|
||||
assert.doesNotThrow(() => menu.setNavigationGroupExpanded(item, true));
|
||||
assert.equal(item.classList.contains("is-expanded"), true);
|
||||
});
|
||||
|
||||
test("exclusively expands one navigation group", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const first = createNavigationGroup();
|
||||
const second = createNavigationGroup();
|
||||
const surface = createNavigationSurface([first, second]);
|
||||
|
||||
menu.setNavigationGroupExpanded(first.item, true);
|
||||
menu.setExclusiveNavigationGroupExpanded(surface, second.item, true);
|
||||
|
||||
assert.equal(first.item.classList.contains("is-expanded"), false);
|
||||
assert.equal(first.toggle.getAttribute("aria-expanded"), "false");
|
||||
assert.equal(first.region.getAttribute("aria-hidden"), "true");
|
||||
assert.equal(first.region.hasAttribute("inert"), true);
|
||||
assert.equal(second.item.classList.contains("is-expanded"), true);
|
||||
assert.equal(second.toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.equal(second.region.getAttribute("aria-hidden"), "false");
|
||||
assert.equal(second.region.hasAttribute("inert"), false);
|
||||
});
|
||||
|
||||
test("collapses only the requested navigation group", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const first = createNavigationGroup();
|
||||
const second = createNavigationGroup();
|
||||
const surface = createNavigationSurface([first, second]);
|
||||
|
||||
menu.setNavigationGroupExpanded(first.item, true);
|
||||
menu.setNavigationGroupExpanded(second.item, true);
|
||||
menu.setExclusiveNavigationGroupExpanded(surface, second.item, false);
|
||||
|
||||
assert.equal(first.item.classList.contains("is-expanded"), true);
|
||||
assert.equal(first.toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.equal(second.item.classList.contains("is-expanded"), false);
|
||||
assert.equal(second.toggle.getAttribute("aria-expanded"), "false");
|
||||
});
|
||||
|
||||
test("ignores missing exclusive navigation group arguments", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const group = createNavigationGroup();
|
||||
const surface = createNavigationSurface([group]);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
menu.setExclusiveNavigationGroupExpanded(null, group.item, true),
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
menu.setExclusiveNavigationGroupExpanded(surface, null, true),
|
||||
);
|
||||
assert.equal(group.item.classList.contains("is-expanded"), false);
|
||||
});
|
||||
|
||||
test("resets every expanded navigation group", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const first = createNavigationGroup();
|
||||
const second = createNavigationGroup();
|
||||
const collapsed = createNavigationGroup();
|
||||
const surface = createNavigationSurface([first, second, collapsed]);
|
||||
|
||||
menu.setNavigationGroupExpanded(first.item, true);
|
||||
menu.setNavigationGroupExpanded(second.item, true);
|
||||
menu.resetNavigationGroups(surface);
|
||||
|
||||
assert.equal(first.item.classList.contains("is-expanded"), false);
|
||||
assert.equal(first.toggle.getAttribute("aria-expanded"), "false");
|
||||
assert.equal(first.region.hasAttribute("inert"), true);
|
||||
assert.equal(second.item.classList.contains("is-expanded"), false);
|
||||
assert.equal(second.toggle.getAttribute("aria-expanded"), "false");
|
||||
assert.equal(second.region.hasAttribute("inert"), true);
|
||||
assert.equal(collapsed.item.classList.contains("is-expanded"), false);
|
||||
assert.equal(collapsed.toggle.getAttribute("aria-expanded"), null);
|
||||
assert.doesNotThrow(() => menu.resetNavigationGroups(null));
|
||||
});
|
||||
|
||||
test("expands the active navigation group exclusively", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const first = createNavigationGroup();
|
||||
const active = createNavigationGroup("is-active-group");
|
||||
const surface = createNavigationSurface([first, active]);
|
||||
|
||||
menu.setNavigationGroupExpanded(first.item, true);
|
||||
menu.expandActiveNavigationGroup(surface);
|
||||
|
||||
assert.equal(first.item.classList.contains("is-expanded"), false);
|
||||
assert.equal(active.item.classList.contains("is-expanded"), true);
|
||||
assert.equal(active.item.classList.contains("is-active-group"), true);
|
||||
assert.equal(active.toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.equal(active.region.hasAttribute("inert"), false);
|
||||
});
|
||||
|
||||
test("ignores missing active navigation groups", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const surface = createNavigationSurface([createNavigationGroup()]);
|
||||
|
||||
assert.doesNotThrow(() => menu.expandActiveNavigationGroup(surface));
|
||||
assert.doesNotThrow(() => menu.expandActiveNavigationGroup(null));
|
||||
});
|
||||
|
||||
test("binds one delegated navigation accordion listener", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const first = createNavigationGroup();
|
||||
const second = createNavigationGroup();
|
||||
const surface = createNavigationSurface([first, second]);
|
||||
let prevented = 0;
|
||||
let stopped = 0;
|
||||
|
||||
menu.setNavigationGroupExpanded(first.item, true);
|
||||
menu.bindNavigationAccordion(surface);
|
||||
menu.bindNavigationAccordion(surface);
|
||||
|
||||
assert.equal(surface.dataset.accordionBound, "true");
|
||||
assert.equal(surface.listenerCount("click"), 1);
|
||||
|
||||
surface.dispatch("click", {
|
||||
target: second.toggle,
|
||||
preventDefault() {
|
||||
prevented += 1;
|
||||
},
|
||||
stopPropagation() {
|
||||
stopped += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, 1);
|
||||
assert.equal(stopped, 1);
|
||||
assert.equal(first.item.classList.contains("is-expanded"), false);
|
||||
assert.equal(second.item.classList.contains("is-expanded"), true);
|
||||
|
||||
surface.dispatch("click", {
|
||||
target: createFakeElement("outside"),
|
||||
preventDefault() {
|
||||
prevented += 1;
|
||||
},
|
||||
stopPropagation() {
|
||||
stopped += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, 1);
|
||||
assert.equal(stopped, 1);
|
||||
assert.equal(second.item.classList.contains("is-expanded"), true);
|
||||
assert.doesNotThrow(() => menu.bindNavigationAccordion(null));
|
||||
});
|
||||
|
||||
test("ignores delegated clicks from targets without closest", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const group = createNavigationGroup();
|
||||
const surface = createNavigationSurface([group]);
|
||||
let prevented = 0;
|
||||
let stopped = 0;
|
||||
|
||||
menu.bindNavigationAccordion(surface);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
surface.dispatch("click", {
|
||||
target: {},
|
||||
preventDefault() {
|
||||
prevented += 1;
|
||||
},
|
||||
stopPropagation() {
|
||||
stopped += 1;
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(prevented, 0);
|
||||
assert.equal(stopped, 0);
|
||||
assert.equal(group.item.classList.contains("is-expanded"), false);
|
||||
});
|
||||
|
||||
test("handles delegated clicks from inside a navigation group toggle", () => {
|
||||
const menu = loadMenuModule(["admin"]);
|
||||
const first = createNavigationGroup();
|
||||
const second = createNavigationGroup();
|
||||
const surface = createNavigationSurface([first, second]);
|
||||
const label = createFakeElement("navigation-group-label");
|
||||
const span = createFakeElement("navigation-group-label-text");
|
||||
let prevented = 0;
|
||||
let stopped = 0;
|
||||
|
||||
label.parentElement = second.toggle;
|
||||
span.parentElement = label;
|
||||
menu.setNavigationGroupExpanded(first.item, true);
|
||||
menu.bindNavigationAccordion(surface);
|
||||
|
||||
surface.dispatch("click", {
|
||||
target: span,
|
||||
preventDefault() {
|
||||
prevented += 1;
|
||||
},
|
||||
stopPropagation() {
|
||||
stopped += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, 1);
|
||||
assert.equal(stopped, 1);
|
||||
assert.equal(first.item.classList.contains("is-expanded"), false);
|
||||
assert.equal(second.item.classList.contains("is-expanded"), true);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,336 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
|
||||
const readStylesheet = (path) =>
|
||||
readFile(new URL(`../src/media/${path}`, import.meta.url), "utf8");
|
||||
|
||||
const [navigationStyles, layoutStyles, overlayStyles] = await Promise.all([
|
||||
readStylesheet("components/_nav.css"),
|
||||
readStylesheet("_layout.css"),
|
||||
readStylesheet("components/_overlay.css"),
|
||||
]);
|
||||
|
||||
const getBlock = (source, selector) => {
|
||||
const selectorIndex = source.indexOf(selector);
|
||||
|
||||
assert.notEqual(selectorIndex, -1, `Missing selector: ${selector}`);
|
||||
|
||||
const blockStart = source.indexOf("{", selectorIndex);
|
||||
|
||||
assert.notEqual(blockStart, -1, `Missing block for selector: ${selector}`);
|
||||
|
||||
let depth = 1;
|
||||
|
||||
for (let index = blockStart + 1; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
if (source[index] === "}") depth -= 1;
|
||||
|
||||
if (depth === 0) return source.slice(blockStart + 1, index);
|
||||
}
|
||||
|
||||
assert.fail(`Unclosed block for selector: ${selector}`);
|
||||
};
|
||||
|
||||
const assertIncludesUtilities = (block, utilities) => {
|
||||
for (const utility of utilities) {
|
||||
assert.match(
|
||||
block,
|
||||
new RegExp(
|
||||
`(^|\\s)${utility.replaceAll("[", "\\[").replaceAll("]", "\\]")}($|\\s|;)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
test("shared navigation styles define active and expanded states", () => {
|
||||
const direct = getBlock(navigationStyles, ".navigation-direct");
|
||||
const directActive = getBlock(
|
||||
navigationStyles,
|
||||
".navigation-direct.is-active-page",
|
||||
);
|
||||
const expandedToggle = getBlock(
|
||||
navigationStyles,
|
||||
".navigation-group.is-expanded > .navigation-group-toggle",
|
||||
);
|
||||
const activeGroupToggle = getBlock(
|
||||
navigationStyles,
|
||||
".navigation-group.is-active-group > .navigation-group-toggle",
|
||||
);
|
||||
const activeSublink = getBlock(
|
||||
navigationStyles,
|
||||
".navigation-sublink.is-active-page",
|
||||
);
|
||||
const sublink = getBlock(navigationStyles, ".navigation-sublink");
|
||||
|
||||
assertIncludesUtilities(direct, ["text-text", "hover:text-text"]);
|
||||
// Active page is a filled brand pill on both direct links and sublinks.
|
||||
assertIncludesUtilities(directActive, [
|
||||
"text-brand",
|
||||
"hover:text-brand",
|
||||
"bg-brand-subtle",
|
||||
]);
|
||||
// Weight is the surface's call — the two run different scales.
|
||||
assert.doesNotMatch(directActive, /font-(?:normal|medium|semibold|bold)/);
|
||||
// An expanded group's label turns brand and rotates its arrow open.
|
||||
assertIncludesUtilities(expandedToggle, ["text-brand"]);
|
||||
assertIncludesUtilities(getBlock(expandedToggle, "&::after"), ["rotate-90"]);
|
||||
// The active group keeps a brand label even when manually collapsed, so the
|
||||
// current section stays marked while its pill is hidden — but it must not
|
||||
// rotate the arrow open in that collapsed state.
|
||||
assertIncludesUtilities(activeGroupToggle, ["text-brand"]);
|
||||
assert.doesNotMatch(activeGroupToggle, /rotate-90/);
|
||||
// The pill shape lives with the pill fill in the shared recipe, so the
|
||||
// hover/active background is rounded the same way on desktop and mobile.
|
||||
assertIncludesUtilities(sublink, [
|
||||
"font-medium",
|
||||
"hover:bg-hover-faint",
|
||||
"rounded-xl",
|
||||
]);
|
||||
assertIncludesUtilities(activeSublink, [
|
||||
"text-brand",
|
||||
"hover:text-brand",
|
||||
"font-semibold",
|
||||
"bg-brand-subtle",
|
||||
]);
|
||||
// The left accent bar is gone — no ::before rail on the active sublink.
|
||||
assert.doesNotMatch(activeSublink, /before/);
|
||||
});
|
||||
|
||||
test("one first-level row recipe serves both kinds on both surfaces", () => {
|
||||
const row = getBlock(navigationStyles, ".nav-row");
|
||||
|
||||
// Defined once, so a group toggle and a direct destination cannot come
|
||||
// out with different frames (#97).
|
||||
assertIncludesUtilities(row, [
|
||||
"flex",
|
||||
"w-full",
|
||||
"items-center",
|
||||
"appearance-none",
|
||||
"border-0",
|
||||
"bg-transparent",
|
||||
"shadow-none",
|
||||
"no-underline",
|
||||
]);
|
||||
// Mode files consume it; no per-kind or per-surface box class survives.
|
||||
assert.match(layoutStyles, /& \.sidebar-list \.nav-row \{/);
|
||||
assert.match(overlayStyles, /& \.nav-row \{/);
|
||||
assert.doesNotMatch(overlayStyles, /mobile-nav-link/);
|
||||
assert.doesNotMatch(navigationStyles, /nav-category \{/);
|
||||
|
||||
// :where() keeps the resting treatment at (0,1,0), below every state.
|
||||
const sidebarResting = getBlock(navigationStyles, ":where(.sidebar-list)");
|
||||
|
||||
assertIncludesUtilities(getBlock(sidebarResting, "& .nav-row"), [
|
||||
"text-text-muted",
|
||||
"hover:text-text",
|
||||
]);
|
||||
assertIncludesUtilities(getBlock(sidebarResting, "& .navigation-direct"), [
|
||||
"hover:bg-hover-faint",
|
||||
]);
|
||||
});
|
||||
|
||||
test("the drawer collapses a group to nothing so the row rhythm is declared", () => {
|
||||
const drawer = getBlock(overlayStyles, ".mobile-menu-overlay");
|
||||
const list = getBlock(drawer, "& .mobile-nav-list");
|
||||
const submenu = getBlock(drawer, "& .mobile-nav-submenu-list");
|
||||
|
||||
// The rhythm is the list's own gap, not a collapsed group's leftover box.
|
||||
assertIncludesUtilities(list, ["max-md:gap-y-4"]);
|
||||
// Full-bleed square rows must keep suppressing the shared selected fill.
|
||||
assertIncludesUtilities(getBlock(drawer, "& .nav-row"), [
|
||||
"max-md:bg-transparent",
|
||||
]);
|
||||
// Direct child of the 0fr track: its own box outlives the collapse.
|
||||
assertIncludesUtilities(submenu, ["max-md:m-0", "max-md:p-0"]);
|
||||
assert.doesNotMatch(submenu, /max-md:(?:mb-|py-|mt-|mx-|pt-|pb-)/);
|
||||
});
|
||||
|
||||
test("shared navigation styles own accordion animation without a guide rail", () => {
|
||||
const toggle = getBlock(navigationStyles, ".navigation-group-toggle");
|
||||
const region = getBlock(navigationStyles, ".navigation-group-region");
|
||||
const expandedRegion = getBlock(
|
||||
navigationStyles,
|
||||
".navigation-group.is-expanded > .navigation-group-region",
|
||||
);
|
||||
const submenu = getBlock(navigationStyles, ".navigation-submenu-list");
|
||||
|
||||
assertIncludesUtilities(getBlock(toggle, "&::after"), [
|
||||
"transition-[transform,opacity]",
|
||||
"duration-[250ms]",
|
||||
]);
|
||||
assert.match(toggle, /var\(--icon-arrow-right\)/);
|
||||
assertIncludesUtilities(region, [
|
||||
"grid",
|
||||
"grid-rows-[0fr]",
|
||||
"opacity-0",
|
||||
"transition-[grid-template-rows,opacity]",
|
||||
"duration-[250ms]",
|
||||
]);
|
||||
assertIncludesUtilities(expandedRegion, ["grid-rows-[1fr]", "opacity-100"]);
|
||||
// The vertical guide rail is removed: the submenu list carries no ::before
|
||||
// hairline anymore.
|
||||
assert.doesNotMatch(submenu, /before/);
|
||||
});
|
||||
|
||||
test("desktop sidebar styles only provide desktop navigation density", () => {
|
||||
const sidebar = getBlock(layoutStyles, 'body[data-nav-type="sidebar"]');
|
||||
const row = getBlock(sidebar, "& .sidebar-list .nav-row");
|
||||
const icon = getBlock(sidebar, "& .sidebar-list .nav-icon");
|
||||
const submenu = getBlock(sidebar, "& .sidebar-submenu");
|
||||
const sublink = getBlock(sidebar, "& .sidebar-submenu .navigation-sublink");
|
||||
|
||||
// Density for both first-level kinds at once.
|
||||
assertIncludesUtilities(row, [
|
||||
"gap-2",
|
||||
"px-3",
|
||||
"py-2",
|
||||
"text-lg",
|
||||
"font-semibold",
|
||||
"tracking-wide",
|
||||
]);
|
||||
// The frame is the shared recipe's; the mode file must not restate it.
|
||||
assert.doesNotMatch(row, /(^|\s)(?:flex|items-center|w-full)($|\s|;)/);
|
||||
// Nor colour: nesting under body[data-nav-type] would outrank the states.
|
||||
assert.doesNotMatch(row, /text-text|text-brand|bg-/);
|
||||
assertIncludesUtilities(icon, ["size-4.5"]);
|
||||
// pl-6.5 hangs sublink text off the parent label (px-3 + icon + gap −
|
||||
// the sublink's own px-3), not the row edge.
|
||||
assertIncludesUtilities(submenu, ["pl-6.5"]);
|
||||
assertIncludesUtilities(sublink, ["px-3", "py-1.5", "text-sm"]);
|
||||
assert.doesNotMatch(
|
||||
sidebar,
|
||||
/sidebar-section|sidebar-group-open|nav-link-active|has-active/,
|
||||
);
|
||||
assert.doesNotMatch(sidebar, /bg-brand-subtle/);
|
||||
});
|
||||
|
||||
test("desktop sidebar toggles move the column and the panel as one", () => {
|
||||
const sidebar = getBlock(layoutStyles, 'body[data-nav-type="sidebar"]');
|
||||
const easing = "250ms_var(--ease-in-out)";
|
||||
|
||||
// The column width and the panel's translate share one curve and length,
|
||||
// so the content edge stays on the sidebar edge in every frame.
|
||||
assert.ok(
|
||||
sidebar.includes(`md:[transition:grid-template-columns_${easing}]`),
|
||||
);
|
||||
assert.ok(
|
||||
getBlock(sidebar, "& .sidebar-panel-inner {").includes(
|
||||
`[transition:translate_${easing}]`,
|
||||
),
|
||||
);
|
||||
// Hidden only after the slide-out, shown at once on the way in.
|
||||
assertIncludesUtilities(
|
||||
getBlock(sidebar, "&.sidebar-collapsed .sidebar-panel {"),
|
||||
["invisible", "[transition:visibility_0s_250ms]"],
|
||||
);
|
||||
assert.doesNotMatch(layoutStyles, /sidebar-anim|sidebar-run-/);
|
||||
});
|
||||
|
||||
test("mobile drawer styles only provide mobile navigation density", () => {
|
||||
const drawer = getBlock(overlayStyles, ".mobile-menu-overlay");
|
||||
const icon = getBlock(drawer, "& .nav-icon");
|
||||
const submenu = getBlock(drawer, "& .mobile-nav-submenu-list");
|
||||
const sublink = getBlock(drawer, "& .mobile-nav-sublink");
|
||||
|
||||
// Row icons at drawer scale, against the text-2xl labels.
|
||||
assertIncludesUtilities(icon, ["max-md:size-6"]);
|
||||
// pl-6 hangs sublink text off the parent label (rows are px-0: icon +
|
||||
// gap − the sublink's own px-3).
|
||||
assertIncludesUtilities(submenu, ["max-md:pl-6"]);
|
||||
assertIncludesUtilities(sublink, [
|
||||
"max-md:min-h-10",
|
||||
"max-md:px-3",
|
||||
"max-md:py-2",
|
||||
"max-md:text-base",
|
||||
]);
|
||||
assert.doesNotMatch(sublink, /max-md:font-(?:normal|medium|semibold|bold)/);
|
||||
assert.doesNotMatch(
|
||||
drawer,
|
||||
/has-submenu|submenu-expanded|nav-link-active|has-active/,
|
||||
);
|
||||
assert.doesNotMatch(drawer, /bg-brand-subtle/);
|
||||
});
|
||||
|
||||
test("mega-menu panels scroll within the viewport", () => {
|
||||
const megaMenu = getBlock(layoutStyles, '[data-nav-type="mega-menu"] &');
|
||||
const panel = getBlock(megaMenu, "& .desktop-nav");
|
||||
|
||||
assert.ok(panel.includes("max-h-[calc(100dvh-3.5rem)]"));
|
||||
assertIncludesUtilities(panel, ["overflow-y-auto", "overscroll-contain"]);
|
||||
});
|
||||
|
||||
test("mega-menu reveal and retract share the page-top origin", () => {
|
||||
const megaMenu = getBlock(layoutStyles, '[data-nav-type="mega-menu"] &');
|
||||
const headerContent = getBlock(layoutStyles, "& .header-content");
|
||||
const container = getBlock(megaMenu, "& .desktop-menu-container");
|
||||
const sheet = getBlock(container, "& .desktop-menu-sheet");
|
||||
const canvas = getBlock(container, "& .desktop-menu-canvas");
|
||||
const panel = getBlock(megaMenu, "& .desktop-nav");
|
||||
const headerLift = layoutStyles.match(
|
||||
/The bar must sit above[\s\S]*?(\[data-nav-type="mega-menu"\][\s\S]*?)\n\s*\.brand/,
|
||||
)?.[1];
|
||||
|
||||
assertIncludesUtilities(headerContent, ["z-10"]);
|
||||
assertIncludesUtilities(container, ["top-0", "z-0"]);
|
||||
assert.doesNotMatch(
|
||||
container,
|
||||
/@apply bg-mega-menu-bg pointer-events-none absolute inset-x-0 top-0 h-14/,
|
||||
);
|
||||
assert.match(
|
||||
container,
|
||||
/&\.active,\s*&\.closing\s*\{[\s\S]*@apply[^;]*\bvisible\b/,
|
||||
);
|
||||
assert.match(
|
||||
container,
|
||||
/&\.closing\s*\{[\s\S]*@apply[^;]*\bopacity-0\b[^;]*\btransition-opacity\b[^;]*\bduration-\[220ms\]/,
|
||||
);
|
||||
assert.match(
|
||||
container,
|
||||
/&\.active\s*\{[\s\S]*@apply[^;]*pointer-events-auto/,
|
||||
);
|
||||
assertIncludesUtilities(sheet, ["top-0", "-translate-y-full"]);
|
||||
assert.match(
|
||||
sheet,
|
||||
/h-\[calc\(var\(--mega-menu-height,0px\)\+3\.5rem\)\]/,
|
||||
);
|
||||
assertIncludesUtilities(canvas, ["translate-y-full"]);
|
||||
assertIncludesUtilities(panel, ["top-14"]);
|
||||
// z-70 must span the retract too (or the curtain dims the closing panel).
|
||||
assert.match(
|
||||
headerLift ?? "",
|
||||
/desktop-menu-container:is\(\.active, \.closing\)\)\s*\{\s*@apply[^;]*z-70/,
|
||||
);
|
||||
// The bar no longer performs its own background transition. The flyout sheet
|
||||
// owns the opened surface and retracts fully to page top before hiding.
|
||||
assert.doesNotMatch(headerLift ?? "", /bg-mega-menu-bg/);
|
||||
});
|
||||
|
||||
test("theme flips repaint the bar in the same frame as the page", () => {
|
||||
const headerDeclaration = layoutStyles.match(
|
||||
/^header \{\s*@apply ([^;]+);/m,
|
||||
)?.[1];
|
||||
|
||||
assert.ok(headerDeclaration, "Missing header root declaration");
|
||||
assertIncludesUtilities(headerDeclaration, ["bg-bg", "sticky"]);
|
||||
// A colour transition on the bar itself would ease its bg/text over
|
||||
// --mega-menu-duration on every data-darkmode flip, lagging the bar behind
|
||||
// the untransitioned page in all three nav modes. The mega-menu wipe colour
|
||||
// lives on .desktop-menu-sheet, which carries its own transition.
|
||||
assert.doesNotMatch(headerDeclaration, /\btransition/);
|
||||
});
|
||||
|
||||
test("mega-menu category masks use Tailwind arbitrary utilities", () => {
|
||||
const title = getBlock(layoutStyles, "& .desktop-nav-title");
|
||||
const icon = getBlock(title, "&::before");
|
||||
|
||||
assert.match(
|
||||
icon,
|
||||
/@apply[^;]*\[mask:var\(--menu-icon,var\(--icon-category\)\)_center\/contain_no-repeat\]/,
|
||||
);
|
||||
// The default lives in the var() fallback only. Declaring --menu-icon on
|
||||
// the title compiles into a (0,5,1) selector chain that outranks every
|
||||
// .desktop-nav-title[data-section=…] (0,2,0) mapping in _nav.css.
|
||||
assert.doesNotMatch(title, /--menu-icon:/);
|
||||
assert.doesNotMatch(layoutStyles, /^\s*mask\s*:/m);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const overlayCss = await readFile(
|
||||
new URL("../src/media/components/_overlay.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const layoutCss = await readFile(
|
||||
new URL("../src/media/_layout.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("mobile and mega navigation share the translucent menu background", () => {
|
||||
const overlayRule = overlayCss.match(
|
||||
/\.mobile-menu-overlay\s*\{([\s\S]*?)\n\s*\.mobile-nav\s*\{/,
|
||||
)?.[1];
|
||||
const navRule = overlayCss.match(
|
||||
/\.mobile-nav\s*\{\s*@apply\s+([^;]+);/,
|
||||
)?.[1];
|
||||
|
||||
assert.ok(overlayRule?.includes("max-md:bg-mega-menu-bg"));
|
||||
assert.ok(!overlayRule?.includes("max-md:bg-scrim"));
|
||||
assert.ok(!overlayRule?.includes("max-md:p-1"));
|
||||
assert.ok(!navRule?.includes("max-md:bg-"));
|
||||
assert.match(layoutCss, /\bbg-mega-menu-bg\b/);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const read = (p) => readFileSync(new URL(p, import.meta.url), "utf8");
|
||||
|
||||
// ── 共享渐进层 ──────────────────────────────────────────────────────────
|
||||
test("the shared page-bg partial exists and both entries import it", () => {
|
||||
const partial = read("../src/media/_page-bg.css");
|
||||
assert.match(partial, /\.page-bg\s*\{/);
|
||||
assert.match(read("../src/media/main.css"), /@import "\.\/_page-bg\.css"/);
|
||||
assert.match(read("../src/media/login.css"), /@import "\.\/_page-bg\.css"/);
|
||||
});
|
||||
|
||||
test("login.css no longer carries its own .login-bg rules, only the token mapping", () => {
|
||||
const login = read("../src/media/login.css");
|
||||
assert.doesNotMatch(login, /\.login-bg/);
|
||||
assert.match(login, /--page-bg:\s*var\(--login-bg\)/);
|
||||
assert.match(login, /--page-bg-lqip:\s*var\(--login-bg-lqip\)/);
|
||||
});
|
||||
|
||||
test("the partial is pure mechanism: it consumes only the --page-bg pair", () => {
|
||||
const partial = read("../src/media/_page-bg.css");
|
||||
// No entry-specific tokens — each consumer maps its own pair, so the
|
||||
// shared sheet never drags admin tokens into login.css (or vice versa).
|
||||
assert.doesNotMatch(partial, /var\(--main-bg/);
|
||||
assert.doesNotMatch(partial, /var\(--login-bg/);
|
||||
// LQIP paints first, the full image fades in on ::after — the login page's
|
||||
// established progressive contract, now shared.
|
||||
assert.match(partial, /background-image:var\(--page-bg-lqip,var\(--page-bg\)\)/);
|
||||
assert.match(partial, /&\.full-loaded::after/);
|
||||
});
|
||||
|
||||
test("sysauth.ut renders the shared layer and its loader reads --page-bg generically", () => {
|
||||
const ut = readFileSync(
|
||||
new URL("../../ucode/template/themes/aurora/sysauth.ut", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
assert.match(ut, /class="page-bg"/);
|
||||
assert.doesNotMatch(ut, /login-bg/);
|
||||
assert.match(ut, /getComputedStyle\(bg\)\.getPropertyValue\('--page-bg'\)/);
|
||||
});
|
||||
|
||||
// ── header.ut 条件渲染:未配置背景时功能不存在 ─────────────────────────
|
||||
test("header.ut gates every main-bg artifact on struct_main_bg", () => {
|
||||
const ut = readFileSync(
|
||||
new URL("../../ucode/template/themes/aurora/header.ut", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
// 共享 preload 助手:login/main 两处调用,data: URI 不 preload
|
||||
assert.match(ut, /function bg_preload_url/);
|
||||
assert.match(ut, /bg_preload_url\(tokens\.struct_login_bg\)/);
|
||||
assert.match(ut, /bg_preload_url\(tokens\.struct_main_bg\)/);
|
||||
// body 的 data-bg 属性与其守卫在同一行模板上:admin 看 struct_main_bg,
|
||||
// 登录页看 struct_login_bg(登录三参数需要这个开关)
|
||||
assert.match(ut, /blank_page \? tokens\.struct_login_bg : tokens\.struct_main_bg[^\n]*data-bg/);
|
||||
assert.match(ut, /class="page-bg"/);
|
||||
// admin loader 与 sysauth 同款:读元素自身的 --page-bg
|
||||
assert.match(ut, /getComputedStyle\(bg\)\.getPropertyValue\('--page-bg'\)/);
|
||||
});
|
||||
|
||||
// ── D 方案:frost 只许上顶栏+侧栏,卡片禁 blur,默认值只在 fallback ────
|
||||
test("scheme D: frost is confined to the two chrome surfaces", () => {
|
||||
const mode = read("../src/media/_page-bg-mode.css");
|
||||
assert.match(mode, /body\[data-bg\]/);
|
||||
// backdrop-filter 恰好出现一次:header + sidebar 合用的那条规则
|
||||
assert.equal(mode.match(/backdrop-filter/g).length, 1);
|
||||
// 卡片规则存在且派生自 --surface(color-mix,无 blur)
|
||||
assert.match(mode, /\.cbi-section[^{]*\{[^}]*color-mix\(in_srgb,var\(--surface\)/);
|
||||
// 遮罩叠在大图层之上
|
||||
assert.match(mode, /page-bg::before/);
|
||||
assert.match(mode, /z-1/);
|
||||
// admin 的 token 映射也归 mode 文件:login.css 永不掺入 --main-bg
|
||||
assert.match(mode, /--page-bg:\s*var\(--main-bg\)/);
|
||||
assert.match(mode, /--page-bg-lqip:\s*var\(--main-bg-lqip\)/);
|
||||
});
|
||||
|
||||
test("scheme D: the three tunables default only in var() fallbacks", () => {
|
||||
const mode = read("../src/media/_page-bg-mode.css");
|
||||
assert.match(mode, /var\(--main-bg-alpha,67%\)/);
|
||||
assert.match(mode, /var\(--main-bg-blur,20px\)/);
|
||||
assert.match(mode, /var\(--main-bg-scrim,20%\)/);
|
||||
// body 上不得声明默认(UCI 注入在 :root,body 层声明会遮蔽)
|
||||
assert.doesNotMatch(mode, /--main-bg-alpha:\s*67%/);
|
||||
assert.match(read("../src/media/main.css"), /@import "\.\/_page-bg-mode\.css"/);
|
||||
assert.doesNotMatch(read("../src/media/login.css"), /_page-bg-mode/);
|
||||
});
|
||||
|
||||
test("reduced transparency restores opaque surfaces under a custom background", () => {
|
||||
const rt = read("../src/media/_reduced-transparency.css");
|
||||
assert.match(rt, /body\[data-bg\][^}]*--main-bg-alpha:\s*100%/s);
|
||||
});
|
||||
|
||||
// 固定定位的 .page-bg 按 CSS 绘制顺序画在非 positioned 普通流内容之上——
|
||||
// 登录页靠 .login-screen 的 relative 浮起,admin 侧必须同样把 #maincontent
|
||||
// 提为 relative。负 z-index 不是出路:body 自身不透明背景会把层盖掉。
|
||||
test("admin content is lifted above the fixed background layer", () => {
|
||||
const mode = read("../src/media/_page-bg-mode.css");
|
||||
assert.match(mode, /& #maincontent\s*\{[^}]*relative/);
|
||||
});
|
||||
|
||||
// 内容画布:#maincontent 铺一层派生自 --bg 的半透明底,给任意插件 DOM 一个
|
||||
// 可读性地板——兼容性不靠认识每个组件,靠这一条通用规则。55% 是定死的
|
||||
// 内部常量,刻意不做成配置项。
|
||||
test("the content canvas floors readability for arbitrary plugin DOM", () => {
|
||||
const mode = read("../src/media/_page-bg-mode.css");
|
||||
assert.match(mode, /& #maincontent\s*\{[^}]*color-mix\(in_srgb,var\(--bg\)_55%,transparent\)/);
|
||||
});
|
||||
|
||||
// 登录背景的三参数:卡片半透明+磨砂、全屏遮罩。默认 100%/0px/0% 逐像素
|
||||
// 复刻无参数时的现状——老用户零变化,磨砂玻璃卡是拉了滑杆才有的选择。
|
||||
test("login tunables default to today's exact look", () => {
|
||||
const login = read("../src/media/login.css");
|
||||
assert.match(login, /var\(--login-bg-alpha,100%\)/);
|
||||
assert.match(login, /var\(--login-bg-blur,0px\)/);
|
||||
assert.match(login, /var\(--login-bg-scrim,0%\)/);
|
||||
assert.match(login, /body\[data-bg\] \.login-card/);
|
||||
// 无障碍:减少透明度偏好下登录卡回不透明——必须写在 login.css 自己身上
|
||||
// (它不引入 _reduced-transparency.css,否则会拖进 admin 专属 token)
|
||||
assert.match(login, /prefers-reduced-transparency[^}]*\{[^]*?--login-bg-alpha:\s*100%/);
|
||||
// 登录页设了背景时 body 也要打 data-bg(模板守卫)
|
||||
const ut = readFileSync(
|
||||
new URL("../../ucode/template/themes/aurora/header.ut", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
assert.match(ut, /blank_page && tokens\.struct_login_bg[^\n]*data-bg|\(blank_page \? tokens\.struct_login_bg : tokens\.struct_main_bg\)[^\n]*data-bg/);
|
||||
});
|
||||
|
||||
// studio 背景组件的版式曾走页面补丁通道(admin-system-aurora),等于把
|
||||
// luci-app-aurora-config 自己的组件绑死在本主题上:装到 shadcn 下补丁不加载,
|
||||
// .bg-preview 丢掉 position/height/overflow,内部八层 absolute 逃出去糊满全页。
|
||||
// 版式已随 app 走(studio.js 的 ensureBgCardStyles 注入),这里只守住"别回来"。
|
||||
test("the studio background component's layout is the app's, not this theme's", () => {
|
||||
const src = new URL("../src/media/patches/admin-system-aurora.css", import.meta.url);
|
||||
const built = new URL(
|
||||
"../../htdocs/luci-static/aurora/patches/admin-system-aurora.css",
|
||||
import.meta.url
|
||||
);
|
||||
assert.ok(!existsSync(src), "the retired patch source must stay deleted");
|
||||
assert.ok(!existsSync(built), "and so must its build artifact");
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = resolve(import.meta.dirname, "../..");
|
||||
const css = readFileSync(resolve(root, "htdocs/luci-static/aurora/main.css"), "utf8");
|
||||
const header = readFileSync(resolve(root, "ucode/template/themes/aurora/header.ut"), "utf8");
|
||||
|
||||
test("only the typed theme toggle captures the page in a view transition", () => {
|
||||
assert.match(header, /ViewTransitionTypeSet \? \{ update: apply, types: \['theme'\] \} : apply/);
|
||||
// Standalone rule: merged into a selector list, an engine without
|
||||
// :active-view-transition-type() would drop its neighbours too.
|
||||
assert.match(css, /\}:root:not\(:active-view-transition-type\(theme\)\)\{view-transition-name:none\}/);
|
||||
});
|
||||
|
||||
test("late content fades in without a filling animation", () => {
|
||||
const rule = css.match(/#view>:not\(\.spinning\)[^{]*\{([^}]*)\}/);
|
||||
assert.ok(rule, "missing page-entry fade rule");
|
||||
assert.match(rule[1], /\bbackwards\b/);
|
||||
assert.doesNotMatch(rule[1], /\bboth\b/);
|
||||
});
|
||||
|
||||
test("a collapsed sidebar is restored before anything resolves body's style", () => {
|
||||
// The sidebar column transitions; a class landing after a style flush
|
||||
// (the page-bg script's getComputedStyle) would ease it shut on load.
|
||||
const restore = header.indexOf("aurora.sidebarCollapsed");
|
||||
assert.ok(restore > header.indexOf("<body"), "restore runs inside body");
|
||||
assert.ok(
|
||||
restore < header.indexOf("getComputedStyle"),
|
||||
"restore precedes the page-bg style read",
|
||||
);
|
||||
assert.ok(
|
||||
restore < header.indexOf("<header>"),
|
||||
"restore precedes the header markup",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveMode } from "@eamonxg/luci-theme-tokens/aurora";
|
||||
|
||||
test("resolveMode(light) returns flat oklch for all tokens", () => {
|
||||
const out = resolveMode("light");
|
||||
// 10 inputs + 19 derived = 29 color tokens.
|
||||
for (const k of [
|
||||
"bg",
|
||||
"surface",
|
||||
"text",
|
||||
"brand",
|
||||
"text_muted",
|
||||
"surface_sunken",
|
||||
"hairline",
|
||||
"brand_subtle",
|
||||
"focus_ring",
|
||||
"progress_start",
|
||||
"progress_end",
|
||||
"info_surface",
|
||||
"scrim",
|
||||
"mega_menu_bg",
|
||||
]) {
|
||||
assert.ok(out[k], `missing ${k}`);
|
||||
assert.ok(
|
||||
!out[k].includes("color-mix") && !out[k].includes("var("),
|
||||
`${k} not flat: ${out[k]}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("progress_end aliases brand", () => {
|
||||
const out = resolveMode("light");
|
||||
assert.equal(out.progress_end, out.brand);
|
||||
});
|
||||
|
||||
test("scrim uses one 60% alpha value in both modes", () => {
|
||||
assert.equal(resolveMode("light").scrim, "oklch(0% 0 0 / 0.6)");
|
||||
assert.equal(resolveMode("dark").scrim, "oklch(0% 0 0 / 0.6)");
|
||||
});
|
||||
|
||||
test("menu panel is fully opaque (frost lives on the curtain, not the panel)", () => {
|
||||
const l = resolveMode("light");
|
||||
const d = resolveMode("dark");
|
||||
// The panel animates height, so it carries no backdrop-filter; and it is
|
||||
// fully opaque so the dimmed curtain never bleeds through to grey it off the
|
||||
// header tone or leak page content. The header lifts to this same colour on
|
||||
// open, so any alpha here would break the one-continuous-surface look.
|
||||
assert.ok(!l.mega_menu_bg.includes("/"), `light opaque: ${l.mega_menu_bg}`);
|
||||
assert.ok(!d.mega_menu_bg.includes("/"), `dark opaque: ${d.mega_menu_bg}`);
|
||||
});
|
||||
|
||||
test("light bg is the snow-blue canvas; surface stays pure white", () => {
|
||||
const light = resolveMode("light");
|
||||
assert.equal(light.bg, "oklch(0.984 0.004 235)");
|
||||
assert.equal(light.surface, "oklch(1 0 0)");
|
||||
});
|
||||
|
||||
test("dark brand and progress colors match the mobile navigation design", () => {
|
||||
const dark = resolveMode("dark");
|
||||
|
||||
assert.equal(dark.brand, "oklch(0.6 0.13 188.745)");
|
||||
assert.equal(dark.on_brand, "oklch(1 0 0)");
|
||||
// Derived from brand like light: end aliases brand, start mixes toward sunken.
|
||||
assert.equal(dark.progress_start, "oklch(44.78% 0.0865 193.2)");
|
||||
assert.equal(dark.progress_end, dark.brand);
|
||||
});
|
||||
|
||||
test("dark differs from light", () => {
|
||||
assert.notEqual(
|
||||
resolveMode("light").surface_sunken,
|
||||
resolveMode("dark").surface_sunken,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, "../..");
|
||||
const source = (path) => readFileSync(resolve(projectRoot, path), "utf8");
|
||||
|
||||
test("navigation transitions avoid blanket and full-page layout animation", () => {
|
||||
const nav = source(".dev/src/media/components/_nav.css");
|
||||
const layout = source(".dev/src/media/_layout.css");
|
||||
|
||||
assert.doesNotMatch(nav, /\btransition-all\b/);
|
||||
assert.doesNotMatch(layout, /transition-\[grid-template-columns\]/);
|
||||
});
|
||||
|
||||
test("login effects do not reserve compositor layers permanently", () => {
|
||||
const login = source(".dev/src/media/login.css");
|
||||
assert.doesNotMatch(login, /\[will-change:opacity\]/);
|
||||
});
|
||||
|
||||
test("login template reuses server data when including the header", () => {
|
||||
const sysauth = source("ucode/template/themes/aurora/sysauth.ut");
|
||||
const header = source("ucode/template/themes/aurora/header.ut");
|
||||
|
||||
assert.match(
|
||||
sysauth,
|
||||
/include\('header',\s*\{[^}]*prefetched_boardinfo:\s*boardinfo[^}]*prefetched_tokens:\s*themeTokens[^}]*\}\)/s,
|
||||
);
|
||||
assert.match(header, /prefetched_boardinfo\s*\?\?/);
|
||||
assert.match(header, /prefetched_tokens\s*\?\?/);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import Color from "colorjs.io";
|
||||
import { resolveMode } from "@eamonxg/luci-theme-tokens/aurora";
|
||||
|
||||
// [L, C, H] in oklch; alpha separate.
|
||||
const lch = (s) => {
|
||||
const c = new Color(s).to("oklch");
|
||||
return { L: c.coords[0], C: c.coords[1], H: c.coords[2] || 0, a: c.alpha };
|
||||
};
|
||||
const nearHue = (s, h, tol = 8) => {
|
||||
const H = lch(s).H;
|
||||
const d = Math.abs(((H - h + 540) % 360) - 180);
|
||||
return d <= tol;
|
||||
};
|
||||
const read = (p) => readFileSync(new URL(p, import.meta.url), "utf8");
|
||||
|
||||
test("light low-emphasis surfaces carry the Gray tint (chroma > 0)", () => {
|
||||
const l = resolveMode("light");
|
||||
assert.ok(lch(l.surface_sunken).C > 0.001, `sunken flat: ${l.surface_sunken}`);
|
||||
assert.ok(lch(l.surface_overlay).C > 0.001, `overlay flat: ${l.surface_overlay}`);
|
||||
});
|
||||
|
||||
test("light bg, sunken and overlay share one hue family (~235)", () => {
|
||||
const l = resolveMode("light");
|
||||
for (const k of ["bg", "surface_sunken", "surface_overlay"]) {
|
||||
assert.ok(nearHue(l[k], 235), `${k} hue off: ${l[k]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("light sunken is darker than bg, overlay is lighter (toward white)", () => {
|
||||
const l = resolveMode("light");
|
||||
assert.ok(lch(l.surface_sunken).L < lch(l.bg).L, "sunken not below bg");
|
||||
assert.ok(lch(l.surface_overlay).L > lch(l.bg).L, "overlay not above bg");
|
||||
});
|
||||
|
||||
test("dark surfaces are the Gray family (~264), not zinc (286)", () => {
|
||||
const d = resolveMode("dark");
|
||||
for (const k of ["bg", "surface", "surface_sunken", "text_muted"]) {
|
||||
assert.ok(nearHue(d[k], 264, 10), `${k} hue off: ${d[k]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("dark layering: page < card, sunken between page and card", () => {
|
||||
const d = resolveMode("dark");
|
||||
const bg = lch(d.bg).L, surf = lch(d.surface).L, sunk = lch(d.surface_sunken).L;
|
||||
assert.ok(bg < surf, "page not darker than card");
|
||||
assert.ok(sunk >= bg && sunk < surf, `sunken not between page and card: ${d.surface_sunken}`);
|
||||
});
|
||||
|
||||
test("mega-menu reveal is compositor-only and the frost never overlaps it", () => {
|
||||
const layout = read("../src/media/_layout.css");
|
||||
const overlay = read("../src/media/components/_overlay.css");
|
||||
// The sheet is the moving surface, so it must carry NO backdrop-filter —
|
||||
// blurring an animating box re-rasterises every frame (the old clip-path
|
||||
// flicker) — and must animate translate, not height: a height transition
|
||||
// pays main-thread layout + repaint per frame (the low-end hover jank).
|
||||
const sheet =
|
||||
layout.match(/& \.desktop-menu-sheet\s*\{([\s\S]*?)\n\s*\}/)?.[1] ?? "";
|
||||
assert.ok(!sheet?.includes("backdrop-blur"), `sheet must not blur: ${sheet}`);
|
||||
assert.ok(
|
||||
!layout.includes(
|
||||
"@apply bg-mega-menu-bg pointer-events-none absolute inset-x-0 top-0 h-14",
|
||||
),
|
||||
"header band must not be static",
|
||||
);
|
||||
// Apple-style geometry: the sheet is the whole opened surface, including the
|
||||
// header-height band. Its closed bottom edge is page top, matching the
|
||||
// reveal origin.
|
||||
assert.ok(
|
||||
sheet?.includes("transition-[translate]") &&
|
||||
sheet?.includes("top-0") &&
|
||||
sheet?.includes("h-[calc(var(--mega-menu-height,0px)+3.5rem)]") &&
|
||||
sheet?.includes("-translate-y-full"),
|
||||
`sheet must move from and to page top: ${sheet}`,
|
||||
);
|
||||
const containerRule =
|
||||
layout.match(/& \.desktop-menu-container\s*\{\s*@apply\s+([^;]+);/)?.[1] ??
|
||||
"";
|
||||
assert.ok(
|
||||
!containerRule.includes("transition") &&
|
||||
!containerRule.includes("will-change"),
|
||||
`container is a static frame, it must not animate: ${containerRule}`,
|
||||
);
|
||||
// A translated sheet moves inside a static clip frame rooted at the page top.
|
||||
// The container adds shadow slack below the travel but does not move itself.
|
||||
assert.ok(
|
||||
containerRule.includes("top-0") &&
|
||||
containerRule.includes("h-[calc(var(--mega-menu-height,0px)+3.5rem+3rem)]") &&
|
||||
containerRule.includes("overflow-clip"),
|
||||
`container must clip the page-top retract: ${containerRule}`,
|
||||
);
|
||||
assert.match(
|
||||
layout,
|
||||
/&\.closing\s*\{[\s\S]*?@apply[^;]*opacity-0[^;]*transition-opacity[^;]*duration-\[220ms\]/,
|
||||
);
|
||||
// The counter-transformed canvas must mirror the sheet's endpoints and
|
||||
// timing exactly or the content drifts during the wipe; both take the
|
||||
// distance-adaptive duration from the same variable.
|
||||
const canvasRule =
|
||||
layout.match(/& \.desktop-menu-canvas\s*\{([\s\S]*?)\n\s*\}/)?.[1] ?? "";
|
||||
for (const token of [
|
||||
"transition-[translate]",
|
||||
"duration-(--mega-menu-duration,300ms)",
|
||||
"translate-y-full",
|
||||
]) {
|
||||
assert.ok(canvasRule.includes(token), `canvas missing ${token}: ${canvasRule}`);
|
||||
}
|
||||
assert.ok(
|
||||
sheet?.includes("duration-(--mega-menu-duration,300ms)"),
|
||||
`sheet must share the adaptive duration: ${sheet}`,
|
||||
);
|
||||
// The curtain only dims: a blurred full-screen fade dropped frames on weak
|
||||
// GPUs. Opacity fades out at once; visibility waits for it without tweening.
|
||||
assert.ok(
|
||||
!overlay.includes("max-md:backdrop-blur-lg"),
|
||||
"opaque mobile overlay must not blur hidden pixels",
|
||||
);
|
||||
const curtain = overlay
|
||||
.split("\n")
|
||||
.find((l) => l.includes("bg-mega-menu-scrim"));
|
||||
assert.ok(
|
||||
!curtain?.includes("backdrop-"),
|
||||
`desktop curtain must not blur: ${curtain}`,
|
||||
);
|
||||
assert.ok(
|
||||
curtain?.includes("[transition:opacity_220ms_") &&
|
||||
curtain.includes(",visibility_0s_220ms]") &&
|
||||
!curtain.includes("delay-"),
|
||||
`desktop curtain: immediate opacity exit, delayed visibility: ${curtain}`,
|
||||
);
|
||||
assert.ok(
|
||||
/&\.active\s*\{\s*@apply[^;]*\[transition-delay:0s\]/.test(overlay),
|
||||
"desktop curtain must show without delay",
|
||||
);
|
||||
assert.ok(!overlay.includes(".settled"), "settle-gated frost must stay gone");
|
||||
});
|
||||
|
||||
test("maincontent cards use a hairline border, not heavy shadow", () => {
|
||||
const card = read("../src/media/components/_card.css");
|
||||
const rule = card.match(/#maincontent &\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
|
||||
assert.ok(rule.includes("border-hairline"), `no hairline: ${rule}`);
|
||||
assert.ok(rule.includes("border"), `no border: ${rule}`);
|
||||
assert.ok(!rule.includes("shadow-lg"), `still shadow-lg: ${rule}`);
|
||||
});
|
||||
|
||||
test("main view does not create an animation stacking context", () => {
|
||||
const layout = read("../src/media/_layout.css");
|
||||
const rule = layout.match(/#view\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
|
||||
|
||||
for (const utility of [
|
||||
"animate-in",
|
||||
"fade-in-0",
|
||||
"slide-in-from-top-2",
|
||||
"fill-mode-backwards",
|
||||
"fill-mode-both",
|
||||
]) {
|
||||
assert.ok(!rule.includes(utility), `view still uses ${utility}: ${rule}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("content dropdowns stay above the closed header and below the open mega-menu", () => {
|
||||
const layer = (value) => ["z", value].join("-");
|
||||
const layout = read("../src/media/_layout.css");
|
||||
const dropdown = read("../src/media/components/_dropdown.css");
|
||||
const message = read("../src/media/components/_message.css");
|
||||
const overlay = read("../src/media/components/_overlay.css");
|
||||
const headerRule = layout.match(/^header\s*\{\s*@apply\s+([^;]+);/m)?.[1] ?? "";
|
||||
const activeHeaderRule =
|
||||
layout.match(
|
||||
/\[data-nav-type="mega-menu"\]\s*&:has\([\s\S]*?\.desktop-menu-container[\s\S]*?\.active[\s\S]*?\)\s*\{\s*@apply\s+([^;]+);/,
|
||||
)?.[1] ?? "";
|
||||
const dropdownRule = dropdown.match(/&\.dropdown\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
|
||||
const messageRule =
|
||||
message.match(/\.alert-message\s*\{[\s\S]*?@apply\s+([^;]+);/)?.[1] ?? "";
|
||||
const overlayRule =
|
||||
overlay.match(/& \.desktop-menu-overlay\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
|
||||
|
||||
// The alert banner stays in flow (d747e5f): ui.addNotification() inserts
|
||||
// banners as siblings, so a sticky one would stack on top of the next and
|
||||
// pin opaque surface over scroll targets. In flow it needs no layer at all.
|
||||
assert.ok(messageRule, "alert-message rule not found");
|
||||
assert.ok(
|
||||
!/\b(sticky|fixed)\b/.test(messageRule) && !/\bz-/.test(messageRule),
|
||||
`alert banner must stay in flow with no layer: ${messageRule}`,
|
||||
);
|
||||
assert.ok(headerRule.includes(layer(40)), `closed header layer changed: ${headerRule}`);
|
||||
assert.ok(dropdownRule.includes(layer(50)), `dropdown layer changed: ${dropdownRule}`);
|
||||
assert.ok(overlayRule.includes(layer(60)), `menu overlay layer changed: ${overlayRule}`);
|
||||
assert.ok(
|
||||
activeHeaderRule.includes(layer(70)),
|
||||
`open mega-menu layer changed: ${activeHeaderRule}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("tables get a hairline frame + sunken header, body stays unclipped", () => {
|
||||
const t = read("../src/media/components/_table.css");
|
||||
const root = t.match(/table\.table,\s*\.table\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
|
||||
assert.ok(root.includes("border-hairline") && root.includes("border"), `no frame: ${root}`);
|
||||
assert.ok(root.includes("rounded-2xl"), `no radius: ${root}`);
|
||||
// Dropdowns inside cells must not be clipped: no global overflow-hidden on the table.
|
||||
assert.ok(!root.includes("overflow-hidden"), `body clipped: ${root}`);
|
||||
assert.ok(root.includes("overflow-visible"), `not overflow-visible: ${root}`);
|
||||
// Header carries the sunken surface.
|
||||
const thRules = [...t.matchAll(/&\s*th,\s*&\s*\.th\s*\{\s*@apply\s+([^;]+);/g)].map((m) => m[1]);
|
||||
const th = thRules.find((rule) => rule.includes("bg-surface-sunken")) ?? "";
|
||||
assert.ok(th, `header not sunken: ${thRules.join(" | ")}`);
|
||||
// Top corners round only on the table's first row; a second header row
|
||||
// (e.g. .cbi-section-table-descr) must stay square.
|
||||
assert.ok(!th.includes("rounded-t"), `generic header cells carry corner radius: ${th}`);
|
||||
const firstRow =
|
||||
t.match(/&:first-child\s*\{\s*&\s*th,\s*&\s*\.th\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
|
||||
assert.ok(
|
||||
firstRow.includes("first:rounded-tl-2xl") && firstRow.includes("last:rounded-tr-2xl"),
|
||||
`first-row corner radius missing: ${firstRow}`,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user