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 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
- 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 |
@@ -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 && 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,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
- const { issues, worthALook } = checkFindings(measured, start.origin, options.ignore, flows, pictured, options.ignorePaths);
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 } } : {}),
@@ -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
@@ -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": { severity: "medium", title: "Sent to sign-in", help: "The route redirected to a sign-in page; the session is missing or expired." },
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: { severity: "low", title: "Text contrast below WCAG", help: "Text needs 4.5:1 (3:1 when large) against its background." },
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
  }