scenescout 3.17.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 +17 -0
- package/README.md +6 -3
- package/dist/check-run.js +14 -7
- package/dist/engine/browser.js +108 -32
- package/dist/engine/check.js +92 -7
- 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/first-run.js +7 -1
- package/dist/intake.js +161 -0
- package/dist/mcp-server.js +41 -0
- package/dist/playbook.js +1 -1
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
3
20
|
## 3.17.0
|
|
4
21
|
|
|
5
22
|
### 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
|
|
|
@@ -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 |
|
|
@@ -761,6 +761,7 @@ test-app/ fixtures for the real-browser smoke tests
|
|
|
761
761
|
skills/scenescout/ the testing method (SKILL.md): a skill in Claude Code, served by the server everywhere else
|
|
762
762
|
docs/how-it-works.md what happens at each stage, in diagrams
|
|
763
763
|
docs/benchmark.md measuring whether a change made runs better
|
|
764
|
+
docs/validation.md scorecards from runs against public open-source apps
|
|
764
765
|
docs/adr/ why it's built this way
|
|
765
766
|
```
|
|
766
767
|
|
|
@@ -774,6 +775,8 @@ docs/adr/ why it's built this way
|
|
|
774
775
|
|
|
775
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.
|
|
776
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
|
+
|
|
777
780
|
The load-bearing choices are recorded as ADRs — read the relevant one before changing a rule it covers:
|
|
778
781
|
|
|
779
782
|
- [1 · Completion is an enforced contract, not a claim](docs/adr/0001-completion-is-a-contract-not-a-vibe.md)
|
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,18 +175,22 @@ 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 } } : {}),
|
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
|
package/dist/engine/check.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { BROWSER_ENGINES } from "../browsers.js";
|
|
15
15
|
import { BASELINE_MODES, baselineEvidence, baselineFingerprint, countStatuses, describeChange, parseThreshold, VISUAL_RULE, } from "./baseline.js";
|
|
16
|
+
import { isNonPageResource } from "./crawl.js";
|
|
16
17
|
import { flowStepEvidence } from "./flow.js";
|
|
17
18
|
import { parseLimitFlag } from "./limits.js";
|
|
18
19
|
import { redactRoute, redactSecrets } from "./memory.js";
|
|
@@ -20,6 +21,23 @@ import { SARIF_ANCHOR_FALLBACKS, checkSarifAnchor, sarifFileFor, sarifLocation }
|
|
|
20
21
|
import { httpErrorDetail, httpStatusOf } from "./oracles.js";
|
|
21
22
|
export const CHECK_SEVERITIES = ["high", "medium", "low"];
|
|
22
23
|
export const FAIL_ON = [...CHECK_SEVERITIES, "never"];
|
|
24
|
+
/**
|
|
25
|
+
* The routes that answered as pages, and those that answered as something
|
|
26
|
+
* else. Only the pages are checked against the page rules and counted as
|
|
27
|
+
* routes; the rest are listed apart, so a feed or a licence file is neither a
|
|
28
|
+
* dead end nor a page the check claims to have measured.
|
|
29
|
+
*/
|
|
30
|
+
export function splitResources(routes) {
|
|
31
|
+
const pages = [];
|
|
32
|
+
const resources = [];
|
|
33
|
+
for (const r of routes) {
|
|
34
|
+
if (isNonPageResource(r) && r.status !== null && r.contentType)
|
|
35
|
+
resources.push({ path: r.path, status: r.status, contentType: r.contentType });
|
|
36
|
+
else
|
|
37
|
+
pages.push(r);
|
|
38
|
+
}
|
|
39
|
+
return { pages, resources };
|
|
40
|
+
}
|
|
23
41
|
export const CHECK_RULES = {
|
|
24
42
|
"route-load-failed": { severity: "high", title: "Page did not load", help: "Navigating to the route failed or timed out." },
|
|
25
43
|
"route-server-error": { severity: "high", title: "Page answered with a server error", help: "The document itself returned HTTP 5xx." },
|
|
@@ -45,7 +63,11 @@ export const CHECK_RULES = {
|
|
|
45
63
|
title: "Credential posted to any origin",
|
|
46
64
|
help: 'The page called postMessage with targetOrigin "*" and a token-shaped value in the message, so whatever origin the receiving window holds can read it. The value is never reported: its path in the message, its shape and its first four characters are.',
|
|
47
65
|
},
|
|
48
|
-
"auth-redirect": {
|
|
66
|
+
"auth-redirect": {
|
|
67
|
+
severity: "medium",
|
|
68
|
+
title: "Sent to sign-in",
|
|
69
|
+
help: "The route redirected to a sign-in page although the check was given a signed-in session, so the session is missing or expired. With no session given, such a route is listed as needing sign-in instead.",
|
|
70
|
+
},
|
|
49
71
|
"dead-end": { severity: "medium", title: "Dead end", help: "The page has no controls at all: no navigation and no way back." },
|
|
50
72
|
"blocking-overlay": {
|
|
51
73
|
severity: "high",
|
|
@@ -81,7 +103,11 @@ export const CHECK_RULES = {
|
|
|
81
103
|
title: "Field labelled only by its placeholder",
|
|
82
104
|
help: "The field has no label, aria-label, aria-labelledby or title. Its placeholder is not a label: it disappears as soon as the user types, and some assistive technology does not announce it.",
|
|
83
105
|
},
|
|
84
|
-
contrast: {
|
|
106
|
+
contrast: {
|
|
107
|
+
severity: "low",
|
|
108
|
+
title: "Text contrast below WCAG",
|
|
109
|
+
help: "Text needs 4.5:1 (3:1 when large) against its background. Text over an image, gradient, video or canvas has no single background colour and is not measured.",
|
|
110
|
+
},
|
|
85
111
|
"focus-indicator": { severity: "low", title: "No visible focus indicator", help: "Tabbing to the control changes nothing on screen." },
|
|
86
112
|
"horizontal-scroll": { severity: "medium", title: "Page scrolls sideways", help: "Content is wider than the viewport." },
|
|
87
113
|
"tiny-target": {
|
|
@@ -214,8 +240,15 @@ export function geometryRule(line) {
|
|
|
214
240
|
* `--ignore-path` drops a fact on the route it names, or one rule there,
|
|
215
241
|
* before the fact is filed. A fact also seen on a route that is not exempted
|
|
216
242
|
* stays, on that route alone.
|
|
243
|
+
*
|
|
244
|
+
* A route that sends the browser to sign-in is an `auth-redirect` issue only
|
|
245
|
+
* when the check was `signedIn`: then a session it was given was lost. With
|
|
246
|
+
* no session, sending a signed-out visitor to sign-in is the route working,
|
|
247
|
+
* so it goes to `needsSignIn` instead, a coverage gap rather than a defect.
|
|
248
|
+
* The default is signed in, so a caller that does not say never hides one.
|
|
217
249
|
*/
|
|
218
|
-
export function checkFindings(routes, origin, ignore = [], flows = [], baselines = null, ignorePaths = []) {
|
|
250
|
+
export function checkFindings(routes, origin, ignore = [], flows = [], baselines = null, ignorePaths = [], signedIn = true) {
|
|
251
|
+
const needsSignIn = [];
|
|
219
252
|
const byKey = new Map();
|
|
220
253
|
const looks = new Map();
|
|
221
254
|
/** A route (and, when named, one rule on it) that --ignore-path exempts. */
|
|
@@ -254,6 +287,10 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
254
287
|
};
|
|
255
288
|
for (const r of routes) {
|
|
256
289
|
const route = r.path;
|
|
290
|
+
// A feed, a text file or an image is not a page: it has no controls to judge. Only one that answered without an
|
|
291
|
+
// error is one (crawl.ts isNonPageResource), so a feed that fails reaches the route's own error below.
|
|
292
|
+
if (isNonPageResource(r))
|
|
293
|
+
continue;
|
|
257
294
|
if (r.loadError !== undefined) {
|
|
258
295
|
add("route-load-failed", `${route}: ${r.loadError}`, route);
|
|
259
296
|
continue;
|
|
@@ -262,7 +299,12 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
262
299
|
add("route-server-error", `${route} → HTTP ${r.status}`, route);
|
|
263
300
|
else if (r.status !== null && r.status >= 400)
|
|
264
301
|
add("route-client-error", `${route} → HTTP ${r.status}`, route);
|
|
265
|
-
if (r.loginRedirect)
|
|
302
|
+
if (r.loginRedirect && !signedIn) {
|
|
303
|
+
// An --ignore-path exemption takes the route off the list too; --ignore auth-redirect does not, since this is coverage, not that rule.
|
|
304
|
+
if (!exempt("auth-redirect", route) && !needsSignIn.includes(route))
|
|
305
|
+
needsSignIn.push(route);
|
|
306
|
+
}
|
|
307
|
+
else if (r.loginRedirect)
|
|
266
308
|
add("auth-redirect", `${route} → ${withoutOrigin(r.url, origin) || "/"}`, route);
|
|
267
309
|
else if (r.elements === 0 && (r.status === null || r.status < 400))
|
|
268
310
|
add("dead-end", `${route}: 0 controls`, route);
|
|
@@ -310,11 +352,12 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
310
352
|
return {
|
|
311
353
|
issues: [...byKey.values(), ...visual].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.rule.localeCompare(b.rule)),
|
|
312
354
|
worthALook: [...looks.values()].sort((a, b) => a.rule.localeCompare(b.rule)),
|
|
355
|
+
needsSignIn,
|
|
313
356
|
};
|
|
314
357
|
}
|
|
315
358
|
/** The defect tier of `checkFindings`: what counts, and what the gate reads. */
|
|
316
|
-
export function issuesFromRoutes(routes, origin, ignore = [], flows = [], ignorePaths = []) {
|
|
317
|
-
return checkFindings(routes, origin, ignore, flows, null, ignorePaths).issues;
|
|
359
|
+
export function issuesFromRoutes(routes, origin, ignore = [], flows = [], ignorePaths = [], signedIn = true) {
|
|
360
|
+
return checkFindings(routes, origin, ignore, flows, null, ignorePaths, signedIn).issues;
|
|
318
361
|
}
|
|
319
362
|
/**
|
|
320
363
|
* Why a check has nothing to give a verdict on, or null when it measured the
|
|
@@ -916,6 +959,42 @@ export function routesTable(result) {
|
|
|
916
959
|
}
|
|
917
960
|
return lines;
|
|
918
961
|
}
|
|
962
|
+
/** The routes that answered with a feed, a file or data, as their own table; nothing when there were none. */
|
|
963
|
+
export function resourcesTable(resources) {
|
|
964
|
+
if (!resources || resources.length === 0)
|
|
965
|
+
return [];
|
|
966
|
+
const lines = [
|
|
967
|
+
"",
|
|
968
|
+
"## Not pages",
|
|
969
|
+
"",
|
|
970
|
+
"Answered with a feed, a file or data, so not checked as pages.",
|
|
971
|
+
"",
|
|
972
|
+
"| Route | Status | Content type |",
|
|
973
|
+
"|---|---|---|",
|
|
974
|
+
];
|
|
975
|
+
for (const r of resources)
|
|
976
|
+
lines.push(`| ${cell(r.path)} | ${r.status} | ${cell(r.contentType)} |`);
|
|
977
|
+
return lines;
|
|
978
|
+
}
|
|
979
|
+
/** The coverage gap a check with no session leaves, in one sentence: how many routes (or pages) need sign-in, and `cover`, what covers them. */
|
|
980
|
+
export function needsSignInText(count, cover, noun = "route") {
|
|
981
|
+
return `${count} ${noun}${count === 1 ? " needs" : "s need"} sign-in; give a role to cover them: ${cover}.`;
|
|
982
|
+
}
|
|
983
|
+
/** The routes that sent a check with no session to sign-in, as their own section; nothing when there were none. */
|
|
984
|
+
export function needsSignInSection(result) {
|
|
985
|
+
const routes = result.needsSignIn ?? [];
|
|
986
|
+
if (routes.length === 0)
|
|
987
|
+
return [];
|
|
988
|
+
const cover = `pass ${code("--storage-state")} with a signed-in session, such as the profile ${code(`scenescout login ${result.url} --role <name>`)} saves`;
|
|
989
|
+
return [
|
|
990
|
+
"",
|
|
991
|
+
`## Needs sign-in (${routes.length})`,
|
|
992
|
+
"",
|
|
993
|
+
`${needsSignInText(routes.length, cover)} The check had no session, so sending it to sign-in is these routes working, not a defect: what is behind them was not measured.`,
|
|
994
|
+
"",
|
|
995
|
+
`${routes.slice(0, 20).map(code).join(", ")}${routes.length > 20 ? " …" : ""}`,
|
|
996
|
+
];
|
|
997
|
+
}
|
|
919
998
|
/** The routes known and not visited, as one line; nothing when every known route was visited. */
|
|
920
999
|
export function unvisitedLine(unvisited, why) {
|
|
921
1000
|
if (unvisited.length === 0)
|
|
@@ -942,12 +1021,15 @@ export function formatCheck(result) {
|
|
|
942
1021
|
: `${couldNotRun > 0 ? "failed" : "**FAILED**"} — ${failingText} · ${counts.high} high · ${counts.medium} medium · ${counts.low} low`) +
|
|
943
1022
|
(unaudited > 0 ? ` · design not measured on ${unaudited} route(s)` : "") +
|
|
944
1023
|
(uncompared > 0 ? ` · ${uncompared} visual target(s) not compared: no baseline yet` : "") +
|
|
945
|
-
(result.worthALook.length > 0 ? ` · ${result.worthALook.length} worth a look, never gated` : "")
|
|
1024
|
+
(result.worthALook.length > 0 ? ` · ${result.worthALook.length} worth a look, never gated` : "") +
|
|
1025
|
+
(result.needsSignIn?.length ? ` · ${result.needsSignIn.length} route(s) need sign-in, not covered` : ""));
|
|
946
1026
|
// Right under the verdict: what a green check was allowed to do is part of what it means.
|
|
947
1027
|
lines.push("", `Settings — ${describeSettings(result)}`);
|
|
948
1028
|
lines.push(...issueSections(result.issues));
|
|
949
1029
|
lines.push(...worthALookSection(result.worthALook));
|
|
1030
|
+
lines.push(...needsSignInSection(result));
|
|
950
1031
|
lines.push(...routesTable(result));
|
|
1032
|
+
lines.push(...resourcesTable(result.resources));
|
|
951
1033
|
lines.push(...unvisitedLine(result.unvisited, "over --max-routes"));
|
|
952
1034
|
if (result.flows.length > 0 || result.skippedFlows.length > 0) {
|
|
953
1035
|
lines.push("", `## Flows (${result.flows.length})`, "");
|
|
@@ -1023,6 +1105,7 @@ export function toSummaryJson(result, toolVersion) {
|
|
|
1023
1105
|
controls: r.elements,
|
|
1024
1106
|
...(r.auditError !== undefined ? { designNotMeasured: r.auditError } : {}),
|
|
1025
1107
|
})),
|
|
1108
|
+
resources: result.resources ?? [],
|
|
1026
1109
|
unvisited: result.unvisited,
|
|
1027
1110
|
...(result.timeBudget ? { timeBudget: result.timeBudget } : {}),
|
|
1028
1111
|
ignored: result.ignored,
|
|
@@ -1042,5 +1125,7 @@ export function toSummaryJson(result, toolVersion) {
|
|
|
1042
1125
|
issues: result.issues,
|
|
1043
1126
|
// Apart from `issues` and `counts`, which the gate reads: none of these is counted or gated.
|
|
1044
1127
|
worthALook: result.worthALook,
|
|
1128
|
+
// Routes a check with no session was sent to sign-in from: a coverage gap. Absent when the check had a session.
|
|
1129
|
+
...(result.needsSignIn ? { needsSignIn: result.needsSignIn } : {}),
|
|
1045
1130
|
};
|
|
1046
1131
|
}
|