feat(qa): record an agent walking a web or iOS app, then open it - #54
feat(qa): record an agent walking a web or iOS app, then open it#54time-attack wants to merge 4 commits into
Conversation
Capture CDP JPEG frames while the agent drives the page, encode to mp4 (ffmpeg) or an HTML player, and optionally open the file. Keep record off the pair-agent tunnel allowlist. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Sina Matian <time-attack@users.noreply.github.com>
Add a self-contained record-session helper that captures GET /screenshot frames from the physical-device daemon, encodes them, and can open the result. No new device backend. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Sina Matian <time-attack@users.noreply.github.com>
Route /recording to $qa --mode Report --module recording. The module starts capture, runs full qa-only or ios-qa, then stops and opens the file. Not a sixth public skill. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Sina Matian <time-attack@users.noreply.github.com>
There was a problem hiding this comment.
16 issues found across 26 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="browse/src/token-registry.ts">
<violation number="1" location="browse/src/token-registry.ts:42">
P2: A read-only token can now start, stop, or open the global recording, consuming disk and interrupting another agent's recording. Remove `record` from `SCOPE_READ` and grant it through a write-capable scope instead.</violation>
</file>
<file name="browse/src/screencast-encode.ts">
<violation number="1" location="browse/src/screencast-encode.ts:142">
P2: When `record start` receives an extensionless path inside a dot-named directory, this regex treats the directory as the extension and writes the MP4 elsewhere. Derive the extension from `path.parse(outputPath)` or the basename instead.</violation>
<violation number="2" location="browse/src/screencast-encode.ts:175">
P2: `record start` without a path calls `defaultRecordingPath()`, which returns a `/tmp` file. The recording module documents `.gstack/qa-reports/recordings/qa-<stamp>.mp4` as the default, so the default workflow bypasses the QA report artifact directory. Return the documented report path or update the module contract.</violation>
</file>
<file name="browse/test/screencast.test.ts">
<violation number="1" location="browse/test/screencast.test.ts:136">
P3: After `record stop`, `recordingStatus().framesDir` is always undefined: `stopRecording` sets `active = null`, and `recordingStatus()` then returns only `{ active: false, lastArtifact }`. So this frames-dir cleanup never executes. On the HTML-player fallback (the CI path, since ffmpeg-present mp4 is untested), `stopRecording` deliberately keeps the frame directory, so this test leaks `gstack-recording-frames-*` dirs into TEMP_DIR on every run. Capture the frames dir before calling `record stop`, or drop the dead lines.</violation>
</file>
<file name="ios-qa/scripts/record-session.ts">
<violation number="1" location="ios-qa/scripts/record-session.ts:52">
P1: When `--token` is used, this state file exposes the bearer with default permissions, commonly 0644, and can authenticate to the daemon. Write the state through an owner-only temporary file, rename it atomically, and preserve 0600 permissions.</violation>
<violation number="2" location="ios-qa/scripts/record-session.ts:102">
P2: When `--out` ends in `.webm`, this command selects `libx264`, so ffmpeg cannot create the requested WebM artifact and `stop` silently falls back to HTML. Select `libvpx-vp9` for WebM or reject that extension.</violation>
<violation number="3" location="ios-qa/scripts/record-session.ts:145">
P1: When a recording reaches the 15-minute or 3,600-frame limit, the poller exits without producing a `RECORDING:` artifact or clearing state. Finalize the bounded recording or clear the state and report that the limit was reached.</violation>
<violation number="4" location="ios-qa/scripts/record-session.ts:159">
P2: When two `start` commands overlap, both can pass this check and spawn pollers, leaving one orphaned and causing conflicting recordings. Claim the state path with an exclusive lock before spawning.</violation>
<violation number="5" location="ios-qa/scripts/record-session.ts:196">
P1: When the detached child runs before `writeState()` completes, the poller sees no state and exits without capturing any frames. Make the `poll` action briefly retry the state read before returning.</violation>
<violation number="6" location="ios-qa/scripts/record-session.ts:199">
P1: When the poller has already exited and its PID is reused, `stop` can terminate an unrelated process. Clear stale state on poller exit and validate process ownership before signaling the PID.</violation>
</file>
<file name="browse/src/server.ts">
<violation number="1" location="browse/src/server.ts:1416">
P2: When the recording CDP session stops responding, shutdown can remain stuck indefinitely in `flushRecordingOnShutdown()` after deleting the state-file credential. Bound the recording flush before continuing teardown, or add a timeout to `stopRecording()`.</violation>
</file>
<file name="browse/src/screencast.ts">
<violation number="1" location="browse/src/screencast.ts:147">
P2: When CDP emits no frames, the 15-minute limit never runs and the recorder remains active indefinitely. Enforce the duration with a timer started with the recording and clear it during stop.</violation>
<violation number="2" location="browse/src/screencast.ts:169">
P2: If `Page.startScreencast` rejects, this listener remains attached and contaminates later retries. Remove the listener in a catch path before rethrowing the start error.</violation>
<violation number="3" location="browse/src/screencast.ts:186">
P2: When finalization fails, `active = null` discards the recording state before an artifact exists. Clear the state only after successful encoding, or retain a pending recording so `record stop` can retry.</violation>
<violation number="4" location="browse/src/screencast.ts:209">
P2: `record stop` encodes with synchronous `spawnSync` calls: `encodeFrameDirectory` first runs `ffmpegAvailable()` (spawnSync probe, up to 4s) then `runFfmpeg` (spawnSync during which `ffmpeg` is given up to 120s per attempt, two attempts). Because this runs on the server's single-threaded event loop inside the command handler, a long recording blocks the entire browse server (all other page/CDP/token requests and streamed output) for the full encode duration. Use async `spawn`/`execFile` with a promise so the encode doesn't stall the server, or at least yield between frames/probes.</violation>
<violation number="5" location="browse/src/screencast.ts:212">
P2: On the HTML-player fallback (hosts without ffmpeg, the documented default path on this host), `stopRecording` never removes the frame directory: the cleanup guard at this line is skipped whenever `encoded.kind === 'html'`, and `flushRecordingOnShutdown` doesn't clean it either. Every recording without ffmpeg permanently leaks a `TEMP_DIR/gstack-recording-frames-<stamp>` directory holding up to MAX_FRAMES (7200) JPEGs, accumulating on long-lived servers. The frames are only needed to serve the html artifact, but there is no lifecycle that removes them once the player is delivered. Clean up the frame directory after the html artifact is produced (e.g. treat the html player as consumable, or track and reap abandoned frame dirs on start/shutdown).</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| let n = 0; | ||
| const maxFrames = 3600; | ||
| const deadline = state.startedAt + 15 * 60 * 1000; | ||
| while (Date.now() < deadline && n < maxFrames) { |
There was a problem hiding this comment.
P1: When a recording reaches the 15-minute or 3,600-frame limit, the poller exits without producing a RECORDING: artifact or clearing state. Finalize the bounded recording or clear the state and report that the limit was reached.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/scripts/record-session.ts, line 145:
<comment>When a recording reaches the 15-minute or 3,600-frame limit, the poller exits without producing a `RECORDING:` artifact or clearing state. Finalize the bounded recording or clear the state and report that the limit was reached.</comment>
<file context>
@@ -0,0 +1,262 @@
+ let n = 0;
+ const maxFrames = 3600;
+ const deadline = state.startedAt + 15 * 60 * 1000;
+ while (Date.now() < deadline && n < maxFrames) {
+ if (!fs.existsSync(statePath())) break;
+ try {
</file context>
|
|
||
| function writeState(state: RecordingState): void { | ||
| fs.mkdirSync(homeDir(), { recursive: true }); | ||
| fs.writeFileSync(statePath(), JSON.stringify(state, null, 2)); |
There was a problem hiding this comment.
P1: When --token is used, this state file exposes the bearer with default permissions, commonly 0644, and can authenticate to the daemon. Write the state through an owner-only temporary file, rename it atomically, and preserve 0600 permissions.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/scripts/record-session.ts, line 52:
<comment>When `--token` is used, this state file exposes the bearer with default permissions, commonly 0644, and can authenticate to the daemon. Write the state through an owner-only temporary file, rename it atomically, and preserve 0600 permissions.</comment>
<file context>
@@ -0,0 +1,262 @@
+
+function writeState(state: RecordingState): void {
+ fs.mkdirSync(homeDir(), { recursive: true });
+ fs.writeFileSync(statePath(), JSON.stringify(state, null, 2));
+}
+
</file context>
| fs.writeFileSync(statePath(), JSON.stringify(state, null, 2)); | |
| const p = statePath(); | |
| const tmp = `${p}.tmp.${process.pid}`; | |
| fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { encoding: 'utf-8', mode: 0o600 }); | |
| fs.renameSync(tmp, p); | |
| fs.chmodSync(p, 0o600); |
| const state = readState(); | ||
| if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL'); | ||
| const flags = parseNamed(args); | ||
| try { process.kill(state.pid, 'SIGTERM'); } catch (err: any) { |
There was a problem hiding this comment.
P1: When the poller has already exited and its PID is reused, stop can terminate an unrelated process. Clear stale state on poller exit and validate process ownership before signaling the PID.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/scripts/record-session.ts, line 199:
<comment>When the poller has already exited and its PID is reused, `stop` can terminate an unrelated process. Clear stale state on poller exit and validate process ownership before signaling the PID.</comment>
<file context>
@@ -0,0 +1,262 @@
+ const state = readState();
+ if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL');
+ const flags = parseNamed(args);
+ try { process.kill(state.pid, 'SIGTERM'); } catch (err: any) {
+ if (err?.code !== 'ESRCH') throw err;
+ }
</file context>
| const state = readState(); | ||
| if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL'); | ||
| const flags = parseNamed(args); |
There was a problem hiding this comment.
P1: When the detached child runs before writeState() completes, the poller sees no state and exits without capturing any frames. Make the poll action briefly retry the state read before returning.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/scripts/record-session.ts, line 196:
<comment>When the detached child runs before `writeState()` completes, the poller sees no state and exits without capturing any frames. Make the `poll` action briefly retry the state read before returning.</comment>
<file context>
@@ -0,0 +1,262 @@
+}
+
+function stop(args: string[]): string {
+ const state = readState();
+ if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL');
+ const flags = parseNamed(args);
</file context>
| const state = readState(); | |
| if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL'); | |
| const flags = parseNamed(args); | |
| let state = readState(); | |
| for (let i = 0; !state && i < 20; i++) { | |
| await Bun.sleep(50); | |
| state = readState(); | |
| } | |
| if (!state) return; | |
| await pollLoop(state); |
| 'snapshot', 'text', 'html', 'links', 'forms', 'accessibility', | ||
| 'console', 'network', 'perf', 'dialog', 'is', 'inspect', | ||
| 'url', 'tabs', 'status', 'screenshot', 'pdf', 'css', 'attrs', | ||
| 'url', 'tabs', 'status', 'screenshot', 'pdf', 'record', 'css', 'attrs', |
There was a problem hiding this comment.
P2: A read-only token can now start, stop, or open the global recording, consuming disk and interrupting another agent's recording. Remove record from SCOPE_READ and grant it through a write-capable scope instead.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/token-registry.ts, line 42:
<comment>A read-only token can now start, stop, or open the global recording, consuming disk and interrupting another agent's recording. Remove `record` from `SCOPE_READ` and grant it through a write-capable scope instead.</comment>
<file context>
@@ -39,7 +39,7 @@ import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
'snapshot', 'text', 'html', 'links', 'forms', 'accessibility',
'console', 'network', 'perf', 'dialog', 'is', 'inspect',
- 'url', 'tabs', 'status', 'screenshot', 'pdf', 'css', 'attrs',
+ 'url', 'tabs', 'status', 'screenshot', 'pdf', 'record', 'css', 'attrs',
'media', 'data',
]);
</file context>
| const encoded: EncodeResult = encodeFrameDirectory(rec.framesDir, rec.outputPath, rec.fps); | ||
| lastArtifact = encoded.artifactPath; | ||
|
|
||
| if (!opts.keepFrames && encoded.kind !== 'html') { |
There was a problem hiding this comment.
P2: On the HTML-player fallback (hosts without ffmpeg, the documented default path on this host), stopRecording never removes the frame directory: the cleanup guard at this line is skipped whenever encoded.kind === 'html', and flushRecordingOnShutdown doesn't clean it either. Every recording without ffmpeg permanently leaks a TEMP_DIR/gstack-recording-frames-<stamp> directory holding up to MAX_FRAMES (7200) JPEGs, accumulating on long-lived servers. The frames are only needed to serve the html artifact, but there is no lifecycle that removes them once the player is delivered. Clean up the frame directory after the html artifact is produced (e.g. treat the html player as consumable, or track and reap abandoned frame dirs on start/shutdown).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/screencast.ts, line 212:
<comment>On the HTML-player fallback (hosts without ffmpeg, the documented default path on this host), `stopRecording` never removes the frame directory: the cleanup guard at this line is skipped whenever `encoded.kind === 'html'`, and `flushRecordingOnShutdown` doesn't clean it either. Every recording without ffmpeg permanently leaks a `TEMP_DIR/gstack-recording-frames-<stamp>` directory holding up to MAX_FRAMES (7200) JPEGs, accumulating on long-lived servers. The frames are only needed to serve the html artifact, but there is no lifecycle that removes them once the player is delivered. Clean up the frame directory after the html artifact is produced (e.g. treat the html player as consumable, or track and reap abandoned frame dirs on start/shutdown).</comment>
<file context>
@@ -0,0 +1,274 @@
+ const encoded: EncodeResult = encodeFrameDirectory(rec.framesDir, rec.outputPath, rec.fps);
+ lastArtifact = encoded.artifactPath;
+
+ if (!opts.keepFrames && encoded.kind !== 'html') {
+ try {
+ fs.rmSync(rec.framesDir, { recursive: true, force: true });
</file context>
| ? `Not recording. Last recording: ${lastArtifact}` | ||
| : 'Not recording. Start with: record start [path]'); | ||
| } | ||
| active = null; |
There was a problem hiding this comment.
P2: When finalization fails, active = null discards the recording state before an artifact exists. Clear the state only after successful encoding, or retain a pending recording so record stop can retry.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/screencast.ts, line 186:
<comment>When finalization fails, `active = null` discards the recording state before an artifact exists. Clear the state only after successful encoding, or retain a pending recording so `record stop` can retry.</comment>
<file context>
@@ -0,0 +1,274 @@
+ ? `Not recording. Last recording: ${lastArtifact}`
+ : 'Not recording. Start with: record start [path]');
+ }
+ active = null;
+
+ try {
</file context>
| }; | ||
|
|
||
| session.on('Page.screencastFrame', rec.onFrame); | ||
| await session.send('Page.startScreencast', { |
There was a problem hiding this comment.
P2: If Page.startScreencast rejects, this listener remains attached and contaminates later retries. Remove the listener in a catch path before rethrowing the start error.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/screencast.ts, line 169:
<comment>If `Page.startScreencast` rejects, this listener remains attached and contaminates later retries. Remove the listener in a catch path before rethrowing the start error.</comment>
<file context>
@@ -0,0 +1,274 @@
+ };
+
+ session.on('Page.screencastFrame', rec.onFrame);
+ await session.send('Page.startScreencast', {
+ format: 'jpeg',
+ quality,
</file context>
| await ackFrame(session, params.sessionId); | ||
| if (active !== rec) return; | ||
| const now = Date.now(); | ||
| if (now - rec.startedAt > MAX_DURATION_MS) { |
There was a problem hiding this comment.
P2: When CDP emits no frames, the 15-minute limit never runs and the recorder remains active indefinitely. Enforce the duration with a timer started with the recording and clear it during stop.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/screencast.ts, line 147:
<comment>When CDP emits no frames, the 15-minute limit never runs and the recorder remains active indefinitely. Enforce the duration with a timer started with the recording and clear it during stop.</comment>
<file context>
@@ -0,0 +1,274 @@
+ await ackFrame(session, params.sessionId);
+ if (active !== rec) return;
+ const now = Date.now();
+ if (now - rec.startedAt > MAX_DURATION_MS) {
+ void stopRecording({ open: false }).catch(() => {});
+ return;
</file context>
| expect(fs.existsSync(artifact)).toBe(true); | ||
| expect(fs.statSync(artifact).size).toBeGreaterThan(20); | ||
| fs.rmSync(dir, { recursive: true, force: true }); | ||
| const framesDir = recordingStatus().framesDir; |
There was a problem hiding this comment.
P3: After record stop, recordingStatus().framesDir is always undefined: stopRecording sets active = null, and recordingStatus() then returns only { active: false, lastArtifact }. So this frames-dir cleanup never executes. On the HTML-player fallback (the CI path, since ffmpeg-present mp4 is untested), stopRecording deliberately keeps the frame directory, so this test leaks gstack-recording-frames-* dirs into TEMP_DIR on every run. Capture the frames dir before calling record stop, or drop the dead lines.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/test/screencast.test.ts, line 136:
<comment>After `record stop`, `recordingStatus().framesDir` is always undefined: `stopRecording` sets `active = null`, and `recordingStatus()` then returns only `{ active: false, lastArtifact }`. So this frames-dir cleanup never executes. On the HTML-player fallback (the CI path, since ffmpeg-present mp4 is untested), `stopRecording` deliberately keeps the frame directory, so this test leaks `gstack-recording-frames-*` dirs into TEMP_DIR on every run. Capture the frames dir before calling `record stop`, or drop the dead lines.</comment>
<file context>
@@ -0,0 +1,156 @@
+ expect(fs.existsSync(artifact)).toBe(true);
+ expect(fs.statSync(artifact).size).toBeGreaterThan(20);
+ fs.rmSync(dir, { recursive: true, force: true });
+ const framesDir = recordingStatus().framesDir;
+ if (framesDir && fs.existsSync(framesDir)) fs.rmSync(framesDir, { recursive: true, force: true });
+ });
</file context>
A calm SFO-origin flight booker with published 2026 schedules, plus a Mac script that serves it, walks a booking in headed browse, records, and opens the file. Cloud agents cannot reach a user's Mac display. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Sina Matian <time-attack@users.noreply.github.com>
There was a problem hiding this comment.
5 issues found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="demos/booked/index.html">
<violation number="1" location="demos/booked/index.html:378">
P3: On initial load the plane `<g id="plane">` keeps its hardcoded `translate(80 260)` while `drawMap()` moves the origin dot to the projected SFO coordinates (~104,186); `animatePlane` only runs after a destination is selected. The plane briefly appears offset from the origin dot. Position it with the projected origin in the no-destination branch as well.</violation>
<violation number="2" location="demos/booked/index.html:382">
P3: In timezones at UTC+13/UTC+14 (e.g. Pacific/Tongatapu, Pacific/Kiritimati), clicking a date button stores the previous day: `new Date(iso + "T12:00:00")` is parsed as local noon, while the selected-date comparison in `renderDates` uses `ymd()`/`toISOString()` (UTC), so the highlight never matches and the booked date is off by one. Append `Z` so the reconstruction uses UTC and stays consistent with the `toISOString`-based calendar in the rendered buttons.</violation>
</file>
<file name="demos/booked/record-mac.sh">
<violation number="1" location="demos/booked/record-mac.sh:10">
P2: `record start "$OUT"` fails: the script writes the recording to `<repo>/.gstack/qa-reports/recordings/`, which is outside the browse server's write scope (TEMP_DIR `TMP`/`/tmp` plus `process.cwd()` = `demos/booked` for `cd demos/booked && ./record-mac.sh`). `validateOutputPath` rejects it with "Path must be within". Write the output under the current directory (e.g. `$ROOT/recordings/`) or under the OS temp dir so the documented Mac flow actually records.</violation>
<violation number="2" location="demos/booked/record-mac.sh:61">
P3: The script `sleep 0.4`s then runs `curl -fsS`; with `set -e` a slow server startup makes curl fail and aborts the whole recording. Retry the health check for a few seconds (e.g. a small loop with timeout) instead of a single fixed sleep.</violation>
<violation number="3" location="demos/booked/record-mac.sh:64">
P2: `run()` expands unquoted `$BROWSE`, so any space in the browse path or checkout path is split into separate arguments and the command fails. On a Mac this is common (username with a space in `/Users/...`). Store the browse command as an array and expand it with `"${BROWSE[@]}"` instead of relying on word-splitting.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| PORT="${PORT:-8765}" | ||
| URL="http://127.0.0.1:${PORT}/" | ||
| STAMP="$(date +%Y%m%d-%H%M%S)" | ||
| OUT="${ROOT}/../../.gstack/qa-reports/recordings/booked-mac-${STAMP}.mp4" |
There was a problem hiding this comment.
P2: record start "$OUT" fails: the script writes the recording to <repo>/.gstack/qa-reports/recordings/, which is outside the browse server's write scope (TEMP_DIR TMP//tmp plus process.cwd() = demos/booked for cd demos/booked && ./record-mac.sh). validateOutputPath rejects it with "Path must be within". Write the output under the current directory (e.g. $ROOT/recordings/) or under the OS temp dir so the documented Mac flow actually records.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At demos/booked/record-mac.sh, line 10:
<comment>`record start "$OUT"` fails: the script writes the recording to `<repo>/.gstack/qa-reports/recordings/`, which is outside the browse server's write scope (TEMP_DIR `TMP`/`/tmp` plus `process.cwd()` = `demos/booked` for `cd demos/booked && ./record-mac.sh`). `validateOutputPath` rejects it with "Path must be within". Write the output under the current directory (e.g. `$ROOT/recordings/`) or under the OS temp dir so the documented Mac flow actually records.</comment>
<file context>
@@ -0,0 +1,85 @@
+PORT="${PORT:-8765}"
+URL="http://127.0.0.1:${PORT}/"
+STAMP="$(date +%Y%m%d-%H%M%S)"
+OUT="${ROOT}/../../.gstack/qa-reports/recordings/booked-mac-${STAMP}.mp4"
+mkdir -p "$(dirname "$OUT")"
+OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")"
</file context>
| curl -fsS -o /dev/null "$URL" | ||
|
|
||
| # shellcheck disable=SC2086 | ||
| run() { $BROWSE --headed "$@"; } |
There was a problem hiding this comment.
P2: run() expands unquoted $BROWSE, so any space in the browse path or checkout path is split into separate arguments and the command fails. On a Mac this is common (username with a space in /Users/...). Store the browse command as an array and expand it with "${BROWSE[@]}" instead of relying on word-splitting.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At demos/booked/record-mac.sh, line 64:
<comment>`run()` expands unquoted `$BROWSE`, so any space in the browse path or checkout path is split into separate arguments and the command fails. On a Mac this is common (username with a space in `/Users/...`). Store the browse command as an array and expand it with `"${BROWSE[@]}"` instead of relying on word-splitting.</comment>
<file context>
@@ -0,0 +1,85 @@
+curl -fsS -o /dev/null "$URL"
+
+# shellcheck disable=SC2086
+run() { $BROWSE --headed "$@"; }
+
+run disconnect >/dev/null 2>&1 || true
</file context>
| renderCities(document.getElementById("search").value); | ||
| renderDates(); | ||
| renderRides(); | ||
| drawMap(); |
There was a problem hiding this comment.
P3: On initial load the plane <g id="plane"> keeps its hardcoded translate(80 260) while drawMap() moves the origin dot to the projected SFO coordinates (~104,186); animatePlane only runs after a destination is selected. The plane briefly appears offset from the origin dot. Position it with the projected origin in the no-destination branch as well.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At demos/booked/index.html, line 378:
<comment>On initial load the plane `<g id="plane">` keeps its hardcoded `translate(80 260)` while `drawMap()` moves the origin dot to the projected SFO coordinates (~104,186); `animatePlane` only runs after a destination is selected. The plane briefly appears offset from the origin dot. Position it with the projected origin in the no-destination branch as well.</comment>
<file context>
@@ -0,0 +1,438 @@
+ renderCities(document.getElementById("search").value);
+ renderDates();
+ renderRides();
+ drawMap();
+}
+
</file context>
| } | ||
|
|
||
| function selectDate(iso) { | ||
| state.date = new Date(iso + "T12:00:00"); |
There was a problem hiding this comment.
P3: In timezones at UTC+13/UTC+14 (e.g. Pacific/Tongatapu, Pacific/Kiritimati), clicking a date button stores the previous day: new Date(iso + "T12:00:00") is parsed as local noon, while the selected-date comparison in renderDates uses ymd()/toISOString() (UTC), so the highlight never matches and the booked date is off by one. Append Z so the reconstruction uses UTC and stays consistent with the toISOString-based calendar in the rendered buttons.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At demos/booked/index.html, line 382:
<comment>In timezones at UTC+13/UTC+14 (e.g. Pacific/Tongatapu, Pacific/Kiritimati), clicking a date button stores the previous day: `new Date(iso + "T12:00:00")` is parsed as local noon, while the selected-date comparison in `renderDates` uses `ymd()`/`toISOString()` (UTC), so the highlight never matches and the booked date is off by one. Append `Z` so the reconstruction uses UTC and stays consistent with the `toISOString`-based calendar in the rendered buttons.</comment>
<file context>
@@ -0,0 +1,438 @@
+}
+
+function selectDate(iso) {
+ state.date = new Date(iso + "T12:00:00");
+ state.flight = null;
+ renderDates();
</file context>
| SERVER_PID=$! | ||
| trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT | ||
| sleep 0.4 | ||
| curl -fsS -o /dev/null "$URL" |
There was a problem hiding this comment.
P3: The script sleep 0.4s then runs curl -fsS; with set -e a slow server startup makes curl fail and aborts the whole recording. Retry the health check for a few seconds (e.g. a small loop with timeout) instead of a single fixed sleep.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At demos/booked/record-mac.sh, line 61:
<comment>The script `sleep 0.4`s then runs `curl -fsS`; with `set -e` a slow server startup makes curl fail and aborts the whole recording. Retry the health check for a few seconds (e.g. a small loop with timeout) instead of a single fixed sleep.</comment>
<file context>
@@ -0,0 +1,85 @@
+SERVER_PID=$!
+trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
+sleep 0.4
+curl -fsS -o /dev/null "$URL"
+
+# shellcheck disable=SC2086
</file context>
Why (in your own words)
If you have a web app or a physical iOS app, you should be able to ask for a screen recording of an agent actually using it, plus a full QA pass, and have that recording open when it is done. Today QA can screenshot and write a report, but there is no walkthrough capture and nothing opens a video at the end. This is that affordance: a QA specialist (
recording.md), not a sixth public skill./recordingis an opt-in alias for$qa --mode Report --module recording. Web capture is$B record; iOS capture polls the existing DebugBridge screenshot endpoint. Mutation stays report-only unless the user explicitly asked to fix.demos/bookedis a calm SFO-origin flight booker (published 2026 schedules, simulated card-on-file payment) plusrecord-mac.shso the walkthrough can be run on a local Mac. A cloud agent cannot open a headed browser on the user's machine;$B recordis also blocked on pair-agent tunnel tokens.Live evidence
$B recordstart → stop writes aRECORDING:artifact. On the cloud VM (Linux Chromium, not the user's Mac) a Booked walkthrough produced:Delta DL 405 SFO→JFK Wed Aug 19 2026, $357, confirmation BKVBFEKH. Viewer open failed on the headless/cloud host (expected).
Browse unit tests:
Scope
$B record start|stop|status|open, iOSrecord-session.ts, QArecording.md+/recordingalias,demos/bookedfixture + Mac runner.record-mac.shis the path for that.Liveness proof (required)
This PR is from a cloud agent. There is no human desktop here to type
GSTACK PRinto a live UI.Checklist
git commit -s) — DCOHow to use it
Summary by cubic
Record agent walkthroughs for web and physical iOS QA and open the result when finished. Previously QA produced screenshots and a report only; now
$B record(web) and an iOS poller capture sessions to mp4 or an HTML player, andstop --openlaunches it; recording remains blocked over the pair‑agent tunnel.Adds
$B record start|stop|status|openusing CDPPage.startScreencast(JPEG frames), encodes viaffmpegwhen present (HTML player fallback), opens via the platform handler, and flushes in‑progress recordings on server shutdown.Introduces
browse/src/screencast.tsandbrowse/src/screencast-encode.ts; validates output paths; caps frames and duration; keepsrecordread‑scoped and off the tunnel allowlist.Adds
ios-qa/scripts/record-session.tsto poll the daemon’s/screenshotfor PNG frames, then encode/open; independent of the web runtime.Routes
/recordingto$qa --mode Report --module recording; the specialist starts capture, then runs webqa-onlyor deviceios-qa, and finally opens the artifact.Demo:
demos/bookedplusrecord-mac.shserves a local app on a Mac, walks a booking in a headed browser, records, and opens the file (cloud hosts cannot open a viewer on the user’s machine).Tests cover encode fallback (JPEG/PNG), command validation and gating, and a live Chromium start/stop producing a
RECORDING:artifact.Usage:
$B record start [path] [--fps N] [--quality Q]then$B record stop [--open]. Ifffmpegis missing, an HTML player is written and opened instead.bun ios-qa/scripts/record-session.ts start --daemon <URL> [--fps N]thenstop --open. No migration required.Written for commit f71a10c. Summary will update on new commits.