// Render gate — the automated visual proof that string/contract tests cannot
// give. Drives a headless Chromium against the REAL css/js and asserts COMPUTED
// values. This is the test that would have caught the bugs that shipped green:
// - O0: a stray "*/" broke tokens-bridge.css (string test passed) -> accent
// stayed cyan instead of the studio purple.
// - mascot: applyMascotState wiped the ops-byte size class -> mascot small.
// Skips cleanly when no Chromium is available (so CI without a browser is green).
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { readFileSync, existsSync, statSync } from "node:fs";
import { spawn } from "node:child_process";
import { join, dirname, normalize } from "node:path";
import { fileURLToPath } from "node:url";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
function findChromium() {
const candidates = [
process.env.CHROMIUM_BIN,
"/home/juan/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome",
].filter(Boolean);
return candidates.find((p) => existsSync(p)) || null;
}
const PROBE = `
x
`;
// WCAG relative luminance + contrast ratio.
function lum(rgb) {
const [r, g, b] = rgb.map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a, b) {
const la = lum(a) + 0.05;
const lb = lum(b) + 0.05;
return la > lb ? la / lb : lb / la;
}
function parseRgb(s) {
const m = String(s).match(/rgb\((\d+),\s*(\d+),\s*(\d+)/);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
function serveRepo(probe) {
const server = http.createServer((req, res) => {
if (req.url.startsWith("/__probe")) {
res.writeHead(200, { "Content-Type": "text/html" });
return res.end(probe);
}
const path = normalize(join(REPO, decodeURIComponent(req.url.split("?")[0])));
if (!path.startsWith(REPO) || !existsSync(path) || statSync(path).isDirectory()) {
res.writeHead(404);
return res.end();
}
const type = path.endsWith(".css")
? "text/css"
: path.endsWith(".js")
? "text/javascript"
: "text/plain";
res.writeHead(200, { "Content-Type": type });
res.end(readFileSync(path));
});
return server;
}
test("render gate: computed skin + mascot size (real browser, or skip)", async (t) => {
const chrome = findChromium();
if (!chrome) {
t.skip("no Chromium available — render gate skipped (set CHROMIUM_BIN to enable)");
return;
}
const server = serveRepo(PROBE);
await new Promise((r) => server.listen(0, r));
const port = server.address().port;
// IMPORTANT: spawn (async), NOT spawnSync — a sync child blocks the event loop,
// so this http server could not answer Chromium's asset requests.
const measure = (theme) =>
new Promise((resolve) => {
const cp = spawn(chrome, [
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--virtual-time-budget=4000",
"--dump-dom",
`http://localhost:${port}/__probe?theme=${theme}`,
]);
let out = "";
const kill = setTimeout(() => cp.kill(), 30000);
cp.stdout.on("data", (d) => (out += d));
cp.on("close", () => (clearTimeout(kill), resolve((out.match(/RG\|[^"<]*/) || [""])[0])));
cp.on("error", () => (clearTimeout(kill), resolve("")));
});
try {
const light = await measure("light");
// 1. O0 skin: --color-accent must resolve to the studio purple, not cyan.
assert.match(light, /accent=rgb\(139,\s*92,\s*246\)/, `skin not applied: ${light}`);
// 2. Mascot: ops-byte (68px) must survive render — applyMascotState must not
// wipe the size class.
const w = Number((light.match(/mascotW=(\d+)/) || [])[1] || 0);
assert.ok(w >= 60, `mascot too small (size class wiped?): width=${w} :: ${light}`);
assert.match(light, /cls=byte-mascot[^|]*ops-byte/, `ops-byte class wiped: ${light}`);
// O6: Mah's "unseen" must be visibly faded vs a seen state (honest — Byte
// isn't perceiving the live). Catches a regression that drops the fade.
const mahU = Number((light.match(/mahU=([\d.]+)/) || [])[1] || 1);
const mahS = Number((light.match(/mahS=([\d.]+)/) || [])[1] || 0);
assert.ok(mahU < 0.6 && mahS > 0.9, `Mah unseen not faded: unseen=${mahU} seen=${mahS}`);
// 3. AA (O5): primary text on a panel must meet WCAG AA (>= 4.5:1) in BOTH
// themes — a contrast regression in either is a real a11y bug.
for (const theme of ["light", "dark"]) {
const line = theme === "light" ? light : await measure(theme);
const text = parseRgb((line.match(/text=([^|]+)/) || [])[1]);
const bg = parseRgb((line.match(/bg=([^|]+)/) || [])[1]);
assert.ok(text && bg, `[${theme}] could not read text/bg: ${line}`);
const ratio = contrast(text, bg);
assert.ok(ratio >= 4.5, `[${theme}] contrast ${ratio.toFixed(2)} < AA 4.5 :: ${line}`);
}
} finally {
server.close();
}
});