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 +25 -0
- package/README.md +61 -3
- package/dist/browsers.js +32 -0
- package/dist/check-run.js +150 -0
- package/dist/cli.js +97 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +526 -38
- package/dist/engine/check.js +705 -0
- package/dist/engine/collector.js +94 -18
- package/dist/engine/design.js +24 -8
- package/dist/engine/flow.js +398 -0
- package/dist/engine/forms.js +290 -0
- package/dist/engine/lane.js +179 -2
- package/dist/engine/memory.js +64 -3
- package/dist/engine/oracles.js +79 -2
- package/dist/engine/pace.js +14 -1
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +40 -9
- package/package.json +8 -4
- package/skills/scenescout/SKILL.md +3 -3
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 (`<`, `>`, `&`, `"`, `'`, `'` and numeric ones), once, in every string of the report, so evidence such as `->` 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
|
|
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 +
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|