449 lines
22 KiB
JavaScript
449 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Theme audit — opens every manager screen in every look (and the palettes in
|
|
* dark + light) and checks the layout in a real browser:
|
|
*
|
|
* - nothing spills out of the box it sits in (the bulk bar's Delete did),
|
|
* - the page never scrolls sideways,
|
|
* - one-line controls (buttons, chips, nav, counts) never wrap,
|
|
* - a checkbox draws its state once: the tick in boxed looks, only the
|
|
* "[x]" text in TUI, and the text matches the box's real state,
|
|
* - a radio in TUI reads ( ) / (*) for its real state,
|
|
* - a circle never sits inside a squared control (switch knob, thumb),
|
|
* - text keeps 3:1 contrast against what it sits on.
|
|
*
|
|
* Needs a built manager (`pnpm build:manager`) and serve.py's API:
|
|
*
|
|
* python scripts/theme-audit/serve.py --port 8199 &
|
|
* node scripts/theme-audit/audit.mjs [--base http://127.0.0.1:8199]
|
|
* [--out theme-audit-out] [--shots] [--looks tui,soft] [--screens all]
|
|
*
|
|
* Writes <out>/report.json and, with --shots, one PNG per look x screen.
|
|
* Exits 1 when any check fails. CI runs it as the theme-audit job.
|
|
*/
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { chromium } from "playwright";
|
|
|
|
const args = process.argv.slice(2);
|
|
function opt(name, fallback) {
|
|
const i = args.indexOf(`--${name}`);
|
|
return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : fallback;
|
|
}
|
|
const BASE = opt("base", "http://127.0.0.1:8199").replace(/\/$/, "");
|
|
const OUT = opt("out", "theme-audit-out");
|
|
const SHOTS = args.includes("--shots");
|
|
const ONLY_LOOKS = opt("looks", "")?.split(",").filter(Boolean);
|
|
const ONLY_SCREENS = opt("screens", "")?.split(",").filter(Boolean);
|
|
const ONLY_PALETTES = opt("palettes", "")?.split(",").filter(Boolean);
|
|
|
|
// Kept in step with LOOK_OPTIONS / PALETTE_OPTIONS (tweaksStore.ts); the
|
|
// vitest guard theme-audit-ids.test.ts fails when they drift.
|
|
export const LOOKS = ["orbit", "crisp", "soft", "terminal", "editorial", "compact", "tui"];
|
|
export const PALETTES = ["orbit", "native", "nebula", "tide", "ember", "contrast", "comfyui"];
|
|
|
|
/** serve.py runs no ComfyUI, so the "ComfyUI palette" theme gets its
|
|
* `/api/settings` from here: a dark imported palette (teal, like a user's
|
|
* own) and Easy-Use's Milk White, light with no `light_theme` flag. */
|
|
const COMFY_SETTINGS = {
|
|
dark: {
|
|
"Comfy.ColorPalette": "aurora_test",
|
|
"Comfy.CustomColorPalettes": { aurora_test: { id: "aurora_test", name: "Aurora", colors: { litegraph_base: {
|
|
NODE_DEFAULT_BGCOLOR: "#0f3d3e", WIDGET_BGCOLOR: "#0b2e2f", WIDGET_TEXT_COLOR: "#d8f3ef",
|
|
WIDGET_SECONDARY_TEXT_COLOR: "#8fbfb8", CLEAR_BACKGROUND_COLOR: "#082222",
|
|
} } } },
|
|
},
|
|
light: {
|
|
"Comfy.ColorPalette": "milk_white",
|
|
"Comfy.CustomColorPalettes": { milk_white: { id: "milk_white", name: "Milk White", colors: { litegraph_base: {
|
|
NODE_DEFAULT_BGCOLOR: "#F5F5F5", WIDGET_BGCOLOR: "#D4D4D4", WIDGET_TEXT_COLOR: "#222",
|
|
WIDGET_SECONDARY_TEXT_COLOR: "#555",
|
|
} } } },
|
|
},
|
|
};
|
|
|
|
/** Ids from bench/bench-library.json, which serve.py seeds. */
|
|
const SCREENS = [
|
|
{ id: "dashboard", path: "dashboard" },
|
|
{ id: "all", path: "all" },
|
|
{ id: "all-selected", path: "all", act: "selectAll" },
|
|
{ id: "all-filters", path: "all", act: "openFilters" },
|
|
{ id: "all-filters-selected", path: "all", act: ["openFilters", "favoritesOnly", "selectAll"] },
|
|
{ id: "wildcards", path: "wildcards" },
|
|
{ id: "wildcards-selected", path: "wildcards", act: "selectAll" },
|
|
{ id: "wildcards-filters", path: "wildcards", act: ["openFilters", "favoritesOnly"] },
|
|
{ id: "fixed-values", path: "fixed-values" },
|
|
{ id: "combines", path: "combines" },
|
|
{ id: "derivations", path: "derivations" },
|
|
{ id: "constraints", path: "constraints" },
|
|
{ id: "bundles", path: "bundles" },
|
|
{ id: "bundles-selected", path: "bundles", act: "selectAll" },
|
|
{ id: "templates", path: "templates" },
|
|
{ id: "templates-selected", path: "templates", act: "selectAll" },
|
|
{ id: "wildcard-editor", path: "wildcards/b0000001/edit" },
|
|
{ id: "fixed-editor", path: "fixed-values/b0000021/edit" },
|
|
{ id: "combine-editor", path: "combines/b0000031/edit" },
|
|
{ id: "derivation-editor", path: "derivations/b0000041/edit" },
|
|
{ id: "constraint-editor", path: "constraints/b0000043/edit" },
|
|
{ id: "bundle-editor", path: "bundles/b00000b1/edit" },
|
|
{ id: "template-editor", path: "templates/b00000c1/edit" },
|
|
{ id: "categories", path: "categories" },
|
|
{ id: "tags", path: "tags" },
|
|
{ id: "import-export", path: "import-export" },
|
|
{ id: "test-runner", path: "test" },
|
|
{ id: "pack-builder", path: "pack-builder" },
|
|
{ id: "settings-general", path: "settings/general" },
|
|
{ id: "settings-appearance", path: "settings/appearance" },
|
|
{ id: "settings-editing", path: "settings/editing" },
|
|
{ id: "settings-canvas", path: "settings/canvas" },
|
|
{ id: "settings-ai", path: "settings/ai" },
|
|
{ id: "settings-library", path: "settings/library" },
|
|
{ id: "settings-advanced", path: "settings/advanced" },
|
|
{ id: "whats-new", path: "whats-new" },
|
|
{ id: "docs", path: "docs" },
|
|
{ id: "kit", path: "kit" },
|
|
{ id: "health", path: "health" },
|
|
];
|
|
|
|
/** Screens the palette pass (colour only) walks in dark and light. */
|
|
const PALETTE_SCREENS = ["all-selected", "all-filters", "wildcard-editor", "constraint-editor", "settings-appearance", "kit"];
|
|
|
|
const ACTIONS = {
|
|
async selectAll(page) {
|
|
const head = page.locator("thead [role=checkbox], thead .wp-check").first();
|
|
if (await head.count()) await head.click();
|
|
},
|
|
async openFilters(page) {
|
|
const btn = page.getByRole("button", { name: /filters/i }).first();
|
|
if (await btn.count()) await btn.click();
|
|
},
|
|
async favoritesOnly(page) {
|
|
const btn = page.locator('[data-test="favorites-filter"]').first();
|
|
if (await btn.count()) await btn.click();
|
|
},
|
|
};
|
|
|
|
/* ---------------------------------------------------------------------- */
|
|
/* In-page checks. One function so Playwright ships it in one evaluate. */
|
|
/* ---------------------------------------------------------------------- */
|
|
function pageChecks({ look }) {
|
|
const issues = [];
|
|
const root = document.querySelector(".wp-content") ?? document.body;
|
|
const vw = document.documentElement.clientWidth;
|
|
|
|
function visible(el) {
|
|
const cs = getComputedStyle(el);
|
|
if (cs.visibility === "hidden" || cs.display === "none" || Number(cs.opacity) === 0) return false;
|
|
const r = el.getBoundingClientRect();
|
|
return r.width > 0 && r.height > 0;
|
|
}
|
|
function label(el) {
|
|
const cls = typeof el.className === "string" ? el.className.trim().split(/\s+/).filter((c) => c.startsWith("wp-")).slice(0, 2).join(".") : "";
|
|
const test = el.getAttribute("data-test");
|
|
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 40);
|
|
return `${el.tagName.toLowerCase()}${cls ? "." + cls : ""}${test ? `[data-test=${test}]` : ""}${text ? ` "${text}"` : ""}`;
|
|
}
|
|
// Something inside a closed <details>, a clipped scroller or a popover
|
|
// layer is not part of the page flow we are judging.
|
|
function outOfFlow(el) {
|
|
for (let n = el; n && n !== document.body; n = n.parentElement) {
|
|
const cs = getComputedStyle(n);
|
|
if (cs.position === "fixed" || cs.position === "sticky") return true;
|
|
if (n.matches?.("[role=dialog], [role=menu], [role=listbox], [role=tooltip], .wp-popover")) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 1. Page scrolls sideways.
|
|
const doc = document.scrollingElement;
|
|
if (doc && doc.scrollWidth > vw + 1) {
|
|
issues.push({ check: "page-hscroll", detail: `page is ${doc.scrollWidth}px wide in a ${vw}px window` });
|
|
}
|
|
|
|
// 2. Children escaping a drawn box (border or own background), when the box
|
|
// does not clip. Reports the escaping child once, against its nearest box.
|
|
const boxes = [...root.querySelectorAll("*")].filter((el) => {
|
|
if (!visible(el) || outOfFlow(el)) return false;
|
|
const cs = getComputedStyle(el);
|
|
const bordered = ["Top", "Right", "Bottom", "Left"].some((s) => parseFloat(cs[`border${s}Width`]) > 0 && cs[`border${s}Style`] !== "none");
|
|
const filled = cs.backgroundColor !== "rgba(0, 0, 0, 0)" && cs.backgroundColor !== "transparent";
|
|
return bordered || filled;
|
|
});
|
|
const boxSet = new Set(boxes);
|
|
for (const el of root.querySelectorAll("*")) {
|
|
if (!visible(el) || outOfFlow(el) || el instanceof SVGElement && !(el instanceof SVGSVGElement)) continue;
|
|
const cs = getComputedStyle(el);
|
|
if (cs.position === "absolute") continue;
|
|
let box = el.parentElement;
|
|
let clipped = false;
|
|
while (box && box !== root.parentElement && !boxSet.has(box)) {
|
|
const bcs = getComputedStyle(box);
|
|
if (bcs.overflowX !== "visible" || bcs.overflowY !== "visible") { clipped = true; break; }
|
|
box = box.parentElement;
|
|
}
|
|
if (!box || clipped || !boxSet.has(box)) continue;
|
|
const bcs = getComputedStyle(box);
|
|
if (bcs.overflowX !== "visible") continue;
|
|
const r = el.getBoundingClientRect();
|
|
const b = box.getBoundingClientRect();
|
|
const out = Math.max(b.left - r.left, r.right - b.right);
|
|
if (out > 2) {
|
|
issues.push({ check: "escapes-box", detail: `${label(el)} sticks ${Math.round(out)}px out of ${label(box)}` });
|
|
}
|
|
}
|
|
|
|
// 3. One-line controls that wrapped.
|
|
const ONE_LINE = ".wp-btn, .wp-chip, .wp-nav__label, .wp-nav__count, .wp-segmented__opt, .wp-tabset__tab, [data-one-line]";
|
|
for (const el of root.querySelectorAll(ONE_LINE)) {
|
|
if (!visible(el) || outOfFlow(el)) continue;
|
|
const cs = getComputedStyle(el);
|
|
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.3;
|
|
// Line boxes of the text itself (icons sit on their own baseline).
|
|
const tops = [];
|
|
const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
for (let t = tw.nextNode(); t; t = tw.nextNode()) {
|
|
if (!t.textContent?.trim()) continue;
|
|
const range = document.createRange();
|
|
range.selectNodeContents(t);
|
|
for (const rr of range.getClientRects()) if (rr.width > 0) tops.push(rr.top);
|
|
}
|
|
tops.sort((x, y) => x - y);
|
|
let lines = tops.length ? 1 : 0;
|
|
for (let i = 1; i < tops.length; i++) if (tops[i] - tops[i - 1] > lh * 0.6) lines++;
|
|
if (lines > 1) {
|
|
issues.push({ check: "wraps", detail: `${label(el)} wraps onto ${lines} lines` });
|
|
}
|
|
}
|
|
|
|
// 4. Checkboxes draw their state once, and the right one.
|
|
for (const el of document.querySelectorAll(".wp-check, [role=checkbox]")) {
|
|
if (!visible(el)) continue;
|
|
const state = el.getAttribute("aria-checked") ?? el.getAttribute("data-checked") ?? "false";
|
|
const mixed = el.getAttribute("data-indeterminate") === "true" || state === "mixed";
|
|
const on = state === "true";
|
|
const glyphs = [...el.querySelectorAll("svg, .pi")].filter(visible);
|
|
const before = getComputedStyle(el, "::before").content;
|
|
const text = before && before !== "none" && before !== "normal" ? before.replace(/^"|"$/g, "") : "";
|
|
if (look === "tui" && el.classList.contains("wp-check")) {
|
|
if (glyphs.length) issues.push({ check: "check-double", detail: `${label(el)} shows a drawn tick beside its text mark` });
|
|
const want = on ? "[x]" : mixed ? "[-]" : "[ ]";
|
|
if (text !== want) issues.push({ check: "check-state", detail: `${label(el)} reads ${text || "nothing"}, should read ${want}` });
|
|
} else if (on || mixed) {
|
|
if (glyphs.length + (text ? 1 : 0) > 1) issues.push({ check: "check-double", detail: `${label(el)} draws its state ${glyphs.length + 1} times` });
|
|
}
|
|
}
|
|
|
|
// 4b. Radios in TUI read ( ) / (*) like the [ ] / [x] checkboxes, and the
|
|
// text matches the real state.
|
|
if (look === "tui") {
|
|
for (const el of document.querySelectorAll(".wp-radio-group__dot, input.wp-radio")) {
|
|
if (!visible(el)) continue;
|
|
const on = el.matches("input") ? el.checked : el.closest("[data-checked]") !== null;
|
|
const before = getComputedStyle(el, "::before").content.replace(/^"|"$/g, "");
|
|
const want = on ? "(*)" : "( )";
|
|
if (before !== want) issues.push({ check: "radio-state", detail: `${label(el.closest(".wp-radio-group__opt, label") ?? el)} reads ${before && before !== "none" ? before : "nothing"}, should read ${want}` });
|
|
}
|
|
}
|
|
|
|
// 5. Pieces that ignore the look: corners rounder than the look allows
|
|
// (circles and dots excepted) and, in the all-mono looks, text that is
|
|
// not mono. A hit means a component hard-codes a radius or a font
|
|
// instead of reading --wp-radius* / --wp-font.
|
|
const MAX_RADIUS = { crisp: 4, terminal: 0, tui: 0 };
|
|
const maxR = MAX_RADIUS[look];
|
|
const rootCs = getComputedStyle(document.documentElement);
|
|
const mono = rootCs.getPropertyValue("--wp-font-mono").split(",")[0].trim().replace(/["']/g, "").toLowerCase();
|
|
const allMono = look === "terminal" || look === "tui";
|
|
const seenRule = new Set();
|
|
for (const el of document.querySelectorAll(".wp-app *, .wp-content *")) {
|
|
if (!visible(el) || el instanceof SVGElement) continue;
|
|
// Previews of other looks and of the canvas draw someone else's corners.
|
|
if (el.closest("[data-look-exempt], .wp-pg-mockup")) continue;
|
|
const cs = getComputedStyle(el);
|
|
if (maxR !== undefined) {
|
|
const r = el.getBoundingClientRect();
|
|
const rad = Math.max(...["TopLeft", "TopRight", "BottomRight", "BottomLeft"].map((k) => {
|
|
const v = cs[`border${k}Radius`];
|
|
return v.endsWith("%") ? (parseFloat(v) / 100) * Math.min(r.width, r.height) : parseFloat(v) || 0;
|
|
}));
|
|
const drawn = cs.backgroundColor !== "rgba(0, 0, 0, 0)" || cs.backgroundImage !== "none" || parseFloat(cs.borderTopWidth) > 0 || parseFloat(cs.borderLeftWidth) > 0 || cs.boxShadow !== "none";
|
|
const round = Math.abs(r.width - r.height) <= 2 && rad >= Math.min(r.width, r.height) / 2 - 1;
|
|
const tiny = Math.min(r.width, r.height) <= 10; // dots, knobs, scrollbar thumbs
|
|
if (drawn && !round && !tiny && rad > maxR + 0.5) {
|
|
const key = `radius|${label(el).split(" ")[0]}`;
|
|
if (!seenRule.has(key)) {
|
|
seenRule.add(key);
|
|
issues.push({ check: "look-radius", detail: `${label(el)} has ${Math.round(rad)}px corners, ${look} allows ${maxR}px` });
|
|
}
|
|
}
|
|
}
|
|
// A circle drawn inside a squared box (a switch knob, a slider thumb, a
|
|
// step mark on a square rail) reads as the old look: the shape inside a
|
|
// control follows the control. Small dots and radios stay round because
|
|
// they are small against the box around them; [data-round-ok] opts out.
|
|
if (maxR !== undefined && !el.closest("[data-round-ok]")) {
|
|
const r = el.getBoundingClientRect();
|
|
const side = Math.min(r.width, r.height);
|
|
const tl = cs.borderTopLeftRadius;
|
|
const rad = tl.endsWith("%") ? (parseFloat(tl) / 100) * side : parseFloat(tl) || 0;
|
|
const drawn = cs.backgroundColor !== "rgba(0, 0, 0, 0)" || parseFloat(cs.borderTopWidth) > 0;
|
|
if (drawn && side >= 6 && Math.abs(r.width - r.height) <= 2 && rad >= side / 2 - 1) {
|
|
let box = el.parentElement;
|
|
for (let i = 0; box && i < 3; i++, box = box.parentElement) {
|
|
const bcs = getComputedStyle(box);
|
|
if (bcs.backgroundColor !== "rgba(0, 0, 0, 0)" || parseFloat(bcs.borderTopWidth) > 0) break;
|
|
}
|
|
if (box && box !== document.body) {
|
|
const br = box.getBoundingClientRect();
|
|
const bRad = parseFloat(getComputedStyle(box).borderTopLeftRadius) || 0;
|
|
const boxRound = Math.abs(br.width - br.height) <= 2 && bRad >= Math.min(br.width, br.height) / 2 - 1;
|
|
if (!boxRound && bRad <= maxR + 0.5 && side >= Math.min(br.width, br.height) * 0.5) {
|
|
const key = `round-in-square|${label(el).split(" ")[0]}`;
|
|
if (!seenRule.has(key)) {
|
|
seenRule.add(key);
|
|
issues.push({ check: "round-in-square", detail: `${label(el)} is a circle inside the square ${label(box).split(" ")[0]}; ${look} squares it` });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (allMono && mono && el.childNodes.length && [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) {
|
|
const fam = cs.fontFamily.split(",")[0].trim().replace(/["']/g, "").toLowerCase();
|
|
if (fam !== mono && !/monospace|mono|primeicons/.test(fam) && !el.closest(".pi, [data-font-exempt]")) {
|
|
const key = `font|${label(el).split(" ")[0]}`;
|
|
if (!seenRule.has(key)) {
|
|
seenRule.add(key);
|
|
issues.push({ check: "look-font", detail: `${label(el)} is set in ${cs.fontFamily.split(",")[0]}, ${look} is all mono` });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. Contrast of text against the first opaque background behind it.
|
|
function rgba(str) {
|
|
const m = str.match(/rgba?\(([^)]+)\)/);
|
|
if (!m) return null;
|
|
const p = m[1].split(/[ ,/]+/).filter(Boolean).map(Number);
|
|
return [p[0], p[1], p[2], p[3] ?? 1];
|
|
}
|
|
function lum([r, g, b]) {
|
|
const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
|
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
}
|
|
function bgOf(el) {
|
|
const layers = [];
|
|
for (let n = el; n; n = n.parentElement) {
|
|
const cs = getComputedStyle(n);
|
|
if (cs.backgroundImage !== "none") return null; // gradients/images: skip
|
|
const c = rgba(cs.backgroundColor);
|
|
if (c && c[3] > 0) { layers.push(c); if (c[3] >= 0.99) break; }
|
|
}
|
|
let out = [255, 255, 255];
|
|
for (const c of layers.reverse()) out = out.map((v, i) => v * (1 - c[3]) + c[i] * c[3]);
|
|
return out;
|
|
}
|
|
const seen = new Set();
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
for (let t = walker.nextNode(); t; t = walker.nextNode()) {
|
|
if (!t.textContent?.trim()) continue;
|
|
const el = t.parentElement;
|
|
if (!el || seen.has(el) || !visible(el) || outOfFlow(el)) continue;
|
|
seen.add(el);
|
|
if (el.closest("[disabled], [aria-disabled=true], .is-disabled, [aria-hidden=true], .wp-kit-swatch, [data-contrast-exempt]")) continue;
|
|
const cs = getComputedStyle(el);
|
|
const fg = rgba(cs.color);
|
|
const bg = bgOf(el);
|
|
if (!fg || !bg || fg[3] < 0.5) continue;
|
|
let o = Number(cs.opacity);
|
|
for (let n = el.parentElement; n; n = n.parentElement) o *= Number(getComputedStyle(n).opacity);
|
|
if (o < 0.95) continue; // faded on purpose (disabled-looking hints)
|
|
const mixed = fg.slice(0, 3).map((v, i) => v * fg[3] + bg[i] * (1 - fg[3]));
|
|
const [a, b] = [lum(mixed), lum(bg)];
|
|
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
if (ratio < 3) issues.push({ check: "contrast", detail: `${label(el)} is ${ratio.toFixed(2)}:1` });
|
|
}
|
|
|
|
return issues;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------- */
|
|
|
|
async function run() {
|
|
mkdirSync(OUT, { recursive: true });
|
|
const browser = await chromium.launch();
|
|
const report = [];
|
|
|
|
const runs = [];
|
|
for (const look of LOOKS) {
|
|
if (ONLY_LOOKS?.length && !ONLY_LOOKS.includes(look)) continue;
|
|
for (const s of SCREENS) {
|
|
if (ONLY_SCREENS?.length && !ONLY_SCREENS.includes(s.id)) continue;
|
|
runs.push({ look, palette: "orbit", mode: "dark", screen: s });
|
|
}
|
|
}
|
|
for (const palette of PALETTES) {
|
|
if (ONLY_PALETTES?.length && !ONLY_PALETTES.includes(palette)) continue;
|
|
if (ONLY_LOOKS?.length && !ONLY_PALETTES?.length) break;
|
|
for (const mode of ["dark", "light"]) {
|
|
for (const look of ["orbit", "tui"]) {
|
|
for (const sid of PALETTE_SCREENS) {
|
|
if (palette === "orbit" && mode === "dark") continue; // covered above
|
|
const s = SCREENS.find((x) => x.id === sid);
|
|
if (ONLY_SCREENS?.length && !ONLY_SCREENS.includes(s.id)) continue;
|
|
runs.push({ look, palette, mode, screen: s });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
|
let current = "";
|
|
let comfySettings = COMFY_SETTINGS.dark;
|
|
await context.route((url) => url.pathname === "/api/settings", (route) =>
|
|
route.fulfill({ contentType: "application/json", body: JSON.stringify(comfySettings) }));
|
|
const page = await context.newPage();
|
|
for (const r of runs) {
|
|
comfySettings = COMFY_SETTINGS[r.mode];
|
|
const key = `${r.look}|${r.palette}|${r.mode}`;
|
|
if (key !== current) {
|
|
current = key;
|
|
await context.clearCookies();
|
|
await page.goto(`${BASE}/wp/kit`);
|
|
await page.evaluate(({ look, palette, mode }) => {
|
|
localStorage.clear();
|
|
localStorage.setItem("wp-tweaks-v1", JSON.stringify({ look, palette, accent: "theme" }));
|
|
localStorage.setItem("wp-theme-mode", mode);
|
|
// Picking Compact folds the sidebar to its icon rail; audit it that way.
|
|
localStorage.setItem("wp-sidebar-collapsed", look === "compact" ? "1" : "0");
|
|
localStorage.setItem("wp-whats-new-after-update", "0");
|
|
localStorage.setItem("wp-last-seen-version", "999.0.0");
|
|
}, r);
|
|
}
|
|
await page.goto(`${BASE}/wp/${r.screen.path}`);
|
|
await page.waitForLoadState("networkidle");
|
|
await page.locator(".wp-content").first().waitFor({ timeout: 10_000 }).catch(() => {});
|
|
await page.addStyleTag({ content: "*,*::before,*::after{transition:none!important;animation:none!important;caret-color:transparent!important}" });
|
|
for (const a of [r.screen.act ?? []].flat()) {
|
|
await ACTIONS[a](page);
|
|
await page.waitForTimeout(150);
|
|
}
|
|
await page.waitForTimeout(250);
|
|
const issues = await page.evaluate(pageChecks, { look: r.look });
|
|
const name = `${r.look}--${r.palette}-${r.mode}--${r.screen.id}`;
|
|
if (SHOTS) await page.screenshot({ path: path.join(OUT, `${name}.png`), fullPage: false });
|
|
report.push({ name, ...r, screen: r.screen.id, issues });
|
|
const tag = issues.length ? `FAIL ${issues.length}` : "ok";
|
|
console.log(`${tag.padEnd(8)} ${name}`);
|
|
for (const i of issues.slice(0, 12)) console.log(` ${i.check}: ${i.detail}`);
|
|
if (issues.length > 12) console.log(` … ${issues.length - 12} more`);
|
|
}
|
|
|
|
await browser.close();
|
|
writeFileSync(path.join(OUT, "report.json"), JSON.stringify(report, null, 2));
|
|
const failed = report.filter((r) => r.issues.length);
|
|
console.log(`\n${report.length - failed.length}/${report.length} screens clean`);
|
|
process.exit(failed.length ? 1 : 0);
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) run();
|