// 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(); } });