Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/jgengine-verify/references/monorepo-capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <game> --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 `<name>-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 `<name>-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 <game> ... --record <name>` or `bun run drive --site '/playground?...' --wait 1000 --record <name>`. `drive --site` writes `shots/site-<name>.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.

Expand Down
42 changes: 42 additions & 0 deletions scripts/capture-storage.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
26 changes: 26 additions & 0 deletions scripts/capture-storage.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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`,
);
}
}
28 changes: 28 additions & 0 deletions scripts/drive-dev.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading