scenescout 3.19.1 → 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,17 @@
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
+
9
+ ## 3.19.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 3b953a7: `refused-empty` now pairs an empty-state sentence with the refused read it is about (#413). A sentence that names what is missing ("No comments yet", "There are no orders") is reported at high when a refused read's path names the same thing, and not at all when a successful data read on the page fetched that thing instead: it is a genuinely empty section beside an unrelated refusal. A named sentence that matches neither is still reported, at medium, so `scenescout check` no longer fails a `--fail-on high` gate on it. Sentences that name nothing in particular ("No results", "Nothing to show") and empty rendered lists pair with any refused read at high, as before, and the finding names the refused read the sentence points at. Existing `refused-empty` fingerprints can change once on upgrade, because the evidence may now name a different read.
14
+
3
15
  ## 3.19.1
4
16
 
5
17
  ### 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)}`);
@@ -935,9 +935,13 @@ export class BrowserEngine {
935
935
  }
936
936
  }
937
937
  for (const found of findContradictions(requests, state, before, acted && actedNow ? { before: acted.before, after: actedNow } : null)) {
938
- if (this.contradictionsReported.has(found.evidence))
938
+ // Once per refused request a session, except that a high reading of it
939
+ // replaces an earlier medium one (#413).
940
+ const severity = found.severity ?? "high";
941
+ const earlier = this.contradictionsReported.get(found.evidence);
942
+ if (earlier === "high" || earlier === severity)
939
943
  continue;
940
- this.contradictionsReported.add(found.evidence);
944
+ this.contradictionsReported.set(found.evidence, severity);
941
945
  this.oracles.noteContradiction(found, url);
942
946
  }
943
947
  }
@@ -974,7 +978,7 @@ export class BrowserEngine {
974
978
  }
975
979
  }
976
980
  /** Contradiction signatures already reported this session — the same refused endpoint on every page must not flood the run. */
977
- contradictionsReported = new Set();
981
+ contradictionsReported = new Map();
978
982
  async scanForInjections() {
979
983
  const page = this.page;
980
984
  // One copy for the whole scan: the list is shared with every lane, and
@@ -1109,7 +1113,7 @@ export class BrowserEngine {
1109
1113
  this.blockNotices.reset();
1110
1114
  this.dialogsSeen = [];
1111
1115
  this.watchedResponses = [];
1112
- this.contradictionsReported = new Set();
1116
+ this.contradictionsReported = new Map();
1113
1117
  this.tokenPostsReported = new Set();
1114
1118
  this.pendingCreations = new Set();
1115
1119
  this.baseUrl = opts.url.replace(/\/$/, "");
@@ -1180,6 +1184,7 @@ export class BrowserEngine {
1180
1184
  viewport: opts.viewport ?? { width: 1280, height: 900 },
1181
1185
  ...(opts.deviceScaleFactor !== undefined ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
1182
1186
  serviceWorkers: serviceWorkerPolicy(this.engineName),
1187
+ ...(opts.videoDir ? { recordVideo: { dir: opts.videoDir } } : {}),
1183
1188
  });
1184
1189
  const restoreSession = sessionStorageInitScript(profile?.sessionStorage ?? []);
1185
1190
  if (restoreSession)
@@ -1505,6 +1510,9 @@ export class BrowserEngine {
1505
1510
  // oracles attached); close foreign-origin popups so exploration cannot
1506
1511
  // silently escape the app under test.
1507
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;
1508
1516
  newPage
1509
1517
  .waitForLoadState("domcontentloaded", { timeout: this.limits.backNavMs })
1510
1518
  .then(() => {
@@ -1667,6 +1675,66 @@ export class BrowserEngine {
1667
1675
  nativeDialogAt = 0;
1668
1676
  /** When a native dialog that asks something (confirm, prompt, a leave confirmation) last opened; an alert only tells. */
1669
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
+ }
1670
1738
  requirePage() {
1671
1739
  if (!this.page || !this.memory) {
1672
1740
  throw new Error("Not attached. Call scout_attach first with the app URL and project path.");
@@ -5081,7 +5149,15 @@ export class BrowserEngine {
5081
5149
  refusedBackground.push(sig);
5082
5150
  return charged;
5083
5151
  };
5084
- 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
+ });
5085
5161
  // Nothing from the crawl before this flow is charged to it.
5086
5162
  this.oracles.drain(false);
5087
5163
  this.blockedRequests = [];
@@ -5265,6 +5341,9 @@ export class BrowserEngine {
5265
5341
  const n = i + 1;
5266
5342
  const did = describeStep(step);
5267
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);
5268
5347
  if (refusal)
5269
5348
  return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
5270
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
+ }
@@ -169,6 +169,14 @@ function stripRefs(line) {
169
169
  function withoutOrigin(text, origin) {
170
170
  return origin ? text.split(origin).join("") : text;
171
171
  }
172
+ /**
173
+ * A violation's own severity where it is lower than its rule's: a refused read
174
+ * beside an empty state that may be about another section is medium (#413).
175
+ * Every other violation takes its rule's severity.
176
+ */
177
+ function violationSeverity(v) {
178
+ return v.kind === "refused_empty" && v.severity === "medium" ? "medium" : undefined;
179
+ }
172
180
  function violationRule(v) {
173
181
  switch (v.kind) {
174
182
  case "page_error":
@@ -309,7 +317,7 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
309
317
  else if (r.elements === 0 && (r.status === null || r.status < 400))
310
318
  add("dead-end", `${route}: 0 controls`, route);
311
319
  for (const v of r.violations)
312
- add(violationRule(v), v.detail, route, { embed: v.embed });
320
+ add(violationRule(v), v.detail, route, { embed: v.embed, severity: violationSeverity(v) });
313
321
  for (const line of r.geometry) {
314
322
  const rule = geometryRule(line);
315
323
  if (rule)
@@ -327,7 +335,7 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
327
335
  }
328
336
  for (const f of flows) {
329
337
  for (const { path, violation } of f.violations)
330
- add(violationRule(violation), violation.detail, path, { embed: violation.embed, flow: f.file });
338
+ add(violationRule(violation), violation.detail, path, { embed: violation.embed, flow: f.file, severity: violationSeverity(violation) });
331
339
  // A refused step is not the app's defect: the check reports it as "could not run" (refusedFlowReason).
332
340
  if (f.outcome.status === "failed")
333
341
  add("flow-step-failed", flowStepEvidence(f), f.outcome.path, { flow: f.file });
@@ -493,7 +501,13 @@ export const CHECK_OPTION_NAMES = [
493
501
  "baselines",
494
502
  "baseline-threshold",
495
503
  "sarif-file-anchor",
504
+ "record",
505
+ "video",
496
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"];
497
511
  export const MAX_CHECK_ROUTES = 150;
498
512
  /** Link discovery rounds: each crawl reveals the routes its pages link to. Past a few, a site is paginating rather than revealing. */
499
513
  export const MAX_DISCOVERY_ROUNDS = 6;
@@ -514,6 +528,14 @@ export function parseCheckArgs(args, cwd) {
514
528
  }
515
529
  const eq = a.indexOf("=");
516
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
+ }
517
539
  const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
518
540
  if (value === undefined || (eq < 0 && value.startsWith("--")))
519
541
  return { ok: false, error: `--${name} needs a value` };
@@ -655,6 +677,14 @@ export function parseCheckArgs(args, cwd) {
655
677
  const anchor = flags.has("sarif-file-anchor") ? checkSarifAnchor(flags.get("sarif-file-anchor")) : undefined;
656
678
  if (anchor && !anchor.ok)
657
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
+ }
658
688
  const resolve = (p) => resolveArgPath(cwd, p);
659
689
  return {
660
690
  ok: true,
@@ -681,6 +711,8 @@ export function parseCheckArgs(args, cwd) {
681
711
  ...(baselinesDir !== undefined ? { baselinesDir: resolve(baselinesDir) } : {}),
682
712
  baselineThreshold: threshold.value,
683
713
  ...(anchor ? { sarifFileAnchor: anchor.value } : {}),
714
+ ...(recordRaw !== undefined ? { record: SWITCH_ON.includes(recordRaw) } : {}),
715
+ ...(videoRaw !== undefined && SWITCH_ON.includes(videoRaw) ? { video: true } : {}),
684
716
  },
685
717
  };
686
718
  }
@@ -145,6 +145,154 @@ export function classify(text) {
145
145
  return "empty";
146
146
  return null;
147
147
  }
148
+ /**
149
+ * Words in an empty-state sentence that name nothing in particular: the
150
+ * generic nouns for "the things", the verbs that follow them ("No results
151
+ * found", "No entries recorded yet") and the adjectives in front of them ("No
152
+ * new items", "No matching records"). Stemmed, as `stem` leaves them.
153
+ */
154
+ const GENERIC_EMPTY_WORDS = new Set([
155
+ "result",
156
+ "item",
157
+ "record",
158
+ "row",
159
+ "data",
160
+ "entry",
161
+ "match",
162
+ "thing",
163
+ "one",
164
+ "yet",
165
+ "found",
166
+ "show",
167
+ "display",
168
+ "here",
169
+ "recorded",
170
+ "added",
171
+ "created",
172
+ "saved",
173
+ "available",
174
+ "been",
175
+ "matching",
176
+ "more",
177
+ "new",
178
+ "recent",
179
+ "upcoming",
180
+ "open",
181
+ "pending",
182
+ "other",
183
+ "further",
184
+ "api",
185
+ "v1",
186
+ "v2",
187
+ "v3",
188
+ ]);
189
+ /** Words that end the subject of an empty-state sentence: "No results | for your search". */
190
+ const SUBJECT_STOP_WORDS = new Set([
191
+ "for",
192
+ "your",
193
+ "you",
194
+ "to",
195
+ "in",
196
+ "on",
197
+ "of",
198
+ "at",
199
+ "by",
200
+ "with",
201
+ "that",
202
+ "this",
203
+ "which",
204
+ "from",
205
+ "match",
206
+ "matches",
207
+ "yet",
208
+ "found",
209
+ "here",
210
+ "available",
211
+ "left",
212
+ "anymore",
213
+ "have",
214
+ "has",
215
+ "had",
216
+ "been",
217
+ "was",
218
+ "were",
219
+ "is",
220
+ "are",
221
+ "such",
222
+ ]);
223
+ /** Crude singular form, enough to match "comments" to "/comment/" and "entries" to "/entry". */
224
+ function stem(word) {
225
+ const w = word.toLowerCase();
226
+ if (w.endsWith("ies") && w.length > 4)
227
+ return `${w.slice(0, -3)}y`;
228
+ if (w.endsWith("yses"))
229
+ return `${w.slice(0, -4)}ysis`;
230
+ if (w.endsWith("uses"))
231
+ return w.slice(0, -2);
232
+ if (w.endsWith("es") && /(?:ss|x|ch|sh)es$/.test(w))
233
+ return w.slice(0, -2);
234
+ if (w.endsWith("s") && !/(?:ss|us|is)$/.test(w) && w.length > 3)
235
+ return w.slice(0, -1);
236
+ return w;
237
+ }
238
+ /** A word or a path segment as stems, split on hyphens and underscores the same way on both sides. */
239
+ function stems(text) {
240
+ return text
241
+ .toLowerCase()
242
+ .split(/[^a-z0-9]+/)
243
+ .filter(Boolean)
244
+ .map(stem);
245
+ }
246
+ /**
247
+ * The words naming what an empty-state sentence says is missing: "comment"
248
+ * for "No comments yet", "corrective action" for "No corrective actions
249
+ * recorded yet", "order" for "There are no orders". Empty for sentences that
250
+ * name nothing in particular ("No results for your search", "Nothing to show",
251
+ * "This list is empty"). Read from where the empty-state phrase starts, so a
252
+ * "no" earlier in the text ("No longer available. No results.") is not taken.
253
+ */
254
+ export function emptyStateSubject(text) {
255
+ const at = EMPTY_RE.exec(text)?.index ?? 0;
256
+ const m = /\bno\s+((?:[a-z][a-z-]*\s+){0,3}[a-z][a-z-]*)/i.exec(text.slice(at));
257
+ if (!m)
258
+ return [];
259
+ const words = [];
260
+ for (const word of m[1].split(/\s+/)) {
261
+ if (SUBJECT_STOP_WORDS.has(word.toLowerCase()))
262
+ break;
263
+ words.push(...stems(word));
264
+ }
265
+ return words.filter((w) => w.length > 2 && !GENERIC_EMPTY_WORDS.has(w));
266
+ }
267
+ /** Whether a request's path names one of these subject words. */
268
+ function pathNames(req, subject) {
269
+ let path;
270
+ try {
271
+ path = new URL(req.url).pathname;
272
+ }
273
+ catch {
274
+ path = req.url;
275
+ }
276
+ const segments = new Set(stems(path));
277
+ return subject.some((w) => segments.has(w));
278
+ }
279
+ /**
280
+ * Pairs one empty-state sentence with the refused reads it could be about.
281
+ * `loaded` are the page's reads that succeeded: positive evidence that a
282
+ * sentence naming what they fetched is a genuinely empty section rather than
283
+ * a refusal hidden behind an empty state.
284
+ */
285
+ export function pairEmptyState(text, refusedReads, loaded) {
286
+ const subject = emptyStateSubject(text);
287
+ if (subject.length === 0)
288
+ return { certain: true, read: refusedReads[0] };
289
+ const named = refusedReads.find((r) => pathNames(r, subject));
290
+ if (named)
291
+ return { certain: true, read: named };
292
+ if (loaded.some((r) => pathNames(r, subject)))
293
+ return { unrelated: true };
294
+ return { certain: false, read: refusedReads[0], subject };
295
+ }
148
296
  /** What counts as an open dialog: a native one or an ARIA one. */
149
297
  const DIALOG_SEL = 'dialog[open], [role="dialog"], [role="alertdialog"]';
150
298
  /** Page-side count of the open dialogs alone, for a caller that needs nothing else the claim scan reads. */
@@ -211,17 +359,40 @@ export function findContradictions(requests, page, before, acted) {
211
359
  return [];
212
360
  const out = [];
213
361
  const reads = refused.filter((r) => !WRITING_METHODS.has(r.method.toUpperCase()));
214
- const saysEmpty = claims.includes("empty") || page.emptyLists > 0;
215
- if (reads.length > 0 && saysEmpty) {
216
- const worst = reads[0];
217
- const how = claims.includes("empty") ? "an empty state" : `an empty list (${page.emptyLists})`;
218
- out.push({
219
- kind: "refused_empty",
220
- detail: `${say(worst)} was refused, and the page shows ${how} with no error. ` +
221
- `The user is told there is nothing to see when the truth is that nothing could be loaded.` +
222
- standIn(worst),
223
- evidence: `refused-empty ${say(worst)}`,
224
- });
362
+ if (reads.length > 0) {
363
+ // An empty-state sentence that names what is missing is evidence about
364
+ // requests for that thing (#413). "No comments yet" beside a refused
365
+ // history read, with the comments read loaded fine, is a genuinely empty
366
+ // comments section, not a refusal shown as an empty state.
367
+ // Positive evidence only: a data read the page made that succeeded. The
368
+ // page's own document, its images and scripts, and redirects say nothing
369
+ // about which section of it is empty.
370
+ const loaded = requests.filter((r) => (r.resourceType === "xhr" || r.resourceType === "fetch") &&
371
+ r.status !== null &&
372
+ r.status >= 200 &&
373
+ r.status < 300 &&
374
+ !WRITING_METHODS.has(r.method.toUpperCase()));
375
+ const pairings = page.texts.filter((_, i) => claims[i] === "empty").map((t) => ({ text: t, pairing: pairEmptyState(t, reads, loaded) }));
376
+ // A sentence naming what a refused read fetched points at that read; a generic one points at none in particular.
377
+ const certain = pairings.find((p) => "certain" in p.pairing && p.pairing.certain && emptyStateSubject(p.text).length > 0) ??
378
+ pairings.find((p) => "certain" in p.pairing && p.pairing.certain);
379
+ const possible = pairings.find((p) => "certain" in p.pairing && !p.pairing.certain);
380
+ const hit = certain ?? (page.emptyLists > 0 ? undefined : possible);
381
+ if (hit || page.emptyLists > 0) {
382
+ const read = hit && "read" in hit.pairing ? hit.pairing.read : reads[0];
383
+ const how = hit ? "an empty state" : `an empty list (${page.emptyLists})`;
384
+ const unsure = hit !== undefined && hit === possible;
385
+ out.push({
386
+ kind: "refused_empty",
387
+ detail: `${say(read)} was refused, and the page shows ${how} with no error. ` +
388
+ (unsure
389
+ ? `The empty state ("${hit.text}") names something no request on the page was for, so it may be about another section; the user may be told there is nothing to see when nothing could be loaded.`
390
+ : `The user is told there is nothing to see when the truth is that nothing could be loaded.`) +
391
+ standIn(read),
392
+ evidence: `refused-empty ${say(read)}`,
393
+ ...(unsure ? { severity: "medium" } : {}),
394
+ });
395
+ }
225
396
  }
226
397
  // Only writes this action sent, and not the page's own infrastructure.
227
398
  const isActionWrite = (r) => WRITING_METHODS.has(r.method.toUpperCase()) && !r.background && !INFRASTRUCTURE_WRITE_RE.test(r.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.19.1",
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",