scenescout 3.8.0 → 3.10.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 +23 -0
- package/README.md +52 -3
- package/dist/browsers.js +12 -0
- package/dist/check-run.js +76 -0
- package/dist/cli.js +71 -6
- package/dist/engine/browser.js +514 -45
- package/dist/engine/check.js +487 -0
- package/dist/engine/collector.js +194 -25
- package/dist/engine/design.js +24 -8
- package/dist/engine/forms.js +290 -0
- package/dist/engine/lane.js +179 -2
- package/dist/engine/memory.js +70 -3
- package/dist/engine/oracles.js +101 -4
- package/dist/engine/pace.js +14 -1
- package/dist/engine/policy.js +118 -0
- package/dist/engine/report.js +54 -5
- package/dist/mcp-server.js +49 -9
- package/package.json +6 -4
- package/skills/scenescout/SKILL.md +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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`.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
18
|
+
## 3.9.0
|
|
19
|
+
|
|
20
|
+
### Minor Changes
|
|
21
|
+
|
|
22
|
+
- e2cf5bb: Snapshots list the controls inside the page's frames, each marked with its frame, and every action works on them by ref. In a frame of another site, container text is masked and typed markup, fuzzing-length values, repeated-click probes and uploads are refused in every mode; ordinary clicks and typing are allowed, and the write policy still refuses the writes such a frame sends outside the app.
|
|
23
|
+
- 5a48f6c: What happens inside another site's frame is attributed to it: failing requests and error responses it sent outside the app are labelled with its origin, kept at medium severity at most and grouped in their own report section (a request it sent to the app stays the app's), and its controls are counted apart from the app's coverage and gap ledger.
|
|
24
|
+
- fa71eb8: `scout_attach` takes `trustedEmbeds`, a list of origins the user trusts (a provider in test mode, say): in safe-write mode only, the writes their frames send outside the app go out, provided every other site involved (each frame up to the page, and the Origin header) is trusted. Anything that is not a plain http(s) origin is refused at attach, trust is ignored in the other modes, and the report names the list.
|
|
25
|
+
|
|
3
26
|
## 3.8.0
|
|
4
27
|
|
|
5
28
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -344,6 +344,52 @@ 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: a bad argument, an app that never answered, or only the sign-in page reached |
|
|
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
|
+
It never writes to the app (`--mode observe` or `read-only`), and 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
|
+
|
|
387
|
+
`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
|
+
|
|
389
|
+
The check fills no forms and follows no flows. That is the exploratory run's job, and its findings belong in a report, not a gate.
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
347
393
|
## 🩺 Troubleshooting
|
|
348
394
|
|
|
349
395
|
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 +595,8 @@ npx -y scenescout watch <path> # the same, live in your browser, with each
|
|
|
549
595
|
src/
|
|
550
596
|
mcp-server.ts the 29 tools + per-session dispatch
|
|
551
597
|
scan.ts project discovery (framework, routes, auth)
|
|
552
|
-
cli.ts scan · serve · install · doctor · status
|
|
598
|
+
cli.ts scan · serve · install · doctor · check · status
|
|
599
|
+
check-run.ts drives a check: attach, crawl every route, collect what was measured
|
|
553
600
|
installer.ts setup logic (skill link, MCP registration, diagnostics)
|
|
554
601
|
engine/
|
|
555
602
|
browser.ts the engine class: attach, snapshot, actions, crawl, plans
|
|
@@ -571,9 +618,10 @@ src/
|
|
|
571
618
|
design.ts the design audit + page scoring
|
|
572
619
|
memory.ts cross-run storage + finding dedup
|
|
573
620
|
report.ts the gap ledger + report generation
|
|
621
|
+
check.ts the check's rules, gate, report and SARIF
|
|
574
622
|
replay.ts the run as one page: steps, tasks, frames under each finding
|
|
575
623
|
… collector · dispatch · fixtures · authloss · reaper
|
|
576
|
-
scripts/ the
|
|
624
|
+
scripts/ the 24 test suites (smoke/ holds the real-browser ones)
|
|
577
625
|
test-app/ fixtures for the real-browser smoke tests
|
|
578
626
|
skills/scenescout/ the testing method (SKILL.md): a skill in Claude Code, served by the server everywhere else
|
|
579
627
|
docs/how-it-works.md what happens at each stage, in diagrams
|
|
@@ -603,6 +651,7 @@ The load-bearing choices are recorded as ADRs — read the relevant one before c
|
|
|
603
651
|
- [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
652
|
- [9 · A refused write is answered, not dropped](docs/adr/0009-a-refused-write-is-answered-not-dropped.md)
|
|
605
653
|
- [10 · A lane's confidence is checked, not trusted](docs/adr/0010-a-confidence-is-checked-not-trusted.md)
|
|
654
|
+
- [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)
|
|
606
655
|
|
|
607
656
|
---
|
|
608
657
|
|
|
@@ -614,7 +663,7 @@ Working on SceneScout itself is the only reason to clone it:
|
|
|
614
663
|
git clone https://github.com/brunoboto96/SceneScout.git scenescout && cd scenescout
|
|
615
664
|
npm install # installs dependencies and builds
|
|
616
665
|
npm run setup # same as `scenescout install`, but registers THIS checkout (the skill is linked, so edits are live)
|
|
617
|
-
npm test # build +
|
|
666
|
+
npm test # build + 24 suites: 22 pure-logic suites (scan, oracle, policy, … bench, hygiene),
|
|
618
667
|
# then smoke and mcp-check (the server over stdio), both with real browsers
|
|
619
668
|
npm run bench -- --all # re-score every archived benchmark run against the current answer key
|
|
620
669
|
npm run demo # regenerate examples/ from the demo app
|
package/dist/browsers.js
CHANGED
|
@@ -160,6 +160,18 @@ 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
|
+
}
|
|
163
175
|
/**
|
|
164
176
|
* Run in every page before its own scripts: takes shared workers away.
|
|
165
177
|
*
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `scenescout check`: attach, crawl every route the engine can find,
|
|
3
|
+
* measure each one, and write the verdict. The rules live in engine/check.ts;
|
|
4
|
+
* this file only drives the browser and the files.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { BrowserEngine } from "./engine/browser.js";
|
|
10
|
+
import { issuesFromRoutes, redactRoute, redactRoutes, withoutOwnResponse } from "./engine/check.js";
|
|
11
|
+
import { MemoryStore, MEMORY_DIRNAME } from "./engine/memory.js";
|
|
12
|
+
/** Link discovery rounds: each crawl reveals the routes its pages link to. Past a few, a site is paginating rather than revealing. */
|
|
13
|
+
const MAX_ROUNDS = 6;
|
|
14
|
+
export async function runCheck(options, log = () => { }) {
|
|
15
|
+
// A throwaway memory: a check is one run, and a store shared with earlier
|
|
16
|
+
// exploratory runs would count their visits as this check's and skip those routes.
|
|
17
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "scenescout-check-"));
|
|
18
|
+
const engine = new BrowserEngine();
|
|
19
|
+
const start = new URL(options.url);
|
|
20
|
+
try {
|
|
21
|
+
// Attached at the origin: the engine joins every crawled path onto the URL
|
|
22
|
+
// it attached to, so attaching to a start page with a path would double it.
|
|
23
|
+
const attached = await engine.attach({
|
|
24
|
+
url: start.origin,
|
|
25
|
+
projectDir: options.projectDir,
|
|
26
|
+
memoryStore: new MemoryStore(scratch),
|
|
27
|
+
mode: options.mode,
|
|
28
|
+
storageStatePath: options.storageStatePath,
|
|
29
|
+
...(options.browser ? { browser: options.browser } : {}),
|
|
30
|
+
objective: "Deterministic check: visit every route and measure it",
|
|
31
|
+
task: "Checking every route",
|
|
32
|
+
});
|
|
33
|
+
// A saved session that no longer signs in would check the sign-in page and call it the app.
|
|
34
|
+
const authFailed = attached.split("\n").find((line) => line.startsWith("⚠ AUTH FAILED"));
|
|
35
|
+
if (authFailed)
|
|
36
|
+
throw new Error(authFailed.replace(/ Continuing now tests a logged-out app\.$/, "").replace(/re-attach/, "run the check again"));
|
|
37
|
+
const routes = [];
|
|
38
|
+
const crawl = async (paths, limit) => {
|
|
39
|
+
await engine.crawl(paths, { inspect: true, limit });
|
|
40
|
+
routes.push(...engine.lastCrawlHealth);
|
|
41
|
+
};
|
|
42
|
+
if (options.paths) {
|
|
43
|
+
await crawl(options.paths.slice(0, options.maxRoutes), options.maxRoutes);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// The start page first, whatever else is known: it is the one route the user named.
|
|
47
|
+
await crawl([`${start.pathname}${start.search}${start.hash}`], 1);
|
|
48
|
+
for (let round = 0; round < MAX_ROUNDS && routes.length < options.maxRoutes; round++) {
|
|
49
|
+
if (engine.crawlableRoutes().length === 0)
|
|
50
|
+
break;
|
|
51
|
+
await crawl(undefined, options.maxRoutes - routes.length);
|
|
52
|
+
log(` ${routes.length} route(s) checked`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const measured = redactRoutes(routes.map(withoutOwnResponse));
|
|
56
|
+
return {
|
|
57
|
+
url: redactRoute(options.url),
|
|
58
|
+
generatedAt: new Date().toISOString(),
|
|
59
|
+
mode: options.mode,
|
|
60
|
+
failOn: options.failOn,
|
|
61
|
+
routes: measured,
|
|
62
|
+
issues: issuesFromRoutes(measured, start.origin, options.ignore),
|
|
63
|
+
// Routes that failed to load are issues already; "not visited" is only what --max-routes left out.
|
|
64
|
+
unvisited: options.paths ? [] : engine.crawlableRoutes().map(redactRoute),
|
|
65
|
+
ignored: options.ignore,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await engine.close().catch((err) => log(`closing the browser failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
70
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Where a check writes when not told: beside the project's other SceneScout output, which is already ignored by git. */
|
|
74
|
+
export function defaultCheckDir(projectDir) {
|
|
75
|
+
return path.join(projectDir, MEMORY_DIRNAME, "check");
|
|
76
|
+
}
|
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, runCheck } from "./check-run.js";
|
|
21
|
+
import { EXIT, formatCheck, parseCheckArgs, summarise, 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,14 @@ 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
|
+
Exit code: 0 passed, 1 failed the gate, 2 could not run.
|
|
42
58
|
scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
|
|
43
59
|
scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
|
|
44
60
|
of its page, and a live stream you can switch on per session
|
|
@@ -385,8 +401,7 @@ async function install(flags) {
|
|
|
385
401
|
}
|
|
386
402
|
else {
|
|
387
403
|
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);
|
|
404
|
+
const done = ensureCommand(planCommand({ packageRoot, nodePath: process.execPath, version: packageVersion(), resolved: onPath(), platform: process.platform }), spawnRunner);
|
|
390
405
|
if (done.status === "present") {
|
|
391
406
|
cli = CLI_NAME;
|
|
392
407
|
console.log(`✓ \`${CLI_NAME}\` command already on PATH: ${done.at}`);
|
|
@@ -445,6 +460,53 @@ async function doctor(flags) {
|
|
|
445
460
|
? "\nAll good. Ask your agent: Use SceneScout to test http://localhost:3000"
|
|
446
461
|
: "\nAll good. In any project, run: /scenescout (or ask: Use SceneScout to test http://localhost:3000)");
|
|
447
462
|
}
|
|
463
|
+
/** `scenescout check`: exit 0 passed, 1 failed the gate, 2 could not run. */
|
|
464
|
+
async function check(args) {
|
|
465
|
+
if (args.includes("--help") || args.includes("-h"))
|
|
466
|
+
usage(0);
|
|
467
|
+
const parsed = parseCheckArgs(args, process.cwd());
|
|
468
|
+
if (!parsed.ok) {
|
|
469
|
+
console.error(`scenescout check: ${parsed.error}`);
|
|
470
|
+
process.exit(EXIT.error);
|
|
471
|
+
}
|
|
472
|
+
const options = parsed.options;
|
|
473
|
+
const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
|
|
474
|
+
let result;
|
|
475
|
+
try {
|
|
476
|
+
console.log(`Checking ${options.url} …`);
|
|
477
|
+
result = await runCheck(options, (line) => console.log(line));
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
console.error(`scenescout check: could not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
481
|
+
process.exit(EXIT.error);
|
|
482
|
+
}
|
|
483
|
+
const unmeasured = unmeasuredReason(result.routes, !options.paths);
|
|
484
|
+
if (unmeasured) {
|
|
485
|
+
console.error(`scenescout check: could not measure ${options.url}: ${unmeasured}`);
|
|
486
|
+
process.exit(EXIT.error);
|
|
487
|
+
}
|
|
488
|
+
const markdown = formatCheck(result);
|
|
489
|
+
try {
|
|
490
|
+
const version = packageVersion();
|
|
491
|
+
if (!options.outDir)
|
|
492
|
+
writeSelfIgnore(path.dirname(outDir));
|
|
493
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
494
|
+
fs.writeFileSync(path.join(outDir, "report.md"), markdown);
|
|
495
|
+
fs.writeFileSync(path.join(outDir, "check.sarif"), JSON.stringify(toSarif(result, version), null, 2) + "\n");
|
|
496
|
+
fs.writeFileSync(path.join(outDir, "check.json"), JSON.stringify(toSummaryJson(result, version), null, 2) + "\n");
|
|
497
|
+
// On GitHub Actions the verdict also goes on the run's summary page.
|
|
498
|
+
if (process.env.GITHUB_STEP_SUMMARY)
|
|
499
|
+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown);
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
// A verdict nobody can read is not a pass or a fail: exit as "could not run", never as the gate's 1.
|
|
503
|
+
console.error(`scenescout check: could not write the results to ${outDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
504
|
+
process.exit(EXIT.error);
|
|
505
|
+
}
|
|
506
|
+
console.log("\n" + markdown);
|
|
507
|
+
console.log(`Wrote report.md, check.sarif and check.json to ${outDir}`);
|
|
508
|
+
process.exit(summarise(result).passed ? EXIT.pass : EXIT.gateFailed);
|
|
509
|
+
}
|
|
448
510
|
const [, , command, ...args] = process.argv;
|
|
449
511
|
// A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
|
|
450
512
|
// path that does not exist and `status` on a half-written status.json both
|
|
@@ -461,8 +523,7 @@ try {
|
|
|
461
523
|
usage(0);
|
|
462
524
|
case "--version":
|
|
463
525
|
case "-v": {
|
|
464
|
-
|
|
465
|
-
console.log(pkg.version);
|
|
526
|
+
console.log(packageVersion());
|
|
466
527
|
break;
|
|
467
528
|
}
|
|
468
529
|
case "scan": {
|
|
@@ -484,6 +545,10 @@ try {
|
|
|
484
545
|
await doctor(args);
|
|
485
546
|
break;
|
|
486
547
|
}
|
|
548
|
+
case "check": {
|
|
549
|
+
await check(args);
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
487
552
|
case "status": {
|
|
488
553
|
status(path.resolve(args[0] ?? process.cwd()));
|
|
489
554
|
break;
|