// Render-regression harness: every screenplay's pinned beats, screenshot // under a paused Playwright clock, diffed pixel-by-pixel against the // goldens in deploy/scene-goldens/. The clock is what makes the pixels // reproducible — pulsing rings, tweens, and fx all advance only when the // harness says so, so the same beat renders the same bytes every run. // // npx tsx deploy/verify-scenes.mjs # verify all scenes // npx tsx deploy/verify-scenes.mjs --update # re-bless the goldens // npx tsx deploy/verify-scenes.mjs the-rout # one scene (mixes with --update) // // Run from the repo root AFTER `npx vite build` in packages/web (the // wrapper deploy/verify-scenes.sh does both). Needs the Playwright // chromium cache (`npx playwright-core install chromium` if missing). import { execFileSync, spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium } from "playwright-core"; import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts"; const args = process.argv.slice(2); const update = args.includes("--update"); const only = args.filter((a) => !a.startsWith("--")); const GOLDENS = join(import.meta.dirname, "scene-goldens"); const DIFFS = join(tmpdir(), "wizwar-scene-diffs"); const PORT = 8807; // A channel may drift this far before a pixel counts as different, and // this fraction of pixels may differ before a beat fails — headroom for // raster jitter, far below any real regression. const CHANNEL_TOL = 6; const FAIL_FRAC = 0.002; const EPOCH = new Date("2026-01-01T00:00:00Z").getTime(); function chromiumExe() { if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME; const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright"); const builds = existsSync(cache) ? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort() : []; for (const build of builds.reverse()) { const exe = join(cache, build, "chrome-mac-arm64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"); if (existsSync(exe)) return exe; } throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)"); } /** Decode a PNG to raw RGBA via ffmpeg — the harness's only image dep. */ function rgba(png) { return execFileSync("ffmpeg", ["-loglevel", "error", "-i", png, "-f", "rawvideo", "-pix_fmt", "rgba", "-"], { maxBuffer: 64 * 1024 * 1024 }); } function compare(goldenPng, actualPng) { const a = rgba(goldenPng), b = rgba(actualPng); if (a.length !== b.length) return { frac: 1, why: "size differs" }; let bad = 0; for (let i = 0; i < a.length; i += 4) { if (Math.abs(a[i] - b[i]) > CHANNEL_TOL || Math.abs(a[i + 1] - b[i + 1]) > CHANNEL_TOL || Math.abs(a[i + 2] - b[i + 2]) > CHANNEL_TOL) bad++; } return { frac: bad / (a.length / 4), why: null }; } // --- A private server on a throwaway data dir. --------------------------- const dataDir = mkdtempSync(join(tmpdir(), "wizwar-scenes-")); const server = spawn("npx", ["tsx", "src/index.ts"], { cwd: join(import.meta.dirname, "..", "packages", "server"), env: { ...process.env, PORT: String(PORT), WIZWAR_DATA_DIR: join(dataDir, "rooms") }, stdio: "ignore", }); const up = async () => { for (let i = 0; i < 60; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/`); if (r.ok) return; } catch { /* not yet */ } await new Promise((r) => setTimeout(r, 500)); } throw new Error("server never came up"); }; let failures = 0; try { await up(); mkdirSync(GOLDENS, { recursive: true }); rmSync(DIFFS, { recursive: true, force: true }); mkdirSync(DIFFS, { recursive: true }); const browser = await chromium.launch({ executablePath: chromiumExe(), args: ["--disable-gpu", "--force-color-profile=srgb", "--font-render-hinting=none"], }); const scenes = SCREENPLAYS.filter((sp) => !only.length || only.includes(sp.name)); if (!scenes.length) throw new Error(`no scenes match: ${only.join(", ")}`); for (const sp of scenes) { // One page per view: the reel steps forward beat to beat under the // paused clock, so a scene's later beats cost only their marginal // steps — and every beat's simulated capture time is still exactly // (steps so far) × 2.6s, identical every run. const takes = [ { view: "fpv", beats: [...(sp.beats ?? [])].sort((a, b) => a - b) }, { view: "board", beats: sp.boardBeat === undefined ? [] : [sp.boardBeat] }, ]; for (const { view, beats } of takes) { if (!beats.length) continue; const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); await page.clock.install({ time: EPOCH }); await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" }); await page.locator("button", { hasText: "❚❚" }).click(); for (let i = 0; i < 4; i++) await page.locator("button", { hasText: "◀" }).click(); if (view === "fpv") await page.getByText("your eyes").click(); // Freeze the world at a fixed instant, then advance it by hand. // Steps on the way to a beat get 0.6 simulated seconds; the beat // itself gets 2.6 to settle its tween, hold, and fx. The schedule // is fixed, so every run renders the same bytes. await page.clock.pauseAt(EPOCH + 60_000); let at = 0; for (const beat of beats) { for (; at < beat; at++) { await page.locator("button", { hasText: "▶" }).last().click(); await page.clock.runFor(at === beat - 1 ? 2600 : 600); } const box = await page.locator(".replay").boundingBox(); const name = `${sp.name}-s${beat}-${view}.png`; const golden = join(GOLDENS, name); const shot = update ? golden : join(DIFFS, name); await page.screenshot({ path: shot, clip: { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }, }); if (update) { console.log(`blessed ${name}`); } else if (!existsSync(golden)) { console.log(`MISSING golden ${name} — run with --update`); failures++; } else { const { frac, why } = compare(golden, shot); if (why || frac > FAIL_FRAC) { console.log(`FAIL ${name}: ${why ?? `${(frac * 100).toFixed(2)}% of pixels drifted`} (actual kept at ${shot})`); failures++; } else { console.log(`OK ${name}`); } } } await ctx.close(); } } await browser.close(); } finally { server.kill(); rmSync(dataDir, { recursive: true, force: true }); } if (!update) console.log(`--- ${failures ? `${failures} beat(s) drifted` : "every pinned beat matches its golden"}`); process.exit(failures ? 1 : 0);