scenescout 3.20.0 → 3.20.2

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.2
4
+
5
+ ### Patch Changes
6
+
7
+ - f8f7ad2: A recorded `scenescout check` (`--record` or `--video`) no longer overwrites a `replay.html` in the output folder that it did not write: it stops before it starts, with exit code 2 and a message naming the file. With `--video`, a video that fails to start no longer stops every later flow from being filmed.
8
+
9
+ ## 3.20.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 5d5240e: `scenescout check --video` now films each saved flow on the session's own page, so sessionStorage one flow writes is there for the next, and only the flows are filmed, not the crawl. The replay page marks and counts every step and visit left without a frame because its session reached the frame cap, has no scripts or inline event handlers, and carries a generator mark: a check removes only a `replay.html` that has it, so a file of that name the project keeps in the output folder stays.
14
+
3
15
  ## 3.20.0
4
16
 
5
17
  ### Minor Changes
package/dist/check-run.js CHANGED
@@ -19,7 +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
+ import { capFrames, isReplayFrameFile, journeyOf, journeyVideoPath, isGeneratedReplay, isJourneyVideoFile, redactReplay, REPLAY_VIDEOS_DIRNAME, replayVideos, REPLAY_FILE, REPLAY_FRAMES_DIRNAME, replayFramePath, replayFrames, replaySessionKey, visitOf, } from "./engine/check-replay.js";
23
23
  /** The baselines folder a check uses: the one --baselines names, else the project's own. */
24
24
  export function baselinesDirOf(options) {
25
25
  return options.baselinesDir ?? defaultBaselinesDir(options.projectDir);
@@ -106,8 +106,9 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
106
106
  const store = new MemoryStore(scratch);
107
107
  if (record)
108
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.
109
+ // --video: each flow is filmed on its session's page, from its first step to its hand-back, and saved beside the report.
110
110
  const video = options.video === true;
111
+ const frameCap = options.maxFrames;
111
112
  const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
112
113
  // The default folder is inside .scenescout/, which ignores itself; its .gitignore goes first, so no frame or video is ever unignored.
113
114
  if ((record || video) && !options.outDir) {
@@ -128,8 +129,7 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
128
129
  memoryStore: store,
129
130
  mode: options.mode,
130
131
  storageStatePath: options.storageStatePath,
131
- ...(record ? { record: true } : {}),
132
- ...(video ? { videoDir: path.join(scratch, "video") } : {}),
132
+ ...(record ? { record: true, ...(frameCap !== undefined ? { maxFrames: frameCap } : {}) } : {}),
133
133
  ...(options.browser ? { browser: options.browser } : {}),
134
134
  actionTimeoutMs: options.actionTimeoutMs,
135
135
  navTimeoutMs: options.navTimeoutMs,
@@ -206,8 +206,7 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
206
206
  memoryStore: new MemoryStore(roleDir),
207
207
  mode: options.mode,
208
208
  role,
209
- ...(record ? { record: true } : {}),
210
- ...(video ? { videoDir: path.join(roleDir, "video") } : {}),
209
+ ...(record ? { record: true, ...(frameCap !== undefined ? { maxFrames: frameCap } : {}) } : {}),
211
210
  ...(options.browser ? { browser: options.browser } : {}),
212
211
  actionTimeoutMs: options.actionTimeoutMs,
213
212
  navTimeoutMs: options.navTimeoutMs,
@@ -229,9 +228,9 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
229
228
  const walk = () => runner.replayFlow(flow.steps, options.flowWrites === "never" ? "observe" : options.mode);
230
229
  let walked;
231
230
  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.
231
+ // Filmed on the session's own page, so the flow runs exactly as it would unfilmed and the video holds it alone.
233
232
  const rel = journeyVideoPath(flowRuns.length + 1, flow.file);
234
- const own = await runner.onOwnPage(walk, path.join(outDir, ...rel.split("/")));
233
+ const own = await runner.filming(path.join(outDir, ...rel.split("/")), walk);
235
234
  walked = own.value;
236
235
  flowVideos.push(own.video ? rel : null);
237
236
  if (own.videoError)
@@ -275,6 +274,7 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
275
274
  .map((flow, i) => ({ flow, outcome: flowRuns[i].outcome, frames: flowFrames[i], video: flowVideos[i] ?? null })),
276
275
  scratch,
277
276
  say: log,
277
+ ...(frameCap !== undefined ? { frameCap } : {}),
278
278
  })
279
279
  : null;
280
280
  if (replay)
@@ -465,12 +465,24 @@ async function takeBaselines(engine, options, mode, targets, log) {
465
465
  * Remove what an earlier recorded check left beside the report: replay.html,
466
466
  * the frames under replay-frames/ and the journey videos under replay-videos/
467
467
  * that a check names as it names them (isReplayFrameFile, isJourneyVideoFile),
468
- * then any folder that leaves empty. Run before every
468
+ * then any folder that leaves empty. replay.html goes only when it carries
469
+ * the check's generator mark (isGeneratedReplay). Run before every
469
470
  * check, recorded or not, so a page from an earlier run is never read, or
470
471
  * uploaded, as this one's; whatever else the folder holds stays.
471
472
  */
472
473
  export function clearReplayOutput(outDir) {
473
- fs.rmSync(path.join(outDir, REPLAY_FILE), { force: true });
474
+ // Only a page a check wrote: a file of the same name the project keeps there is its own.
475
+ const page = path.join(outDir, REPLAY_FILE);
476
+ let text = null;
477
+ try {
478
+ text = fs.readFileSync(page, "utf8");
479
+ }
480
+ catch (err) {
481
+ if (err.code !== "ENOENT")
482
+ throw err;
483
+ }
484
+ if (text !== null && isGeneratedReplay(text))
485
+ fs.rmSync(page);
474
486
  const videos = path.join(outDir, REPLAY_VIDEOS_DIRNAME);
475
487
  if (fs.existsSync(videos)) {
476
488
  for (const file of fs.readdirSync(videos, { withFileTypes: true }))
@@ -500,6 +512,27 @@ export function clearReplayOutput(outDir) {
500
512
  if (fs.readdirSync(root).length === 0)
501
513
  fs.rmdirSync(root);
502
514
  }
515
+ /**
516
+ * Why a recorded check (--record or --video) must not start, or null: a
517
+ * replay.html in the output folder that a check did not write is the
518
+ * project's own, and is neither removed nor overwritten. Asked before the
519
+ * browser starts, so the run is not spent first.
520
+ */
521
+ export function replayPageConflict(outDir) {
522
+ const page = path.join(outDir, REPLAY_FILE);
523
+ let text;
524
+ try {
525
+ text = fs.readFileSync(page, "utf8");
526
+ }
527
+ catch (err) {
528
+ if (err.code === "ENOENT")
529
+ return null;
530
+ throw err;
531
+ }
532
+ if (isGeneratedReplay(text))
533
+ return null;
534
+ return `${page} is not a page SceneScout wrote, so a recorded check will not overwrite it. Move or rename it, or pass --out to write the check somewhere else`;
535
+ }
503
536
  /**
504
537
  * The replay page's model for a recorded check, with its frames copied from
505
538
  * the browsers' scratch folders to replay-frames/ beside the report: routes
@@ -510,25 +543,30 @@ function recordReplay(o) {
510
543
  const outDir = o.options.outDir ?? defaultCheckDir(o.options.projectDir);
511
544
  /** Where each frame the page names is copied from. */
512
545
  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;
546
+ /** A shot with its frame's path moved to where it is copied beside the page. */
547
+ const beside = (shot, memoryDir) => {
548
+ if (!shot?.frame)
549
+ return shot?.pastCap ? { pastCap: true } : {};
550
+ const to = replayFramePath(shot.frame);
551
+ if (!to)
552
+ return {};
553
+ sources.set(to, path.join(memoryDir, ...shot.frame.split("/")));
554
+ return { frame: to };
518
555
  };
519
- // The crawl's frames, by the path each visit asked for, in the order they were taken.
556
+ // What the crawl kept for each visit, by the path it asked for, in the order the visits ran.
520
557
  const own = replaySessionKey();
521
558
  const crawled = new Map();
522
559
  for (const e of o.log) {
523
- if (e.action !== "crawl" || !e.frame || e.target === undefined || (e.session ?? own) !== own)
560
+ if (e.action !== "crawl" || e.target === undefined || (e.session ?? own) !== own)
524
561
  continue;
525
- crawled.set(e.target, [...(crawled.get(e.target) ?? []), e.frame]);
562
+ const shot = e.frame ? { frame: e.frame } : e.framePastCap ? { pastCap: true } : {};
563
+ crawled.set(e.target, [...(crawled.get(e.target) ?? []), shot]);
526
564
  }
527
565
  const ownSession = {
528
566
  role: o.ownRole,
529
567
  own: true,
530
568
  // 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)),
569
+ visits: o.visited.map((route) => visitOf(route, beside(crawled.get(route.path)?.shift(), path.join(o.scratch, MEMORY_DIRNAME)))),
532
570
  journeys: [],
533
571
  };
534
572
  const roles = [ownSession];
@@ -544,7 +582,7 @@ function recordReplay(o) {
544
582
  }
545
583
  role.journeys.push(journey);
546
584
  }
547
- const replay = redactReplay(capFrames({ startedAt: o.startedAt, roles, framesLeftOut: 0 }));
585
+ const replay = redactReplay(capFrames({ startedAt: o.startedAt, roles, framesLeftOut: 0, ...(o.frameCap !== undefined ? { frameCap: o.frameCap } : {}) }));
548
586
  // A frame that cannot be copied is left off the page rather than shown broken.
549
587
  const copied = new Set();
550
588
  let failed = false;
package/dist/cli.js CHANGED
@@ -21,7 +21,7 @@ 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, clearReplayOutput, defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
24
+ import { baselinesDirOf, clearReplayOutput, replayPageConflict, defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
25
25
  import { buildCheckReplayHtml, commitOf, REPLAY_FILE, replayFrames, replayVideos } from "./engine/check-replay.js";
26
26
  import { recordChoice } from "./engine/capture.js";
27
27
  import { httpClient, httpJudgeAsk, runCi } from "./ci-run.js";
@@ -111,9 +111,8 @@ Usage:
111
111
  --record [on|off]: keep a frame after each route visit and each flow step
112
112
  and write replay.html beside the report, role → journey → step (default:
113
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))
114
+ --video [on|off]: record a WebM of each saved flow, and only of the flows,
115
+ into replay-videos/, played on replay.html beside its steps (default off))
117
116
  Exit code: 0 passed, 1 failed the gate, 2 could not run.
118
117
  scenescout ci <url> An exploratory run with no person present: a model reached through its API
119
118
  drives the tools by the SceneScout method and the run ends in the report.
@@ -584,6 +583,10 @@ async function check(args) {
584
583
  try {
585
584
  // --record, else SCENESCOUT_RECORD, else off.
586
585
  options = { ...options, record: recordChoice(options.record, process.env) };
586
+ // A replay.html the project keeps there is its own: a recorded check refuses to start rather than overwrite it.
587
+ const conflict = options.record || options.video ? replayPageConflict(outDir) : null;
588
+ if (conflict)
589
+ throw new Error(conflict);
587
590
  // A replay page an earlier run left must never be read, or uploaded, as this run's.
588
591
  clearReplayOutput(outDir);
589
592
  inputs = readCheckInputs(options);
@@ -16,6 +16,7 @@ import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js"
16
16
  import { formatJourney, journeyTime, measureJourney } from "./journey.js";
17
17
  import { describeStep, elementStateMatches, FLOW_AFTER_LAST_STEP_MS, isAction, matchRequest, notActionable, parseTarget, repeatFailure, splitRefusals, TARGET_HELP, urlMatches, } from "./flow.js";
18
18
  import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
19
+ import { film } from "./film.js";
19
20
  import { InFlightRequests, keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
20
21
  import { crawledRoute, crawlLine, isFileMediaType, isNonPageResource, mainStateFlag, mediaTypeOf } from "./crawl.js";
21
22
  import { authToRemember, BODY_FETCH_MAX, buildRequestScript, formatPageRequests, formatReplay, PageRequests, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, resolveTarget, staleCredentialNote, toReplayResult, wantsView, } from "./request.js";
@@ -460,6 +461,8 @@ export class BrowserEngine {
460
461
  * checked, a recording shows it.
461
462
  */
462
463
  recording = false;
464
+ /** Most frames this recorded session keeps (attach's maxFrames). */
465
+ maxFrames = RECORD_MAX_FRAMES;
463
466
  framesKept = 0;
464
467
  /** How many frames could not be written. The first one says so in the log; the rest are counted. */
465
468
  framesFailed = 0;
@@ -592,8 +595,13 @@ export class BrowserEngine {
592
595
  * page looked like: `...(await this.frameFor("crawl"))`.
593
596
  */
594
597
  async frameFor(action) {
598
+ const pastCap = this.framePastCap();
595
599
  const frame = await this.recordFrame(action);
596
- return frame ? { frame } : {};
600
+ return frame ? { frame } : pastCap ? { framePastCap: true } : {};
601
+ }
602
+ /** On a recorded session, whether the next step gets no frame because the session already keeps as many as it may. */
603
+ framePastCap() {
604
+ return this.recording && this.framesKept >= this.maxFrames;
597
605
  }
598
606
  /**
599
607
  * The frame for the step just taken, as a path relative to the memory
@@ -603,7 +611,7 @@ export class BrowserEngine {
603
611
  */
604
612
  async recordFrame(action) {
605
613
  const dir = this.memory?.dir;
606
- if (!this.recording || !dir || this.framesKept >= RECORD_MAX_FRAMES)
614
+ if (!this.recording || !dir || this.framesKept >= this.maxFrames)
607
615
  return undefined;
608
616
  const jpeg = await this.liveShot(RECORD_SHOT_TIMEOUT_MS);
609
617
  if (!jpeg)
@@ -630,8 +638,8 @@ export class BrowserEngine {
630
638
  return undefined;
631
639
  }
632
640
  this.framesKept += 1;
633
- if (this.framesKept === RECORD_MAX_FRAMES) {
634
- this.logAction({ action: "record:full", target: `${RECORD_MAX_FRAMES} frames kept; later steps have none`, url: this.page?.url() ?? "" });
641
+ if (this.framesKept === this.maxFrames) {
642
+ this.logAction({ action: "record:full", target: `${this.maxFrames} frames kept; later steps have none`, url: this.page?.url() ?? "" });
635
643
  }
636
644
  return rel;
637
645
  }
@@ -1104,6 +1112,7 @@ export class BrowserEngine {
1104
1112
  this.setTask(opts.task ?? "Attaching and taking stock", opts.task !== undefined);
1105
1113
  this.setPace(opts.paceMs);
1106
1114
  this.recording = opts.record === true;
1115
+ this.maxFrames = opts.maxFrames ?? RECORD_MAX_FRAMES;
1107
1116
  // A re-attached engine starts a new recording: numbering from where the
1108
1117
  // last one stopped would run into the cap with frames it never took.
1109
1118
  this.framesKept = 0;
@@ -1184,7 +1193,6 @@ export class BrowserEngine {
1184
1193
  viewport: opts.viewport ?? { width: 1280, height: 900 },
1185
1194
  ...(opts.deviceScaleFactor !== undefined ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
1186
1195
  serviceWorkers: serviceWorkerPolicy(this.engineName),
1187
- ...(opts.videoDir ? { recordVideo: { dir: opts.videoDir } } : {}),
1188
1196
  });
1189
1197
  const restoreSession = sessionStorageInitScript(profile?.sessionStorage ?? []);
1190
1198
  if (restoreSession)
@@ -1510,9 +1518,6 @@ export class BrowserEngine {
1510
1518
  // oracles attached); close foreign-origin popups so exploration cannot
1511
1519
  // silently escape the app under test.
1512
1520
  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;
1516
1521
  newPage
1517
1522
  .waitForLoadState("domcontentloaded", { timeout: this.limits.backNavMs })
1518
1523
  .then(() => {
@@ -1675,65 +1680,21 @@ export class BrowserEngine {
1675
1680
  nativeDialogAt = 0;
1676
1681
  /** When a native dialog that asks something (confirm, prompt, a leave confirmation) last opened; an alert only tells. */
1677
1682
  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
1683
  /**
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).
1684
+ * Run `work` while the session's page is filmed into `videoTo` (a WebM,
1685
+ * Playwright's page screencast), so the video holds that work and nothing
1686
+ * the session did before or after it. The page, its tab and so its
1687
+ * sessionStorage are the session's own: the work runs exactly as it would
1688
+ * unfilmed. The video is null when it could not be started or saved
1689
+ * (`videoError` says why); the work's own result and errors are untouched.
1709
1690
  */
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
- }
1691
+ async filming(videoTo, work) {
1692
+ const page = this.requirePage();
1693
+ // The page the filming began on is the one stopped, even if the work moved the session to a popup.
1694
+ return film(page.screencast, videoTo, page.viewportSize(), work, {
1695
+ prepare: (file) => fs.promises.mkdir(path.dirname(file), { recursive: true }).then(() => undefined),
1696
+ written: (file) => fs.promises.stat(file).then((st) => st.size > 0, () => false),
1697
+ });
1737
1698
  }
1738
1699
  requirePage() {
1739
1700
  if (!this.page || !this.memory) {
@@ -4469,7 +4430,8 @@ export class BrowserEngine {
4469
4430
  async inspectRoute(page, elements, url) {
4470
4431
  const { geometry, brokenImages } = await this.measureLayout(page, elements, url);
4471
4432
  try {
4472
- const { defects, sampled } = await this.auditPage();
4433
+ // The crawl has just kept this page's frame; a second of the same page would only use up the session's cap.
4434
+ const { defects, sampled } = await this.auditPage({ frame: false });
4473
4435
  // Nothing styled to read is not a clean page: contrast, focus and target size went unmeasured.
4474
4436
  return sampled > 0
4475
4437
  ? { geometry, brokenImages, design: defects }
@@ -5342,8 +5304,11 @@ export class BrowserEngine {
5342
5304
  const did = describeStep(step);
5343
5305
  const { failure, refusal } = step.action === "repeat" ? await runRepeat(step) : await runStep(step);
5344
5306
  // 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);
5307
+ if (this.recording) {
5308
+ const pastCap = this.framePastCap();
5309
+ const frame = await this.recordFrame(`flow-${step.action}`);
5310
+ frames.push(frame ? { frame } : pastCap ? { pastCap: true } : {});
5311
+ }
5347
5312
  if (refusal)
5348
5313
  return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
5349
5314
  if (failure)
@@ -5402,7 +5367,7 @@ export class BrowserEngine {
5402
5367
  return `URL: ${url}\n` + report;
5403
5368
  }
5404
5369
  /** The design audit's measurements, as data as well as prose; scout_design_audit and a check's crawl share it. */
5405
- async auditPage() {
5370
+ async auditPage(opts = {}) {
5406
5371
  const page = this.requirePage();
5407
5372
  await this.settle();
5408
5373
  const payload = (await page.evaluate(DESIGN_COLLECT_SCRIPT));
@@ -5424,7 +5389,12 @@ export class BrowserEngine {
5424
5389
  this.memory?.setPageScore(route, { ...score, at: new Date().toISOString(), url: page.url() });
5425
5390
  this.memory?.markRouteFact(route, { audited: true });
5426
5391
  }
5427
- this.logAction({ action: "design-audit", url: page.url(), result: score ? `score:${score.overall}` : undefined, ...(await this.frameFor("design-audit")) });
5392
+ this.logAction({
5393
+ action: "design-audit",
5394
+ url: page.url(),
5395
+ result: score ? `score:${score.overall}` : undefined,
5396
+ ...(opts.frame === false ? {} : await this.frameFor("design-audit")),
5397
+ });
5428
5398
  return { url: page.url(), report, defects, sampled: payload.records.length };
5429
5399
  }
5430
5400
  /**
@@ -17,22 +17,34 @@ import { describeStep } from "./flow.js";
17
17
  import { escapeHtml, plainSegment, RECORD_MAX_FRAMES } from "./replay.js";
18
18
  /** The file the page is written to, beside report.md. */
19
19
  export const REPLAY_FILE = "replay.html";
20
+ /** The page's generator mark: an earlier run's page is removed only when it carries it, never a file of the same name. */
21
+ export const REPLAY_GENERATOR = "scenescout-check-replay";
22
+ /** Whether a replay.html is one a check wrote. */
23
+ export function isGeneratedReplay(html) {
24
+ return html.includes(`<meta name="generator" content="${REPLAY_GENERATOR}">`);
25
+ }
20
26
  /** The folder beside it that holds the frames the page shows. */
21
27
  export const REPLAY_FRAMES_DIRNAME = "replay-frames";
22
28
  /** The folder beside it that holds one video per journey (--video). */
23
29
  export const REPLAY_VIDEOS_DIRNAME = "replay-videos";
30
+ /** A shot's fields as an item carries them. */
31
+ function shotFields(shot) {
32
+ if (shot?.frame)
33
+ return { frame: shot.frame };
34
+ return shot?.pastCap ? { pastCap: true } : {};
35
+ }
24
36
  /**
25
37
  * Each step of a replayed flow with its caption, its result and its frame.
26
38
  * The replay stops at the first step that breaks, so every step before it
27
39
  * 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.
40
+ * frame). `frames` holds one entry per executed step: its frame, or why it has none.
29
41
  */
30
42
  export function journeySteps(steps, outcome, frames = []) {
31
43
  const broke = outcome.status === "passed" ? null : outcome.step;
32
44
  return steps.map((step, i) => {
33
45
  const n = i + 1;
34
- const frame = broke === null || n <= broke ? (frames[i] ?? undefined) : undefined;
35
- const base = { n, caption: describeStep(step), ...(frame ? { frame } : {}) };
46
+ const shot = broke === null || n <= broke ? shotFields(frames[i]) : {};
47
+ const base = { n, caption: describeStep(step), ...shot };
36
48
  if (broke === null || n < broke)
37
49
  return { ...base, result: "passed" };
38
50
  if (n > broke)
@@ -53,26 +65,30 @@ export function journeyOf(flow, outcome, frames) {
53
65
  };
54
66
  }
55
67
  /** One visited route, from what the crawl measured. */
56
- export function visitOf(route, frame) {
68
+ export function visitOf(route, shot) {
57
69
  const result = route.loadError !== undefined ? "not-loaded" : route.loginRedirect ? "sign-in" : route.status !== null && route.status >= 400 ? "http-error" : "loaded";
58
70
  return {
59
71
  path: route.path,
60
72
  status: route.status,
61
73
  result,
62
74
  ...(route.loadError !== undefined ? { reason: route.loadError } : {}),
63
- ...(frame ? { frame } : {}),
75
+ ...shotFields(shot),
64
76
  };
65
77
  }
66
78
  /**
67
79
  * At most `max` frames per role, the first ones kept: the same cap a recorded
68
80
  * 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.
81
+ * more than the run was allowed to take, whatever it is handed. A step
82
+ * dropped here is marked past the cap like one the session did not take, and
83
+ * `framesLeftOut` counts both, so the page says why each has no frame.
70
84
  */
71
- export function capFrames(replay, max = RECORD_MAX_FRAMES) {
72
- let leftOut = replay.framesLeftOut;
85
+ export function capFrames(replay, max = replay.frameCap ?? RECORD_MAX_FRAMES) {
86
+ let leftOut = 0;
73
87
  const roles = replay.roles.map((role) => {
74
88
  let kept = 0;
75
89
  const take = (item) => {
90
+ if (item.pastCap)
91
+ leftOut += 1;
76
92
  if (!item.frame)
77
93
  return item;
78
94
  if (kept < max) {
@@ -81,7 +97,7 @@ export function capFrames(replay, max = RECORD_MAX_FRAMES) {
81
97
  }
82
98
  leftOut += 1;
83
99
  const { frame: _dropped, ...rest } = item;
84
- return rest;
100
+ return { ...rest, pastCap: true };
85
101
  };
86
102
  return {
87
103
  ...role,
@@ -89,7 +105,7 @@ export function capFrames(replay, max = RECORD_MAX_FRAMES) {
89
105
  journeys: role.journeys.map((j) => ({ ...j, steps: j.steps.map(take) })),
90
106
  };
91
107
  });
92
- return { ...replay, roles, framesLeftOut: leftOut };
108
+ return { ...replay, roles, framesLeftOut: leftOut, frameCap: max };
93
109
  }
94
110
  /** The report's redaction (memory.ts redactRoute) on every path, caption and reason the page shows. */
95
111
  export function redactReplay(replay) {
@@ -167,24 +183,31 @@ const VISIT_WORD = {
167
183
  function stamp(iso) {
168
184
  return iso.length >= 19 ? `${iso.slice(0, 10)} ${iso.slice(11, 19)} UTC` : iso;
169
185
  }
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>`);
186
+ /**
187
+ * A frame, or a line saying why there is none. No script: the note sits in the
188
+ * same grid cell under the picture, so a picture that loads covers it and one
189
+ * that is missing (a copy sent without replay-frames/) leaves it showing.
190
+ */
191
+ function frameHtml(item, alt, cap) {
192
+ if (!item.frame)
193
+ return item.pastCap
194
+ ? `<p class="noframe">No frame: the session had already kept ${cap}, the most one session keeps.</p>`
195
+ : `<p class="noframe">No frame</p>`;
196
+ const src = escapeHtml(item.frame);
197
+ return (`<a class="frame" href="${src}" target="_blank" rel="noreferrer" data-testid="replay-frame-open">` +
198
+ `<span class="gone-note">If no picture shows here, this frame is not beside this file. Frames live in the <code>${REPLAY_FRAMES_DIRNAME}/</code> folder, which travels with it.</span>` +
199
+ `<img loading="lazy" src="${src}" alt="${escapeHtml(alt)}"></a>`);
177
200
  }
178
- function stepHtml(s, anchor) {
201
+ function stepHtml(s, anchor, cap) {
179
202
  const first = s.result === "failed" || s.result === "refused";
180
203
  const detail = [s.reason, s.path ? `on ${s.path}` : ""].filter(Boolean).join(" ");
181
204
  return (`<li class="step ${s.result}${first ? " first-failing" : ""}"${first ? ` id="${anchor}"` : ""} data-result="${s.result}">` +
182
205
  `<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
206
  (detail ? `<p class="why">${escapeHtml(detail)}</p>` : "") +
184
- (s.result === "not-run" ? "" : frameHtml(s.frame, `The page after step ${s.n}: ${s.caption}`)) +
207
+ (s.result === "not-run" ? "" : frameHtml(s, `The page after step ${s.n}: ${s.caption}`, cap)) +
185
208
  `</li>`);
186
209
  }
187
- function journeyHtml(j, id) {
210
+ function journeyHtml(j, id, cap) {
188
211
  const ok = j.status === "passed";
189
212
  const anchor = `${id}-first-failing`;
190
213
  const badge = ok ? `<span class="badge pass">passed</span>` : `<span class="badge fail">${j.status}</span>`;
@@ -196,25 +219,27 @@ function journeyHtml(j, id) {
196
219
  ? `<figure class="video"><video controls preload="metadata" src="${escapeHtml(j.video)}" data-testid="replay-journey-video"></video>` +
197
220
  `<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
221
  : "") +
199
- `<ol class="steps">${j.steps.map((s) => stepHtml(s, anchor)).join("")}</ol></details>`);
222
+ `<ol class="steps">${j.steps.map((s) => stepHtml(s, anchor, cap)).join("")}</ol></details>`);
200
223
  }
201
- function visitsHtml(visits) {
224
+ function visitsHtml(visits, cap) {
202
225
  if (visits.length === 0)
203
226
  return "";
204
227
  const items = visits
205
228
  .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
229
  (v.reason ? `<p class="why">${escapeHtml(v.reason)}</p>` : "") +
207
- frameHtml(v.frame, `The page at ${v.path}`) +
230
+ frameHtml(v, `The page at ${v.path}`, cap) +
208
231
  `</li>`)
209
232
  .join("");
210
233
  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
234
  }
212
- function roleHtml(r, i) {
235
+ function roleHtml(r, i, cap) {
213
236
  const failing = r.journeys.filter((j) => j.status !== "passed").length;
214
237
  const tally = `${r.journeys.length} journey${r.journeys.length === 1 ? "" : "s"}${failing ? `, ${failing} not passed` : ""}`;
215
238
  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>`) +
239
+ visitsHtml(r.visits, cap) +
240
+ (r.journeys.length > 0
241
+ ? r.journeys.map((j, k) => journeyHtml(j, `role-${i}-journey-${k}`, cap)).join("")
242
+ : `<p class="none">No journey ran as this role.</p>`) +
218
243
  `</section>`);
219
244
  }
220
245
  const STYLE = `
@@ -248,17 +273,17 @@ ol.steps { list-style:none; margin:10px 0 0; padding:0; }
248
273
  .result { margin-left:auto; font-size:12px; font-weight:700; }
249
274
  .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
275
  .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; }
276
+ a.frame { display:grid; margin:6px 0 2px; max-width:min(100%,720px); color:var(--muted); font-size:12px; }
277
+ a.frame > * { grid-area:1 / 1; }
278
+ a.frame img { position:relative; max-width:100%; max-height:400px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; background:var(--panel); }
279
+ .gone-note { align-self:end; padding:2.6em 12px 12px; border:1px dashed var(--line); border-radius:6px; }
255
280
  .noframe, .none { color:var(--muted); font-size:12px; font-style:italic; margin:4px 0; }
256
281
  .note { color:var(--muted); font-size:13px; }
257
282
  figure.video { margin:10px 0; max-width:min(100%,720px); }
258
283
  figure.video video { width:100%; border:1px solid var(--line); border-radius:6px; display:block; background:#000; }
259
284
  figure.video figcaption { color:var(--muted); font-size:12px; margin-top:4px; }
260
285
  `;
261
- /** The whole page: one HTML file with no external assets or scripts; its frames sit beside it in replay-frames/. */
286
+ /** The whole page: one HTML file with no scripts, no event handlers and no external assets; its frames and videos sit beside it. */
262
287
  export function buildCheckReplayHtml(replay, meta) {
263
288
  const verdict = meta.couldNotRun > 0 ? "could not run every flow" : meta.passed ? "passed" : "failed";
264
289
  const badge = `<span class="badge ${meta.passed && meta.couldNotRun === 0 ? "pass" : "fail"}" data-testid="replay-verdict">${verdict}</span>`;
@@ -269,6 +294,7 @@ export function buildCheckReplayHtml(replay, meta) {
269
294
  <head>
270
295
  <meta charset="utf-8">
271
296
  <meta name="viewport" content="width=device-width, initial-scale=1">
297
+ <meta name="generator" content="${REPLAY_GENERATOR}">
272
298
  <title>SceneScout check replay — ${escapeHtml(meta.origin)}</title>
273
299
  <style>${STYLE}</style>
274
300
  </head>
@@ -278,8 +304,10 @@ export function buildCheckReplayHtml(replay, meta) {
278
304
  <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
305
  </header>
280
306
  <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>'}
307
+ <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
308
+ ? ` ${replay.framesLeftOut} step(s) and visit(s) have no frame because their session had already kept ${replay.frameCap ?? RECORD_MAX_FRAMES}, the most one session keeps.`
309
+ : ""}</p>
310
+ ${replay.roles.map((r, i) => roleHtml(r, i, replay.frameCap ?? RECORD_MAX_FRAMES)).join("\n") || '<p class="none">Nothing was recorded.</p>'}
283
311
  </main>
284
312
  </body>
285
313
  </html>
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Filming one piece of work into a video file (`scenescout check --video`):
3
+ * start, run the work, stop, and say whether a video was saved. The browser
4
+ * hands in its page's screencast; nothing here needs Playwright, so the
5
+ * failure paths are table-tested.
6
+ */
7
+ const why = (err) => (err instanceof Error ? err.message.split("\n")[0] : String(err));
8
+ /**
9
+ * Run `work` while `screencast` films it into `videoTo`. The work's own result
10
+ * and errors are never touched by the filming: a video that cannot be started
11
+ * or saved is reported in `videoError`, and the work runs regardless.
12
+ *
13
+ * A start that fails is followed by a stop, its error ignored: a screencast
14
+ * left half-started would refuse every later start ("already started"), so
15
+ * one failure would cost the video of every flow after it.
16
+ */
17
+ export async function film(screencast, videoTo, size, work, io) {
18
+ let videoError;
19
+ let filming = false;
20
+ try {
21
+ await io.prepare(videoTo);
22
+ await screencast.start({ path: videoTo, ...(size ? { size } : {}) });
23
+ filming = true;
24
+ }
25
+ catch (err) {
26
+ videoError = `the video could not be started: ${why(err)}`;
27
+ // Whatever the failed start left running is stopped, so the next flow can start afresh; there is nothing to save.
28
+ await screencast.stop().catch(() => undefined);
29
+ }
30
+ let value;
31
+ try {
32
+ value = await work();
33
+ }
34
+ finally {
35
+ if (filming)
36
+ await screencast.stop().catch((err) => (videoError = `the video could not be saved: ${why(err)}`));
37
+ }
38
+ if (videoError)
39
+ return { value, video: null, videoError };
40
+ return (await io.written(videoTo)) ? { value, video: videoTo } : { value, video: null, videoError: "no video was written" };
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.20.0",
3
+ "version": "3.20.2",
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",