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 +29 -0
- package/README.md +13 -7
- package/dist/check-run.js +15 -7
- package/dist/cli.js +3 -4
- package/dist/engine/browser.js +108 -32
- package/dist/engine/check.js +152 -8
- package/dist/engine/collector.js +62 -21
- package/dist/engine/crawl.js +37 -0
- package/dist/engine/design.js +73 -3
- package/dist/engine/fingerprint.js +16 -0
- package/dist/engine/memory.js +74 -20
- package/dist/engine/policy.js +31 -0
- package/dist/engine/probes.js +75 -16
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -0
- package/dist/first-run.js +8 -1
- package/dist/installer.js +111 -5
- package/dist/intake.js +161 -0
- package/dist/mcp-server.js +135 -9
- package/dist/playbook.js +1 -1
- package/dist/prompts.js +106 -0
- package/package.json +3 -3
- package/skills/scenescout/SKILL.md +2 -2
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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: `
|
|
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 &&
|
|
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 -
|
|
125
|
-
log(` ${
|
|
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() &&
|
|
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
|
-
|
|
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; --
|
|
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
|
|
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
|
}
|
package/dist/engine/browser.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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.
|
|
1835
|
-
const aliases =
|
|
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,
|
|
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
|
|
2336
|
-
* destructive, nor one to an endpoint
|
|
2337
|
-
* a mutation): naming it would change
|
|
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
|
-
|
|
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
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
this.
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
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:
|
|
4475
|
-
|
|
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
|