scenescout 3.9.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 CHANGED
@@ -1,5 +1,20 @@
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 (`&lt;`, `&gt;`, `&amp;`, `&quot;`, `&#39;`, `&apos;` and numeric ones), once, in every string of the report, so evidence such as `-&gt;` matches the finding filed as `->` again. A reply that holds a literal `<` or `>` was not escaped on the way and is left as it is, so a lane reporting a page that double-escapes its text keeps its evidence. Length caps apply to the decoded text, so a route that only went over its cap by being escaped is no longer refused, and the fold says how many references it decoded.
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
+
3
18
  ## 3.9.0
4
19
 
5
20
  ### 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 23 test suites (smoke/ holds the real-browser ones)
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 + 23 suites: 21 pure-logic suites (scan, oracle, policy, … bench, hygiene),
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 { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME } from "./engine/memory.js";
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
- console.log(`SceneScout — AI exploratory UI testing engine (MCP)
32
+ // The version beside the name: an old global install is otherwise easy to mistake for this one.
33
+ console.log(`SceneScout ${packageVersion()} — AI exploratory UI testing engine (MCP)
26
34
 
27
35
  Usage:
28
36
  scenescout scan <projectPath> Discover framework, routes, auth states
@@ -39,6 +47,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 pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
389
- const done = ensureCommand(planCommand({ packageRoot, nodePath: process.execPath, version: pkg.version, resolved: onPath(), platform: process.platform }), spawnRunner);
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
- const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
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;