scenescout 3.19.2 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - eb79ce8: `scenescout check --record` (or `SCENESCOUT_RECORD=on`) keeps a frame after each route visit and each saved-flow step and writes `replay.html` beside the report: each role, each journey with a pass or fail badge, each step with its caption, result and frame, the first failing step highlighted, and the run's version, times, origin and commit in the header. `--video` records a WebM of each saved flow, each on a page of its own, and the replay page plays it beside the journey's steps. The GitHub Action gains `record` and `video` inputs and a `replay` output, and keeps the page, its frames and its videos in the uploaded artifact.
8
+
3
9
  ## 3.19.2
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -277,7 +277,9 @@ Use SceneScout to test http://localhost:3000, record the run
277
277
  ```
278
278
 
279
279
  or, on the tool directly, `scout_attach {record: true}`. `SCENESCOUT_RECORD=on` in
280
- the server's environment records every run.
280
+ the server's environment records every run. A CI gate records too:
281
+ `scenescout check --record` writes `replay.html`, every journey step by step with
282
+ its frames ([recording a check](docs/ci.md#recording-a-check)).
281
283
 
282
284
  Then `scout_report` writes two files side by side in `.scenescout/`:
283
285
  `report.md` as always, and `report.html` — the whole run as one self-contained
@@ -454,6 +456,7 @@ With the default settings its saved flows send no HTTP write (they replay under
454
456
  - `--on-refused-step report|stop` (default `report`): `report` marks a flow whose step was refused "could not run", keeps every other verdict and exits 2; `stop` exits 2 at that step with no results.
455
457
  - `--gate-retests never|high|all` (default `high`): which still-reproducing re-tested findings fail the gate.
456
458
  - `--baseline off|compare|update` (default `off`), with `--baselines <dir>` and `--baseline-threshold <percent>` (default `0.1`, so small anti-aliasing noise between machines passes): visual baselines, below.
459
+ - `--record` (or `SCENESCOUT_RECORD=on`; the action's `record: on`) keeps a frame after each route visit and each saved-flow step and writes `replay.html` beside the report: each role, each journey with a pass or fail badge, each step with its caption, result and frame, the first failing step highlighted. `--video` (the action's `video: on`) adds a WebM of each journey, played on that page beside its steps. Both off by default. [docs/ci.md](docs/ci.md#recording-a-check) covers size, privacy and publishing it.
457
460
 
458
461
  The defaults are what an unconfigured check does, for a first try or an AI agent running it unattended: its flows send no HTTP write and it never silently hides a result. Each setting is a choice for the project; the report and `check.json` print the values a check ran with.
459
462
 
package/dist/check-run.js CHANGED
@@ -19,6 +19,7 @@ import { decodePng, encodePng } from "./engine/png.js";
19
19
  import { firstLineOf } from "./engine/limits.js";
20
20
  import { isNonPageResource } from "./engine/crawl.js";
21
21
  import { checkRetestPlan, retestResults, wellFormedFindings } from "./engine/verify.js";
22
+ import { capFrames, isReplayFrameFile, journeyOf, journeyVideoPath, isJourneyVideoFile, redactReplay, REPLAY_VIDEOS_DIRNAME, replayVideos, REPLAY_FILE, REPLAY_FRAMES_DIRNAME, replayFramePath, replayFrames, replaySessionKey, visitOf, } from "./engine/check-replay.js";
22
23
  /** The baselines folder a check uses: the one --baselines names, else the project's own. */
23
24
  export function baselinesDirOf(options) {
24
25
  return options.baselinesDir ?? defaultBaselinesDir(options.projectDir);
@@ -99,6 +100,21 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
99
100
  const engine = new BrowserEngine();
100
101
  /** One browser per role a flow runs as, each attached once, on first use, and closed with the check's own. */
101
102
  const roleEngines = new Map();
103
+ // --record: every browser keeps a frame after each step, under its own session name, in its own memory folder.
104
+ const record = options.record === true;
105
+ const startedAt = new Date().toISOString();
106
+ const store = new MemoryStore(scratch);
107
+ if (record)
108
+ engine.sessionKey = replaySessionKey();
109
+ // --video: each browser records every page into its own scratch folder, and each flow's own page is saved beside the report.
110
+ const video = options.video === true;
111
+ const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
112
+ // The default folder is inside .scenescout/, which ignores itself; its .gitignore goes first, so no frame or video is ever unignored.
113
+ if ((record || video) && !options.outDir) {
114
+ const scenescoutDir = path.join(options.projectDir, MEMORY_DIRNAME);
115
+ fs.mkdirSync(scenescoutDir, { recursive: true });
116
+ writeSelfIgnore(scenescoutDir);
117
+ }
102
118
  const start = new URL(options.url);
103
119
  // From the start of the run, browser launch included: the budget is wall-clock time a person waits.
104
120
  const deadline = options.timeBudgetMs !== undefined ? Date.now() + options.timeBudgetMs : undefined;
@@ -109,9 +125,11 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
109
125
  const attached = await engine.attach({
110
126
  url: start.origin,
111
127
  projectDir: options.projectDir,
112
- memoryStore: new MemoryStore(scratch),
128
+ memoryStore: store,
113
129
  mode: options.mode,
114
130
  storageStatePath: options.storageStatePath,
131
+ ...(record ? { record: true } : {}),
132
+ ...(video ? { videoDir: path.join(scratch, "video") } : {}),
115
133
  ...(options.browser ? { browser: options.browser } : {}),
116
134
  actionTimeoutMs: options.actionTimeoutMs,
117
135
  navTimeoutMs: options.navTimeoutMs,
@@ -178,6 +196,8 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
178
196
  return known;
179
197
  const roleEngine = new BrowserEngine();
180
198
  roleEngines.set(role, roleEngine);
199
+ if (record)
200
+ roleEngine.sessionKey = replaySessionKey(role);
181
201
  const roleDir = path.join(scratch, `role-${role}`);
182
202
  fs.mkdirSync(roleDir, { recursive: true });
183
203
  const attachedAs = await roleEngine.attach({
@@ -186,6 +206,8 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
186
206
  memoryStore: new MemoryStore(roleDir),
187
207
  mode: options.mode,
188
208
  role,
209
+ ...(record ? { record: true } : {}),
210
+ ...(video ? { videoDir: path.join(roleDir, "video") } : {}),
189
211
  ...(options.browser ? { browser: options.browser } : {}),
190
212
  actionTimeoutMs: options.actionTimeoutMs,
191
213
  navTimeoutMs: options.navTimeoutMs,
@@ -197,10 +219,29 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
197
219
  throw new Error(`the saved sign-in for role "${role}" no longer signs in: ${lost.replace(/ Continuing now tests a logged-out app\.$/, "")}`);
198
220
  return roleEngine;
199
221
  };
222
+ /** On a recorded check, the frames each flow's steps left, beside the flow. */
223
+ const flowFrames = [];
224
+ /** On a check with --video, each flow's video beside the report (relative to it), or null where none was saved. */
225
+ const flowVideos = [];
200
226
  for (const flow of runFlows) {
201
227
  const runner = await engineFor(flow.role);
202
228
  // --flow-writes never: observe's rule, whatever --mode lets the crawl do.
203
- const replay = await runner.replayFlow(flow.steps, options.flowWrites === "never" ? "observe" : options.mode);
229
+ const walk = () => runner.replayFlow(flow.steps, options.flowWrites === "never" ? "observe" : options.mode);
230
+ let walked;
231
+ if (video) {
232
+ // A page of its own, so the video is this journey's alone; the context, and so the sign-in, is the role's.
233
+ const rel = journeyVideoPath(flowRuns.length + 1, flow.file);
234
+ const own = await runner.onOwnPage(walk, path.join(outDir, ...rel.split("/")));
235
+ walked = own.value;
236
+ flowVideos.push(own.video ? rel : null);
237
+ if (own.videoError)
238
+ log(` flow ${flow.name}: its video could not be saved (${own.videoError})`);
239
+ }
240
+ else {
241
+ walked = await walk();
242
+ }
243
+ const { frames, ...replay } = walked;
244
+ flowFrames.push(frames ?? []);
204
245
  flowRuns.push({ name: flow.name, file: flow.file, steps: flow.steps.length, ...(flow.role === undefined ? {} : { role: flow.role }), ...replay });
205
246
  log(` flow ${flow.name}${flow.role === undefined ? "" : ` (as ${flow.role})`}: ${replay.outcome.status}${replay.outcome.status === "passed" ? "" : ` at step ${replay.outcome.step}`}`);
206
247
  // --on-refused-step stop: nothing after a refused step runs, and the check ends without a verdict.
@@ -222,6 +263,22 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
222
263
  }))),
223
264
  }
224
265
  : null;
266
+ const replay = record || video
267
+ ? recordReplay({
268
+ options,
269
+ startedAt,
270
+ ownRole: engine.role,
271
+ log: store.actionLog,
272
+ visited: [...routes, ...retestPages],
273
+ flows: runFlows
274
+ .slice(0, flowRuns.length)
275
+ .map((flow, i) => ({ flow, outcome: flowRuns[i].outcome, frames: flowFrames[i], video: flowVideos[i] ?? null })),
276
+ scratch,
277
+ say: log,
278
+ })
279
+ : null;
280
+ if (replay)
281
+ log(` kept ${replayFrames(replay).length} frame(s) and ${replayVideos(replay).length} video(s) for ${REPLAY_FILE}`);
225
282
  const { pages: measured, resources } = splitResources(redactRoutes(routes.map(withoutOwnResponse)));
226
283
  const flows = redactFlowRuns(flowRuns);
227
284
  const pictured = baselines ? redactBaselineRun(baselines) : null;
@@ -248,6 +305,7 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
248
305
  retest,
249
306
  settings: settingsOf(options),
250
307
  baselines: pictured,
308
+ ...(replay ? { replay } : {}),
251
309
  };
252
310
  // A value taken from the environment (a code, a password) is never written, wherever the page echoed it.
253
311
  return envValues.size > 0 ? JSON.parse(maskEnvValues(JSON.stringify(result), envValues)) : result;
@@ -403,3 +461,118 @@ async function takeBaselines(engine, options, mode, targets, log) {
403
461
  }
404
462
  return { mode, engine: engineName, threshold: options.baselineThreshold, dir: shownDir(dir, options.projectDir), results };
405
463
  }
464
+ /**
465
+ * Remove what an earlier recorded check left beside the report: replay.html,
466
+ * the frames under replay-frames/ and the journey videos under replay-videos/
467
+ * that a check names as it names them (isReplayFrameFile, isJourneyVideoFile),
468
+ * then any folder that leaves empty. Run before every
469
+ * check, recorded or not, so a page from an earlier run is never read, or
470
+ * uploaded, as this one's; whatever else the folder holds stays.
471
+ */
472
+ export function clearReplayOutput(outDir) {
473
+ fs.rmSync(path.join(outDir, REPLAY_FILE), { force: true });
474
+ const videos = path.join(outDir, REPLAY_VIDEOS_DIRNAME);
475
+ if (fs.existsSync(videos)) {
476
+ for (const file of fs.readdirSync(videos, { withFileTypes: true }))
477
+ if (file.isFile() && isJourneyVideoFile(file.name))
478
+ fs.rmSync(path.join(videos, file.name));
479
+ if (fs.readdirSync(videos).length === 0)
480
+ fs.rmdirSync(videos);
481
+ }
482
+ const root = path.join(outDir, REPLAY_FRAMES_DIRNAME);
483
+ let folders;
484
+ try {
485
+ folders = fs.readdirSync(root, { withFileTypes: true });
486
+ }
487
+ catch (err) {
488
+ if (err.code === "ENOENT")
489
+ return;
490
+ throw err;
491
+ }
492
+ for (const folder of folders.filter((f) => f.isDirectory())) {
493
+ const dir = path.join(root, folder.name);
494
+ for (const file of fs.readdirSync(dir, { withFileTypes: true }))
495
+ if (file.isFile() && isReplayFrameFile(file.name))
496
+ fs.rmSync(path.join(dir, file.name));
497
+ if (fs.readdirSync(dir).length === 0)
498
+ fs.rmdirSync(dir);
499
+ }
500
+ if (fs.readdirSync(root).length === 0)
501
+ fs.rmdirSync(root);
502
+ }
503
+ /**
504
+ * The replay page's model for a recorded check, with its frames copied from
505
+ * the browsers' scratch folders to replay-frames/ beside the report: routes
506
+ * visited in the check's own session, then each flow under the role it ran
507
+ * as. Capped and redacted here, so what is copied is exactly what the page shows.
508
+ */
509
+ function recordReplay(o) {
510
+ const outDir = o.options.outDir ?? defaultCheckDir(o.options.projectDir);
511
+ /** Where each frame the page names is copied from. */
512
+ const sources = new Map();
513
+ const beside = (recorded, memoryDir) => {
514
+ const to = recorded ? replayFramePath(recorded) : null;
515
+ if (to && recorded)
516
+ sources.set(to, path.join(memoryDir, ...recorded.split("/")));
517
+ return to;
518
+ };
519
+ // The crawl's frames, by the path each visit asked for, in the order they were taken.
520
+ const own = replaySessionKey();
521
+ const crawled = new Map();
522
+ for (const e of o.log) {
523
+ if (e.action !== "crawl" || !e.frame || e.target === undefined || (e.session ?? own) !== own)
524
+ continue;
525
+ crawled.set(e.target, [...(crawled.get(e.target) ?? []), e.frame]);
526
+ }
527
+ const ownSession = {
528
+ role: o.ownRole,
529
+ own: true,
530
+ // A MemoryStore keeps its files, frames included, in the .scenescout folder of the directory it is given.
531
+ visits: o.visited.map((route) => visitOf(route, beside(crawled.get(route.path)?.shift(), path.join(o.scratch, MEMORY_DIRNAME)) ?? undefined)),
532
+ journeys: [],
533
+ };
534
+ const roles = [ownSession];
535
+ for (const { flow, outcome, frames, video } of o.flows) {
536
+ const memoryDir = path.join(flow.role === undefined ? o.scratch : path.join(o.scratch, `role-${flow.role}`), MEMORY_DIRNAME);
537
+ const journey = journeyOf(flow, outcome, frames.map((f) => beside(f, memoryDir)));
538
+ if (video)
539
+ journey.video = video;
540
+ let role = flow.role === undefined ? ownSession : roles.find((r) => !r.own && r.role === flow.role);
541
+ if (!role) {
542
+ role = { role: flow.role, own: false, visits: [], journeys: [] };
543
+ roles.push(role);
544
+ }
545
+ role.journeys.push(journey);
546
+ }
547
+ const replay = redactReplay(capFrames({ startedAt: o.startedAt, roles, framesLeftOut: 0 }));
548
+ // A frame that cannot be copied is left off the page rather than shown broken.
549
+ const copied = new Set();
550
+ let failed = false;
551
+ for (const rel of replayFrames(replay)) {
552
+ const from = sources.get(rel);
553
+ if (!from)
554
+ continue;
555
+ try {
556
+ const to = path.join(outDir, ...rel.split("/"));
557
+ fs.mkdirSync(path.dirname(to), { recursive: true });
558
+ fs.copyFileSync(from, to);
559
+ copied.add(rel);
560
+ }
561
+ catch (err) {
562
+ // Said once: a folder that refuses one frame usually refuses them all.
563
+ if (!failed)
564
+ o.say(` could not copy a recorded frame to ${outDir}: ${firstLineOf(err)}; frames that could not be copied are left off ${REPLAY_FILE}`);
565
+ failed = true;
566
+ }
567
+ }
568
+ const keep = (item) => {
569
+ if (!item.frame || copied.has(item.frame))
570
+ return item;
571
+ const { frame: _gone, ...rest } = item;
572
+ return rest;
573
+ };
574
+ return {
575
+ ...replay,
576
+ roles: replay.roles.map((r) => ({ ...r, visits: r.visits.map(keep), journeys: r.journeys.map((j) => ({ ...j, steps: j.steps.map(keep) })) })),
577
+ };
578
+ }
package/dist/cli.js CHANGED
@@ -21,7 +21,9 @@ import { APPROX_DISK_MB, defaultAttachNote, defaultEngine, launchTarget, parseBr
21
21
  import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
22
22
  import { CLAUDE_CODE_NOT_NEEDED, CLI_NAME, desktopExtensionRoots, diagnose, doctorAllGood, findDesktopExtension, installClosing, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
23
23
  import { downloadBrowsers, presentBrowsers } from "./installer.js";
24
- import { baselinesDirOf, defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
24
+ import { baselinesDirOf, clearReplayOutput, defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
25
+ import { buildCheckReplayHtml, commitOf, REPLAY_FILE, replayFrames, replayVideos } from "./engine/check-replay.js";
26
+ import { recordChoice } from "./engine/capture.js";
25
27
  import { httpClient, httpJudgeAsk, runCi } from "./ci-run.js";
26
28
  import { runExport } from "./export-run.js";
27
29
  import { runLogin, runScriptedLogin, savedLine } from "./login-run.js";
@@ -29,7 +31,7 @@ import { credentialRedactor, LOGIN_ENV, readScriptedLogin } from "./engine/scrip
29
31
  import { parseLoginArgs } from "./engine/profiles.js";
30
32
  import { detectProvider, EXIT_CI, judgeEffort, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
31
33
  import { VISUAL_DIRNAME } from "./engine/baseline.js";
32
- import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason, } from "./engine/check.js";
34
+ import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, summarise, toSarif, toSummaryJson, unmeasuredReason, } from "./engine/check.js";
33
35
  import { downloadLine, EXIT_FIRST_RUN, FIRST_RUN_DIRNAME, firstRunCheckOptions, firstRunDownloads, firstRunSummary, formatFirstRun, modeSentence, parseFirstRunArgs, reportFolderProblem, unreachableReason, writeFirstRunReport, } from "./first-run.js";
34
36
  import { credentialSecrets, EXIT_EXPORT, parseExportArgs } from "./engine/export.js";
35
37
  import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
@@ -105,7 +107,13 @@ Usage:
105
107
  passes; 0 counts every changed pixel);
106
108
  --sarif-file-anchor path: the repository file a SARIF result points at when
107
109
  no saved flow raised it (default: the running workflow's file on GitHub
108
- Actions, else package.json, else README.md))
110
+ Actions, else package.json, else README.md);
111
+ --record [on|off]: keep a frame after each route visit and each flow step
112
+ and write replay.html beside the report, role → journey → step (default:
113
+ SCENESCOUT_RECORD, else off; the frames go in replay-frames/);
114
+ --video [on|off]: record a WebM of each saved flow, each on a page of its
115
+ own, into replay-videos/, linked from replay.html beside its steps
116
+ (default off))
109
117
  Exit code: 0 passed, 1 failed the gate, 2 could not run.
110
118
  scenescout ci <url> An exploratory run with no person present: a model reached through its API
111
119
  drives the tools by the SceneScout method and the run ends in the report.
@@ -570,10 +578,14 @@ async function check(args) {
570
578
  console.error(`scenescout check: ${parsed.error}`);
571
579
  process.exit(EXIT.error);
572
580
  }
573
- const options = parsed.options;
581
+ let options = parsed.options;
574
582
  const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
575
583
  let inputs;
576
584
  try {
585
+ // --record, else SCENESCOUT_RECORD, else off.
586
+ options = { ...options, record: recordChoice(options.record, process.env) };
587
+ // A replay page an earlier run left must never be read, or uploaded, as this run's.
588
+ clearReplayOutput(outDir);
577
589
  inputs = readCheckInputs(options);
578
590
  }
579
591
  catch (err) {
@@ -618,6 +630,19 @@ async function check(args) {
618
630
  console.error(`scenescout check: ${sarifFiles.warning}`);
619
631
  fs.writeFileSync(path.join(outDir, "check.sarif"), JSON.stringify(toSarif(result, version, sarifFiles), null, 2) + "\n");
620
632
  fs.writeFileSync(path.join(outDir, "check.json"), JSON.stringify(toSummaryJson(result, version), null, 2) + "\n");
633
+ if (result.replay) {
634
+ const { passed, couldNotRun } = summarise(result);
635
+ const html = buildCheckReplayHtml(result.replay, {
636
+ version,
637
+ origin: new URL(result.url).origin,
638
+ startedAt: result.replay.startedAt,
639
+ endedAt: result.generatedAt,
640
+ commit: commitOf(process.env),
641
+ passed,
642
+ couldNotRun,
643
+ });
644
+ fs.writeFileSync(path.join(outDir, REPLAY_FILE), html);
645
+ }
621
646
  // On GitHub Actions the verdict also goes on the run's summary page.
622
647
  if (process.env.GITHUB_STEP_SUMMARY)
623
648
  fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown);
@@ -629,6 +654,8 @@ async function check(args) {
629
654
  }
630
655
  console.log("\n" + markdown);
631
656
  console.log(`Wrote report.md, check.sarif and check.json to ${outDir}`);
657
+ if (result.replay)
658
+ console.log(`Wrote ${REPLAY_FILE} to ${outDir}, with ${replayFrames(result.replay).length} frame(s) and ${replayVideos(result.replay).length} journey video(s) beside it: open it in a browser to see each step`);
632
659
  const pictured = result.baselines?.results.filter((r) => r.files).length ?? 0;
633
660
  if (pictured > 0)
634
661
  console.log(`Wrote the pictures of ${pictured} changed baseline(s) under ${path.join(outDir, VISUAL_DIRNAME)}`);
@@ -1184,6 +1184,7 @@ export class BrowserEngine {
1184
1184
  viewport: opts.viewport ?? { width: 1280, height: 900 },
1185
1185
  ...(opts.deviceScaleFactor !== undefined ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
1186
1186
  serviceWorkers: serviceWorkerPolicy(this.engineName),
1187
+ ...(opts.videoDir ? { recordVideo: { dir: opts.videoDir } } : {}),
1187
1188
  });
1188
1189
  const restoreSession = sessionStorageInitScript(profile?.sessionStorage ?? []);
1189
1190
  if (restoreSession)
@@ -1509,6 +1510,9 @@ export class BrowserEngine {
1509
1510
  // oracles attached); close foreign-origin popups so exploration cannot
1510
1511
  // silently escape the app under test.
1511
1512
  this.context.on("page", (newPage) => {
1513
+ // A page the engine opened for itself (onOwnPage) is not a popup to adopt or close.
1514
+ if (this.openingOwnPage)
1515
+ return;
1512
1516
  newPage
1513
1517
  .waitForLoadState("domcontentloaded", { timeout: this.limits.backNavMs })
1514
1518
  .then(() => {
@@ -1671,6 +1675,66 @@ export class BrowserEngine {
1671
1675
  nativeDialogAt = 0;
1672
1676
  /** When a native dialog that asks something (confirm, prompt, a leave confirmation) last opened; an alert only tells. */
1673
1677
  nativeQuestionAt = 0;
1678
+ /** True while onOwnPage opens its page, so the popup handler leaves it alone. */
1679
+ openingOwnPage = false;
1680
+ /** Open a page in this session's context and drive it from now on, wired as attach wires its first page. */
1681
+ async driveNewPage() {
1682
+ const context = this.context;
1683
+ if (!context)
1684
+ throw new Error("Not attached. Call scout_attach first with the app URL and project path.");
1685
+ this.openingOwnPage = true;
1686
+ let page;
1687
+ try {
1688
+ page = await context.newPage();
1689
+ }
1690
+ finally {
1691
+ this.openingOwnPage = false;
1692
+ }
1693
+ this.oracles.attach(page);
1694
+ this.wireDialogHandler(page);
1695
+ this.wireEmbedMoves(page);
1696
+ this.page = page;
1697
+ this.refs.clear();
1698
+ this.snapshotUrl = "";
1699
+ this.forgetSnapshots();
1700
+ return page;
1701
+ }
1702
+ /**
1703
+ * Run `work` on a page of its own in this session's context, then close it.
1704
+ * The context is the session's, so its cookies and storage carry over; on a
1705
+ * session attached with `videoDir`, the page's video is this run's alone,
1706
+ * and is saved to `videoTo`. Every page the work left open is closed with
1707
+ * it, and a fresh page is driven afterwards. The video is null when the
1708
+ * session records none or it could not be saved (`videoError` says why).
1709
+ */
1710
+ async onOwnPage(work, videoTo) {
1711
+ const own = await this.driveNewPage();
1712
+ let value;
1713
+ try {
1714
+ value = await work();
1715
+ }
1716
+ finally {
1717
+ const context = this.context;
1718
+ // The work may have adopted a popup, or been left on its own page: everything open now is the work's.
1719
+ if (context)
1720
+ await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map((p) => p.close().catch(() => { }))), 10_000);
1721
+ this.page = null;
1722
+ if (context)
1723
+ await this.driveNewPage();
1724
+ }
1725
+ const recording = own.video();
1726
+ if (!recording || !videoTo)
1727
+ return { value, video: null };
1728
+ try {
1729
+ await fs.promises.mkdir(path.dirname(videoTo), { recursive: true });
1730
+ // saveAs waits until the page is closed and the video written.
1731
+ await recording.saveAs(videoTo);
1732
+ return { value, video: videoTo };
1733
+ }
1734
+ catch (err) {
1735
+ return { value, video: null, videoError: err instanceof Error ? err.message.split("\n")[0] : String(err) };
1736
+ }
1737
+ }
1674
1738
  requirePage() {
1675
1739
  if (!this.page || !this.memory) {
1676
1740
  throw new Error("Not attached. Call scout_attach first with the app URL and project path.");
@@ -5085,7 +5149,15 @@ export class BrowserEngine {
5085
5149
  refusedBackground.push(sig);
5086
5150
  return charged;
5087
5151
  };
5088
- const done = (outcome) => ({ outcome, violations, refusedBackground, websockets: [...websockets] });
5152
+ /** On a recorded session, the frame after each step that ran (null where none was kept), for the check's replay page. */
5153
+ const frames = [];
5154
+ const done = (outcome) => ({
5155
+ outcome,
5156
+ violations,
5157
+ refusedBackground,
5158
+ websockets: [...websockets],
5159
+ ...(this.recording ? { frames } : {}),
5160
+ });
5089
5161
  // Nothing from the crawl before this flow is charged to it.
5090
5162
  this.oracles.drain(false);
5091
5163
  this.blockedRequests = [];
@@ -5269,6 +5341,9 @@ export class BrowserEngine {
5269
5341
  const n = i + 1;
5270
5342
  const did = describeStep(step);
5271
5343
  const { failure, refusal } = step.action === "repeat" ? await runRepeat(step) : await runStep(step);
5344
+ // Taken whatever the step's result: the frame of a step that broke is the evidence of how it broke.
5345
+ if (this.recording)
5346
+ frames.push((await this.recordFrame(`flow-${step.action}`)) ?? null);
5272
5347
  if (refusal)
5273
5348
  return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
5274
5349
  if (failure)
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The replay page of a recorded `scenescout check` (`--record`): what each
3
+ * role did, journey by journey and step by step, with the frame the page
4
+ * showed after each step, so a green run leaves evidence of what passed and a
5
+ * red one shows where it broke.
6
+ *
7
+ * A journey is a saved flow (flow.ts); a role is the session it ran in, the
8
+ * check's own or the one its `role` names. The check's own session also lists
9
+ * the routes it visited, one frame each.
10
+ *
11
+ * Everything here is pure: the mapping of steps to captions and results, the
12
+ * frame cap, the redaction and the page itself are table-tested. check-run.ts
13
+ * gathers the frames from the browser and copies them beside the page.
14
+ */
15
+ import { redactRoute } from "./memory.js";
16
+ import { describeStep } from "./flow.js";
17
+ import { escapeHtml, plainSegment, RECORD_MAX_FRAMES } from "./replay.js";
18
+ /** The file the page is written to, beside report.md. */
19
+ export const REPLAY_FILE = "replay.html";
20
+ /** The folder beside it that holds the frames the page shows. */
21
+ export const REPLAY_FRAMES_DIRNAME = "replay-frames";
22
+ /** The folder beside it that holds one video per journey (--video). */
23
+ export const REPLAY_VIDEOS_DIRNAME = "replay-videos";
24
+ /**
25
+ * Each step of a replayed flow with its caption, its result and its frame.
26
+ * The replay stops at the first step that breaks, so every step before it
27
+ * passed, it is the failing one, and those after it never ran (and have no
28
+ * frame). `frames` holds one entry per executed step, null where none was kept.
29
+ */
30
+ export function journeySteps(steps, outcome, frames = []) {
31
+ const broke = outcome.status === "passed" ? null : outcome.step;
32
+ return steps.map((step, i) => {
33
+ const n = i + 1;
34
+ const frame = broke === null || n <= broke ? (frames[i] ?? undefined) : undefined;
35
+ const base = { n, caption: describeStep(step), ...(frame ? { frame } : {}) };
36
+ if (broke === null || n < broke)
37
+ return { ...base, result: "passed" };
38
+ if (n > broke)
39
+ return { n, caption: base.caption, result: "not-run" };
40
+ // `broke` is set only when the outcome is not "passed".
41
+ const failed = outcome;
42
+ return { ...base, result: failed.status, reason: failed.reason, path: failed.path };
43
+ });
44
+ }
45
+ /** One journey of the page, from a flow and how its replay went. */
46
+ export function journeyOf(flow, outcome, frames) {
47
+ return {
48
+ name: flow.name,
49
+ file: flow.file,
50
+ status: outcome.status,
51
+ steps: journeySteps(flow.steps, outcome, frames),
52
+ ...(outcome.status === "passed" ? {} : { firstFailing: outcome.step }),
53
+ };
54
+ }
55
+ /** One visited route, from what the crawl measured. */
56
+ export function visitOf(route, frame) {
57
+ const result = route.loadError !== undefined ? "not-loaded" : route.loginRedirect ? "sign-in" : route.status !== null && route.status >= 400 ? "http-error" : "loaded";
58
+ return {
59
+ path: route.path,
60
+ status: route.status,
61
+ result,
62
+ ...(route.loadError !== undefined ? { reason: route.loadError } : {}),
63
+ ...(frame ? { frame } : {}),
64
+ };
65
+ }
66
+ /**
67
+ * At most `max` frames per role, the first ones kept: the same cap a recorded
68
+ * session keeps (RECORD_MAX_FRAMES), held here too so the page never shows
69
+ * more than the run was allowed to take, whatever it is handed.
70
+ */
71
+ export function capFrames(replay, max = RECORD_MAX_FRAMES) {
72
+ let leftOut = replay.framesLeftOut;
73
+ const roles = replay.roles.map((role) => {
74
+ let kept = 0;
75
+ const take = (item) => {
76
+ if (!item.frame)
77
+ return item;
78
+ if (kept < max) {
79
+ kept += 1;
80
+ return item;
81
+ }
82
+ leftOut += 1;
83
+ const { frame: _dropped, ...rest } = item;
84
+ return rest;
85
+ };
86
+ return {
87
+ ...role,
88
+ visits: role.visits.map(take),
89
+ journeys: role.journeys.map((j) => ({ ...j, steps: j.steps.map(take) })),
90
+ };
91
+ });
92
+ return { ...replay, roles, framesLeftOut: leftOut };
93
+ }
94
+ /** The report's redaction (memory.ts redactRoute) on every path, caption and reason the page shows. */
95
+ export function redactReplay(replay) {
96
+ return {
97
+ ...replay,
98
+ roles: replay.roles.map((role) => ({
99
+ ...role,
100
+ visits: role.visits.map((v) => ({ ...v, path: redactRoute(v.path), ...(v.reason !== undefined ? { reason: redactRoute(v.reason) } : {}) })),
101
+ journeys: role.journeys.map((j) => ({
102
+ ...j,
103
+ steps: j.steps.map((s) => ({
104
+ ...s,
105
+ caption: redactRoute(s.caption),
106
+ ...(s.reason !== undefined ? { reason: redactRoute(s.reason) } : {}),
107
+ ...(s.path !== undefined ? { path: redactRoute(s.path) } : {}),
108
+ })),
109
+ })),
110
+ })),
111
+ };
112
+ }
113
+ /**
114
+ * Where the video of the n-th journey run (1-based) goes beside the page. The
115
+ * flow's file name is reduced to a plain segment, so it decides nothing about
116
+ * where the file is written; the number keeps two flows' videos apart.
117
+ */
118
+ export function journeyVideoPath(n, file) {
119
+ return `${REPLAY_VIDEOS_DIRNAME}/journey-${String(n).padStart(2, "0")}-${plainSegment(file.replace(/\.json$/i, ""), "flow")}.webm`;
120
+ }
121
+ /** Whether a file under replay-videos/ is a video a check wrote, and so one an earlier run's clean-up may remove. */
122
+ export function isJourneyVideoFile(name) {
123
+ return /^journey-\d{2,}-[a-z0-9._-]+\.webm$/i.test(name);
124
+ }
125
+ /** Every journey video the page links, in page order. */
126
+ export function replayVideos(replay) {
127
+ return replay.roles.flatMap((r) => r.journeys.flatMap((j) => (j.video ? [j.video] : [])));
128
+ }
129
+ /** Every frame the page shows, in page order. */
130
+ export function replayFrames(replay) {
131
+ return replay.roles.flatMap((r) => [
132
+ ...r.visits.flatMap((v) => (v.frame ? [v.frame] : [])),
133
+ ...r.journeys.flatMap((j) => j.steps.flatMap((s) => (s.frame ? [s.frame] : []))),
134
+ ]);
135
+ }
136
+ const RECORDED_FRAME = /^recordings\/([a-z0-9._-]+)\/(\d{4}-[a-z0-9._-]+\.jpg)$/i;
137
+ /**
138
+ * Where a frame a session recorded (replay.ts framePath) goes beside the
139
+ * page, or null for anything that is not one: the path is used to write a
140
+ * file, so nothing else is let through.
141
+ */
142
+ export function replayFramePath(recorded) {
143
+ const m = RECORDED_FRAME.exec(recorded);
144
+ return m ? `${REPLAY_FRAMES_DIRNAME}/${m[1]}/${m[2]}` : null;
145
+ }
146
+ /** Whether a file in a session folder under replay-frames/ is a frame a check wrote, and so one an earlier run's clean-up may remove. */
147
+ export function isReplayFrameFile(name) {
148
+ return /^\d{4}-[a-z0-9._-]+\.jpg$/i.test(name);
149
+ }
150
+ /** The session name a check's own browser records its frames under, and one for each role's. Distinct, so their frames never share a folder. */
151
+ export function replaySessionKey(role) {
152
+ return role === undefined ? "check" : `role-${plainSegment(role, "role")}`;
153
+ }
154
+ /** A commit to name in the page's header: GITHUB_SHA when it is one, else none. */
155
+ export function commitOf(env) {
156
+ const sha = (env.GITHUB_SHA ?? "").trim();
157
+ return /^[0-9a-f]{7,64}$/i.test(sha) ? sha.toLowerCase() : undefined;
158
+ }
159
+ const RESULT_WORD = { passed: "passed", failed: "failed", refused: "refused", "not-run": "not run" };
160
+ const VISIT_WORD = {
161
+ loaded: "loaded",
162
+ "http-error": "HTTP error",
163
+ "sign-in": "sent to sign-in",
164
+ "not-loaded": "did not load",
165
+ };
166
+ /** A UTC time as the page shows it: the reader's own clock is not this page's to assume. */
167
+ function stamp(iso) {
168
+ return iso.length >= 19 ? `${iso.slice(0, 10)} ${iso.slice(11, 19)} UTC` : iso;
169
+ }
170
+ const GONE = `onerror="this.parentNode.classList.add('gone')"`;
171
+ function frameHtml(frame, alt) {
172
+ if (!frame)
173
+ return `<p class="noframe">No frame</p>`;
174
+ const src = escapeHtml(frame);
175
+ return (`<a class="frame" href="${src}" target="_blank" rel="noreferrer" data-testid="replay-frame-open"><img loading="lazy" ${GONE} src="${src}" alt="${escapeHtml(alt)}">` +
176
+ `<span class="gone-note">This frame is not beside this file. Frames live in the <code>${REPLAY_FRAMES_DIRNAME}/</code> folder, which travels with it.</span></a>`);
177
+ }
178
+ function stepHtml(s, anchor) {
179
+ const first = s.result === "failed" || s.result === "refused";
180
+ const detail = [s.reason, s.path ? `on ${s.path}` : ""].filter(Boolean).join(" ");
181
+ return (`<li class="step ${s.result}${first ? " first-failing" : ""}"${first ? ` id="${anchor}"` : ""} data-result="${s.result}">` +
182
+ `<div class="caption"><span class="n">${s.n}</span> <span class="what">${escapeHtml(s.caption)}</span> <span class="result r-${s.result}">${RESULT_WORD[s.result]}</span></div>` +
183
+ (detail ? `<p class="why">${escapeHtml(detail)}</p>` : "") +
184
+ (s.result === "not-run" ? "" : frameHtml(s.frame, `The page after step ${s.n}: ${s.caption}`)) +
185
+ `</li>`);
186
+ }
187
+ function journeyHtml(j, id) {
188
+ const ok = j.status === "passed";
189
+ const anchor = `${id}-first-failing`;
190
+ const badge = ok ? `<span class="badge pass">passed</span>` : `<span class="badge fail">${j.status}</span>`;
191
+ const jump = j.firstFailing !== undefined
192
+ ? `<a class="jump" href="#${anchor}" data-testid="replay-first-failing-link">Step ${j.firstFailing} ${j.status}</a>`
193
+ : `<span class="count">${j.steps.length} step${j.steps.length === 1 ? "" : "s"}</span>`;
194
+ return (`<details class="journey ${ok ? "pass" : "fail"}"${ok ? "" : " open"} data-status="${j.status}"><summary data-testid="replay-journey-toggle">${badge} <b>${escapeHtml(j.name)}</b> <span class="file">${escapeHtml(j.file)}</span> ${jump}</summary>` +
195
+ (j.video
196
+ ? `<figure class="video"><video controls preload="metadata" src="${escapeHtml(j.video)}" data-testid="replay-journey-video"></video>` +
197
+ `<figcaption>The whole journey as it ran. <a href="${escapeHtml(j.video)}" target="_blank" rel="noreferrer" data-testid="replay-video-open">Open the video</a></figcaption></figure>`
198
+ : "") +
199
+ `<ol class="steps">${j.steps.map((s) => stepHtml(s, anchor)).join("")}</ol></details>`);
200
+ }
201
+ function visitsHtml(visits) {
202
+ if (visits.length === 0)
203
+ return "";
204
+ const items = visits
205
+ .map((v) => `<li class="visit ${v.result}"><div class="caption"><span class="what">visit ${escapeHtml(v.path)}</span> <span class="result v-${v.result}">${v.status ?? "—"} · ${VISIT_WORD[v.result]}</span></div>` +
206
+ (v.reason ? `<p class="why">${escapeHtml(v.reason)}</p>` : "") +
207
+ frameHtml(v.frame, `The page at ${v.path}`) +
208
+ `</li>`)
209
+ .join("");
210
+ return `<details class="visits"><summary data-testid="replay-visits-toggle">Routes visited <span class="count">${visits.length}</span></summary><ol class="steps">${items}</ol></details>`;
211
+ }
212
+ function roleHtml(r, i) {
213
+ const failing = r.journeys.filter((j) => j.status !== "passed").length;
214
+ const tally = `${r.journeys.length} journey${r.journeys.length === 1 ? "" : "s"}${failing ? `, ${failing} not passed` : ""}`;
215
+ return (`<section class="role" id="role-${i}"><h2>${escapeHtml(r.role)} <span class="count">${r.own ? "the check's own session · " : ""}${tally}</span></h2>` +
216
+ visitsHtml(r.visits) +
217
+ (r.journeys.length > 0 ? r.journeys.map((j, k) => journeyHtml(j, `role-${i}-journey-${k}`)).join("") : `<p class="none">No journey ran as this role.</p>`) +
218
+ `</section>`);
219
+ }
220
+ const STYLE = `
221
+ :root { color-scheme: light dark; --bg:#f6f7f9; --panel:#fff; --line:#d9dde3; --text:#15181d; --muted:#5d6673; --accent:#2563eb; --pass:#047857; --pass-bg:#d1fae5; --fail:#b91c1c; --fail-bg:#fee2e2; }
222
+ @media (prefers-color-scheme: dark) { :root { --bg:#0e1116; --panel:#161a21; --line:#2a303a; --text:#e6e9ee; --muted:#98a2b3; --accent:#7aa2ff; --pass:#6ee7b7; --pass-bg:#063b2c; --fail:#fca5a5; --fail-bg:#3f1010; } }
223
+ * { box-sizing:border-box; }
224
+ body { margin:0; background:var(--bg); color:var(--text); font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif; }
225
+ header { padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
226
+ header h1 { margin:0 0 4px; font-size:18px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
227
+ header dl { display:flex; flex-wrap:wrap; gap:4px 18px; margin:0; font-size:13px; color:var(--muted); }
228
+ header dt { font-weight:600; } header dd { margin:0 0 0 4px; overflow-wrap:anywhere; }
229
+ header .pair { display:flex; }
230
+ main { max-width:1000px; margin:0 auto; padding:20px 16px 64px; }
231
+ h2 { font-size:19px; margin:28px 0 10px; }
232
+ .count, .file { color:var(--muted); font-size:13px; font-weight:400; }
233
+ .badge { display:inline-block; padding:1px 8px; border-radius:999px; font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; }
234
+ .badge.pass { color:var(--pass); background:var(--pass-bg); } .badge.fail { color:var(--fail); background:var(--fail-bg); }
235
+ details.journey, details.visits { background:var(--panel); border:1px solid var(--line); border-radius:8px; margin:10px 0; padding:10px 14px; }
236
+ details.journey.fail { border-color:var(--fail); }
237
+ summary { cursor:pointer; }
238
+ .jump { color:var(--fail); font-size:13px; margin-left:6px; }
239
+ ol.steps { list-style:none; margin:10px 0 0; padding:0; }
240
+ .step, .visit { border-left:3px solid var(--line); padding:6px 10px; margin:0 0 10px; }
241
+ .step.passed, .visit.loaded { border-color:var(--pass); }
242
+ .step.failed, .step.refused, .visit.http-error, .visit.not-loaded, .visit.sign-in { border-color:var(--fail); }
243
+ .step.first-failing { background:var(--fail-bg); border-left-width:6px; }
244
+ .step.not-run { opacity:.6; }
245
+ .caption { display:flex; flex-wrap:wrap; gap:6px 10px; align-items:baseline; }
246
+ .caption .n { font-weight:700; color:var(--muted); min-width:1.5em; }
247
+ .caption .what { font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
248
+ .result { margin-left:auto; font-size:12px; font-weight:700; }
249
+ .r-passed, .v-loaded { color:var(--pass); } .r-failed, .r-refused, .v-http-error, .v-not-loaded, .v-sign-in { color:var(--fail); } .r-not-run { color:var(--muted); }
250
+ .why { margin:4px 0; font-size:13px; overflow-wrap:anywhere; }
251
+ a.frame { display:block; margin:6px 0 2px; max-width:min(100%,720px); }
252
+ a.frame img { max-width:100%; max-height:400px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; }
253
+ .gone-note { display:none; padding:12px; border:1px dashed var(--line); border-radius:6px; color:var(--muted); font-size:12px; }
254
+ a.frame.gone img { display:none; } a.frame.gone .gone-note { display:block; }
255
+ .noframe, .none { color:var(--muted); font-size:12px; font-style:italic; margin:4px 0; }
256
+ .note { color:var(--muted); font-size:13px; }
257
+ figure.video { margin:10px 0; max-width:min(100%,720px); }
258
+ figure.video video { width:100%; border:1px solid var(--line); border-radius:6px; display:block; background:#000; }
259
+ figure.video figcaption { color:var(--muted); font-size:12px; margin-top:4px; }
260
+ `;
261
+ /** The whole page: one HTML file with no external assets or scripts; its frames sit beside it in replay-frames/. */
262
+ export function buildCheckReplayHtml(replay, meta) {
263
+ const verdict = meta.couldNotRun > 0 ? "could not run every flow" : meta.passed ? "passed" : "failed";
264
+ const badge = `<span class="badge ${meta.passed && meta.couldNotRun === 0 ? "pass" : "fail"}" data-testid="replay-verdict">${verdict}</span>`;
265
+ const pair = (term, value) => `<div class="pair"><dt>${term}</dt><dd>${escapeHtml(value)}</dd></div>`;
266
+ const frames = replayFrames(replay).length;
267
+ return `<!doctype html>
268
+ <html lang="en">
269
+ <head>
270
+ <meta charset="utf-8">
271
+ <meta name="viewport" content="width=device-width, initial-scale=1">
272
+ <title>SceneScout check replay — ${escapeHtml(meta.origin)}</title>
273
+ <style>${STYLE}</style>
274
+ </head>
275
+ <body>
276
+ <header>
277
+ <h1>SceneScout check replay ${badge}</h1>
278
+ <dl>${pair("App", meta.origin)}${pair("Started", stamp(meta.startedAt))}${pair("Ended", stamp(meta.endedAt))}${meta.commit ? pair("Commit", meta.commit) : ""}${pair("SceneScout", `v${meta.version}`)}${pair("Frames", String(frames))}${replayVideos(replay).length > 0 ? pair("Videos", String(replayVideos(replay).length)) : ""}</dl>
279
+ </header>
280
+ <main>
281
+ <p class="note">Each role, then each journey it walked, step by step, with the page as it was after the step${frames === 0 ? " when the check was recorded (--record)" : ""}. Typed values are never shown, and secrets in addresses are redacted as in the report.${replay.framesLeftOut > 0 ? ` ${replay.framesLeftOut} frame(s) past the cap of ${RECORD_MAX_FRAMES} per role are not kept; those steps show no frame.` : ""}</p>
282
+ ${replay.roles.map(roleHtml).join("\n") || '<p class="none">Nothing was recorded.</p>'}
283
+ </main>
284
+ </body>
285
+ </html>
286
+ `;
287
+ }
@@ -501,7 +501,13 @@ export const CHECK_OPTION_NAMES = [
501
501
  "baselines",
502
502
  "baseline-threshold",
503
503
  "sarif-file-anchor",
504
+ "record",
505
+ "video",
504
506
  ];
507
+ /** Options that may be given alone, meaning on: `--record`, as well as `--record on` and `--record=off`. */
508
+ const SWITCH_OPTIONS = new Set(["record", "video"]);
509
+ const SWITCH_ON = ["on", "true", "1"];
510
+ const SWITCH_OFF = ["off", "false", "0"];
505
511
  export const MAX_CHECK_ROUTES = 150;
506
512
  /** Link discovery rounds: each crawl reveals the routes its pages link to. Past a few, a site is paginating rather than revealing. */
507
513
  export const MAX_DISCOVERY_ROUNDS = 6;
@@ -522,6 +528,14 @@ export function parseCheckArgs(args, cwd) {
522
528
  }
523
529
  const eq = a.indexOf("=");
524
530
  const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
531
+ // A switch takes the next argument only when it is on or off: `--record http://…` records, and checks that URL.
532
+ if (eq < 0 && SWITCH_OPTIONS.has(name)) {
533
+ const next = args[i + 1]?.toLowerCase();
534
+ if (next !== undefined && [...SWITCH_ON, ...SWITCH_OFF].includes(next))
535
+ i += 1;
536
+ flags.set(name, next !== undefined && [...SWITCH_ON, ...SWITCH_OFF].includes(next) ? next : "on");
537
+ continue;
538
+ }
525
539
  const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
526
540
  if (value === undefined || (eq < 0 && value.startsWith("--")))
527
541
  return { ok: false, error: `--${name} needs a value` };
@@ -663,6 +677,14 @@ export function parseCheckArgs(args, cwd) {
663
677
  const anchor = flags.has("sarif-file-anchor") ? checkSarifAnchor(flags.get("sarif-file-anchor")) : undefined;
664
678
  if (anchor && !anchor.ok)
665
679
  return anchor;
680
+ const recordRaw = flags.get("record")?.trim().toLowerCase();
681
+ if (recordRaw !== undefined && !SWITCH_ON.includes(recordRaw) && !SWITCH_OFF.includes(recordRaw)) {
682
+ return { ok: false, error: "--record is on or off, or given alone for on" };
683
+ }
684
+ const videoRaw = flags.get("video")?.trim().toLowerCase();
685
+ if (videoRaw !== undefined && !SWITCH_ON.includes(videoRaw) && !SWITCH_OFF.includes(videoRaw)) {
686
+ return { ok: false, error: "--video is on or off, or given alone for on" };
687
+ }
666
688
  const resolve = (p) => resolveArgPath(cwd, p);
667
689
  return {
668
690
  ok: true,
@@ -689,6 +711,8 @@ export function parseCheckArgs(args, cwd) {
689
711
  ...(baselinesDir !== undefined ? { baselinesDir: resolve(baselinesDir) } : {}),
690
712
  baselineThreshold: threshold.value,
691
713
  ...(anchor ? { sarifFileAnchor: anchor.value } : {}),
714
+ ...(recordRaw !== undefined ? { record: SWITCH_ON.includes(recordRaw) } : {}),
715
+ ...(videoRaw !== undefined && SWITCH_ON.includes(videoRaw) ? { video: true } : {}),
692
716
  },
693
717
  };
694
718
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.19.2",
3
+ "version": "3.20.0",
4
4
  "description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
5
5
  "license": "MIT",
6
6
  "author": "brunoboto96",