scenescout 3.20.1 → 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,11 @@
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
+
3
9
  ## 3.20.1
4
10
 
5
11
  ### Patch Changes
package/dist/check-run.js CHANGED
@@ -512,6 +512,27 @@ export function clearReplayOutput(outDir) {
512
512
  if (fs.readdirSync(root).length === 0)
513
513
  fs.rmdirSync(root);
514
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
+ }
515
536
  /**
516
537
  * The replay page's model for a recorded check, with its frames copied from
517
538
  * the browsers' scratch folders to replay-frames/ beside the report: routes
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";
@@ -583,6 +583,10 @@ async function check(args) {
583
583
  try {
584
584
  // --record, else SCENESCOUT_RECORD, else off.
585
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);
586
590
  // A replay page an earlier run left must never be read, or uploaded, as this run's.
587
591
  clearReplayOutput(outDir);
588
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";
@@ -1689,30 +1690,11 @@ export class BrowserEngine {
1689
1690
  */
1690
1691
  async filming(videoTo, work) {
1691
1692
  const page = this.requirePage();
1692
- const why = (err) => (err instanceof Error ? err.message.split("\n")[0] : String(err));
1693
- let videoError;
1694
- let filming = false;
1695
- try {
1696
- await fs.promises.mkdir(path.dirname(videoTo), { recursive: true });
1697
- await page.screencast.start({ path: videoTo, ...(page.viewportSize() ? { size: page.viewportSize() } : {}) });
1698
- filming = true;
1699
- }
1700
- catch (err) {
1701
- videoError = `the video could not be started: ${why(err)}`;
1702
- }
1703
- let value;
1704
- try {
1705
- value = await work();
1706
- }
1707
- finally {
1708
- // The page the filming began on, even if the work moved the session to a popup.
1709
- if (filming)
1710
- await page.screencast.stop().catch((err) => (videoError = `the video could not be saved: ${why(err)}`));
1711
- }
1712
- if (videoError)
1713
- return { value, video: null, videoError };
1714
- const saved = await fs.promises.stat(videoTo).then((st) => st.size > 0, () => false);
1715
- return saved ? { value, video: videoTo } : { value, video: null, videoError: "no video was written" };
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
+ });
1716
1698
  }
1717
1699
  requirePage() {
1718
1700
  if (!this.page || !this.memory) {
@@ -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.1",
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",