scenescout 3.10.0 → 3.11.1
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 +18 -0
- package/README.md +12 -3
- package/dist/browsers.js +64 -0
- package/dist/check-run.js +79 -5
- package/dist/cli.js +32 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +426 -26
- package/dist/engine/calibration.js +99 -14
- package/dist/engine/check.js +251 -33
- package/dist/engine/fingerprint.js +12 -7
- package/dist/engine/flow.js +398 -0
- package/dist/engine/lane.js +81 -0
- package/dist/engine/memory.js +48 -6
- package/dist/engine/unload.js +201 -0
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +4 -4
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.11.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 847c149: Two different defects on one element now stay two findings. Finding dedup compares what is wrong as well as where: identical evidence, a shared quoted label or a reworded title merges two findings only when they are one kind of defect (the same category, or neighbouring categories of one family such as `page-error` and `console-error`). `visual`, `ux-polish`, `a11y` and `missing-testid` are each their own kind, so a link clipped out of view and the same link styled like body text are no longer merged. When `scout_finding` merges a filing it now names the category of the finding it joined and which categories the filing could have merged with.
|
|
8
|
+
- aca7a28: When a lane report is folded, a judged defect on a failing request now counts as filed when a finding names that request with its path written as a template: `GET /api/things/{id} 500` (or `:id`, or `*`) covers a lane's `GET /api/things/7 500`. A template stands only for one id segment (a number, a UUID or a long hex id), never for a word such as `me` or `export`. A defect naming several failing requests counts as filed only when every one of them is. On a request that did not fail, the lane's evidence, once the paths are aligned, must be the finding's, a restatement of part of it, or the finding's evidence followed only by the status the call should have returned, as in `… 200 as role=viewer (expected 403)`.
|
|
9
|
+
- 2b8ac9a: The write policy now judges writes a page sends as it is being left. In Chromium, a `navigator.sendBeacon` or `fetch(..., { keepalive: true })` sent on `pagehide`, `visibilitychange` or `unload` was never intercepted and reached the server in every mode, observe included; the engine now catches it at the browser level and applies the same rules, so under observe (and under read-only, for a destructive write) it is refused and reported with the other refused writes, and a mode that allows it still sends it. Such a write is recognised by its headers, since the page that sent it is gone: one out of the app whose Origin is another site's, or `null`, is treated as an embed's and refused outside destructive, and the exception for a captcha on the app's sign-in page is decided on the Referer, the page it was sent from. Browsers send only the origin as the Referer of a request to another site by default, so that exception seldom applies and such a captcha write is refused; a sign-in the tester completes while the page is open is not affected. In Chromium, a write a 307 or 308 redirect carries on to a new address is now judged there as well. In every browser, the pages the engine closes (at the end of a session, on a re-attach, and a popup of another site) are left for `about:blank` first, so what they send on the way out meets the policy too: in Firefox and WebKit such a write had also gone out unseen.
|
|
10
|
+
|
|
11
|
+
## 3.11.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
3
21
|
## 3.10.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -363,7 +363,7 @@ npx scenescout check http://127.0.0.1:3000 --fail-on high
|
|
|
363
363
|
|---|---|
|
|
364
364
|
| 0 | Passed the gate |
|
|
365
365
|
| 1 | Failed it: something at the `--fail-on` severity or worse |
|
|
366
|
-
| 2 | Could not run: a bad argument, an app that never answered,
|
|
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
367
|
|
|
368
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
369
|
|
|
@@ -377,16 +377,24 @@ On GitHub Actions, this repository is also an action that installs everything an
|
|
|
377
377
|
|
|
378
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
379
|
|
|
380
|
-
|
|
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
381
|
|
|
382
382
|
- `--fail-on medium` or `low` makes the gate stricter.
|
|
383
383
|
- `--ignore <rule>` drops a rule.
|
|
384
384
|
- `--paths /a,/b` checks only those pages.
|
|
385
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.
|
|
386
392
|
|
|
387
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).
|
|
388
394
|
|
|
389
|
-
|
|
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.
|
|
390
398
|
|
|
391
399
|
---
|
|
392
400
|
|
|
@@ -652,6 +660,7 @@ The load-bearing choices are recorded as ADRs — read the relevant one before c
|
|
|
652
660
|
- [9 · A refused write is answered, not dropped](docs/adr/0009-a-refused-write-is-answered-not-dropped.md)
|
|
653
661
|
- [10 · A lane's confidence is checked, not trusted](docs/adr/0010-a-confidence-is-checked-not-trusted.md)
|
|
654
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)
|
|
655
664
|
|
|
656
665
|
---
|
|
657
666
|
|
package/dist/browsers.js
CHANGED
|
@@ -172,6 +172,70 @@ export function focusAdvanceKey(engine, platform) {
|
|
|
172
172
|
export function echoesFailedLoads(engine) {
|
|
173
173
|
return engine !== "firefox";
|
|
174
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
|
+
* Where the write policy meets a write a page sends as it is being left (a
|
|
186
|
+
* sendBeacon or keepalive fetch on `pagehide`, `visibilitychange` or
|
|
187
|
+
* `unload`, or one still leaving as a page is closed).
|
|
188
|
+
*
|
|
189
|
+
* - `route`: Firefox and WebKit hand it to the context's route handler like
|
|
190
|
+
* any other request.
|
|
191
|
+
* - `browser-fetch`: Chromium never routes it (it arrives with no network id,
|
|
192
|
+
* or after the page's session has gone, and the driver sends it on), so on
|
|
193
|
+
* its own it reached the server in every mode. The engine also pauses
|
|
194
|
+
* requests at the browser level with the DevTools Fetch domain and judges
|
|
195
|
+
* there, by the same rules, every write the route handler did not
|
|
196
|
+
* (unload.ts). Only in modes where the policy can refuse: destructive
|
|
197
|
+
* installs neither.
|
|
198
|
+
*
|
|
199
|
+
* Either way the write is judged and a refusal is reported like any other
|
|
200
|
+
* (ADR 2); the unload smoke suite asserts it per engine.
|
|
201
|
+
*/
|
|
202
|
+
export function unloadWriteInterception(engine) {
|
|
203
|
+
return engine === "chromium" ? "browser-fetch" : "route";
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Whether a write a page sends as it is being left may be lost after the
|
|
207
|
+
* write policy lets it through. WebKit cancels such a request when the route
|
|
208
|
+
* handler answers after the page has gone, so a mode that allows the write
|
|
209
|
+
* does not promise it arrives; nothing is sent that the policy refused. Firefox
|
|
210
|
+
* sends it on, and so does Chromium's browser-level interception. In
|
|
211
|
+
* destructive mode nothing is intercepted and every engine sends it.
|
|
212
|
+
*/
|
|
213
|
+
export function allowedUnloadWritesMayBeLost(engine) {
|
|
214
|
+
return engine === "webkit";
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Whether the writes another site's frame sends on `pagehide` may never be
|
|
218
|
+
* issued when the top page is left. Firefox can tear such a frame down before
|
|
219
|
+
* its requests reach the route handler, so there is nothing to refuse or to
|
|
220
|
+
* report, and nothing is sent. It depends on timing: seen in CI and under load
|
|
221
|
+
* locally, not on an idle machine. Chromium's browser-level interception and
|
|
222
|
+
* WebKit's route handler met them every time in the same runs. The writes the
|
|
223
|
+
* top page itself sends are not affected.
|
|
224
|
+
*/
|
|
225
|
+
export function frameUnloadWritesMayGoUnissued(engine) {
|
|
226
|
+
return engine === "firefox";
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Whether a write carried on by a redirect (a 307 or 308 keeps the method and
|
|
230
|
+
* the body) is judged at its new address. The route handler sees only the
|
|
231
|
+
* first request of a redirect in every engine. In Chromium the browser-level
|
|
232
|
+
* interception (`unloadWriteInterception`) sees each hop and judges it like any
|
|
233
|
+
* write; Firefox and WebKit send the later hops on unjudged, a known limit of
|
|
234
|
+
* the policy there (ADR 2).
|
|
235
|
+
*/
|
|
236
|
+
export function writeRedirectHopsJudged(engine) {
|
|
237
|
+
return engine === "chromium";
|
|
238
|
+
}
|
|
175
239
|
/**
|
|
176
240
|
* Run in every page before its own scripts: takes shared workers away.
|
|
177
241
|
*
|
package/dist/check-run.js
CHANGED
|
@@ -1,17 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Runs `scenescout check`: attach, crawl every route the engine can find,
|
|
3
|
-
* measure each one,
|
|
4
|
-
*
|
|
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.
|
|
5
7
|
*/
|
|
6
8
|
import fs from "node:fs";
|
|
7
9
|
import os from "node:os";
|
|
8
10
|
import path from "node:path";
|
|
9
11
|
import { BrowserEngine } from "./engine/browser.js";
|
|
10
|
-
import { issuesFromRoutes, redactRoute, redactRoutes, withoutOwnResponse } from "./engine/check.js";
|
|
12
|
+
import { issuesFromRoutes, redactFlowRuns, redactRoute, redactRoutes, settingsOf, withoutOwnResponse, } from "./engine/check.js";
|
|
13
|
+
import { loadFlows, resolveFlowsDir } from "./engine/flow.js";
|
|
11
14
|
import { MemoryStore, MEMORY_DIRNAME } from "./engine/memory.js";
|
|
15
|
+
import { checkRetestPlan, retestResults, wellFormedFindings } from "./engine/verify.js";
|
|
12
16
|
/** Link discovery rounds: each crawl reveals the routes its pages link to. Past a few, a site is paginating rather than revealing. */
|
|
13
17
|
const MAX_ROUNDS = 6;
|
|
14
|
-
|
|
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 }) {
|
|
15
46
|
// A throwaway memory: a check is one run, and a store shared with earlier
|
|
16
47
|
// exploratory runs would count their visits as this check's and skip those routes.
|
|
17
48
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "scenescout-check-"));
|
|
@@ -52,17 +83,60 @@ export async function runCheck(options, log = () => { }) {
|
|
|
52
83
|
log(` ${routes.length} route(s) checked`);
|
|
53
84
|
}
|
|
54
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;
|
|
55
124
|
const measured = redactRoutes(routes.map(withoutOwnResponse));
|
|
125
|
+
const flows = redactFlowRuns(flowRuns);
|
|
56
126
|
return {
|
|
57
127
|
url: redactRoute(options.url),
|
|
58
128
|
generatedAt: new Date().toISOString(),
|
|
59
129
|
mode: options.mode,
|
|
60
130
|
failOn: options.failOn,
|
|
61
131
|
routes: measured,
|
|
62
|
-
issues: issuesFromRoutes(measured, start.origin, options.ignore),
|
|
132
|
+
issues: issuesFromRoutes(measured, start.origin, options.ignore, flows),
|
|
63
133
|
// Routes that failed to load are issues already; "not visited" is only what --max-routes left out.
|
|
64
134
|
unvisited: options.paths ? [] : engine.crawlableRoutes().map(redactRoute),
|
|
65
135
|
ignored: options.ignore,
|
|
136
|
+
flows,
|
|
137
|
+
skippedFlows: inputs.skippedFlows ?? [],
|
|
138
|
+
retest,
|
|
139
|
+
settings: settingsOf(options),
|
|
66
140
|
};
|
|
67
141
|
}
|
|
68
142
|
finally {
|
package/dist/cli.js
CHANGED
|
@@ -17,8 +17,8 @@ import { fileURLToPath } from "node:url";
|
|
|
17
17
|
import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, defaultAttachNote, defaultEngine, launchTarget, parseBrowserSelection, playwrightInstallArgs, } from "./browsers.js";
|
|
18
18
|
import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
|
|
19
19
|
import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
20
|
-
import { defaultCheckDir, runCheck } from "./check-run.js";
|
|
21
|
-
import { EXIT, formatCheck, parseCheckArgs,
|
|
20
|
+
import { defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
21
|
+
import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason } from "./engine/check.js";
|
|
22
22
|
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
|
|
23
23
|
import { formatStatus, liveEngines, liveTokenFileName, localClock, LIVE_TOKEN_FILE, pidAlive, watchTarget, wholeSessions, } from "./engine/live.js";
|
|
24
24
|
import { formatScan, scanProject } from "./scan.js";
|
|
@@ -53,7 +53,16 @@ Usage:
|
|
|
53
53
|
--max-routes N (default 50); --paths /a,/b to check only those;
|
|
54
54
|
--ignore rule,rule; --storage-state file to check signed in;
|
|
55
55
|
--project dir (default: here); --out dir (default: .scenescout/check);
|
|
56
|
-
--browser chromium|firefox|webkit
|
|
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))
|
|
57
66
|
Exit code: 0 passed, 1 failed the gate, 2 could not run.
|
|
58
67
|
scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
|
|
59
68
|
scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
|
|
@@ -471,10 +480,18 @@ async function check(args) {
|
|
|
471
480
|
}
|
|
472
481
|
const options = parsed.options;
|
|
473
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
|
+
}
|
|
474
491
|
let result;
|
|
475
492
|
try {
|
|
476
|
-
console.log(`Checking ${options.url}
|
|
477
|
-
result = await runCheck(options, (line) => console.log(line));
|
|
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);
|
|
478
495
|
}
|
|
479
496
|
catch (err) {
|
|
480
497
|
console.error(`scenescout check: could not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -485,6 +502,12 @@ async function check(args) {
|
|
|
485
502
|
console.error(`scenescout check: could not measure ${options.url}: ${unmeasured}`);
|
|
486
503
|
process.exit(EXIT.error);
|
|
487
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
|
+
}
|
|
488
511
|
const markdown = formatCheck(result);
|
|
489
512
|
try {
|
|
490
513
|
const version = packageVersion();
|
|
@@ -505,7 +528,10 @@ async function check(args) {
|
|
|
505
528
|
}
|
|
506
529
|
console.log("\n" + markdown);
|
|
507
530
|
console.log(`Wrote report.md, check.sarif and check.json to ${outDir}`);
|
|
508
|
-
|
|
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));
|
|
509
535
|
}
|
|
510
536
|
const [, , command, ...args] = process.argv;
|
|
511
537
|
// A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
|