clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

parity: script the headless tour run

Dami committed 5368a47
The tour has always been reachable without a hand on the keyboard: the dev
build serves the same frontend over plain HTTP and writes its port to
inspector.url, window.__atlasTour() is the entry F9 reaches, and the log
posts to /parity/result as ever. What was missing was the choreography, and
until now it lived in whoever last performed it.

run.mjs performs it whole: launch a dev build of its own -- the fresh-launch
rule by construction -- optionally pinned to a bundles directory so a
recorded baseline names exactly one build, drive a headless Chromium at the
served app through playwright-cli, run the tour, and hand back the result
file this run wrote rather than whichever one is newest on disk. Everything
it starts, it stops; the browser session lives in a scratch directory and
leaves nothing in the repo.
commit
5368a47867cdb84faec13468a2d07c987c63585b
parent
13191bf55a35d23f84e87b7b44c6a04313b95e86
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 0.6 of 3, Jev's estimate
1 file changed, +117 -0
Afrontend/parity/run.mjs117++++++++++++++++++++++++++++++
A frontend/parity/run.mjs +117 -0
@@ -0,0 +1,117 @@
1+// Runs the behavior-parity tour without a hand on the keyboard. The dev2+// build (`go run -tags dev .`) is a desktop window, but it also serves the3+// same frontend over plain HTTP and writes the port to inspector.url in the4+// app's config dir -- so a headless Chromium can load the app, call5+// window.__atlasTour() (the same entry F9 reaches), and let the tour post6+// its log to the dev-only /parity/result route as ever.7+//8+// The fresh-launch rule is honored by construction: every invocation starts9+// its own app process and a new browser session, and nothing touches either10+// before the tour does. The tour itself clears localStorage at both ends.11+//12+// node frontend/parity/run.mjs [--bundles <dir>] [--out <file>]13+//14+// --bundles pins ATLAS_BUNDLES_DIR so a recorded baseline names exactly one15+// bundle build; --out copies the freshly written tour log there. The browser16+// is driven through playwright-cli via npx, so the only prerequisite beyond17+// Node is a Playwright Chromium already installed (npx playwright install18+// chromium). macOS paths, like the dev loop itself.19+20+import { spawn, spawnSync } from "node:child_process";21+import { copyFileSync, mkdtempSync, readdirSync, readFileSync, statSync } from "node:fs";22+import { tmpdir, homedir } from "node:os";23+import { dirname, join, resolve } from "node:path";24+import { fileURLToPath } from "node:url";25+26+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");27+const configDir = join(homedir(), "Library/Application Support/dev.felinestatemachine.atlas");28+const parityDir = join(configDir, "parity");29+const inspectorURL = join(configDir, "inspector.url");30+31+const args = process.argv.slice(2);32+const flag = (name) => {33+ const at = args.indexOf(name);34+ return at >= 0 ? args[at + 1] : undefined;35+};36+37+const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));38+39+// The playwright-cli keeps its session beside its working directory, so each40+// run drives a browser of its own from a scratch dir and leaves nothing in41+// the repo.42+const cliDir = mkdtempSync(join(tmpdir(), "atlas-parity-"));43+const cli = (...cliArgs) => {44+ const result = spawnSync(45+ "npx",46+ ["--yes", "--package=@playwright/cli@latest", "playwright-cli", ...cliArgs],47+ { cwd: cliDir, encoding: "utf8", timeout: 600_000 },48+ );49+ if (result.status !== 0) {50+ throw new Error(`playwright-cli ${cliArgs[0]} failed:\n${result.stdout}\n${result.stderr}`);51+ }52+ return result.stdout;53+};54+55+// A launch is only this run's if its port file is newer than this moment:56+// the file survives from earlier sessions and would otherwise answer for a57+// server that is no longer there.58+const launchedAt = Date.now();59+const app = spawn("go", ["run", "-tags", "dev", "."], {60+ cwd: repoRoot,61+ env: flag("--bundles")62+ ? { ...process.env, ATLAS_BUNDLES_DIR: resolve(flag("--bundles")) }63+ : process.env,64+ stdio: "ignore",65+ detached: true,66+});67+68+// `go run` fronts the real binary, so teardown signals the whole process69+// group rather than the runner alone.70+const stopApp = () => {71+ try { process.kill(-app.pid, "SIGTERM"); } catch { /* already gone */ }72+};73+process.on("exit", stopApp);74+75+try {76+ let url = "";77+ for (let i = 0; i < 60 && !url; i++) {78+ await sleep(1000);79+ try {80+ if (statSync(inspectorURL).mtimeMs >= launchedAt) {81+ url = readFileSync(inspectorURL, "utf8").trim();82+ }83+ } catch { /* not written yet */ }84+ }85+ if (!url) throw new Error("the dev build never wrote inspector.url");86+87+ cli("open", url);88+ const badge = cli("run-code", `async page => {89+ await page.waitForFunction(() =>90+ window.__atlasTour && window.__atlasDebug &&91+ document.querySelectorAll(".category-row").length > 0, null, { timeout: 60000 });92+ await page.evaluate(() => { window.__atlasTour(); });93+ await page.waitForFunction(() => {94+ const badge = document.querySelector("#parity-badge");95+ return badge && (badge.textContent.includes("complete") || badge.textContent.includes("failed"));96+ }, null, { timeout: 480000 });97+ return await page.evaluate(() => document.querySelector("#parity-badge").textContent);98+ }`);99+ if (!badge.includes("complete")) throw new Error(`tour did not complete:\n${badge}`);100+101+ // The tour posted its log to /parity/result, which writes a timestamped102+ // file; the one belonging to this run is the one born after the launch.103+ const newest = readdirSync(parityDir)104+ .filter((name) => name.startsWith("tour-") && name.endsWith(".json"))105+ .map((name) => join(parityDir, name))106+ .filter((path) => statSync(path).mtimeMs >= launchedAt)107+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];108+ if (!newest) throw new Error("the tour reported success but no result file arrived");109+110+ const steps = JSON.parse(readFileSync(newest, "utf8")).steps.length;111+ if (flag("--out")) copyFileSync(newest, flag("--out"));112+ console.log(`${badge.match(/parity tour complete[^"]*/)?.[0] || "complete"}`);113+ console.log(`result: ${flag("--out") || newest} (${steps} steps)`);114+} finally {115+ try { cli("close"); } catch { /* browser already gone */ }116+ stopApp();117+}