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.1920import { spawn, spawnSync } from "node:child_process";21import { copyFileSync, mkdtempSync, readdirSync, readFileSync, statSync } from "node:fs";22import { tmpdir, homedir } from "node:os";23import { dirname, join, resolve } from "node:path";24import { fileURLToPath } from "node:url";2526const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");27const configDir = join(homedir(), "Library/Application Support/dev.felinestatemachine.atlas");28const parityDir = join(configDir, "parity");29const inspectorURL = join(configDir, "inspector.url");3031const args = process.argv.slice(2);32const flag = (name) => {33 const at = args.indexOf(name);34 return at >= 0 ? args[at + 1] : undefined;35};3637const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));3839// 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.42const cliDir = mkdtempSync(join(tmpdir(), "atlas-parity-"));43const 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};5455// 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.58const launchedAt = Date.now();59// ATLAS_HEADLESS keeps the sweep out of the reader's way: the dev build60// serves the same frontend over HTTP and opens no window at all.61const app = spawn("go", ["run", "-tags", "dev", "."], {62 cwd: repoRoot,63 env: {64 ...process.env,65 ATLAS_HEADLESS: "1",66 ...(flag("--bundles") ? { ATLAS_BUNDLES_DIR: resolve(flag("--bundles")) } : {}),67 },68 stdio: "ignore",69 detached: true,70});7172// `go run` fronts the real binary, so teardown signals the whole process73// group rather than the runner alone.74const stopApp = () => {75 try { process.kill(-app.pid, "SIGTERM"); } catch { /* already gone */ }76};77process.on("exit", stopApp);7879try {80 let url = "";81 for (let i = 0; i < 60 && !url; i++) {82 await sleep(1000);83 try {84 if (statSync(inspectorURL).mtimeMs >= launchedAt) {85 url = readFileSync(inspectorURL, "utf8").trim();86 }87 } catch { /* not written yet */ }88 }89 if (!url) throw new Error("the dev build never wrote inspector.url");9091 cli("open", url);92 const badge = cli("run-code", `async page => {93 await page.waitForFunction(() =>94 window.__atlasTour && window.__atlasDebug &&95 document.querySelectorAll(".category-row").length > 0, null, { timeout: 60000 });96 await page.evaluate(() => { window.__atlasTour(); });97 await page.waitForFunction(() => {98 const badge = document.querySelector("#parity-badge");99 return badge && (badge.textContent.includes("complete") || badge.textContent.includes("failed"));100 }, null, { timeout: 480000 });101 return await page.evaluate(() => document.querySelector("#parity-badge").textContent);102 }`);103 // A tour that finished but found the map, the footer and the dock telling104 // different stories fails here, with the mismatches read out of the log it105 // still wrote.106 // The badge's own words, not merely the word "complete": the CLI's output107 // wraps the value returned to us, and matching loosely let a tour that108 // finished red be reported green.109 if (!badge.includes("parity tour complete")) {110 const failed = readdirSync(parityDir)111 .filter((name) => name.startsWith("tour-") && name.endsWith(".json"))112 .map((name) => join(parityDir, name))113 .filter((path) => statSync(path).mtimeMs >= launchedAt)114 .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];115 const found = failed ? JSON.parse(readFileSync(failed, "utf8")).problems || [] : [];116 throw new Error([`tour did not complete:\n${badge}`, ...found].join("\n "));117 }118119 // The tour posted its log to /parity/result, which writes a timestamped120 // file; the one belonging to this run is the one born after the launch.121 const newest = readdirSync(parityDir)122 .filter((name) => name.startsWith("tour-") && name.endsWith(".json"))123 .map((name) => join(parityDir, name))124 .filter((path) => statSync(path).mtimeMs >= launchedAt)125 .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];126 if (!newest) throw new Error("the tour reported success but no result file arrived");127128 const steps = JSON.parse(readFileSync(newest, "utf8")).steps.length;129 if (flag("--out")) copyFileSync(newest, flag("--out"));130 console.log(`${badge.match(/parity tour complete[^"]*/)?.[0] || "complete"}`);131 console.log(`result: ${flag("--out") || newest} (${steps} steps)`);132} finally {133 try { cli("close"); } catch { /* browser already gone */ }134 stopApp();135}