deploy/verify-scenes.sh replays each screenplay to its authored beats under a paused Playwright clock — pulses, tweens, and fx advance only on the harness's schedule, so the same beat renders the same bytes every run — screenshots the reel, and diffs pixels against the goldens in deploy/scene-goldens (30 frames: warp transits, impacts, curses landing, in both views). A channel may drift 6 before a pixel counts, 0.2% of pixels before a beat fails; --update blesses intended changes. Proven both ways before landing: a clean double-run reproduces all 30 goldens exactly, and resurrecting the warp-smear cutaway bug fails the warp-transit beat at 61% pixel drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
159 lines
7.0 KiB
JavaScript
159 lines
7.0 KiB
JavaScript
// 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);
|