diff --git a/.claude/skills/jgengine-verify/references/monorepo-capture.md b/.claude/skills/jgengine-verify/references/monorepo-capture.md index 55e5c5bc5..bf46a914f 100644 --- a/.claude/skills/jgengine-verify/references/monorepo-capture.md +++ b/.claude/skills/jgengine-verify/references/monorepo-capture.md @@ -4,14 +4,14 @@ Deep reference for `shoot`/`drive` inside the jgengine monorepo: the managed cap ## Managed capture session -1. Run `bun run shoot daemon start`. Startup keeps only headless Chrome warm; `apps/dev` and `apps/web` start lazily on first use and then remain warm. A non-zero exit means capture is unavailable, not permission to repeat cold launches. +1. Just run a capture: `bun run shoot`, `bun run drive` and `bun run perf` acquire the warm daemon themselves — they reuse a live one, start one when none runs, and replace one that serves another commit, printing `warm: reused :9261 (0.2s)` or `cold: started :9261 (4.1s)`. Never run `bun run shoot daemon start` first and never sleep waiting for a server. Startup keeps only headless Chrome warm; `apps/dev` and `apps/web` start lazily on first use and then remain warm. `--cold` skips the daemon for an evidence run that must prove a clean boot, and a daemon that cannot be had falls back to a direct launch with one printed line. A non-zero exit means capture is unavailable, not permission to repeat cold launches. 2. Iterate on games with `bun run shoot --mode play --quality draft --inspect` (also on `drive`): half size plus the low graphics tier pinned through `?quality=low` for that page only, written as `-draft.png`. Take the final shot with the default `--quality full`; draft frames are for judging composition, never for PR evidence or look/lighting claims. Capture real website routes directly with `bun run shoot --site '/playground?...' --size half --inspect`; `--site` uses managed `apps/web` and does not build or boot the embedded `/play` runner unless `/play` is requested. `--quick` on `shoot` and `drive` is the cheapest iteration preset: 960x540, one settled frame (drive waits up to 2s for the game view), no video, no perf pass, written as `-quick.png`. It refuses `--width`/`--height` off that size, `--size`, a non-desktop `--device`, `--playtest` and the recording flags, because a quick frame cannot support a 1600x900 pixel claim or a frame-time claim; its line prints the wall time and says so. 3. Record game or website behavior with the same browser: `bun run drive ... --record ` or `bun run drive --site '/playground?...' --wait 1000 --record `. `drive --site` writes `shots/site-.mp4`; present the MP4 or representative stills, never the generated GIF. 4. Every invocation opens and closes a fresh page target while retaining process-level V8, HTTP, module, shader, and Vite caches. Editing code while the daemon stays live is supported; a stale-shaped post-HMR failure gets one cache-bypassed retry without restarting the daemon. 5. Changing *commit* under a warm server is not an edit HMR can absorb, so a warm Vite is keyed to the commit it booted from: `shoot`/`drive` retire it, clear the dep-optimizer cache, and boot a clean one whenever HEAD has moved. Never stop the daemon or delete `node_modules/.vite` by hand for this — if you are doing that, the capture stack has a bug worth fixing instead. 6. Reject blank, sparse, overflow, and collision failures before opening the PNG. Capture the final accepted state at full resolution, embed screenshots with `bun run pr-shots`, and stop the session with `bun run shoot daemon stop` when the loop ends. -Do not run bare cold `shoot` or `drive` repeatedly in a multi-shot loop. For a single proof where persistent processes are undesirable, one ephemeral invocation is supported and cleans up Chrome/Vite afterward; measured local evidence for `/playground` is roughly 12 seconds cold wall time and 3 seconds inside the browser. If managed capture fails twice, stop retrying and report lower-rung deterministic evidence. +Do not run bare cold `shoot` or `drive` repeatedly in a multi-shot loop: every invocation acquires the warm daemon first, so a loop pays page-ready once, not a boot per shot. For a single proof where persistent processes are undesirable, `--cold` gives one ephemeral invocation that cleans up Chrome/Vite afterward; measured local evidence for `/playground` is roughly 12 seconds cold wall time and 3 seconds inside the browser. If managed capture fails twice, stop retrying and report lower-rung deterministic evidence. Every cold start — shoot, drive, perf, perf gate, smoke, daemon — goes through one handshake: launch, wait for the debugger endpoint, open a page target. Each step is named in the failure line with its elapsed time and profile path, a transient failure gets one retry with a fresh profile and a doubled wait, and the wait is `JG_CHROME_READY_MS` (30s default). A launch that recovers is not a failed run; a budget, assertion or capture failure is never retried. diff --git a/scripts/capture-storage.test.ts b/scripts/capture-storage.test.ts new file mode 100644 index 000000000..a72b1c7df --- /dev/null +++ b/scripts/capture-storage.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; + +import { clearOriginStorage } from "./capture-storage"; + +/** + * A warm daemon keeps its browser profile, so a capture must clear this origin's storage before it + * navigates — otherwise the same command renders a resumed save instead of a first run. + */ +describe("capture storage clearing", () => { + test("asks Chrome for this origin's storage before the first navigation", async () => { + const sent: { method: string; params: unknown }[] = []; + const session = { + send: async (method: string, params: unknown) => { + sent.push({ method, params }); + return {}; + }, + }; + await clearOriginStorage(session as never, "http://127.0.0.1:4517"); + expect(sent).toHaveLength(1); + expect(sent[0]!.method).toBe("Storage.clearDataForOrigin"); + expect(sent[0]!.params).toMatchObject({ + origin: "http://127.0.0.1:4517", + storageTypes: "local_storage,indexeddb,websql,cache_storage,service_workers", + }); + }); + + test("a Chrome without the verb warns instead of failing the capture", async () => { + const warned: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => warned.push(args.map(String).join(" ")); + try { + await clearOriginStorage( + { send: async () => { throw new Error("Method not found"); } } as never, + "http://127.0.0.1:4517", + ); + } finally { + console.error = original; + } + expect(warned).toHaveLength(1); + expect(warned[0]).toContain("could not clear http://127.0.0.1:4517 storage"); + }); +}); diff --git a/scripts/capture-storage.ts b/scripts/capture-storage.ts new file mode 100644 index 000000000..656fae9e1 --- /dev/null +++ b/scripts/capture-storage.ts @@ -0,0 +1,26 @@ +import type { CdpSession } from "./browser-lib"; + +/** + * Clear localStorage / IndexedDB / origin storage for the capture target BEFORE + * navigating to it, so a game that auto-restores a save boots clean instead of + * resuming a prior run's session off a warm/persistent Chrome profile (issue + * #1505). A warm daemon keeps its profile across captures, so without this a + * reused browser renders "continue your story" where a cold one renders "hit the + * street" — the same command, two different pictures. + * + * `Storage.clearDataForOrigin` takes an explicit origin, so it works from the + * initial `about:blank` page without a round-trip navigation. Best effort: a + * Chrome build that lacks the verb must not abort the capture. + */ +export async function clearOriginStorage(session: CdpSession, origin: string): Promise { + try { + await session.send("Storage.clearDataForOrigin", { + origin, + storageTypes: "local_storage,indexeddb,websql,cache_storage,service_workers", + }); + } catch (error) { + console.error( + `capture: could not clear ${origin} storage before capture (${error instanceof Error ? error.message : error}) — a restored save may change the picture`, + ); + } +} diff --git a/scripts/drive-dev.test.ts b/scripts/drive-dev.test.ts new file mode 100644 index 000000000..2c1cdcdbf --- /dev/null +++ b/scripts/drive-dev.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; + +/** + * drive's command line: `--cold` is engine-only (the portable harness has no daemon to skip), so it + * is filtered before the shared flag parser and must still be accepted rather than reported unknown. + */ +describe("drive CLI", () => { + test("accepts --cold and documents the warm default", () => { + const run = spawnSync(process.execPath, [join(import.meta.dir, "drive-dev.ts"), "demo", "--cold", "--help"], { + encoding: "utf8", + timeout: 30_000, + }); + expect({ status: run.status, stderr: run.stderr.slice(0, 300) }).toEqual({ status: 0, stderr: "" }); + expect(run.stdout).toContain("--cold"); + expect(run.stdout).toContain("warm: reused"); + }, 60_000); + + test("still refuses an unknown option", () => { + const run = spawnSync(process.execPath, [join(import.meta.dir, "drive-dev.ts"), "demo", "--bogus"], { + encoding: "utf8", + timeout: 30_000, + }); + expect(run.status).not.toBe(0); + expect(run.stderr).toContain("unknown option --bogus"); + }, 60_000); +}); diff --git a/scripts/drive-dev.ts b/scripts/drive-dev.ts index 655994a31..23c120bbd 100644 --- a/scripts/drive-dev.ts +++ b/scripts/drive-dev.ts @@ -45,7 +45,7 @@ import { type Device, type ViewportOverride, } from "./browser-lib"; -import { attachDaemon, ensureDaemonTarget } from "./shoot-daemon"; +import { acquireWarmCapture, daemonDiedUnder, ensureDaemonTarget, withDaemonWatch, type ShootDaemonState } from "./shoot-daemon"; import { lookSearchParams, parseLookAim } from "./lookArg"; import { driveTargetUrl, externalCaptureUrl, parseCaptureDevice, requireReusableCaptureStorage } from "./captureTarget"; import { @@ -123,6 +123,8 @@ type Args = { style?: string; /** Show a real window (off-screen) instead of headless Chrome. */ headed: boolean; + /** `--cold`: boot Chrome and Vite directly, proving a clean start. */ + cold: boolean; /** 960x540 iteration preset: one settled frame in a 2s window, no video, no perf pass. */ quick: boolean; /** Step file this run reads, when it reads one. */ @@ -205,9 +207,11 @@ Notes: claim, so asking for either alongside it fails; re-run without --quick for evidence. -Warm loop: - bun run shoot daemon start - bun run drive --click START --shot before --keep +Warm capture (started for you; no lane starts the daemon by hand): + bun run drive --click START --shot before # reuses or starts the daemon + --cold skips the daemon entirely: fresh Chrome and Vite, for evidence runs that + must prove a clean boot. Each run prints which happened and how long it took: + warm: reused :9261 (0.2s) cold: started :9261 (4.1s) bun run drive --click START --shot after --connect --quick `; @@ -230,8 +234,10 @@ function flowSource(dir: string): FlowSource { function parseArgs(argv: string[]): Args { // `--headed` is parsed here, not in CAPTURE_FLAGS: the scaffold's drive.mjs has no local window to // show, so the flag stays engine-only instead of parsing there and doing nothing. const quick = splitQuickFlag("drive", argv); + // `--cold` is parsed here, not in CAPTURE_FLAGS: the portable harness has no daemon to skip. + const cold = quick.argv.includes("--cold"); const headed = quick.argv.includes("--headed"); - const parsed = parseCaptureArgs("drive", quick.argv.filter((arg) => arg !== "--headed"), CAPTURE_FLAGS); + const parsed = parseCaptureArgs("drive", quick.argv.filter((arg) => arg !== "--headed" && arg !== "--cold"), CAPTURE_FLAGS); const o = parsed.options; const str = (name: string): string | undefined => (typeof o[name] === "string" ? (o[name] as string) : undefined); const num = (name: string): number | undefined => (str(name) === undefined ? undefined : Number(str(name))); @@ -245,6 +251,7 @@ function parseArgs(argv: string[]): Args { // `--headed` is parsed here, not in size: sizeForQuality(parseQualityArg(str("quality") ?? "full"), str("size") === undefined ? undefined : parseSizeArg(str("size"))), connect: num("connect"), keep: o.keep === true, + cold, help: parsed.help, timeoutMs: (num("timeout") ?? 60) * 1000, timeoutExplicit: str("timeout") !== undefined, @@ -744,331 +751,360 @@ const clickPoints = new Map(); } } if (args.port !== undefined) process.env.JG_DEV_PORT = String(args.port); -const daemon = args.connect === undefined && args.port === undefined ? await attachDaemon() : null; -requireReusableCaptureStorage(args.reuseStorage, args.connect !== undefined, daemon !== null); -if (args.url !== undefined && !(await isUp(args.url))) { - throw new Error(`drive: nothing is listening at ${args.url} — start that external server first`); -} -const dev = args.url !== undefined - ? { base: args.url, child: null, pid: undefined } - : daemon === null - ? args.site === undefined ? await ensureDevServer() : await ensureWebServer() - : await ensureDaemonTarget(daemon, args.site === undefined ? "dev" : "web"); - -const lockstep = args.record !== undefined && !args.recordRealtime && !args.playtest; -const lockstepVirtualMs = args.steps.reduce( - (sum, step) => sum + (step.kind === "key" ? step.holdMs : step.kind === "wait" ? step.ms : 0), - 0, -); -const lockstepProjected = Math.ceil(lockstepVirtualMs / Math.max(20, Math.round(1000 / args.recordFps))); +async function driveSession(daemon: ShootDaemonState | null): Promise { + requireReusableCaptureStorage(args.reuseStorage, args.connect !== undefined, daemon !== null); + if (args.url !== undefined && !(await isUp(args.url))) { + throw new Error(`drive: nothing is listening at ${args.url} — start that external server first`); + } + const dev = args.url !== undefined + ? { base: args.url, child: null, pid: undefined } + : daemon === null + ? args.site === undefined ? await ensureDevServer() : await ensureWebServer() + : await ensureDaemonTarget(daemon, args.site === undefined ? "dev" : "web"); -const exitCode = await withBrowserSession( - { - keep: args.keep, - connect: args.connect, - timeoutMs: args.timeoutMs, - server: dev.child, - serverPid: dev.pid, - debugPort: daemon?.chromePort, - attach: daemon !== null, - leaveWarm: args.keep || daemon !== null, - chromePrefix: "jg-drive-", - headed: args.headed, - // One real render + capture per output frame — give the watchdog room. - ...(lockstep ? { hardDeadlineMs: args.timeoutMs + 120_000 + lockstepProjected * 3_000 } : {}), - }, - async ({ debugPort }) => { - let code = 0; - const session = await openPageSession(debugPort); - const stopConsoleForward = forwardPageConsole(session, "drive:"); - try { - await session.send("Page.enable"); - await session.send("Runtime.enable"); - await applyDevice(session, args.device, args.size, viewport); - const url = applyCaptureQuality(driveTargetUrl(args, dev.base), args.quality); - url.searchParams.set("capture", "1"); - if (args.spawn !== undefined && args.spawn.length > 0) url.searchParams.set("spawn", args.spawn); - for (const [key, value] of args.params) url.searchParams.set(key, value); - if (args.view !== undefined) url.searchParams.set("view", args.view); - if (args.state !== undefined) url.searchParams.set("state", args.state); - if (args.run !== undefined) url.searchParams.set("run", args.run); - if (args.style !== undefined) url.searchParams.set("style", args.style); - if (args.settle !== undefined) url.searchParams.set("settle", String(args.settle)); - const aim = parseLookAim(args.look, args.lookFrom, { withNamedView: args.view !== undefined }); - if (aim !== undefined) { - for (const [key, value] of Object.entries(lookSearchParams(aim))) url.searchParams.set(key, value); - } - if (args.playtest) url.searchParams.set("seed", String(args.seed)); - if (!args.reuseStorage) await clearOriginStorage(session, new URL(url.toString()).origin); - await navigateCapturePageWithRetry(session, url.toString(), dev.base, args.timeoutMs, 2, { legacyCanvas: args.url !== undefined }); - if (aim !== undefined) { - const applied = await session.evaluate(`document.documentElement.dataset.jgLook ?? null`); - if (typeof applied !== "string" || applied.length === 0) { - throw new Error(`--look ${args.look} never reached the camera — the runner reported no applied aim`); - } - console.error(`drive: look applied ${applied}`); - } - await new Promise((r) => setTimeout(r, 500)); - await installFrameCounter(session); + const lockstep = args.record !== undefined && !args.recordRealtime && !args.playtest; + const lockstepVirtualMs = args.steps.reduce( + (sum, step) => sum + (step.kind === "key" ? step.holdMs : step.kind === "wait" ? step.ms : 0), + 0, + ); + const lockstepProjected = Math.ceil(lockstepVirtualMs / Math.max(20, Math.round(1000 / args.recordFps))); - let recorder: LockstepRecorder | null = null; - if (lockstep) { - if (lockstepProjected > 900) { - throw new Error( - `drive: --record would capture ~${lockstepProjected} frames (${Math.round(lockstepVirtualMs / 1000)}s at ${args.recordFps}fps) — every frame costs a real render on software GL. Choreograph a shorter drive or lower --record-fps.`, - ); + return withDaemonWatch(daemon, withBrowserSession( + { + keep: args.keep, + connect: args.connect, + timeoutMs: args.timeoutMs, + server: dev.child, + serverPid: dev.pid, + debugPort: daemon?.chromePort, + attach: daemon !== null, + leaveWarm: args.keep || daemon !== null, + chromePrefix: "jg-drive-", + headed: args.headed, + // One real render + capture per output frame — give the watchdog room. + ...(lockstep ? { hardDeadlineMs: args.timeoutMs + 120_000 + lockstepProjected * 3_000 } : {}), + }, + async ({ debugPort }) => { + let code = 0; + const session = await openPageSession(debugPort); + const stopConsoleForward = forwardPageConsole(session, "drive:"); + try { + await session.send("Page.enable"); + await session.send("Runtime.enable"); + await applyDevice(session, args.device, args.size, viewport); + const url = applyCaptureQuality(driveTargetUrl(args, dev.base), args.quality); + url.searchParams.set("capture", "1"); + if (args.spawn !== undefined && args.spawn.length > 0) url.searchParams.set("spawn", args.spawn); + for (const [key, value] of args.params) url.searchParams.set(key, value); + if (args.view !== undefined) url.searchParams.set("view", args.view); + if (args.state !== undefined) url.searchParams.set("state", args.state); + if (args.run !== undefined) url.searchParams.set("run", args.run); + if (args.style !== undefined) url.searchParams.set("style", args.style); + if (args.settle !== undefined) url.searchParams.set("settle", String(args.settle)); + const aim = parseLookAim(args.look, args.lookFrom, { withNamedView: args.view !== undefined }); + if (aim !== undefined) { + for (const [key, value] of Object.entries(lookSearchParams(aim))) url.searchParams.set(key, value); } - if (lockstepProjected > 450) { - console.error( - `drive: --record will capture ~${lockstepProjected} frames — expect several minutes of wall clock; consider a shorter drive or lower --record-fps`, - ); + if (args.playtest) url.searchParams.set("seed", String(args.seed)); + if (!args.reuseStorage) await clearOriginStorage(session, new URL(url.toString()).origin); + await navigateCapturePageWithRetry(session, url.toString(), dev.base, args.timeoutMs, 2, { legacyCanvas: args.url !== undefined }); + if (aim !== undefined) { + const applied = await session.evaluate(`document.documentElement.dataset.jgLook ?? null`); + if (typeof applied !== "string" || applied.length === 0) { + throw new Error(`--look ${args.look} never reached the camera — the runner reported no applied aim`); + } + console.error(`drive: look applied ${applied}`); } - recorder = new LockstepRecorder(session, args.recordFps); - await recorder.start(); - } + await new Promise((r) => setTimeout(r, 500)); + await installFrameCounter(session); - const recorded: TimedPng[] = []; - if (args.record !== undefined && !lockstep) { - session.on("Page.screencastFrame", (params) => { - const data = params.data; - const sessionId = params.sessionId; - const metadata = params.metadata as { timestamp?: number } | undefined; - if (typeof sessionId === "number" || typeof sessionId === "string") { - void session.send("Page.screencastFrameAck", { sessionId }).catch(() => {}); + let recorder: LockstepRecorder | null = null; + if (lockstep) { + if (lockstepProjected > 900) { + throw new Error( + `drive: --record would capture ~${lockstepProjected} frames (${Math.round(lockstepVirtualMs / 1000)}s at ${args.recordFps}fps) — every frame costs a real render on software GL. Choreograph a shorter drive or lower --record-fps.`, + ); } - if (typeof data !== "string" || data.length === 0) return; - const tSec = metadata?.timestamp; - recorded.push({ - png: Buffer.from(data, "base64"), - tMs: typeof tSec === "number" ? tSec * 1000 : Date.now(), - }); - }); - await session.send("Page.startScreencast", { - format: "png", - maxWidth: args.recordWidth, - maxHeight: args.recordWidth, - everyNthFrame: 1, - }); - } + if (lockstepProjected > 450) { + console.error( + `drive: --record will capture ~${lockstepProjected} frames — expect several minutes of wall clock; consider a shorter drive or lower --record-fps`, + ); + } + recorder = new LockstepRecorder(session, args.recordFps); + await recorder.start(); + } - // Renderer classification for the playtest verdict: a software-GL host - // (cloud SwiftShader) delivers a tiny fraction of real-time fps, so a - // flat progress probe cannot be trusted as a genuine softlock (#1506). - const gl = args.playtest ? await detectSoftwareGl(session) : { renderer: null, software: false }; - const samples: ProbeSample[] = []; - let sampling = args.playtest; - const sampleStart = Date.now(); - const framesAtStart = args.playtest ? await readFrames(session) : 0; - const sampler = args.playtest - ? (async () => { - while (sampling) { - const metrics = await readProbe(session); - if (metrics !== null) samples.push({ t: Date.now() - sampleStart, metrics }); - await new Promise((r) => setTimeout(r, args.sampleMs)); + const recorded: TimedPng[] = []; + if (args.record !== undefined && !lockstep) { + session.on("Page.screencastFrame", (params) => { + const data = params.data; + const sessionId = params.sessionId; + const metadata = params.metadata as { timestamp?: number } | undefined; + if (typeof sessionId === "number" || typeof sessionId === "string") { + void session.send("Page.screencastFrameAck", { sessionId }).catch(() => {}); } - })() - : Promise.resolve(); + if (typeof data !== "string" || data.length === 0) return; + const tSec = metadata?.timestamp; + recorded.push({ + png: Buffer.from(data, "base64"), + tMs: typeof tSec === "number" ? tSec * 1000 : Date.now(), + }); + }); + await session.send("Page.startScreencast", { + format: "png", + maxWidth: args.recordWidth, + maxHeight: args.recordWidth, + everyNthFrame: 1, + }); + } - const conditionClock = (): DriveConditionClock => recorder === null - ? { sleep: (ms: number) => new Promise((r) => setTimeout(r, ms)), now: () => Date.now() } - : { sleep: (ms: number) => recorder!.advance(ms), now: () => recorder!.virtualNow }; - const waitOn = (label: string, entry: { is: string; value?: unknown; timeoutMs?: number; note?: string }) => - awaitDriveCondition(session as unknown as DriveSession, label, entry, conditionClock()); + // Renderer classification for the playtest verdict: a software-GL host + // (cloud SwiftShader) delivers a tiny fraction of real-time fps, so a + // flat progress probe cannot be trusted as a genuine softlock (#1506). + const gl = args.playtest ? await detectSoftwareGl(session) : { renderer: null, software: false }; + const samples: ProbeSample[] = []; + let sampling = args.playtest; + const sampleStart = Date.now(); + const framesAtStart = args.playtest ? await readFrames(session) : 0; + const sampler = args.playtest + ? (async () => { + while (sampling) { + const metrics = await readProbe(session); + if (metrics !== null) samples.push({ t: Date.now() - sampleStart, metrics }); + await new Promise((r) => setTimeout(r, args.sampleMs)); + } + })() + : Promise.resolve(); - const logPath = args.log === undefined ? undefined : resolve(args.log); - if (logPath !== undefined) { - mkdirSync(dirname(logPath), { recursive: true }); - writeFileSync(logPath, ""); - } + const conditionClock = (): DriveConditionClock => recorder === null + ? { sleep: (ms: number) => new Promise((r) => setTimeout(r, ms)), now: () => Date.now() } + : { sleep: (ms: number) => recorder!.advance(ms), now: () => recorder!.virtualNow }; + const waitOn = (label: string, entry: { is: string; value?: unknown; timeoutMs?: number; note?: string }) => + awaitDriveCondition(session as unknown as DriveSession, label, entry, conditionClock()); - const done = await runDriveSteps(args.steps, { - now: () => performance.now(), - line: (text) => console.error(text), - ...(recorder === null ? {} : { virtualNow: () => recorder!.virtualNow }), - ...(logPath === undefined ? {} : { record: (entry: DriveStepRecord) => appendFileSync(logPath, JSON.stringify(entry) + "\n") }), - run: async (step, index) => { - const label = "steps[" + index + "]"; - const timings: string[] = []; - if (step.before !== undefined) timings.push("before " + (await waitOn(label + ".before", step.before)).elapsedMs + "ms"); - if (step.kind === "click" || step.kind === "double-click") { - await click(session, step.text, step.kind === "double-click" ? 2 : 1); - // Let the UI react on the virtual clock so the click's effect is in frame. - if (recorder !== null) await recorder.advance(300); - } else if (step.kind === "fill") { - await fill(session, step.label, step.value); - if (recorder !== null) await recorder.advance(300); - } else if (step.kind === "key") { - if (recorder !== null) { - // Lockstep hold: the virtual clock guarantees the sim steps under the - // held key, so no frame-starvation grace dance is needed. - await focusSurface(session); - await sendKeys(session, step.code, "keyDown"); - await recorder.advance(step.holdMs); - await sendKeys(session, step.code, "keyUp"); + const logPath = args.log === undefined ? undefined : resolve(args.log); + if (logPath !== undefined) { + mkdirSync(dirname(logPath), { recursive: true }); + writeFileSync(logPath, ""); + } + + const done = await runDriveSteps(args.steps, { + now: () => performance.now(), + line: (text) => console.error(text), + ...(recorder === null ? {} : { virtualNow: () => recorder!.virtualNow }), + ...(logPath === undefined ? {} : { record: (entry: DriveStepRecord) => appendFileSync(logPath, JSON.stringify(entry) + "\n") }), + run: async (step, index) => { + const label = "steps[" + index + "]"; + const timings: string[] = []; + if (step.before !== undefined) timings.push("before " + (await waitOn(label + ".before", step.before)).elapsedMs + "ms"); + if (step.kind === "click" || step.kind === "double-click") { + await click(session, step.text, step.kind === "double-click" ? 2 : 1); + // Let the UI react on the virtual clock so the click's effect is in frame. + if (recorder !== null) await recorder.advance(300); + } else if (step.kind === "fill") { + await fill(session, step.label, step.value); + if (recorder !== null) await recorder.advance(300); + } else if (step.kind === "key") { + if (recorder !== null) { + // Lockstep hold: the virtual clock guarantees the sim steps under the + // held key, so no frame-starvation grace dance is needed. + await focusSurface(session); + await sendKeys(session, step.code, "keyDown"); + await recorder.advance(step.holdMs); + await sendKeys(session, step.code, "keyUp"); + } else { + await holdKey(session, step.code, step.holdMs); + } + } else if (step.kind === "wait") { + if (recorder !== null) await recorder.advance(step.ms); + else await new Promise((r) => setTimeout(r, step.ms)); + } else if (step.kind === "waitFor") { + timings.push("waitFor " + (await waitOn(label + ".waitFor", { is: step.expression, timeoutMs: step.timeoutMs })).elapsedMs + "ms"); + } else if (step.kind === "assert") { + timings.push("actual " + JSON.stringify((await evaluateAssertStep(session as unknown as DriveSession, step)) ?? null)); + } else if (step.kind === "reload") { + await navigateCapturePageWithRetry(session, url.toString(), dev.base, args.timeoutMs, 2, { legacyCanvas: args.url !== undefined }); + await installFrameCounter(session); + } else if (step.kind === "click-at") { + await session.send("Page.bringToFront"); + await dispatchClickAt(session, clickPoints.get(step)!, step.button); + if (recorder !== null) await recorder.advance(300); + } else if (step.kind === "rpc") { + await rpc(session, step.json); + if (step.summarized) console.error(`drive: debug_snapshot printed its summary; pass {"method":"debug_snapshot","detail":"full"} for everything`); + } + else if (step.kind === "probe") { + const metrics = await readProbe(session); + console.log(JSON.stringify({ probe: step.name, metrics })); } else { - await holdKey(session, step.code, step.holdMs); + // A recording owns the screencast pump; a shot must not start/stop it underneath. + const path = step.out ?? join(outDir, `${args.game}-${step.name}${sizeSuffix(args.size)}${qualitySuffix(args.quality)}${quickSuffix(args.quick)}.png`); + const previous = preparedShots.has(path) ? preparedShots.get(path) : clearShotTarget(path); + preparedShots.delete(path); + await screenshot( + session, + path, + args.size, + args.device, + args.record === undefined, + previous, + viewport, + args.quick ? QUICK_DRIVE_WINDOW_MS : 60_000, + ); } - } else if (step.kind === "wait") { - if (recorder !== null) await recorder.advance(step.ms); - else await new Promise((r) => setTimeout(r, step.ms)); - } else if (step.kind === "waitFor") { - timings.push("waitFor " + (await waitOn(label + ".waitFor", { is: step.expression, timeoutMs: step.timeoutMs })).elapsedMs + "ms"); - } else if (step.kind === "assert") { - timings.push("actual " + JSON.stringify((await evaluateAssertStep(session as unknown as DriveSession, step)) ?? null)); - } else if (step.kind === "reload") { - await navigateCapturePageWithRetry(session, url.toString(), dev.base, args.timeoutMs, 2, { legacyCanvas: args.url !== undefined }); - await installFrameCounter(session); - } else if (step.kind === "click-at") { - await session.send("Page.bringToFront"); - await dispatchClickAt(session, clickPoints.get(step)!, step.button); - if (recorder !== null) await recorder.advance(300); - } else if (step.kind === "rpc") { - await rpc(session, step.json); - if (step.summarized) console.error(`drive: debug_snapshot printed its summary; pass {"method":"debug_snapshot","detail":"full"} for everything`); - } - else if (step.kind === "probe") { - const metrics = await readProbe(session); - console.log(JSON.stringify({ probe: step.name, metrics })); - } else { - // A recording owns the screencast pump; a shot must not start/stop it underneath. - const path = step.out ?? join(outDir, `${args.game}-${step.name}${sizeSuffix(args.size)}${qualitySuffix(args.quality)}${quickSuffix(args.quick)}.png`); - const previous = preparedShots.has(path) ? preparedShots.get(path) : clearShotTarget(path); - preparedShots.delete(path); - await screenshot( - session, - path, - args.size, - args.device, - args.record === undefined, - previous, - viewport, - args.quick ? QUICK_DRIVE_WINDOW_MS : 60_000, - ); - } - if (step.after !== undefined) timings.push("after " + (await waitOn(label + ".after", step.after)).elapsedMs + "ms"); - return timings.join(" "); - }, - }); - if (done.failed > 0) code = 1; + if (step.after !== undefined) timings.push("after " + (await waitOn(label + ".after", step.after)).elapsedMs + "ms"); + return timings.join(" "); + }, + }); + if (done.failed > 0) code = 1; - if (recorder !== null) { - await recorder.stop(); - const mp4Path = join(outDir, `${args.game}-${args.record}${sizeSuffix(args.size)}.mp4`); - if (recorder.frames.length === 0) { - console.error(`drive: --record captured no frames`); - code = 1; - } else { - const frames = recorder.frames.map((frame) => ({ png: frame.png, delayMs: recorder.frameMs })); - assembleMp4(frames, mp4Path); - console.log(mp4Path); - const bytes = statSync(mp4Path).size; - console.error( - `drive: recorded ${frames.length} frame(s) at ${args.recordFps}fps, ${(bytes / 1_000_000).toFixed(2)}MB mp4 (${(frames.length / args.recordFps).toFixed(1)}s of game time)`, - ); - } - } else if (args.record !== undefined) { - await session.send("Page.stopScreencast"); - // Let an in-flight frame land before assembling. - await new Promise((r) => setTimeout(r, 300)); - let frames = framesFromTimeline(recorded); - if (frames.length === 0) { - console.error(`drive: --record captured no frames — the page never repainted during the drive`); - code = 1; - } else { + if (recorder !== null) { + await recorder.stop(); const mp4Path = join(outDir, `${args.game}-${args.record}${sizeSuffix(args.size)}.mp4`); - try { + if (recorder.frames.length === 0) { + console.error(`drive: --record captured no frames`); + code = 1; + } else { + const frames = recorder.frames.map((frame) => ({ png: frame.png, delayMs: recorder.frameMs })); assembleMp4(frames, mp4Path); console.log(mp4Path); - } catch (error) { + const bytes = statSync(mp4Path).size; console.error( - `drive: mp4 encode failed (${error instanceof Error ? error.message : error}) — no usable video evidence was produced`, + `drive: recorded ${frames.length} frame(s) at ${args.recordFps}fps, ${(bytes / 1_000_000).toFixed(2)}MB mp4 (${(frames.length / args.recordFps).toFixed(1)}s of game time)`, ); - code = 1; } - let gif = assembleGif(frames); - let thinned = 0; - while (gif.length > RECORD_BUDGET_BYTES && frames.length > 2) { - frames = thinFrames(frames); - gif = assembleGif(frames); - thinned += 1; + } else if (args.record !== undefined) { + await session.send("Page.stopScreencast"); + // Let an in-flight frame land before assembling. + await new Promise((r) => setTimeout(r, 300)); + let frames = framesFromTimeline(recorded); + if (frames.length === 0) { + console.error(`drive: --record captured no frames — the page never repainted during the drive`); + code = 1; + } else { + const mp4Path = join(outDir, `${args.game}-${args.record}${sizeSuffix(args.size)}.mp4`); + try { + assembleMp4(frames, mp4Path); + console.log(mp4Path); + } catch (error) { + console.error( + `drive: mp4 encode failed (${error instanceof Error ? error.message : error}) — no usable video evidence was produced`, + ); + code = 1; + } + let gif = assembleGif(frames); + let thinned = 0; + while (gif.length > RECORD_BUDGET_BYTES && frames.length > 2) { + frames = thinFrames(frames); + gif = assembleGif(frames); + thinned += 1; + } + const outPath = join(outDir, `${args.game}-${args.record}${sizeSuffix(args.size)}.gif`); + writePngAtomic(outPath, gif); + console.log(outPath); + console.error( + `drive: recorded ${frames.length} frame(s), ${(gif.length / 1_000_000).toFixed(2)}MB animated GIF` + + (thinned > 0 ? ` (thinned x${thinned} to fit the ~4.5MB GitHub camo budget)` : ""), + ); } - const outPath = join(outDir, `${args.game}-${args.record}${sizeSuffix(args.size)}.gif`); - writePngAtomic(outPath, gif); - console.log(outPath); - console.error( - `drive: recorded ${frames.length} frame(s), ${(gif.length / 1_000_000).toFixed(2)}MB animated GIF` + - (thinned > 0 ? ` (thinned x${thinned} to fit the ~4.5MB GitHub camo budget)` : ""), - ); } - } - if (args.playtest) { - sampling = false; - await sampler; - const framesRendered = (await readFrames(session)) - framesAtStart; - const result = summarizePlaytest(samples, { - seed: args.seed, - softlockThresholdMs: args.softlockMs, - epsilon: args.epsilon, - }); - // Effective render cadence over the sampled window. Below LOW_FPS a held - // key advances the sim only a few frames total, so a flat progress probe - // is indistinguishable from a genuine softlock — the verdict is unsafe. - const { effectiveFps, unreliable: unreliableRender } = classifyRenderCadence( - framesRendered, - result.durationMs, - gl.software, - LOW_FPS_THRESHOLD, - ); - console.log(JSON.stringify({ ...result, effectiveFps: Number(effectiveFps.toFixed(2)), softwareGl: gl.software, renderer: gl.renderer })); - if (!result.probed) { - console.error( - `drive: no progress probe read — ${args.game} exposes no capture.probe (or it returned no metrics). Declare capture.probe to run the playtest rung.`, - ); - if (args.strict) code = 1; - } else if (result.softlocked && unreliableRender) { - // Do NOT report a false softlock on a software-GL / frame-starved host. - console.error( - `drive: playtest rung UNRELIABLE on this host — ${gl.software ? `software GL (${gl.renderer ?? "unknown renderer"})` : `only ~${effectiveFps.toFixed(1)}fps effective`} rendered ${framesRendered} frame(s) in ${(result.durationMs / 1000).toFixed(1)}s, so flat progress does NOT prove a softlock (#1506). Not treating this as a failure.`, - ); - console.error( - `drive: for a trustworthy verdict, run on a GPU host, or use the deterministic headless rung — step the sim directly with stepPlayerMovement / createHeadlessRunner(...).step(1/60) instead of real-time frames (see jgengine-verify "Scene and gameplay proof").`, - ); - // Intentionally does not set code=1 even under --strict: an untrustworthy - // signal must not fail CI as if it were a real softlock. - } else if (result.softlocked) { - console.error( - `drive: SOFTLOCK — progress stayed flat for ${result.softlockWindowMs}ms under active input (threshold ${args.softlockMs}ms, seed ${args.seed}). The loop did not advance.`, + if (args.playtest) { + sampling = false; + await sampler; + const framesRendered = (await readFrames(session)) - framesAtStart; + const result = summarizePlaytest(samples, { + seed: args.seed, + softlockThresholdMs: args.softlockMs, + epsilon: args.epsilon, + }); + // Effective render cadence over the sampled window. Below LOW_FPS a held + // key advances the sim only a few frames total, so a flat progress probe + // is indistinguishable from a genuine softlock — the verdict is unsafe. + const { effectiveFps, unreliable: unreliableRender } = classifyRenderCadence( + framesRendered, + result.durationMs, + gl.software, + LOW_FPS_THRESHOLD, ); - if (args.strict) code = 1; - } else if (unreliableRender && args.steps.some((step) => step.kind === "key")) { + console.log(JSON.stringify({ ...result, effectiveFps: Number(effectiveFps.toFixed(2)), softwareGl: gl.software, renderer: gl.renderer })); + if (!result.probed) { + console.error( + `drive: no progress probe read — ${args.game} exposes no capture.probe (or it returned no metrics). Declare capture.probe to run the playtest rung.`, + ); + if (args.strict) code = 1; + } else if (result.softlocked && unreliableRender) { + // Do NOT report a false softlock on a software-GL / frame-starved host. + console.error( + `drive: playtest rung UNRELIABLE on this host — ${gl.software ? `software GL (${gl.renderer ?? "unknown renderer"})` : `only ~${effectiveFps.toFixed(1)}fps effective`} rendered ${framesRendered} frame(s) in ${(result.durationMs / 1000).toFixed(1)}s, so flat progress does NOT prove a softlock (#1506). Not treating this as a failure.`, + ); + console.error( + `drive: for a trustworthy verdict, run on a GPU host, or use the deterministic headless rung — step the sim directly with stepPlayerMovement / createHeadlessRunner(...).step(1/60) instead of real-time frames (see jgengine-verify "Scene and gameplay proof").`, + ); + // Intentionally does not set code=1 even under --strict: an untrustworthy + // signal must not fail CI as if it were a real softlock. + } else if (result.softlocked) { + console.error( + `drive: SOFTLOCK — progress stayed flat for ${result.softlockWindowMs}ms under active input (threshold ${args.softlockMs}ms, seed ${args.seed}). The loop did not advance.`, + ); + if (args.strict) code = 1; + } else if (unreliableRender && args.steps.some((step) => step.kind === "key")) { + console.error( + `drive: note — playtest ran under ${gl.software ? "software GL" : `~${effectiveFps.toFixed(1)}fps`} (${framesRendered} frame(s) in ${(result.durationMs / 1000).toFixed(1)}s); progress read as advancing, but on a frame-starved host prefer the deterministic stepPlayerMovement rung for firm evidence.`, + ); + } else if (args.steps.every((step) => step.kind !== "key")) { + console.error("drive: --playtest ran with no --key hold — nothing drove input, so progress is unproven."); + } + } + if (args.quick) { + const shotProfile = captureProfile(args.device, args.size, viewport); console.error( - `drive: note — playtest ran under ${gl.software ? "software GL" : `~${effectiveFps.toFixed(1)}fps`} (${framesRendered} frame(s) in ${(result.durationMs / 1000).toFixed(1)}s); progress read as advancing, but on a frame-starved host prefer the deterministic stepPlayerMovement rung for firm evidence.`, + quickTimingLine("drive", { + seconds: (performance.now() - startedAt) / 1_000, + width: Math.round(shotProfile.width * shotProfile.deviceScaleFactor), + height: Math.round(shotProfile.height * shotProfile.deviceScaleFactor), + device: args.device, + }), ); - } else if (args.steps.every((step) => step.kind !== "key")) { - console.error("drive: --playtest ran with no --key hold — nothing drove input, so progress is unproven."); } + if (args.keep) { + console.error(`drive: kept warm — chrome debug port ${debugPort}, dev server on ${dev.base}`); + console.error(`drive: next drive → bun run drive ${args.url === undefined ? `${args.game} --mode ${args.mode}` : `--url ${args.url}`} --connect ${debugPort} --size half ...`); + } + } finally { + stopConsoleForward(); + await session.close(); } - if (args.quick) { - const shotProfile = captureProfile(args.device, args.size, viewport); - console.error( - quickTimingLine("drive", { - seconds: (performance.now() - startedAt) / 1_000, - width: Math.round(shotProfile.width * shotProfile.deviceScaleFactor), - height: Math.round(shotProfile.height * shotProfile.deviceScaleFactor), - device: args.device, - }), - ); - } - if (args.keep) { - console.error(`drive: kept warm — chrome debug port ${debugPort}, dev server on ${dev.base}`); - console.error(`drive: next drive → bun run drive ${args.url === undefined ? `${args.game} --mode ${args.mode}` : `--url ${args.url}`} --connect ${debugPort} --size half ...`); - } - } finally { - stopConsoleForward(); - await session.close(); - } - return code; - }, -); + return code; + }, + )); +} + +/** Run one drive, reporting a failure the same way `withBrowserSession` does. */ +async function attempt(daemon: ShootDaemonState | null): Promise { + try { + return await driveSession(daemon); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + return 1; + } +} + +const explicitTarget = args.connect !== undefined || args.port !== undefined || args.url !== undefined; +// The daemon's Chrome was launched headless with the display frame clock, so a run that needs a +// window of its own must not borrow it. An external --url is not this checkout's app either. +const ownBrowser = args.headed ? "--headed needs its own browser window" : undefined; +const warm = explicitTarget + ? { daemon: null, mode: "cold" as const, seconds: 0 } + : await acquireWarmCapture({ + cold: args.cold || ownBrowser !== undefined, + ...(ownBrowser === undefined ? {} : { because: ownBrowser }), + }); +let exitCode = await attempt(warm.daemon); +if (warm.daemon !== null && exitCode !== 0 && (await daemonDiedUnder(warm.daemon))) { + console.error( + `cold: the daemon on :${warm.daemon.chromePort} died mid-run — falling back to a direct launch, no retry`, + ); + exitCode = await attempt(null); +} process.exit(exitCode); diff --git a/scripts/perf-play.ts b/scripts/perf-play.ts index 986669be0..c3254dd69 100644 --- a/scripts/perf-play.ts +++ b/scripts/perf-play.ts @@ -15,6 +15,13 @@ import { type CaptureQuality, type FrameClock, } from "./browser-lib"; +import { + acquireWarmCapture, + daemonDiedUnder, + ensureDaemonTarget, + withDaemonWatch, + type ShootDaemonState, +} from "./shoot-daemon"; import { summarizeFrameWindow, type FrameWindow, @@ -123,14 +130,19 @@ export interface PerfArgs { frameClock: FrameClock; /** Show a real window (off-screen) instead of headless Chrome. */ headed?: boolean; + /** `--cold`: boot Chrome and Vite directly instead of reusing the warm daemon. */ + cold?: boolean; } export type ArgResult = { kind: "run"; args: PerfArgs } | { kind: "help" }; const USAGE = `usage: bun run perf [--quality draft|full] [--profile] [--seconds n] [--telemetry on|off] - [--state name] [--param k=v ...] [--set key=value ...] [--uncapped] [--headed] + [--state name] [--param k=v ...] [--set key=value ...] [--uncapped] [--headed] [--cold] game id under Games/ (for example harbor-heat) + --cold skip the warm daemon and boot Chrome + Vite directly: fresh caches, larger + numbers than a warm run, for an evidence run that must prove a clean start. + Every run prints which it got: warm: reused :9261 (0.2s) / cold: started :9261 (4.1s) --quality graphics tier: full (default) or draft (?quality=low, half viewport) --profile also print the inclusive-time table to stderr --seconds measured window in seconds (default ${DEFAULT_SECONDS}) @@ -310,6 +322,7 @@ export function parsePerfArgs(argv: readonly string[]): ArgResult { let telemetry: "on" | "off" = "on"; let frameClock: FrameClock = "capped"; let headed = false; + let cold = false; let state: string | undefined; const params: [string, string][] = []; const settings: PerfSettingOverride[] = []; @@ -328,6 +341,10 @@ export function parsePerfArgs(argv: readonly string[]): ArgResult { headed = true; continue; } + if (arg === "--cold") { + cold = true; + continue; + } if (arg === "--state") { const value = argv[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--state needs a capture.states name"); @@ -382,7 +399,7 @@ export function parsePerfArgs(argv: readonly string[]): ArgResult { game = arg; } if (game === undefined) throw new Error("a game id is required"); - return { kind: "run", args: { game, quality, profile, seconds, telemetry, settings, frameClock, ...(headed ? { headed: true } : {}), ...(state === undefined ? {} : { state }), params } }; + return { kind: "run", args: { game, quality, profile, seconds, telemetry, settings, frameClock, ...(headed ? { headed: true } : {}), ...(cold ? { cold: true } : {}), ...(state === undefined ? {} : { state }), params } }; } /** One sampled stack frame reduced to a readable key and its self time. */ @@ -1014,37 +1031,78 @@ async function main(): Promise { throw new Error(`no game "${game}" in Games/ — run \`bun run games:clone\` first, then retry`); } - const server = await ensureDevServer(repoRoot); - const exitCode = await withBrowserSession( - { keep: false, timeoutMs: 120_000, frameClock, headed, server: server.child, serverPid: server.pid }, - async ({ debugPort }) => { - const session = await openPageSession(debugPort); - try { - const measured = await measureGame({ session, base: server.base, game, quality, seconds, telemetry, settings, state, params, frameClock }); - const pin = perfStateLine(state, params); - if (pin !== null) console.error(pin); - const clock = perfFrameClockLine(frameClock); - if (clock !== null) console.error(clock); - if (measured.clockProbeMs !== null) { - console.error(`perf: blank page ticked at ${measured.clockProbeMs} ms on the uncapped clock`); - } - if (measured.record.overrides.length > 0) { - console.error(`perf: overrides ${measured.record.overrides.join(" ")}`); - } - console.log(JSON.stringify(measured.record, null, 2)); - if (profile && measured.profile !== null) { - printInclusive(aggregateInclusiveTime(measured.profile, measured.profileMs)); - } - if (measured.record.profile?.top.length === 0) console.error("perf: the CPU profile produced no samples"); - if (measured.record.gl.attached !== true) { - console.error("perf: GL allocation counters saw no activity — the WebGL hooks did not attach"); + // The daemon's Chrome runs the display's frame clock and no window, so a run that pins its own + // clock or asks for a window must launch its own browser instead of borrowing the daemon's. + const ownBrowser = frameClock === "uncapped" + ? "--uncapped needs its own browser flags" + : headed + ? "--headed needs its own browser window" + : undefined; + const warm = await acquireWarmCapture({ + cold: parsed.args.cold === true || ownBrowser !== undefined, + ...(ownBrowser === undefined ? {} : { because: ownBrowser }), + }); + /** + * One measured run against a target: the warm daemon when there is one, otherwise a Vite and + * Chrome this run owns. A function so a daemon that dies under a run can be retried cold once. + */ + const runPerf = async (daemon: ShootDaemonState | null): Promise => { + const server = daemon === null ? await ensureDevServer(repoRoot) : await ensureDaemonTarget(daemon, "dev"); + return withDaemonWatch(daemon, withBrowserSession( + { + keep: false, + timeoutMs: 120_000, + frameClock, + headed, + server: server.child, + serverPid: server.pid, + debugPort: daemon?.chromePort, + attach: daemon !== null, + leaveWarm: daemon !== null, + }, + async ({ debugPort }) => { + const session = await openPageSession(debugPort); + try { + const measured = await measureGame({ session, base: server.base, game, quality, seconds, telemetry, settings, state, params, frameClock }); + const pin = perfStateLine(state, params); + if (pin !== null) console.error(pin); + const clock = perfFrameClockLine(frameClock); + if (clock !== null) console.error(clock); + if (measured.clockProbeMs !== null) { + console.error(`perf: blank page ticked at ${measured.clockProbeMs} ms on the uncapped clock`); + } + if (measured.record.overrides.length > 0) { + console.error(`perf: overrides ${measured.record.overrides.join(" ")}`); + } + console.log(JSON.stringify(measured.record, null, 2)); + if (profile && measured.profile !== null) { + printInclusive(aggregateInclusiveTime(measured.profile, measured.profileMs)); + } + if (measured.record.profile?.top.length === 0) console.error("perf: the CPU profile produced no samples"); + if (measured.record.gl.attached !== true) { + console.error("perf: GL allocation counters saw no activity — the WebGL hooks did not attach"); + } + if (measured.record.render.nodes === null) console.error("perf: could not count scene nodes on this page"); + } finally { + await session.close(); } - if (measured.record.render.nodes === null) console.error("perf: could not count scene nodes on this page"); - } finally { - await session.close(); - } - }, - ); + }, + )); + }; + + let exitCode = await runPerf(warm.daemon).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + return 1; + }); + if (warm.daemon !== null && exitCode !== 0 && (await daemonDiedUnder(warm.daemon))) { + console.error( + `cold: the daemon on :${warm.daemon.chromePort} died mid-run — falling back to a direct launch, no retry`, + ); + exitCode = await runPerf(null).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + return 1; + }); + } if (exitCode !== 0) process.exit(exitCode); } diff --git a/scripts/shoot-daemon.test.ts b/scripts/shoot-daemon.test.ts index 2e25c8dae..92b3f8b02 100644 --- a/scripts/shoot-daemon.test.ts +++ b/scripts/shoot-daemon.test.ts @@ -7,7 +7,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + acquireWarmCapture, clearDaemonState, + daemonDiedUnder, + daemonRevisionDrift, daemonStatePath, ensureDaemonTarget, isDaemonArgv, @@ -319,3 +322,173 @@ describe("shoot daemon readiness", () => { } }); }); + +/** Collect the lines a lane prints while it decides, so the decision is asserted, not assumed. */ +async function captureStderr(run: () => Promise): Promise<{ value: T; lines: string[] }> { + const lines: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(" ")); + }; + try { + return { value: await run(), lines }; + } finally { + console.error = original; + } +} + +const liveState = (head?: string): ShootDaemonState => ({ + identity: "checkout", + chromePort: 9261, + ...(head === undefined ? {} : { head }), + startedAt: "fixture", +}); + +describe("capture lanes acquire a warm daemon by default", () => { + test("reuses a live daemon serving this commit and never starts another", async () => { + let started = 0; + const { value, lines } = await captureStderr(() => + acquireWarmCapture({ + cold: false, + probe: async () => liveState("abc12345"), + start: async () => { + started += 1; + return liveState("abc12345"); + }, + head: () => "abc12345", + stop: async () => false, + }), + ); + expect(value.mode).toBe("warm"); + expect(value.daemon?.chromePort).toBe(9261); + expect(started).toBe(0); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^warm: reused :9261 \(\d+\.\ds\)$/); + }); + + test("starts one when none is live, and says so with the time it took", async () => { + let started = 0; + const { value, lines } = await captureStderr(() => + acquireWarmCapture({ + probe: async () => null, + start: async () => { + started += 1; + return liveState("abc12345"); + }, + head: () => "abc12345", + stop: async () => false, + }), + ); + expect(value.mode).toBe("cold"); + expect(value.daemon?.chromePort).toBe(9261); + expect(started).toBe(1); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^cold: started :9261 \(\d+\.\ds\)$/); + }); + + test("--cold never probes, starts, or reuses a daemon", async () => { + let probed = 0; + let started = 0; + const { value, lines } = await captureStderr(() => + acquireWarmCapture({ + cold: true, + probe: async () => { + probed += 1; + return liveState("abc12345"); + }, + start: async () => { + started += 1; + return liveState("abc12345"); + }, + head: () => "abc12345", + stop: async () => false, + }), + ); + expect(value).toMatchObject({ daemon: null, mode: "cold" }); + expect({ probed, started }).toEqual({ probed: 0, started: 0 }); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("--cold"); + expect(lines[0]).toContain("no daemon reused"); + }); + + test("replaces a daemon serving another commit instead of reusing it", async () => { + const stopped: string[] = []; + const { value, lines } = await captureStderr(() => + acquireWarmCapture({ + probe: async () => liveState("old00000"), + start: async () => liveState("new11111"), + head: () => "new11111", + stop: async () => { + stopped.push("stop"); + return true; + }, + }), + ); + expect(stopped).toEqual(["stop"]); + expect(value.mode).toBe("cold"); + expect(value.daemon?.head).toBe("new11111"); + expect(lines[0]).toContain("replaced daemon on :9261 serving old00000"); + expect(lines[1]).toMatch(/^cold: started :9261/); + }); + + test("falls back once, with one line, when the port is taken and no daemon can start", async () => { + let started = 0; + const { value, lines } = await captureStderr(() => + acquireWarmCapture({ + probe: async () => null, + start: async () => { + started += 1; + throw new Error("shoot daemon: Chrome debug port 9261 is already reachable without a live record"); + }, + head: () => "abc12345", + stop: async () => false, + }), + ); + expect({ started, mode: value.mode, daemon: value.daemon }).toEqual({ started: 1, mode: "cold", daemon: null }); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("cold: daemon unavailable"); + expect(lines[0]).toContain("no retry"); + }); + + test("falls back when the stale daemon refuses to stop", async () => { + const { value, lines } = await captureStderr(() => + acquireWarmCapture({ + probe: async () => liveState("old00000"), + start: async () => liveState("new11111"), + head: () => "new11111", + stop: async () => { + throw new Error("would not die"); + }, + }), + ); + expect(value).toMatchObject({ daemon: null, mode: "cold" }); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("would not stop"); + }); + + test("a daemon record with no recorded commit is unproven, not reused", () => { + expect(daemonRevisionDrift(undefined, "abc12345")).toBe(true); + expect(daemonRevisionDrift("abc12345", "abc12345")).toBe(false); + expect(daemonRevisionDrift("old00000", "new11111")).toBe(true); + expect(daemonRevisionDrift(undefined, undefined)).toBe(false); + expect(daemonRevisionDrift("abc12345", undefined)).toBe(false); + }); + + test("a caller that must not share the daemon's browser says why", async () => { + const { lines } = await captureStderr(() => + acquireWarmCapture({ cold: true, because: "--headed needs its own browser window" }), + ); + expect(lines[0]).toContain("--headed needs its own browser window"); + expect(lines[0]).toContain("no daemon reused or started"); + }); + + test("a run only falls back cold when its daemon is really gone", async () => { + const cwd = mkdtempSync(join(tmpdir(), "jg-shoot-died-")); + try { + await expect(daemonDiedUnder(null, cwd)).resolves.toBe(false); + await expect(daemonDiedUnder(liveState("abc12345"), cwd)).resolves.toBe(true); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/shoot-daemon.ts b/scripts/shoot-daemon.ts index 8bae14c09..44e4979f2 100644 --- a/scripts/shoot-daemon.ts +++ b/scripts/shoot-daemon.ts @@ -3,11 +3,17 @@ * starts and retains apps/dev or apps/web when a capture needs that target. * Later shots reuse browser, module, and shader caches. * + * Every capture lane (shoot, drive, perf) acquires it through + * {@link acquireWarmCapture}: reuse a live one, start one when none is running, + * replace one that serves another commit, and fall back to a direct launch when + * it cannot be had. `--cold` skips it entirely. + * + * bun run shoot --mode play # starts or reuses the daemon, then attaches + * bun run shoot --cold # no daemon: clean boot for evidence runs * bun run shoot --serve # start daemon (foreground) * bun run shoot daemon start # start daemon (background) * bun run shoot daemon status * bun run shoot daemon stop - * bun run shoot --mode play # auto-attaches when daemon is live */ import type { ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; @@ -24,6 +30,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; +import { headRevision, shortRevision } from "./captureRevision"; import { checkoutIdentity, ensureDevServer, @@ -45,6 +52,8 @@ import { localOk } from "./localhostHttp"; export interface ShootDaemonState { identity: string; chromePort: number; + /** Engine commit this daemon's Chrome and Vite booted from; absent on records written before revision keying. */ + head?: string; devPort?: number; devBase?: string; chromePid?: number; @@ -158,6 +167,130 @@ export async function attachDaemon(cwd = process.cwd()): Promise Promise; + /** Start a daemon and return it. Defaults to {@link startDaemon}. */ + start?: (cwd: string) => Promise; + /** Retire a daemon that serves another commit. Defaults to {@link stopDaemon}. */ + stop?: (cwd: string) => Promise; + /** Commit the tree holds. Defaults to {@link headRevision}. */ + head?: (cwd: string) => string | undefined; +} + +/** + * The one decision every capture lane makes: attach to the daemon, start it, or + * boot alone. Prints exactly one line saying which happened and how long it took, + * and never waits on a daemon that is not there — a failure to provide one is a + * printed fallback to the direct launch, not a retry. + */ +export async function acquireWarmCapture(options: AcquireWarmCaptureOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const startedAt = performance.now(); + const head = options.head ?? headRevision; + const probe = options.probe ?? attachDaemon; + const start = options.start ?? ((root: string) => startDaemon({ cwd: root })); + const stop = options.stop ?? ((root: string) => stopDaemon({ cwd: root })); + const done = (daemon: ShootDaemonState | null, mode: WarmCapture["mode"], line: string): WarmCapture => { + console.error(line); + return { daemon, mode, seconds: (performance.now() - startedAt) / 1_000 }; + }; + const elapsed = (): string => ((performance.now() - startedAt) / 1_000).toFixed(1); + + if (options.cold === true) { + return done(null, "cold", `cold: ${options.because ?? "--cold asked for a clean boot"} — no daemon reused or started`); + } + + let live: ShootDaemonState | null = null; + try { + live = await probe(cwd); + } catch (error) { + return done(null, "cold", `cold: could not read the daemon (${reason(error)}) — direct launch, no retry`); + } + + if (live !== null) { + const current = head(cwd); + if (daemonRevisionDrift(live.head, current)) { + try { + await stop(cwd); + } catch (error) { + return done(null, "cold", `cold: a stale daemon would not stop (${reason(error)}) — direct launch, no retry`); + } + console.error( + `cold: replaced daemon on :${live.chromePort} serving ${shortRevision(live.head)}, engine is ${shortRevision(current)}`, + ); + } else { + return done(live, "warm", `warm: reused :${live.chromePort} (${elapsed()}s)`); + } + } + + try { + const state = await start(cwd); + return done(state, "cold", `cold: started :${state.chromePort} (${elapsed()}s)`); + } catch (error) { + return done(null, "cold", `cold: daemon unavailable (${reason(error)}) — direct launch, no retry`); + } +} + +function reason(error: unknown): string { + const text = error instanceof Error ? error.message : String(error); + return text.replace(/\s+/g, " ").trim().slice(0, 160) || "unknown error"; +} + +/** + * True when a run that attached to `daemon` then failed, and the daemon is gone: + * the failure is explained by the daemon dying, so the lane may boot once by itself. + */ +export async function daemonDiedUnder(daemon: ShootDaemonState | null, cwd = process.cwd()): Promise { + if (daemon === null) return false; + return !(await isDaemonLive(cwd)); +} + +/** + * Settle only when the daemon stops answering. A CDP call into a browser that died + * does not fail — it waits — so a capture that attached to the daemon races this and + * fails in seconds, which turns a hang into the one-line fallback. + */ +export async function watchDaemon(daemon: ShootDaemonState, cwd = process.cwd(), intervalMs = 2_000): Promise { + for (;;) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + if (!(await isDaemonLive(cwd))) throw new Error(`shoot daemon: :${daemon.chromePort} stopped answering mid-run`); + } +} + +/** Run a capture session, failing early if the daemon it borrowed dies under it. */ +export async function withDaemonWatch(daemon: ShootDaemonState | null, run: Promise, cwd = process.cwd()): Promise { + if (daemon === null) return run; + return Promise.race([run, watchDaemon(daemon, cwd)]); +} + /** Start or reuse one Vite target and persist its endpoint in daemon state. */ export async function ensureDaemonTarget( state: ShootDaemonState, @@ -267,7 +400,14 @@ export async function startDaemon(options: { const chrome = launched.browser.child; const chromePid = launched.browser.pid; const userDataDir = launched.browser.userDataDir; - const started: ShootDaemonState = { identity, chromePort, chromePid, userDataDir, startedAt: new Date().toISOString() }; + const started: ShootDaemonState = { + identity, + chromePort, + head: headRevision(cwd), + chromePid, + userDataDir, + startedAt: new Date().toISOString(), + }; writeDaemonState(started, cwd); return { state: started, chrome, started: true }; }, 35_000); diff --git a/scripts/shoot-dev.test.ts b/scripts/shoot-dev.test.ts new file mode 100644 index 000000000..f1a49b039 --- /dev/null +++ b/scripts/shoot-dev.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; + +/** + * shoot's command line: `--cold` is engine-only (the portable harness has no daemon to skip), so it + * is filtered before the shared flag parser and must still be accepted rather than reported unknown. + */ +describe("shoot CLI", () => { + test("accepts --cold and documents the warm default", () => { + const run = spawnSync(process.execPath, [join(import.meta.dir, "shoot-dev.ts"), "demo", "--cold", "--help"], { + encoding: "utf8", + timeout: 30_000, + }); + expect({ status: run.status, stderr: run.stderr.slice(0, 300) }).toEqual({ status: 0, stderr: "" }); + expect(run.stdout).toContain("--cold"); + expect(run.stdout).toContain("warm: reused"); + }, 60_000); + + test("still refuses an unknown option", () => { + const run = spawnSync(process.execPath, [join(import.meta.dir, "shoot-dev.ts"), "demo", "--bogus"], { + encoding: "utf8", + timeout: 30_000, + }); + expect(run.status).not.toBe(0); + expect(run.stderr).toContain("unknown option --bogus"); + }, 60_000); +}); diff --git a/scripts/shoot-dev.ts b/scripts/shoot-dev.ts index c05f6f051..ddd8903f4 100644 --- a/scripts/shoot-dev.ts +++ b/scripts/shoot-dev.ts @@ -41,6 +41,8 @@ import { type SizeMode, } from "./browser-lib"; import { decodePng } from "./png-reader"; +import { clearOriginStorage } from "./capture-storage"; +import { headRevision } from "./captureRevision"; import { externalCaptureUrl } from "./captureTarget"; import { assertQuickIsIterationOnly, quickProfile, quickSuffix, quickTimingLine, splitQuickFlag } from "./capture-preset"; import { CAPTURE_FLAGS, captureFlagHelp, parseCaptureArgs } from "../packages/jgengine/src/templates/captureArgs"; @@ -61,11 +63,13 @@ import { type PreviousShot, } from "./shotProvenance"; import { - attachDaemon, + acquireWarmCapture, + daemonDiedUnder, ensureDaemonTarget, isDaemonArgv, runDaemonCommand, startDaemon, + withDaemonWatch, writeDaemonState, type ShootDaemonState, } from "./shoot-daemon"; @@ -109,6 +113,8 @@ type Args = { site?: string; connect?: number; keep: boolean; + /** `--cold`: boot Chrome and Vite directly, proving a clean start. */ + cold: boolean; inspect: boolean; /** Show a real window (off-screen) instead of headless Chrome. */ headed: boolean; @@ -130,10 +136,13 @@ ${captureFlagHelp("shoot", "engine", CAPTURE_FLAGS)} JG_CAPTURE_SCREENCAST=0 forces Page.captureScreenshot instead of the screencast frame pump (same pixels, several times slower on software GL). -Persistent daemon (preferred for multi-shot loops): +Persistent daemon (started for you; no lane ever starts it by hand): + bun run shoot --mode play # starts or reuses the daemon, then attaches + --cold skips the daemon entirely: fresh Chrome and Vite, for evidence runs that + must prove a clean boot. Every run prints which happened and how long it took: + warm: reused :9261 (0.2s) cold: started :9261 (4.1s) bun run shoot --serve # keep Chrome warm; Vite starts on demand bun run shoot daemon start|status|stop # managed background service - bun run shoot --mode play # auto-attaches when daemon is live (<5s) Manual warm loop: bun run shoot --mode play --keep # cold boot, stays warm @@ -153,8 +162,10 @@ function parseArgs(argv: string[]): Args { // `--headed` is parsed here, not in CAPTURE_FLAGS: the scaffold's shoot.mjs has no local window to // show, so the flag stays engine-only instead of parsing there and doing nothing. const quick = splitQuickFlag("shoot", argv); + // `--cold` is parsed here, not in CAPTURE_FLAGS: the portable harness has no daemon to skip. + const cold = quick.argv.includes("--cold"); const headed = quick.argv.includes("--headed"); - const parsed = parseCaptureArgs("shoot", quick.argv.filter((arg) => arg !== "--headed"), CAPTURE_FLAGS); + const parsed = parseCaptureArgs("shoot", quick.argv.filter((arg) => arg !== "--headed" && arg !== "--cold"), CAPTURE_FLAGS); const o = parsed.options; const str = (name: string): string | undefined => (typeof o[name] === "string" ? (o[name] as string) : undefined); const num = (name: string): number | undefined => (str(name) === undefined ? undefined : Number(str(name))); @@ -187,6 +198,7 @@ function parseArgs(argv: string[]): Args { site: str("site"), connect: num("connect"), keep: o.keep === true, + cold, inspect: o.inspect === true, headed, quick: quick.quick, @@ -381,6 +393,9 @@ async function shootOne( const target = new URL(targetUrl(args, device, devBase)); for (const [key, value] of args.params) target.searchParams.set(key, value); const url = target.toString(); + // A warm profile keeps the previous run's saves and settings, so a reused browser would render + // "continue your story" where a clean one renders "hit the street" (#warm-capture signatures). + await clearOriginStorage(session, target.origin); await navigateCapturePageWithRetry(session, url, devBase, args.timeoutMs, CAPTURE_MAX_ATTEMPTS, { legacyCanvas: args.url !== undefined }); mark("ready"); await reportAppliedAim(session, args); @@ -522,35 +537,30 @@ if (args.port !== undefined) process.env.JG_DEV_PORT = String(args.port); // start on a drive that cannot hold this capture. sweepCaptureProfiles(); ensureCaptureFreeSpace(); -const daemon: ShootDaemonState | null = - args.connect === undefined && args.port === undefined ? await attachDaemon() : null; -let server: ChildProcess | null = null; -let serverPid: number | undefined; -let devBase = ""; -let attachedDaemon = false; -if (args.url !== undefined) { - if (args.site !== undefined) throw new Error("shoot: --url and --site select different targets; pass one"); - if (!(await isUp(args.url))) { - throw new Error(`shoot: nothing is listening at ${args.url} — start that external server first`); +interface ShootTarget { + devBase: string; + server: ChildProcess | null; + serverPid: number | undefined; + attachedDaemon: boolean; +} + +/** Resolve the page this shot draws from: explicit URL, a daemon target, or a Vite this run owns. */ +async function resolveTarget(daemon: ShootDaemonState | null): Promise { + if (args.url !== undefined) { + if (args.site !== undefined) throw new Error("shoot: --url and --site select different targets; pass one"); + if (!(await isUp(args.url))) { + throw new Error(`shoot: nothing is listening at ${args.url} — start that external server first`); + } + return { devBase: args.url, server: null, serverPid: undefined, attachedDaemon: daemon !== null }; + } + if (daemon !== null) { + const target = await ensureDaemonTarget(daemon, args.site !== undefined ? "web" : "dev"); + console.error(`shoot: attached to daemon — chrome :${daemon.chromePort}, target ${target.base}`); + return { devBase: target.base, server: null, serverPid: undefined, attachedDaemon: true }; } - devBase = args.url; - attachedDaemon = daemon !== null; -} else if (daemon !== null) { - const target = await ensureDaemonTarget(daemon, args.site !== undefined ? "web" : "dev"); - devBase = target.base; - attachedDaemon = true; - console.error(`shoot: attached to daemon — chrome :${daemon.chromePort}, target ${devBase}`); -} else if (args.site !== undefined) { - const web = await ensureWebServer(); - server = web.child; - serverPid = web.pid; - devBase = web.base; -} else if (args.url === undefined) { - const dev = await ensureDevServer(); - server = dev.child; - serverPid = dev.pid; - devBase = dev.base; + const server = args.site === undefined ? await ensureDevServer() : await ensureWebServer(); + return { devBase: server.base, server: server.child, serverPid: server.pid, attachedDaemon: false }; } /** * Print the game's declared `capture.views`. The runner publishes them on `data-jg-shots` as soon @@ -586,56 +596,92 @@ async function listViews(debugPort: number, base: string, game: string): Promise } } -const exitCode = await withBrowserSession( - { - keep: args.keep, - connect: args.connect, - timeoutMs: args.timeoutMs, - server, - serverPid, - debugPort: daemon !== null ? daemon.chromePort : undefined, - attach: attachedDaemon, - leaveWarm: args.keep || attachedDaemon, - chromePrefix: "jg-shoot-", - headed: args.headed, - hardDeadlineMs: - args.site === undefined - ? args.timeoutMs * Math.max(targets.length, 1) + 120_000 - : args.timeoutMs * CAPTURE_MAX_ATTEMPTS * Math.max(targets.length, 1) + 10_000, - }, - async ({ debugPort, chrome }) => { - if (args.listViews) return listViews(debugPort, devBase, args.game); - let code = 0; - for (const device of targets) { - const outPath = outPathFor(args, device, outDir); - const previous = preparedShots.get(outPath); - const fits = await shootOne(debugPort, args, device, outPath, devBase, previous); - if (!fits) code = 1; - } - if (args.keep && !attachedDaemon) { - const state: ShootDaemonState = { - identity: checkoutIdentity(), - chromePort: debugPort, - chromePid: chrome?.pid, - userDataDir: chrome?.userDataDir, - startedAt: new Date().toISOString(), - }; - const target = { port: Number(new URL(devBase).port), base: devBase, pid: serverPid ?? server?.pid }; - if (args.url === undefined && args.site === undefined) { - state.devPort = target.port; - state.devBase = target.base; - state.devPid = target.pid; - } else if (args.url === undefined) { - state.webPort = target.port; - state.webBase = target.base; - state.webPid = target.pid; +/** + * One capture pass against a resolved target. Kept a function so a run whose daemon + * died under it can be repeated once on the direct path instead of failing. + */ +async function shootSession(daemon: ShootDaemonState | null): Promise { + const { devBase, server, serverPid, attachedDaemon } = await resolveTarget(daemon); + return withDaemonWatch(daemon, withBrowserSession( + { + keep: args.keep, + connect: args.connect, + timeoutMs: args.timeoutMs, + server, + serverPid, + debugPort: daemon !== null ? daemon.chromePort : undefined, + attach: attachedDaemon, + leaveWarm: args.keep || attachedDaemon, + chromePrefix: "jg-shoot-", + headed: args.headed, + hardDeadlineMs: + args.site === undefined + ? args.timeoutMs * Math.max(targets.length, 1) + 120_000 + : args.timeoutMs * CAPTURE_MAX_ATTEMPTS * Math.max(targets.length, 1) + 10_000, + }, + async ({ debugPort, chrome }) => { + if (args.listViews) return listViews(debugPort, devBase, args.game); + let code = 0; + for (const device of targets) { + const outPath = outPathFor(args, device, outDir); + const previous = preparedShots.get(outPath); + const fits = await shootOne(debugPort, args, device, outPath, devBase, previous); + if (!fits) code = 1; } - writeDaemonState(state); - console.error(`shoot: kept warm — chrome debug port ${debugPort}, dev server on ${devBase}`); - console.error(`shoot: next shot → bun run shoot ${args.url === undefined ? `${args.game} --mode ${args.mode}` : `--url ${args.url}`} (daemon auto-attach)`); - console.error(`shoot: or explicit → bun run shoot ${args.url === undefined ? `${args.game} --mode ${args.mode}` : `--url ${args.url}`} --connect ${debugPort} --size half`); - } - return code; - }, -); + if (args.keep && !attachedDaemon) { + const state: ShootDaemonState = { + identity: checkoutIdentity(), + chromePort: debugPort, + head: headRevision(), + chromePid: chrome?.pid, + userDataDir: chrome?.userDataDir, + startedAt: new Date().toISOString(), + }; + const target = { port: Number(new URL(devBase).port), base: devBase, pid: serverPid ?? server?.pid }; + if (args.url === undefined && args.site === undefined) { + state.devPort = target.port; + state.devBase = target.base; + state.devPid = target.pid; + } else if (args.url === undefined) { + state.webPort = target.port; + state.webBase = target.base; + state.webPid = target.pid; + } + writeDaemonState(state); + console.error(`shoot: kept warm — chrome debug port ${debugPort}, dev server on ${devBase}`); + console.error(`shoot: next shot → bun run shoot ${args.url === undefined ? `${args.game} --mode ${args.mode}` : `--url ${args.url}`} (daemon auto-attach)`); + console.error(`shoot: or explicit → bun run shoot ${args.url === undefined ? `${args.game} --mode ${args.mode}` : `--url ${args.url}`} --connect ${debugPort} --size half`); + } + return code; + }, + )); +} + +const explicitTarget = args.connect !== undefined || args.port !== undefined || args.url !== undefined; +// The daemon's Chrome was launched headless with the display frame clock, so a run that needs a +// window of its own must not borrow it. An external --url is not this checkout's app either. +const ownBrowser = args.headed ? "--headed needs its own browser window" : undefined; +const warm = explicitTarget + ? { daemon: null, mode: "cold" as const, seconds: 0 } + : await acquireWarmCapture({ + cold: args.cold || ownBrowser !== undefined, + ...(ownBrowser === undefined ? {} : { because: ownBrowser }), + }); +/** Run one session, reporting a failure the same way `withBrowserSession` does. */ +async function attempt(daemon: ShootDaemonState | null): Promise { + try { + return await shootSession(daemon); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + return 1; + } +} + +let exitCode = await attempt(warm.daemon); +if (warm.daemon !== null && exitCode !== 0 && (await daemonDiedUnder(warm.daemon))) { + console.error( + `cold: the daemon on :${warm.daemon.chromePort} died mid-run — falling back to a direct launch, no retry`, + ); + exitCode = await attempt(null); +} process.exit(exitCode);