scenescout 3.16.0 → 3.18.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,34 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1a6ce12: Ask the start-of-run questions as one form where the client can show one. A new `scout_intake` tool checks whether the client declared MCP elicitation in form mode and, if it did, asks the address, whether and how to sign in, what to check and whether the site holds real data in a single form, then returns the `scout_login` and `scout_attach` calls the answers choose. With no form support, or when the person declines or closes the form, it returns the questions for the agent to ask in chat, as before. The form never asks for a password or a code: signing in stays in the window `scout_login` opens. The skill and the `explore` prompt call `scout_intake` before setup.
8
+
9
+ ### Patch Changes
10
+
11
+ - 96bee5c: `scenescout check` and the first look no longer report a feed, a plain-text file, XML, JSON, a PDF or an image as a dead end. A route's response content type now decides whether it is a page: one served as anything but `text/html` or `application/xhtml+xml` is listed under "Not pages" in the report and as `resources` in `check.json`, is not checked against the page rules, and does not count towards the routes checked or `--max-routes`. One that answers 4xx or 5xx is still reported as the route's error.
12
+ - 952fbf3: A check or first look with no signed-in session no longer reports each route that redirects to sign-in as an `auth-redirect` issue. It lists them once as a coverage gap ("N routes need sign-in; give a role to cover them"), under "Needs sign-in" in the report and as `needsSignIn` in `check.json`. With `--storage-state`, a redirect to sign-in is still reported as a lost session.
13
+ - 6073a0d: A link or button with no text is now named by a descendant's `aria-label` (an icon element inside the link) and by an image's `title` when its alt text is empty, as the browser names it, so `unnamed-control` no longer reports those controls. Coverage recorded for them under their earlier keys carries over.
14
+ - cd0639b: Text drawn over a positioned `<img>`, `<picture>`, `<video>` or `<canvas>` is no longer reported as a contrast failure measured against the page background (often as 1.00:1). Like text over a CSS background image, it has no single background colour, so the design audit and `scenescout check` leave it unmeasured.
15
+ - 680e4f5: The focus-indicator rule no longer reports an iframe as a control with no visible focus indicator. A Tab onto a frame moves focus into the frame's document, so the design audit and `scenescout check` now follow focus into a same-origin frame and measure the control focused there, reported by its own name. A press that leaves focus inside another site's frame is skipped.
16
+ - fe16d4c: A page that locks scrolling behind a modal opened inside a full-viewport frame is no longer reported as a leaked scroll lock. The overlay probe now counts a visible iframe that covers the viewport, is pinned itself or through a fixed ancestor, and is neither faded out nor click-through as an open overlay, provided that, when the frame is same-origin, a dialog or a dimming backdrop is showing inside it. A frame left mounted after its modal closed does not justify the lock, so that page is still reported at high severity.
17
+ - 96b74aa: The design audit's `image-aspect` rule no longer reports an image that keeps its proportions through `object-fit: cover`, `contain`, `scale-down` or `none`, whether set inline or by a class. Only an image stretched to its box under `fill`, the default, is reported as distorted.
18
+ - 7fc6013: The gap ledger's list of pages whose POST observe refused now names the page that sent the POST. A page whose script posted as it loaded could be listed as the page the session came from, because the browser had not yet reported the new page; the request's Referer now decides, with the session's page as the fallback.
19
+
20
+ ## 3.17.0
21
+
22
+ ### Minor Changes
23
+
24
+ - b0709f0: `scenescout check` accepts `--ignore-path`: a path drops every rule filed on that route, and `rule:/path` drops that one rule there. A page meant to answer HTTP 500 can stay off the gate while the same status on another path still fails `--fail-on high`. The GitHub Action takes the same input.
25
+ - 547d006: Add the `live` and `login` MCP prompts. `live` returns the current session's loopback live-view URL. `login` takes a role and tells the agent to call `scout_login`, with no password argument.
26
+ - 7ba8712: Add `scout_status`, a run-status pane built as an MCP App (`io.modelcontextprotocol/ui`, specification 2026-01-26). In a client that renders MCP Apps it shows each session's objective and task, open findings by severity, coverage and a button for the live view, and refreshes itself every 2.5 seconds through the app-only `scout_status_poll` tool. Every other client gets the same as text, starting with the live view's loopback address. The pane's page loads nothing from outside and shows only what the live view already shows.
27
+
28
+ ### Patch Changes
29
+
30
+ - 745a38e: On Windows, `scenescout install --client` starts an npm-installed client (a `.cmd` or `.bat` shim) through `cmd.exe`, so registration runs the client's own command instead of stopping and printing it to run by hand.
31
+
3
32
  ## 3.16.0
4
33
 
5
34
  ### Minor Changes
package/README.md CHANGED
@@ -205,7 +205,7 @@ The agent scans the project (if there is one), attaches read-only, explores, and
205
205
 
206
206
  **Common flags** — `--level minimal|medium|extensive` · `--url <app>` · `--role <name\|path>` (who to explore as: a login saved with `scenescout login`, a storage state found by the scan, or a path to a Playwright storage-state JSON) · `--focus <text>` (a ticket or a sentence to check) · `--observe` / `--read-only` / `--safe-write` / `--allow-destructive`.
207
207
 
208
- **No flags at all** (`/scenescout` on its own) and the agent asks four plain questions instead: the address, whether and how you sign in, what to check (tickets or a description), and whether the site holds real data. Real data, or not being sure, means nothing but `GET` requests leave the page; you are never asked to pick a mode. Any flag skips the questions. See [Plain questions instead of flags](docs/guide/Ways-to-use-it.md#plain-questions-instead-of-flags).
208
+ **No flags at all** (`/scenescout` on its own) and the agent asks four plain questions instead: the address, whether and how you sign in, what to check (tickets or a description), and whether the site holds real data. Where your client can show a form, `scout_intake` asks them as one form, never for a password; otherwise the agent asks in chat. Real data, or not being sure, means nothing but `GET` requests leave the page; you are never asked to pick a mode. Any flag skips the questions. See [Plain questions instead of flags](docs/guide/Ways-to-use-it.md#plain-questions-instead-of-flags).
209
209
 
210
210
  ### 🔑 Signing in as a role
211
211
 
@@ -229,7 +229,7 @@ Before a parallel run, `scout_lane_brief` checks that the planner's saved login
229
229
 
230
230
  ## 📺 Watching a run live
231
231
 
232
- When a session attaches, the engine starts a small live view and hands the agent its address on a `Live view:` line, which the agent passes on to you. On a desktop the engine also opens it in your default browser, and opens the report when it is written; nothing opens in CI or over SSH, and `SCENESCOUT_OPEN=none` (or `live`, `report`, `both`) chooses otherwise. From a terminal, `scenescout watch` opens the same page. There is one card per session:
232
+ When a session attaches, the engine starts a small live view and hands the agent its address on a `Live view:` line, which the agent passes on to you. On a local desktop it also opens that page in your default browser as the session attaches, and opens `report.html` when `scout_report` writes it, whether or not the browser window is shown. Nothing opens in CI, over SSH, or on Linux with no display. `SCENESCOUT_OPEN` (`live`, `report`, `both` or `none`) in the server's environment chooses otherwise, and `scout_attach {open}` wins over it. `scenescout ci` opens nothing unless `SCENESCOUT_OPEN` is set. From a terminal, `scenescout watch` opens the same page. There is one card per session:
233
233
 
234
234
  <p align="center"><img src="examples/screenshots/live-view.png" alt="The live view during a run of three parallel agents against the demo app: one card per session, each with its role and objective, the task it is on, the tool it is running, the page it is on, a live thumbnail, and a feed of the actions it just took, tinted one colour per task" width="880" /></p>
235
235
 
@@ -329,11 +329,11 @@ Snapshots are cheap: re-snapshotting a route returns only *what changed*, with s
329
329
 
330
330
  ## 🧰 The toolbox
331
331
 
332
- 34 deterministic tools. The agent picks; you rarely call these by hand.
332
+ 35 deterministic tools. The agent picks; you rarely call these by hand.
333
333
 
334
334
  | Phase | Tools | What they do |
335
335
  |---|---|---|
336
- | **Set up** | `scout_playbook` `scout_scan` `scout_attach` `scout_session` | Hand the testing method to an agent that has no skill loaded; discover routes; launch a browser in a write-mode; keep several authenticated roles alive at once |
336
+ | **Set up** | `scout_playbook` `scout_intake` `scout_scan` `scout_attach` `scout_session` | Hand the testing method to an agent that has no skill loaded; ask the start-of-run questions as one form where the client can show one; discover routes; launch a browser in a write-mode; keep several authenticated roles alive at once |
337
337
  | **Explore** | `scout_crawl` `scout_coverage` | Sweep every route in one call; ask what's still untested |
338
338
  | **Look** | `scout_snapshot` `scout_hover` `scout_screenshot` `scout_capture` | Read the structured scene (diffed); reveal tooltips/hover cards; capture pixels only when needed; save one element as a PNG to show someone |
339
339
  | **Ask the server** | `scout_request` `scout_network` | Call the app's own API as this session, with the UI bypassed — the check that turns a hidden button into a proven refusal; list the requests the page itself made since it loaded |
@@ -378,7 +378,7 @@ That refusal *is* the guarantee: an extensive report can only exist when nothing
378
378
  - 🟢 **`read-only` by default.** Destructive-labeled elements (delete/revoke/archive/…) **and** all `PUT/PATCH/DELETE` + destructive `POST`s are blocked at the network layer — see [`src/engine/policy.ts`](src/engine/policy.ts). Non-destructive `POST`s are allowed, because submitting forms is how a tester finds validation bugs — so read-only means *nothing existing is changed or removed*, not *nothing is ever created*.
379
379
  - 🟡 **`safe-write`** (`--safe-write`) lets the agent create data and edit/delete **only what it created** this run — never pre-existing records.
380
380
  - 🔴 **`destructive`** (`--allow-destructive`) allows everything, and only ever when *you* confirm the environment is disposable. The skill will never choose this itself.
381
- - 📂 Findings, memory, and reports live in a `.scenescout/` folder where you ran it. It ignores itself in git, so a stray `git add -A` never commits test data.
381
+ - 📂 Findings, memory, and reports live in a `.scenescout/` folder in the project. A client with no project folder, such as a desktop chat, gets one folder per tested site under `Documents/SceneScout/<host>/` by default, and the attach says where; `SCENESCOUT_PROJECTS_DIR` moves it. The folder ignores itself in git, so a stray `git add -A` never commits test data.
382
382
 
383
383
  A `🛡 WRITE-POLICY blocked` notice is the safety net doing its job, not an app bug. The server never sees a blocked request, but a page's own `fetch` or XHR is answered with a `403` in its place rather than dropped, so the page's handling of a refusal really runs: a page that then claims success is reported as a `false_success` ([ADR 9](docs/adr/0009-a-refused-write-is-answered-not-dropped.md)).
384
384
 
@@ -388,6 +388,8 @@ A control is judged by its own label: a dropdown by the option picked, a row by
388
388
 
389
389
  ## 📋 What you get
390
390
 
391
+ `.scenescout/report.md` and `report.html` open **In plain words**: a short summary, then each problem this run found, worst first, with its impact, the steps that led to it, what was expected, what happened and a picture when there is one. The technical detail (id, category, evidence, route) stays one click away.
392
+
391
393
  `.scenescout/report.md` — a deduplicated, worst-first report with:
392
394
 
393
395
  - 🐛 **Findings** with repro traces and generated Playwright regression-test skeletons.
@@ -398,9 +400,9 @@ A control is judged by its own label: a dropdown by the option picked, a row by
398
400
  - ⏱️ **How the run was paced** — how closely each session kept working, and apart from that, how long finished lanes held their browsers waiting to be collected, so neither hides the other.
399
401
  - 🎯 **How well the lanes judged** — on a parallel run, whether the confidence each lane stated matched what the project went on to file, beside what later re-tests found ([ADR 10](docs/adr/0010-a-confidence-is-checked-not-trusted.md)).
400
402
 
401
- `.scenescout/report.html` — the same report as one self-contained page, with every session's trail beside it, and on a [recorded run](#-recording-a-run-and-reading-it-back) the screenshots under each finding.
403
+ `.scenescout/report.html` — the same report as one self-contained page, with every session's trail beside it. Each finding shows a picture of the element it is about, or of the page as it was. A [recorded run](#-recording-a-run-and-reading-it-back) also shows the screenshots around each finding.
402
404
 
403
- 👀 Watch a run live: `node dist/cli.js status <project-path>`.
405
+ 👀 Watch a run live: `npx scenescout watch`, or `npx scenescout status <project-path>` for the same information as text.
404
406
 
405
407
  ---
406
408
 
@@ -759,6 +761,7 @@ test-app/ fixtures for the real-browser smoke tests
759
761
  skills/scenescout/ the testing method (SKILL.md): a skill in Claude Code, served by the server everywhere else
760
762
  docs/how-it-works.md what happens at each stage, in diagrams
761
763
  docs/benchmark.md measuring whether a change made runs better
764
+ docs/validation.md scorecards from runs against public open-source apps
762
765
  docs/adr/ why it's built this way
763
766
  ```
764
767
 
@@ -772,6 +775,8 @@ docs/adr/ why it's built this way
772
775
 
773
776
  **[Measuring whether a change helped](docs/benchmark.md)** — the demo app's answer key, the scorecard (recall, precision, judged-not-filed, severity, calibration), and the results log of every run, including what did not help.
774
777
 
778
+ **[Validation on public open-source apps](docs/validation.md)** — runs against three well-known open-source web apps, with a scorecard for each: issues by severity, how many were real and how many were false positives, and the engine problems the runs exposed.
779
+
775
780
  The load-bearing choices are recorded as ADRs — read the relevant one before changing a rule it covers:
776
781
 
777
782
  - [1 · Completion is an enforced contract, not a claim](docs/adr/0001-completion-is-a-contract-not-a-vibe.md)
@@ -787,6 +792,7 @@ The load-bearing choices are recorded as ADRs — read the relevant one before c
787
792
  - [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)
788
793
  - [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)
789
794
  - [13 · What depends on a project's convention is the project's to decide](docs/adr/0013-a-convention-is-the-projects-to-decide.md)
795
+ - [21 · Update the docs in the same pull request, unless the change has no user-facing surface](docs/adr/0021-update-the-docs-in-the-pull-request.md)
790
796
 
791
797
  ---
792
798
 
package/dist/check-run.js CHANGED
@@ -11,11 +11,12 @@ import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { BASELINE_CAPTURE, baselineFiles, baselineMeta, defaultBaselinesDir, elementTarget, isVisualPicture, judgeBaseline, missingTargetsMessage, parseBaselineTargets, readStoredBaseline, TARGETS_FILE, VISUAL_DIRNAME, visualFiles, } from "./engine/baseline.js";
13
13
  import { BrowserEngine } from "./engine/browser.js";
14
- import { checkFindings, redactBaselineRun, MAX_DISCOVERY_ROUNDS, redactFlowRuns, redactRoute, redactRoutes, settingsOf, unmeasuredReason, withoutOwnResponse, } from "./engine/check.js";
14
+ import { checkFindings, redactBaselineRun, MAX_DISCOVERY_ROUNDS, redactFlowRuns, redactRoute, redactRoutes, settingsOf, splitResources, unmeasuredReason, withoutOwnResponse, } from "./engine/check.js";
15
15
  import { loadFlows, resolveFlowsDir } from "./engine/flow.js";
16
16
  import { MemoryStore, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
17
17
  import { decodePng, encodePng } from "./engine/png.js";
18
18
  import { firstLineOf } from "./engine/limits.js";
19
+ import { isNonPageResource } from "./engine/crawl.js";
19
20
  import { checkRetestPlan, retestResults, wellFormedFindings } from "./engine/verify.js";
20
21
  /** The baselines folder a check uses: the one --baselines names, else the project's own. */
21
22
  export function baselinesDirOf(options) {
@@ -107,6 +108,8 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
107
108
  if (authFailed)
108
109
  throw new Error(authFailed.replace(/ Continuing now tests a logged-out app\.$/, "").replace(/re-attach/, "run the check again"));
109
110
  const routes = [];
111
+ // A feed or a file the crawl followed is not a page, so it does not use up --max-routes.
112
+ const pagesSoFar = () => routes.filter((r) => !isNonPageResource(r)).length;
110
113
  const crawl = async (paths, limit, deadline) => {
111
114
  await engine.crawl(paths, { inspect: true, limit, deadline });
112
115
  routes.push(...engine.lastCrawlHealth);
@@ -118,15 +121,15 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
118
121
  // The start page first, whatever else is known: it is the one route the user named. The
119
122
  // time budget does not apply to it, so a run always measures at least the page it was given.
120
123
  await crawl([`${start.pathname}${start.search}${start.hash}`], 1);
121
- for (let round = 0; round < MAX_DISCOVERY_ROUNDS && routes.length < options.maxRoutes; round++) {
124
+ for (let round = 0; round < MAX_DISCOVERY_ROUNDS && pagesSoFar() < options.maxRoutes; round++) {
122
125
  if (engine.crawlableRoutes().length === 0 || pastDeadline())
123
126
  break;
124
- await crawl(undefined, options.maxRoutes - routes.length, deadline);
125
- log(` ${routes.length} route(s) checked`);
127
+ await crawl(undefined, options.maxRoutes - pagesSoFar(), deadline);
128
+ log(` ${pagesSoFar()} route(s) checked`);
126
129
  }
127
130
  }
128
131
  // Out of time with routes still to visit; a cap of routes reached first is --max-routes's to report.
129
- const timeLimitReached = pastDeadline() && routes.length < options.maxRoutes && engine.crawlableRoutes().length > 0;
132
+ const timeLimitReached = pastDeadline() && pagesSoFar() < options.maxRoutes && engine.crawlableRoutes().length > 0;
130
133
  // Pages of open findings the crawl did not load exactly: loaded now, so each re-test has its own measurement.
131
134
  // Kept apart from `routes`: they are measured only to re-test, never checked against the page rules, and do not count
132
135
  // towards --max-routes. With --paths the check stays on the paths it was given.
@@ -172,22 +175,27 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
172
175
  }))),
173
176
  }
174
177
  : null;
175
- const measured = redactRoutes(routes.map(withoutOwnResponse));
178
+ const { pages: measured, resources } = splitResources(redactRoutes(routes.map(withoutOwnResponse)));
176
179
  const flows = redactFlowRuns(flowRuns);
177
180
  const pictured = baselines ? redactBaselineRun(baselines) : null;
178
- const { issues, worthALook } = checkFindings(measured, start.origin, options.ignore, flows, pictured);
181
+ // Signed in only when given a session: with none, a route that sends the browser to sign-in needs one, and is not a lost session.
182
+ const signedIn = options.storageStatePath !== undefined;
183
+ const { issues, worthALook, needsSignIn } = checkFindings(measured, start.origin, options.ignore, flows, pictured, options.ignorePaths, signedIn);
179
184
  return {
180
185
  url: redactRoute(options.url),
181
186
  generatedAt: new Date().toISOString(),
182
187
  mode: options.mode,
183
188
  failOn: options.failOn,
184
189
  routes: measured,
190
+ resources,
185
191
  issues,
186
192
  worthALook,
193
+ ...(signedIn ? {} : { needsSignIn }),
187
194
  // Routes that failed to load are issues already; "not visited" is only what --max-routes (or the time budget) left out.
188
195
  unvisited: options.paths ? [] : engine.crawlableRoutes().map(redactRoute),
189
196
  ...(options.timeBudgetMs !== undefined ? { timeBudget: { ms: options.timeBudgetMs, reached: timeLimitReached } } : {}),
190
197
  ignored: options.ignore,
198
+ ignoredPaths: options.ignorePaths,
191
199
  flows,
192
200
  skippedFlows: inputs.skippedFlows ?? [],
193
201
  retest,
package/dist/cli.js CHANGED
@@ -78,7 +78,8 @@ Usage:
78
78
  so it can gate a pull request. Writes report.md, check.sarif and check.json.
79
79
  (--fail-on high|medium|low|never (default high); --mode observe|read-only;
80
80
  --max-routes N (default 50); --paths /a,/b to check only those;
81
- --ignore rule,rule; --storage-state file to check signed in;
81
+ --ignore rule,rule; --ignore-path /a,rule:/b to exempt a path, or one rule on it;
82
+ --storage-state file to check signed in;
82
83
  --project dir (default: here); --out dir (default: .scenescout/check);
83
84
  --browser chromium|firefox|webkit;
84
85
  --action-timeout-ms N (default 5000), --nav-timeout-ms N (default 20000;
@@ -482,10 +483,8 @@ async function install(flags) {
482
483
  }
483
484
  else {
484
485
  failed = true;
485
- // On Windows a client installed through npm is a .cmd shim, which node cannot start directly.
486
- const windowsNote = process.platform === "win32" ? " (or it is installed as a .cmd shim, which cannot be started from here)" : "";
487
486
  console.log(reg.status === "client-missing"
488
- ? `· ${label} was not found on this machine${windowsNote}, so nothing was registered with it.`
487
+ ? `· ${label} was not found on this machine, so nothing was registered with it.`
489
488
  : `✗ Registering with ${label} failed: ${reg.detail}`);
490
489
  console.log(` To do it by hand, ${reg.manual}\n`);
491
490
  }
@@ -1,8 +1,8 @@
1
1
  import { chromium, firefox, webkit, } from "playwright";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { elementKey, fingerprintState, isNonPageRoute, keyAliases, normalizePath, ordinalKeys, refsSurviveUrlChange, routeBase, } from "./fingerprint.js";
5
- import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, reachedRoutes, redactSecrets, TASK_SET } from "./memory.js";
4
+ import { elementKey, fingerprintState, isNonPageRoute, keyAliasSteps, normalizePath, ordinalKeys, refsSurviveUrlChange, routeBase, } from "./fingerprint.js";
5
+ import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, NAME_RULE, reachedRoutes, redactSecrets, TASK_SET, } from "./memory.js";
6
6
  import { normalizeTask } from "./task.js";
7
7
  import { ACTED_CONTROL_SRC, CLAIM_SCAN_SCRIPT, findContradictions, INFRASTRUCTURE_WRITE_RE, isBackgroundRequest, OPEN_DIALOGS_SCRIPT, quietAnswer, saidSince, } from "./claims.js";
8
8
  import { POSTMESSAGE_BINDING, describeTokenPost, postMessageCaptureScript, tokenHits, tokenPostKey } from "./postmessage.js";
@@ -17,7 +17,7 @@ import { formatJourney, journeyTime, measureJourney } from "./journey.js";
17
17
  import { describeStep, FLOW_AFTER_LAST_STEP_MS, isAction, matchRequest, parseTarget, splitRefusals, TARGET_HELP, urlMatches, } from "./flow.js";
18
18
  import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
19
19
  import { InFlightRequests, keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
20
- import { crawledRoute, crawlLine, mainStateFlag } from "./crawl.js";
20
+ import { crawledRoute, crawlLine, isFileMediaType, isNonPageResource, mainStateFlag, mediaTypeOf } from "./crawl.js";
21
21
  import { authToRemember, BODY_FETCH_MAX, buildRequestScript, formatPageRequests, formatReplay, PageRequests, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, resolveTarget, staleCredentialNote, toReplayResult, wantsView, } from "./request.js";
22
22
  import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, unloadWriteInterception, closeWaitsForLeavingWrites, } from "../browsers.js";
23
23
  import { revealedLines } from "./hover.js";
@@ -28,7 +28,7 @@ import { performScroll, probeFocusIndicators, probeOverlays, scrollContainer } f
28
28
  import { boundedTeardown } from "./teardown.js";
29
29
  import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
30
30
  import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
31
- import { answersWithRefusal, destructiveRefusal, destructiveLabelOf, pickIsDestructive, isDestructive, isDestructiveWire, blockSignature, BlockNotices, dialogNote, dialogResponse, ESCAPE_REFUSAL, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, MAX_READ_POSTS, READ_POSTS_ENV, readPostAllowed, matchReadPost, readPostEntries, readPostsSetting, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, WriteRule, } from "./policy.js";
31
+ import { answersWithRefusal, destructiveRefusal, destructiveLabelOf, pickIsDestructive, isDestructive, isDestructiveWire, blockSignature, BlockNotices, dialogNote, dialogResponse, ESCAPE_REFUSAL, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, MAX_READ_POSTS, READ_POSTS_ENV, readPostAllowed, matchReadPost, refusedPostPage, readPostEntries, readPostsSetting, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, WriteRule, } from "./policy.js";
32
32
  import { scanProject } from "../scan.js";
33
33
  import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
34
34
  import { acceptMatches, generatedUpload } from "./fixtures.js";
@@ -1406,7 +1406,7 @@ export class BrowserEngine {
1406
1406
  const refuse = (why) => {
1407
1407
  const answered = answersWithRefusal(req.resourceType());
1408
1408
  if (rule === "observe" && method === "POST" && !why && answered && !BENIGN_MUTATION_RE.test(url))
1409
- this.noteObserveRefusedPost(url, req.postData());
1409
+ this.noteObserveRefusedPost(url, req.postData(), this.sentByMainFrame(req) ? req.headers()["referer"] : undefined);
1410
1410
  this.noteBlocked({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why, type: req.resourceType() });
1411
1411
  this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${why ? ` (${why})` : ""}`, url: this.page?.url() ?? "" });
1412
1412
  this.refusedByPolicy.add(req);
@@ -1826,16 +1826,16 @@ export class BrowserEngine {
1826
1826
  // A live region listed for what it says is known by its role, not its
1827
1827
  // text, so a new message reads as the same region saying something else.
1828
1828
  const baseKeyOf = (el, name, tag) => frameElementKey(el.liveOnly ? `live:${el.role}` : elementKey({ ...el, name }), tag);
1829
+ // Each element's base key under every name rule, rule 1 first, so each step between two rules gets its own aliases.
1829
1830
  const bases = all.map(({ raw: el, tag }) => ({
1830
- base: baseKeyOf(el, el.name, tag),
1831
- prior: baseKeyOf(el, el.priorName ?? el.name, tag),
1831
+ byRule: Array.from({ length: NAME_RULE }, (_, i) => baseKeyOf(el, i < NAME_RULE - 1 ? (el.priorNames?.[i] ?? el.name) : el.name, tag)),
1832
1832
  tracked: !el.liveOnly,
1833
1833
  }));
1834
- const keys = ordinalKeys(bases.map((b) => b.base));
1835
- const aliases = keyAliases(bases);
1834
+ const keys = ordinalKeys(bases.map((b) => b.byRule[NAME_RULE - 1]));
1835
+ const aliases = keyAliasSteps(bases);
1836
1836
  const built = all.map(({ raw: el, frame, tag }, i) => {
1837
1837
  const key = keys[i];
1838
- const { ownText: _ownText, centre: _centre, cut: _cut, priorName: _priorName, ...listed } = el;
1838
+ const { ownText: _ownText, centre: _centre, cut: _cut, priorNames: _priorNames, ...listed } = el;
1839
1839
  const full = {
1840
1840
  ...listed,
1841
1841
  ...(tag ? { frame: tag } : {}),
@@ -2332,18 +2332,29 @@ export class BrowserEngine {
2332
2332
  }
2333
2333
  /**
2334
2334
  * Remember in project memory, for the gap ledger, a script's POST that
2335
- * observe refused on the page the session is on. Not one that looks
2336
- * destructive, nor one to an endpoint already named as a read (its body was
2337
- * a mutation): naming it would change nothing.
2335
+ * observe refused, charged to the page that sent it (policy.ts
2336
+ * refusedPostPage). Not one that looks destructive, nor one to an endpoint
2337
+ * already named as a read (its body was a mutation): naming it would change
2338
+ * nothing.
2338
2339
  */
2339
- noteObserveRefusedPost(url, body) {
2340
- const pageUrl = this.page?.url();
2340
+ noteObserveRefusedPost(url, body, referer) {
2341
+ const pageUrl = refusedPostPage(this.baseUrl, referer, this.page?.url());
2341
2342
  if (!pageUrl || !this.memory || isDestructiveWire(pathnameOf(url), body) || matchReadPost(this.readPosts, this.baseUrl, url))
2342
2343
  return;
2343
2344
  const endpoint = this.refusedPostEndpoint(url);
2344
2345
  if (endpoint)
2345
2346
  this.memory.noteObserveRefusedPost(normalizePath(pageUrl), endpoint);
2346
2347
  }
2348
+ /** Whether the session's page sent `req` from its top document, not from a frame or a service worker. */
2349
+ sentByMainFrame(req) {
2350
+ try {
2351
+ return !!this.page && req.frame() === this.page.mainFrame();
2352
+ }
2353
+ catch {
2354
+ // A service worker's request has no frame.
2355
+ return false;
2356
+ }
2357
+ }
2347
2358
  /** A POST to this endpoint went out, so the pages observe refused it on are no longer a gap for it. */
2348
2359
  clearRefusedPost(url) {
2349
2360
  const endpoint = this.refusedPostEndpoint(url);
@@ -4332,7 +4343,9 @@ export class BrowserEngine {
4332
4343
  /** The full route contract: scanned filesystem routes ∪ link-discovered route classes. */
4333
4344
  allKnownRoutes() {
4334
4345
  const discovered = this.memory ? Object.keys(this.memory.discoveredRoutes) : [];
4335
- return [...new Set([...this.knownRoutes, ...discovered])].filter((r) => !isNonPageRoute(r));
4346
+ // A route that answered with a feed or a file is not a page, whatever its path looks like.
4347
+ const resources = new Set(Object.keys(this.memory?.resourceRoutes ?? {}).map(normalizePath));
4348
+ return [...new Set([...this.knownRoutes, ...discovered])].filter((r) => !isNonPageRoute(r) && !resources.has(normalizePath(r)));
4336
4349
  }
4337
4350
  /**
4338
4351
  * Contract routes neither visited nor attempted by THIS role.
@@ -4450,29 +4463,89 @@ export class BrowserEngine {
4450
4463
  this.actionStartedAt = Date.now();
4451
4464
  this.oracles.drain(false); // discard pre-route leftovers WITHOUT marking their signatures as reported
4452
4465
  let status = "ERR";
4466
+ // The main document's response, as the browser received it. Kept apart from goto's own result: a browser that
4467
+ // treats a PDF, a feed or an attachment as a download throws from goto, and this is then the only record of it.
4468
+ const answered = { last: null };
4469
+ const asked = new URL(url).href;
4470
+ const onResponse = (r) => {
4471
+ if (r.frame() !== page.mainFrame() || !r.request().isNavigationRequest())
4472
+ return;
4473
+ // A late event from the page before is not this route's: its redirect chain must start at the URL asked for.
4474
+ let first = r.request();
4475
+ for (let from = first.redirectedFrom(); from; from = from.redirectedFrom())
4476
+ first = from;
4477
+ if (first.url() !== asked)
4478
+ return;
4479
+ answered.last = { url: r.url(), status: r.status(), contentType: mediaTypeOf(r.headers()["content-type"]) };
4480
+ };
4481
+ page.on("response", onResponse);
4482
+ let resource = null;
4453
4483
  try {
4454
4484
  const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs });
4455
4485
  status = resp?.status() ?? "no-response";
4486
+ const contentType = mediaTypeOf(resp?.headers()["content-type"]);
4487
+ if (resp && isNonPageResource({ status: resp.status(), contentType }))
4488
+ resource = { url: page.url(), status: resp.status(), contentType: contentType };
4456
4489
  }
4457
4490
  catch (err) {
4458
- const explained = explainTimeout(err, "nav", this.limits.crawlNavMs);
4459
- const reason = explained instanceof Error ? explained.message.split("\n")[0] : String(explained);
4460
- // Not retried by later crawls in this process (a check would otherwise try it
4461
- // on every discovery round). Deliberately not written to memory: one outage
4462
- // must not count a route as covered in every later run's gap ledger.
4463
- if (!opts.measureOnly)
4464
- this.loadFailedRoutes.add(normalizePath(url));
4465
- await this.errorPageCommitted(page);
4466
- summary.push(`${path} — LOAD FAILED`);
4467
- // The page a re-attach went back to never loaded, so nothing says whether it worked.
4468
- if (this.authLoss.reattaching)
4469
- this.authLoss.abortReattach(`the page it went back to, ${path}, did not load (${reason})`);
4470
- problems.push(`${path}: ${reason}`);
4491
+ // A browser that took the answer as a download failed the navigation, but the route did answer, with its status:
4492
+ // a feed that is missing is the route's 404, not a page that did not load.
4493
+ const seen = answered.last;
4494
+ if (seen && isFileMediaType(seen.contentType))
4495
+ resource = { url: seen.url, status: seen.status, contentType: seen.contentType };
4496
+ else {
4497
+ const explained = explainTimeout(err, "nav", this.limits.crawlNavMs);
4498
+ const reason = explained instanceof Error ? explained.message.split("\n")[0] : String(explained);
4499
+ // Not retried by later crawls in this process (a check would otherwise try it
4500
+ // on every discovery round). Deliberately not written to memory: one outage
4501
+ // must not count a route as covered in every later run's gap ledger.
4502
+ if (!opts.measureOnly)
4503
+ this.loadFailedRoutes.add(normalizePath(url));
4504
+ await this.errorPageCommitted(page);
4505
+ summary.push(`${path} — LOAD FAILED`);
4506
+ // The page a re-attach went back to never loaded, so nothing says whether it worked.
4507
+ if (this.authLoss.reattaching)
4508
+ this.authLoss.abortReattach(`the page it went back to, ${path}, did not load (${reason})`);
4509
+ problems.push(`${path}: ${reason}`);
4510
+ this.crawlHealth.push({
4511
+ path,
4512
+ url,
4513
+ status: null,
4514
+ loadError: reason,
4515
+ loginRedirect: false,
4516
+ elements: 0,
4517
+ unnamed: [],
4518
+ placeholderOnly: [],
4519
+ violations: [],
4520
+ geometry: [],
4521
+ brokenImages: [],
4522
+ design: [],
4523
+ });
4524
+ continue;
4525
+ }
4526
+ }
4527
+ finally {
4528
+ page.off("response", onResponse);
4529
+ }
4530
+ // A feed, a file or data: recorded as what it is and left there. It has no controls to collect or audit, and one
4531
+ // that answered leaves the route contract, so it is neither a dead end nor a gap.
4532
+ if (resource) {
4533
+ if (!opts.measureOnly) {
4534
+ // Only a file that answered leaves the contract; a failing one is attempted with its status, as a failing page is.
4535
+ if (isNonPageResource(resource))
4536
+ memory.markResource(normalizePath(url), resource.contentType);
4537
+ else
4538
+ memory.markAttempted(normalizePath(url), `status:${resource.status}`, this.role);
4539
+ }
4540
+ this.oracles.drain(false);
4541
+ summary.push(`${path} — ${resource.status} · not a page (${resource.contentType})`);
4542
+ if (!isNonPageResource(resource))
4543
+ problems.push(`${path} → HTTP ${resource.status} (${resource.contentType})`);
4471
4544
  this.crawlHealth.push({
4472
4545
  path,
4473
- url,
4474
- status: null,
4475
- loadError: reason,
4546
+ url: resource.url,
4547
+ status: resource.status,
4548
+ contentType: resource.contentType,
4476
4549
  loginRedirect: false,
4477
4550
  elements: 0,
4478
4551
  unnamed: [],
@@ -4494,6 +4567,9 @@ export class BrowserEngine {
4494
4567
  memory.visitState(fp, finalUrl, route, trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
4495
4568
  for (const f of forms)
4496
4569
  memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
4570
+ // A route remembered as a feed or a file that now answers as a page (a route class whose ids serve both, or
4571
+ // one that served a plain-text maintenance reply once) is a page again, so it rejoins the contract.
4572
+ memory.clearResource(normalizePath(url));
4497
4573
  memory.recordRoleAccess(this.role, route, "reached");
4498
4574
  }
4499
4575
  // If we landed somewhere else (auth wall, canonical redirect), the