scenescout 3.9.0 → 3.11.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,30 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f61141e: `scenescout check` replays saved flows and re-tests open findings. Flows saved as `.scenescout/flows/*.json` use `scout_run_plan`'s steps (navigate, click, type, select, press) plus `expect-text`, `expect-url` and `expect-request`; each is replayed after the crawl with no model, and a flow whose step breaks fails the gate with a high `flow-step-failed` issue naming the flow and the step. A flow file that is not valid stops the check with exit 2, naming the file and the field. Open findings in the project's memory that a page load can reproduce (a failed GET, nothing done on the page but looking) are re-tested and reported as still reproducing or possibly fixed; the memory is never written. New options, also inputs of the GitHub Action: `--flows <dir|off>`, `--retest on|off`, and three settings for what a check may do, whose effective values are printed under the verdict and in `check.json`: `--flow-writes never|allow` (default `never`: flows replay under observe's rule whatever `--mode` says; `allow` replays them under `--mode`, so their form submissions are sent), `--on-refused-step report|stop` (default `report`: a flow whose step was refused is marked "could not run" in the report, JSON and SARIF, every other verdict is kept, and the check exits 2; `stop` exits 2 at once with no results) and `--gate-retests never|high|all` (default `high`: a re-tested finding filed high that still reproduces fails the gate). The defaults are what an unconfigured check does, for a first try or an agent running it unattended: its flows send no HTTP write and it never silently hides a result. A refused beacon or ping (`navigator.sendBeacon`, `<a ping>`) is listed in the flow's result as a background request and charged to no step, whatever its origin, while every other refused write (fetch, XHR, form post) is charged to its step wherever it goes; a write the last step sets off within 750 ms is still charged to it; the WebSocket connections a flow's page opened are listed; a `role=` target must name an ARIA role; anything else in the flows directory is listed as skipped with its reason. Pages loaded only to re-test a finding are measured and nothing else: they are not checked routes, do not count towards `--max-routes`, and add nothing to the route list. The GitHub Action also outputs `could-not-run` and `retests-failing`, and its annotation says when re-tested findings are among what failed the gate. `scout_run_plan` targets accept `role=<role>[name="…"]`, and the `.gitignore` SceneScout writes in `.scenescout/` no longer ignores `flows/*.json`, so flows can be committed.
8
+
9
+ ### Patch Changes
10
+
11
+ - a1f8011: The benchmark has a second, held-out app (`npm run holdout:serve`), a library loans desk with its own planted defects and answer key, which nothing in the engine or the skill is tuned against. `npm run bench` takes `--app demo|holdout` to choose the key a run is scored with (the demo stays the default), records the app in every new archive, refuses to score an archive against another app's key, and `--all` scores each archive against its own app's key, one table per app.
12
+
13
+ ## 3.10.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 8901a03: `scenescout check` is now a GitHub Action: `uses: brunoboto96/SceneScout@v3.10.0` (or the release you are on) with a `url` installs SceneScout and the browser (cached between runs), runs the check, puts the report on the job summary, keeps `report.md`, `check.json` and `check.sarif` as an artifact, and can upload the SARIF to code scanning (`upload-sarif: true`, which needs `security-events: write`). Its inputs are the CLI's options by name, its outputs are the verdict and the counts by severity, and the step fails with the CLI's exit code: 1 when the gate fails, 2 with a "could not run" annotation when there is no verdict. `docs/ci.md` has a complete workflow and the same check on GitLab CI, CircleCI and plain shell. Each release also points the major tag (`v3`) at itself where the repository settings allow it.
18
+ - 9b98a50: New `scenescout check <url>`: a deterministic sweep that needs no model, so it can gate a pull request. It visits the start page, the project's scanned routes and every same-origin link it finds, and measures each one: HTTP and page errors, layout geometry (covered, clipped and overlapping controls, blocking overlays), broken images, controls with no name, contrast, focus indicators and pages with no way out. It writes `report.md`, `check.sarif` and `check.json`, adds the report to the GitHub Actions job summary, and exits 0 on a pass, 1 when the gate fails (`--fail-on high` by default) and 2 when it could not run. It never writes to the app.
19
+ - ccf5802: `scout_coverage` lists the forms seen this run that no session has submitted empty: a `<form>` with a native submit control (a button linked by `form="id"` included) and at least one text field, until a click on that submit control, or Enter in one of its text inputs, goes while every text field is blank. A form is known by its `id`, else its `name`, else its `action` and method, and only when it has none of those by its submit control. Forms with no text field (only checkboxes, radios or selects), search boxes, forms whose submit is disabled while they are blank, and fields with no `<form>` around them are not listed. The gap ledger is unchanged: like the dropdown options, this is a coverage prompt, not a gate on `extensive`.
20
+
21
+ ### Patch Changes
22
+
23
+ - 4f883c7: The console line Chromium and WebKit print for a failed load ("Failed to load resource: …") now goes where its request went: when another site's frame sent the request outside the app, the line is attributed to that embed at medium severity, instead of being filed as a high-severity console error of the app. The same line for a request the app sent stays the app's, other console and page errors are still not attributed by frame, and Firefox prints no such line.
24
+ - 8b18c82: `scout_close` no longer closes a lane of a parallel run whose report has not been accepted yet, since folding the report needs the lane's session attached. A session counts as a lane once `scout_lane_brief` or `scout_lane_report` has named it (a brief lane sharing the name of a session already live does not count), until the run's last session closes; a refused report does not count as folded. The refusal says to fold the lane with `scout_lane_report`, and `scout_close {all: true}` names every such lane, offers the other sessions to close by name, and closes nothing. Pass `force: true` to close anyway, which loses that lane's decisions. A session no lane tool has named closes as before.
25
+ - 42f6a55: `scout_lane_report` decodes the HTML character references a relay adds when it escapes a lane's reply (`&lt;`, `&gt;`, `&amp;`, `&quot;`, `&#39;`, `&apos;` and numeric ones), once, in every string of the report, so evidence such as `-&gt;` matches the finding filed as `->` again. A reply that holds a literal `<` or `>` was not escaped on the way and is left as it is, so a lane reporting a page that double-escapes its text keeps its evidence. Length caps apply to the decoded text, so a route that only went over its cap by being escaped is no longer refused, and the fold says how many references it decoded.
26
+ - d487207: A text field whose only label is its placeholder is no longer treated as labelled. The snapshot still shows the placeholder as the field's name and now flags it `no label: placeholder only`, the crawl counts it as unnamed, and `scenescout check` files it under a new medium rule, `placeholder-only-label`. A field named by nothing but its `name` attribute or type is flagged `no label` and filed as `unnamed-control`. A `<label>` (by `for` or wrapping the field), `aria-label`, `aria-labelledby` or `title` counts as a label. Element keys in project memory are unchanged.
27
+
3
28
  ## 3.9.0
4
29
 
5
30
  ### Minor Changes
package/README.md CHANGED
@@ -344,6 +344,60 @@ A `🛡 WRITE-POLICY blocked` notice is the safety net doing its job, not an app
344
344
 
345
345
  ---
346
346
 
347
+ ## 🚦 In CI: a deterministic check
348
+
349
+ An exploratory run is driven by a model, so two runs never find exactly the same things. That suits a report, but not a gate. `scenescout check` is the part that needs no model. It visits the start URL, the project's scanned routes and every same-origin link it finds, and measures each page:
350
+
351
+ - HTTP and page errors
352
+ - layout geometry (covered, clipped and overlapping controls, blocking overlays)
353
+ - broken images
354
+ - controls with no name, and fields whose only label is a placeholder
355
+ - contrast and focus
356
+ - pages with no way out
357
+
358
+ ```bash
359
+ npx scenescout check http://127.0.0.1:3000 --fail-on high
360
+ ```
361
+
362
+ | Exit code | Meaning |
363
+ |---|---|
364
+ | 0 | Passed the gate |
365
+ | 1 | Failed it: something at the `--fail-on` severity or worse |
366
+ | 2 | Could not run, or not all of it: a bad argument, an app that never answered, only the sign-in page reached, a saved flow that is not valid, or a flow step the write policy refused |
367
+
368
+ It writes `report.md`, `check.sarif` (for code-scanning dashboards) and `check.json` to `.scenescout/check/`, and on GitHub Actions it also puts the report on the job's summary page.
369
+
370
+ On GitHub Actions, this repository is also an action that installs everything and keeps the results:
371
+
372
+ ```yaml
373
+ - uses: brunoboto96/SceneScout@v3.10.0
374
+ with:
375
+ url: http://127.0.0.1:3000
376
+ ```
377
+
378
+ [docs/ci.md](docs/ci.md) has a complete workflow (start the app, wait for it, check it), the action's inputs and outputs, code-scanning upload, and the same check on GitLab CI, CircleCI or any shell.
379
+
380
+ With the default settings its saved flows send no HTTP write (they replay under observe's rule), and its crawl runs under `--mode observe` or `read-only`; `--flow-writes allow` lets flows write as `--mode` allows. By default it fails only on facts that mean a page is broken: a page that did not load, an uncaught exception, a 5xx, a failure shown as success. Other options:
381
+
382
+ - `--fail-on medium` or `low` makes the gate stricter.
383
+ - `--ignore <rule>` drops a rule.
384
+ - `--paths /a,/b` checks only those pages.
385
+ - `--storage-state <file>` checks while signed in.
386
+ - `--flows <dir>` or `off` chooses which saved flows to replay; `--retest off` skips re-testing open findings.
387
+ - `--flow-writes never|allow` (default `never`): `never` replays flows under observe's rule whatever `--mode` says; `allow` replays them under `--mode`, so in `read-only` a flow's form submissions are sent to the target on every run.
388
+ - `--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.
389
+ - `--gate-retests never|high|all` (default `high`): which still-reproducing re-tested findings fail the gate.
390
+
391
+ 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.
392
+
393
+ `scenescout check --help` lists every option. Why the defaults are what they are: [ADR 11](docs/adr/0011-a-gate-is-deterministic-and-fails-only-on-what-it-can-prove.md).
394
+
395
+ It also replays the flows saved in `.scenescout/flows/*.json`, with no model: the steps `scout_run_plan` takes (navigate, click, type, select, press) plus `expect-text`, `expect-url` and `expect-request`. A flow whose step breaks fails the gate, naming the flow and the step. And it re-tests the open findings earlier runs left in the project's memory that a page load can reproduce, reporting each as still reproducing or possibly fixed; by default a finding filed high that still reproduces fails the gate. [docs/ci.md](docs/ci.md#saved-flows) has the flow format; [ADR 12](docs/adr/0012-a-check-replays-saved-flows-and-reports-re-tests.md) says why it works this way.
396
+
397
+ Beyond those flows it explores nothing and fills no forms. That is the exploratory run's job, and its findings belong in a report, not a gate.
398
+
399
+ ---
400
+
347
401
  ## 🩺 Troubleshooting
348
402
 
349
403
  Run `npx -y scenescout doctor` first — it checks every setup item below (everything but the last row, which is about your app) and prints the fix.
@@ -549,7 +603,8 @@ npx -y scenescout watch <path> # the same, live in your browser, with each
549
603
  src/
550
604
  mcp-server.ts the 29 tools + per-session dispatch
551
605
  scan.ts project discovery (framework, routes, auth)
552
- cli.ts scan · serve · install · doctor · status
606
+ cli.ts scan · serve · install · doctor · check · status
607
+ check-run.ts drives a check: attach, crawl every route, collect what was measured
553
608
  installer.ts setup logic (skill link, MCP registration, diagnostics)
554
609
  engine/
555
610
  browser.ts the engine class: attach, snapshot, actions, crawl, plans
@@ -571,9 +626,10 @@ src/
571
626
  design.ts the design audit + page scoring
572
627
  memory.ts cross-run storage + finding dedup
573
628
  report.ts the gap ledger + report generation
629
+ check.ts the check's rules, gate, report and SARIF
574
630
  replay.ts the run as one page: steps, tasks, frames under each finding
575
631
  … collector · dispatch · fixtures · authloss · reaper
576
- scripts/ the 23 test suites (smoke/ holds the real-browser ones)
632
+ scripts/ the 24 test suites (smoke/ holds the real-browser ones)
577
633
  test-app/ fixtures for the real-browser smoke tests
578
634
  skills/scenescout/ the testing method (SKILL.md): a skill in Claude Code, served by the server everywhere else
579
635
  docs/how-it-works.md what happens at each stage, in diagrams
@@ -603,6 +659,8 @@ The load-bearing choices are recorded as ADRs — read the relevant one before c
603
659
  - [8 · Recording is opt-in, and a recorded run is one self-contained page](docs/adr/0008-a-recorded-run-is-evidence-and-must-be-asked-for.md)
604
660
  - [9 · A refused write is answered, not dropped](docs/adr/0009-a-refused-write-is-answered-not-dropped.md)
605
661
  - [10 · A lane's confidence is checked, not trusted](docs/adr/0010-a-confidence-is-checked-not-trusted.md)
662
+ - [11 · A gate is deterministic, and fails only on what it can prove](docs/adr/0011-a-gate-is-deterministic-and-fails-only-on-what-it-can-prove.md)
663
+ - [12 · A check replays saved flows and re-tests open findings, within settings whose defaults do the least harm](docs/adr/0012-a-check-replays-saved-flows-and-reports-re-tests.md)
606
664
 
607
665
  ---
608
666
 
@@ -614,7 +672,7 @@ Working on SceneScout itself is the only reason to clone it:
614
672
  git clone https://github.com/brunoboto96/SceneScout.git scenescout && cd scenescout
615
673
  npm install # installs dependencies and builds
616
674
  npm run setup # same as `scenescout install`, but registers THIS checkout (the skill is linked, so edits are live)
617
- npm test # build + 23 suites: 21 pure-logic suites (scan, oracle, policy, … bench, hygiene),
675
+ npm test # build + 24 suites: 22 pure-logic suites (scan, oracle, policy, … bench, hygiene),
618
676
  # then smoke and mcp-check (the server over stdio), both with real browsers
619
677
  npm run bench -- --all # re-score every archived benchmark run against the current answer key
620
678
  npm run demo # regenerate examples/ from the demo app
package/dist/browsers.js CHANGED
@@ -160,6 +160,38 @@ export function screencastSupport(engine) {
160
160
  export function focusAdvanceKey(engine, platform) {
161
161
  return engine === "webkit" && platform === "darwin" ? "Alt+Tab" : "Tab";
162
162
  }
163
+ /**
164
+ * Whether the browser prints a console error of its own for a subresource that
165
+ * failed: "Failed to load resource: the server responded with a status of
166
+ * 500 …" or "Failed to load resource: net::ERR_…". Chromium and WebKit do, and
167
+ * give the resource's address as the message's location; Firefox prints
168
+ * nothing. Where it is printed, the console oracle charges the line to
169
+ * whoever sent the request (oracles.ts `failedLoadEchoOf`), so one failing
170
+ * request inside an embed is not also filed as the app's console error.
171
+ */
172
+ export function echoesFailedLoads(engine) {
173
+ return engine !== "firefox";
174
+ }
175
+ /**
176
+ * The resource type a navigator.sendBeacon request carries when it is
177
+ * intercepted: Chromium calls it "ping", Firefox and WebKit "beacon". A saved
178
+ * flow excuses a refused beacon from its step (flow.ts `splitRefusals`), so
179
+ * every engine's name for it must be known.
180
+ */
181
+ export function beaconResourceType(engine) {
182
+ return engine === "chromium" ? "ping" : "beacon";
183
+ }
184
+ /**
185
+ * Whether a beacon a page sends as it is being left (sendBeacon, or a
186
+ * keepalive fetch, on `pagehide`) escapes interception. In Chromium it is
187
+ * never routed, so the write policy does not see it and it reaches the server
188
+ * in every mode; Firefox and WebKit route it, and the policy refuses it like
189
+ * any other. A limit of the network-layer policy (ADR 2), reported in ADR 12;
190
+ * the check smoke suite asserts both directions per engine.
191
+ */
192
+ export function unloadBeaconsEscapePolicy(engine) {
193
+ return engine === "chromium";
194
+ }
163
195
  /**
164
196
  * Run in every page before its own scripts: takes shared workers away.
165
197
  *
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Runs `scenescout check`: attach, crawl every route the engine can find,
3
+ * measure each one, replay the saved flows, re-test the open findings a page
4
+ * load can reproduce, and write the verdict. The rules live in
5
+ * engine/check.ts, engine/flow.ts and engine/verify.ts; this file only drives
6
+ * the browser and the files.
7
+ */
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { BrowserEngine } from "./engine/browser.js";
12
+ import { issuesFromRoutes, redactFlowRuns, redactRoute, redactRoutes, settingsOf, withoutOwnResponse, } from "./engine/check.js";
13
+ import { loadFlows, resolveFlowsDir } from "./engine/flow.js";
14
+ import { MemoryStore, MEMORY_DIRNAME } from "./engine/memory.js";
15
+ import { checkRetestPlan, retestResults, wellFormedFindings } from "./engine/verify.js";
16
+ /** Link discovery rounds: each crawl reveals the routes its pages link to. Past a few, a site is paginating rather than revealing. */
17
+ const MAX_ROUNDS = 6;
18
+ /**
19
+ * Read the flows and the findings, before any browser starts. Throws with a
20
+ * sentence naming the file and the field on anything it cannot read: a flow
21
+ * that is silently skipped is a flow that silently passes.
22
+ */
23
+ export function readCheckInputs(options) {
24
+ const where = resolveFlowsDir(options.flows, options.projectDir, (p) => fs.existsSync(p) && fs.statSync(p).isDirectory());
25
+ if ("error" in where)
26
+ throw new Error(where.error);
27
+ const { flows, skipped: skippedFlows } = where.dir ? loadFlows(where.dir) : { flows: [], skipped: [] };
28
+ if (!options.retest)
29
+ return { flows, skippedFlows, findings: null };
30
+ // Read, never written: a check leaves the project's memory as it found it.
31
+ const memoryPath = path.join(options.projectDir, MEMORY_DIRNAME, "memory.json");
32
+ if (!fs.existsSync(memoryPath))
33
+ return { flows, skippedFlows, findings: null };
34
+ let findings;
35
+ try {
36
+ findings = JSON.parse(fs.readFileSync(memoryPath, "utf8")).findings;
37
+ }
38
+ catch (err) {
39
+ throw new Error(`could not read ${memoryPath} to re-test its findings (${err instanceof Error ? err.message : String(err)}). Pass --retest off to check without it`);
40
+ }
41
+ if (findings !== undefined && !Array.isArray(findings))
42
+ throw new Error(`${memoryPath}: "findings" is not a list. Pass --retest off to check without it`);
43
+ return { flows, skippedFlows, findings: wellFormedFindings(findings ?? []) };
44
+ }
45
+ export async function runCheck(options, log = () => { }, inputs = { flows: [], findings: null }) {
46
+ // A throwaway memory: a check is one run, and a store shared with earlier
47
+ // exploratory runs would count their visits as this check's and skip those routes.
48
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "scenescout-check-"));
49
+ const engine = new BrowserEngine();
50
+ const start = new URL(options.url);
51
+ try {
52
+ // Attached at the origin: the engine joins every crawled path onto the URL
53
+ // it attached to, so attaching to a start page with a path would double it.
54
+ const attached = await engine.attach({
55
+ url: start.origin,
56
+ projectDir: options.projectDir,
57
+ memoryStore: new MemoryStore(scratch),
58
+ mode: options.mode,
59
+ storageStatePath: options.storageStatePath,
60
+ ...(options.browser ? { browser: options.browser } : {}),
61
+ objective: "Deterministic check: visit every route and measure it",
62
+ task: "Checking every route",
63
+ });
64
+ // A saved session that no longer signs in would check the sign-in page and call it the app.
65
+ const authFailed = attached.split("\n").find((line) => line.startsWith("⚠ AUTH FAILED"));
66
+ if (authFailed)
67
+ throw new Error(authFailed.replace(/ Continuing now tests a logged-out app\.$/, "").replace(/re-attach/, "run the check again"));
68
+ const routes = [];
69
+ const crawl = async (paths, limit) => {
70
+ await engine.crawl(paths, { inspect: true, limit });
71
+ routes.push(...engine.lastCrawlHealth);
72
+ };
73
+ if (options.paths) {
74
+ await crawl(options.paths.slice(0, options.maxRoutes), options.maxRoutes);
75
+ }
76
+ else {
77
+ // The start page first, whatever else is known: it is the one route the user named.
78
+ await crawl([`${start.pathname}${start.search}${start.hash}`], 1);
79
+ for (let round = 0; round < MAX_ROUNDS && routes.length < options.maxRoutes; round++) {
80
+ if (engine.crawlableRoutes().length === 0)
81
+ break;
82
+ await crawl(undefined, options.maxRoutes - routes.length);
83
+ log(` ${routes.length} route(s) checked`);
84
+ }
85
+ }
86
+ // Pages of open findings the crawl did not load exactly: loaded now, so each re-test has its own measurement.
87
+ // Kept apart from `routes`: they are measured only to re-test, never checked against the page rules, and do not count
88
+ // towards --max-routes. With --paths the check stays on the paths it was given.
89
+ const plan = inputs.findings ? checkRetestPlan(inputs.findings) : null;
90
+ const retestPages = [];
91
+ if (plan && !options.paths) {
92
+ const missing = [...new Set(plan.candidates.map((c) => c.path))].filter((p) => !routes.some((r) => r.path === p));
93
+ if (missing.length > 0) {
94
+ await engine.crawl(missing, { limit: missing.length, measureOnly: true });
95
+ retestPages.push(...engine.lastCrawlHealth);
96
+ log(` ${missing.length} page(s) of open findings loaded only to re-test them`);
97
+ }
98
+ }
99
+ const flowRuns = [];
100
+ for (const flow of inputs.flows) {
101
+ // --flow-writes never: observe's rule, whatever --mode lets the crawl do.
102
+ const replay = await engine.replayFlow(flow.steps, options.flowWrites === "never" ? "observe" : options.mode);
103
+ flowRuns.push({ name: flow.name, file: flow.file, steps: flow.steps.length, ...replay });
104
+ log(` flow ${flow.name}: ${replay.outcome.status}${replay.outcome.status === "passed" ? "" : ` at step ${replay.outcome.step}`}`);
105
+ // --on-refused-step stop: nothing after a refused step runs, and the check ends without a verdict.
106
+ if (replay.outcome.status === "refused" && options.onRefusedStep === "stop")
107
+ break;
108
+ }
109
+ const retest = plan
110
+ ? {
111
+ open: plan.open,
112
+ extraPages: retestPages.length,
113
+ results: retestResults(plan.candidates, [...routes, ...retestPages].map((r) => ({
114
+ path: r.path,
115
+ status: r.status,
116
+ ...(r.loadError !== undefined ? { loadError: r.loadError } : {}),
117
+ loginRedirect: r.loginRedirect,
118
+ httpErrors: withoutOwnResponse(r)
119
+ .violations.filter((v) => v.kind === "http_error")
120
+ .map((v) => v.detail),
121
+ }))),
122
+ }
123
+ : null;
124
+ const measured = redactRoutes(routes.map(withoutOwnResponse));
125
+ const flows = redactFlowRuns(flowRuns);
126
+ return {
127
+ url: redactRoute(options.url),
128
+ generatedAt: new Date().toISOString(),
129
+ mode: options.mode,
130
+ failOn: options.failOn,
131
+ routes: measured,
132
+ issues: issuesFromRoutes(measured, start.origin, options.ignore, flows),
133
+ // Routes that failed to load are issues already; "not visited" is only what --max-routes left out.
134
+ unvisited: options.paths ? [] : engine.crawlableRoutes().map(redactRoute),
135
+ ignored: options.ignore,
136
+ flows,
137
+ skippedFlows: inputs.skippedFlows ?? [],
138
+ retest,
139
+ settings: settingsOf(options),
140
+ };
141
+ }
142
+ finally {
143
+ await engine.close().catch((err) => log(`closing the browser failed: ${err instanceof Error ? err.message : String(err)}`));
144
+ fs.rmSync(scratch, { recursive: true, force: true });
145
+ }
146
+ }
147
+ /** Where a check writes when not told: beside the project's other SceneScout output, which is already ignored by git. */
148
+ export function defaultCheckDir(projectDir) {
149
+ return path.join(projectDir, MEMORY_DIRNAME, "check");
150
+ }
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * scenescout serve Run the MCP server on stdio
7
7
  * scenescout install Install the skill, download the browser, register the MCP server
8
8
  * scenescout doctor Check every piece of the setup and say how to fix what is missing
9
+ * scenescout check <url> Visit every route, measure it, and pass or fail (no model involved)
9
10
  */
10
11
  import { spawnSync } from "node:child_process";
11
12
  import fs from "node:fs";
@@ -16,13 +17,20 @@ import { fileURLToPath } from "node:url";
16
17
  import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, defaultAttachNote, defaultEngine, launchTarget, parseBrowserSelection, playwrightInstallArgs, } from "./browsers.js";
17
18
  import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
18
19
  import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
19
- import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME } from "./engine/memory.js";
20
+ import { defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
21
+ import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason } from "./engine/check.js";
22
+ import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
20
23
  import { formatStatus, liveEngines, liveTokenFileName, localClock, LIVE_TOKEN_FILE, pidAlive, watchTarget, wholeSessions, } from "./engine/live.js";
21
24
  import { formatScan, scanProject } from "./scan.js";
22
25
  const here = path.dirname(fileURLToPath(import.meta.url));
23
26
  const packageRoot = path.resolve(here, "..");
27
+ /** This package's version, as published. */
28
+ function packageVersion() {
29
+ return JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
30
+ }
24
31
  function usage(exitCode = 1) {
25
- console.log(`SceneScout — AI exploratory UI testing engine (MCP)
32
+ // The version beside the name: an old global install is otherwise easy to mistake for this one.
33
+ console.log(`SceneScout ${packageVersion()} — AI exploratory UI testing engine (MCP)
26
34
 
27
35
  Usage:
28
36
  scenescout scan <projectPath> Discover framework, routes, auth states
@@ -39,6 +47,23 @@ Usage:
39
47
  scenescout doctor Check the setup and print the fix for anything missing
40
48
  (--engine: only node, the build and the browser — for plugin
41
49
  installs and other MCP clients)
50
+ scenescout check <url> Visit every route, measure each one, and pass or fail — no model involved,
51
+ so it can gate a pull request. Writes report.md, check.sarif and check.json.
52
+ (--fail-on high|medium|low|never (default high); --mode observe|read-only;
53
+ --max-routes N (default 50); --paths /a,/b to check only those;
54
+ --ignore rule,rule; --storage-state file to check signed in;
55
+ --project dir (default: here); --out dir (default: .scenescout/check);
56
+ --browser chromium|firefox|webkit;
57
+ --flows dir|off: replay the flows saved there (default: .scenescout/flows);
58
+ --retest on|off: re-test open findings a page load reproduces (default on);
59
+ --flow-writes never|allow: never (default) replays flows under observe's
60
+ rule whatever --mode says; allow replays them under --mode, so their
61
+ form submissions are sent;
62
+ --on-refused-step report|stop: report (default) marks a flow whose step was
63
+ refused "could not run", keeps every other verdict and exits 2; stop exits 2 there;
64
+ --gate-retests never|high|all: which still-reproducing findings fail the gate
65
+ (default high: those filed high))
66
+ Exit code: 0 passed, 1 failed the gate, 2 could not run.
42
67
  scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
43
68
  scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
44
69
  of its page, and a live stream you can switch on per session
@@ -385,8 +410,7 @@ async function install(flags) {
385
410
  }
386
411
  else {
387
412
  const onPath = () => findOnUserPath({ names: process.platform === "win32" ? [`${CLI_NAME}.cmd`] : [CLI_NAME], pathValue: process.env.PATH ?? "" });
388
- const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
389
- const done = ensureCommand(planCommand({ packageRoot, nodePath: process.execPath, version: pkg.version, resolved: onPath(), platform: process.platform }), spawnRunner);
413
+ const done = ensureCommand(planCommand({ packageRoot, nodePath: process.execPath, version: packageVersion(), resolved: onPath(), platform: process.platform }), spawnRunner);
390
414
  if (done.status === "present") {
391
415
  cli = CLI_NAME;
392
416
  console.log(`✓ \`${CLI_NAME}\` command already on PATH: ${done.at}`);
@@ -445,6 +469,70 @@ async function doctor(flags) {
445
469
  ? "\nAll good. Ask your agent: Use SceneScout to test http://localhost:3000"
446
470
  : "\nAll good. In any project, run: /scenescout (or ask: Use SceneScout to test http://localhost:3000)");
447
471
  }
472
+ /** `scenescout check`: exit 0 passed, 1 failed the gate, 2 could not run. */
473
+ async function check(args) {
474
+ if (args.includes("--help") || args.includes("-h"))
475
+ usage(0);
476
+ const parsed = parseCheckArgs(args, process.cwd());
477
+ if (!parsed.ok) {
478
+ console.error(`scenescout check: ${parsed.error}`);
479
+ process.exit(EXIT.error);
480
+ }
481
+ const options = parsed.options;
482
+ const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
483
+ let inputs;
484
+ try {
485
+ inputs = readCheckInputs(options);
486
+ }
487
+ catch (err) {
488
+ console.error(`scenescout check: ${err instanceof Error ? err.message : String(err)}`);
489
+ process.exit(EXIT.error);
490
+ }
491
+ let result;
492
+ try {
493
+ console.log(`Checking ${options.url} …${inputs.flows.length > 0 ? ` (and ${inputs.flows.length} saved flow(s))` : ""}`);
494
+ result = await runCheck(options, (line) => console.log(line), inputs);
495
+ }
496
+ catch (err) {
497
+ console.error(`scenescout check: could not run: ${err instanceof Error ? err.message : String(err)}`);
498
+ process.exit(EXIT.error);
499
+ }
500
+ const unmeasured = unmeasuredReason(result.routes, !options.paths);
501
+ if (unmeasured) {
502
+ console.error(`scenescout check: could not measure ${options.url}: ${unmeasured}`);
503
+ process.exit(EXIT.error);
504
+ }
505
+ const refused = refusedFlowReason(result);
506
+ // --on-refused-step stop: no verdict is written at all.
507
+ if (refused && options.onRefusedStep === "stop") {
508
+ console.error(`scenescout check: could not run a saved flow: ${refused}`);
509
+ process.exit(EXIT.error);
510
+ }
511
+ const markdown = formatCheck(result);
512
+ try {
513
+ const version = packageVersion();
514
+ if (!options.outDir)
515
+ writeSelfIgnore(path.dirname(outDir));
516
+ fs.mkdirSync(outDir, { recursive: true });
517
+ fs.writeFileSync(path.join(outDir, "report.md"), markdown);
518
+ fs.writeFileSync(path.join(outDir, "check.sarif"), JSON.stringify(toSarif(result, version), null, 2) + "\n");
519
+ fs.writeFileSync(path.join(outDir, "check.json"), JSON.stringify(toSummaryJson(result, version), null, 2) + "\n");
520
+ // On GitHub Actions the verdict also goes on the run's summary page.
521
+ if (process.env.GITHUB_STEP_SUMMARY)
522
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown);
523
+ }
524
+ catch (err) {
525
+ // A verdict nobody can read is not a pass or a fail: exit as "could not run", never as the gate's 1.
526
+ console.error(`scenescout check: could not write the results to ${outDir}: ${err instanceof Error ? err.message : String(err)}`);
527
+ process.exit(EXIT.error);
528
+ }
529
+ console.log("\n" + markdown);
530
+ console.log(`Wrote report.md, check.sarif and check.json to ${outDir}`);
531
+ // --on-refused-step report: everything else has its verdict in the files, and the exit code still says the run was incomplete.
532
+ if (refused)
533
+ console.error(`scenescout check: could not run a saved flow: ${refused}`);
534
+ process.exit(exitCodeOf(result));
535
+ }
448
536
  const [, , command, ...args] = process.argv;
449
537
  // A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
450
538
  // path that does not exist and `status` on a half-written status.json both
@@ -461,8 +549,7 @@ try {
461
549
  usage(0);
462
550
  case "--version":
463
551
  case "-v": {
464
- const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
465
- console.log(pkg.version);
552
+ console.log(packageVersion());
466
553
  break;
467
554
  }
468
555
  case "scan": {
@@ -484,6 +571,10 @@ try {
484
571
  await doctor(args);
485
572
  break;
486
573
  }
574
+ case "check": {
575
+ await check(args);
576
+ break;
577
+ }
487
578
  case "status": {
488
579
  status(path.resolve(args[0] ?? process.cwd()));
489
580
  break;