Spaces:
Sleeping
Sleeping
Download dashboard/tests/render_gate.test.js from JuanCS-Dev/twitch-byte-bot: direct link, hf CLI and curl.
- Browser
- Download file 6.93 kB
-
https://huggingface.co/spaces/JuanCS-Dev/twitch-byte-bot/resolve/main/dashboard/tests/render_gate.test.js
- Command line
-
hf download hf://spaces/JuanCS-Dev/twitch-byte-bot/dashboard/tests/render_gate.test.js
-
curl -L -o render_gate.test.js https://huggingface.co/spaces/JuanCS-Dev/twitch-byte-bot/resolve/main/dashboard/tests/render_gate.test.js
6.93 kB
| // 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 = `<!doctype html><html><head><meta charset="utf-8"> | |
| <link rel="stylesheet" href="/dashboard/styles/tokens.css"> | |
| <link rel="stylesheet" href="/dashboard/styles/base.css"> | |
| <link rel="stylesheet" href="/dashboard/styles/components.css"> | |
| <link rel="stylesheet" href="/dashboard/styles/tokens-bridge.css"> | |
| <link rel="stylesheet" href="/dashboard/styles/mascot.css"> | |
| </head><body> | |
| <div id="probe" style="background: var(--color-accent)"></div> | |
| <div id="txt" style="color: var(--text-primary); background: var(--bg-surface)">x</div> | |
| <div id="byteMascot" class="byte-mascot ops-byte"></div> | |
| <div id="mahU" class="mah-avatar is-unseen"></div> | |
| <div id="mahS" class="mah-avatar is-happy"></div> | |
| <script type="module"> | |
| import { createMascot } from "/dashboard/features/mascot/mascot.js"; | |
| import { MAH_SVG } from "/dashboard/features/streamer/mah.svg.js"; | |
| document.getElementById("mahU").innerHTML = MAH_SVG; | |
| document.getElementById("mahS").innerHTML = MAH_SVG; | |
| // Theme comes from the query so the gate can check AA in BOTH light and dark. | |
| document.documentElement.dataset.theme = | |
| new URLSearchParams(location.search).get("theme") || "light"; | |
| createMascot({ mount: document.getElementById("byteMascot"), getMood: () => ({ count: 0 }), win: window }); | |
| const accent = getComputedStyle(document.getElementById("probe")).backgroundColor; | |
| const t = getComputedStyle(document.getElementById("txt")); | |
| const m = document.getElementById("byteMascot").getBoundingClientRect(); | |
| document.title = "RG|accent=" + accent + "|mascotW=" + Math.round(m.width) | |
| + "|text=" + t.color + "|bg=" + t.backgroundColor | |
| + "|mahU=" + getComputedStyle(document.getElementById("mahU")).opacity | |
| + "|mahS=" + getComputedStyle(document.getElementById("mahS")).opacity | |
| + "|cls=" + document.getElementById("byteMascot").className; | |
| </script></body></html>`; | |
| // 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(); | |
| } | |
| }); | |