scenescout 3.17.0 → 3.19.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.
@@ -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,21 +352,39 @@ 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
  }
362
+ /**
363
+ * Fewest controls a start page must show for a check that measured nothing
364
+ * else to count as having measured the app (unmeasuredReason).
365
+ */
366
+ export const START_PAGE_MIN_CONTROLS = 2;
319
367
  /**
320
368
  * Why a check has nothing to give a verdict on, or null when it measured the
321
369
  * app. Neither case is a pass: a gate that goes green on a login page or a
322
370
  * dead server would pass every pull request while the app was down.
323
371
  */
324
- export function unmeasuredReason(routes, startIsFirst) {
372
+ export function unmeasuredReason(routes, startIsFirst, unvisited = []) {
325
373
  const loaded = routes.filter((r) => r.loadError === undefined);
326
374
  if (loaded.length === 0)
327
375
  return "no page loaded. Is the app running at that URL?";
376
+ // A start page measured before the app drew it (a client-rendered shell still loading, a blank page) links nowhere,
377
+ // so the crawl ends there and every rule is silent: a pass that checked nothing. A real one-page app has controls.
378
+ const start = routes[0];
379
+ // `unvisited` empty: the page linked nowhere. A crawl that --max-routes cut short did find more, and measured what it was asked to.
380
+ if (startIsFirst &&
381
+ unvisited.length === 0 &&
382
+ loaded.length === 1 &&
383
+ start === loaded[0] &&
384
+ !start.loginRedirect &&
385
+ start.elements < START_PAGE_MIN_CONTROLS) {
386
+ return `only the start page was measured, and it showed ${start.elements} control(s) and linked nowhere, so the app may not have finished drawing. Check again, raise --nav-timeout-ms if the app is slow to render, pass --project so routes come from the source, or name them with --paths.`;
387
+ }
328
388
  // Without --paths the first route is the start page. When it bounces, what follows is the sign-in
329
389
  // page's own links (sign-up, forgot password): pages that load, but not the app.
330
390
  // With --paths there is no start page, only a list, and one walled path among public ones is an auth-redirect issue.
@@ -818,7 +878,7 @@ const BASELINE_MARK = {
818
878
  /** One target's line in the report. */
819
879
  function baselineLine(r, run) {
820
880
  const what = `${BASELINE_MARK[r.status]} ${code(r.element)} on ${code(r.path)}`;
821
- const notes = [r.platformNote, r.partial]
881
+ const notes = [r.platformNote, r.partial, r.unsteady]
822
882
  .filter(Boolean)
823
883
  .map((n) => ` _(${cell(n)})_`)
824
884
  .join("");
@@ -916,6 +976,42 @@ export function routesTable(result) {
916
976
  }
917
977
  return lines;
918
978
  }
979
+ /** The routes that answered with a feed, a file or data, as their own table; nothing when there were none. */
980
+ export function resourcesTable(resources) {
981
+ if (!resources || resources.length === 0)
982
+ return [];
983
+ const lines = [
984
+ "",
985
+ "## Not pages",
986
+ "",
987
+ "Answered with a feed, a file or data, so not checked as pages.",
988
+ "",
989
+ "| Route | Status | Content type |",
990
+ "|---|---|---|",
991
+ ];
992
+ for (const r of resources)
993
+ lines.push(`| ${cell(r.path)} | ${r.status} | ${cell(r.contentType)} |`);
994
+ return lines;
995
+ }
996
+ /** 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. */
997
+ export function needsSignInText(count, cover, noun = "route") {
998
+ return `${count} ${noun}${count === 1 ? " needs" : "s need"} sign-in; give a role to cover them: ${cover}.`;
999
+ }
1000
+ /** The routes that sent a check with no session to sign-in, as their own section; nothing when there were none. */
1001
+ export function needsSignInSection(result) {
1002
+ const routes = result.needsSignIn ?? [];
1003
+ if (routes.length === 0)
1004
+ return [];
1005
+ const cover = `pass ${code("--storage-state")} with a signed-in session, such as the profile ${code(`scenescout login ${result.url} --role <name>`)} saves`;
1006
+ return [
1007
+ "",
1008
+ `## Needs sign-in (${routes.length})`,
1009
+ "",
1010
+ `${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.`,
1011
+ "",
1012
+ `${routes.slice(0, 20).map(code).join(", ")}${routes.length > 20 ? " …" : ""}`,
1013
+ ];
1014
+ }
919
1015
  /** The routes known and not visited, as one line; nothing when every known route was visited. */
920
1016
  export function unvisitedLine(unvisited, why) {
921
1017
  if (unvisited.length === 0)
@@ -942,22 +1038,26 @@ export function formatCheck(result) {
942
1038
  : `${couldNotRun > 0 ? "failed" : "**FAILED**"} — ${failingText} · ${counts.high} high · ${counts.medium} medium · ${counts.low} low`) +
943
1039
  (unaudited > 0 ? ` · design not measured on ${unaudited} route(s)` : "") +
944
1040
  (uncompared > 0 ? ` · ${uncompared} visual target(s) not compared: no baseline yet` : "") +
945
- (result.worthALook.length > 0 ? ` · ${result.worthALook.length} worth a look, never gated` : ""));
1041
+ (result.worthALook.length > 0 ? ` · ${result.worthALook.length} worth a look, never gated` : "") +
1042
+ (result.needsSignIn?.length ? ` · ${result.needsSignIn.length} route(s) need sign-in, not covered` : ""));
946
1043
  // Right under the verdict: what a green check was allowed to do is part of what it means.
947
1044
  lines.push("", `Settings — ${describeSettings(result)}`);
948
1045
  lines.push(...issueSections(result.issues));
949
1046
  lines.push(...worthALookSection(result.worthALook));
1047
+ lines.push(...needsSignInSection(result));
950
1048
  lines.push(...routesTable(result));
1049
+ lines.push(...resourcesTable(result.resources));
951
1050
  lines.push(...unvisitedLine(result.unvisited, "over --max-routes"));
952
1051
  if (result.flows.length > 0 || result.skippedFlows.length > 0) {
953
1052
  lines.push("", `## Flows (${result.flows.length})`, "");
954
1053
  for (const f of result.flows) {
955
1054
  const o = f.outcome;
1055
+ const as = f.role === undefined ? "" : ` as ${code(f.role)}`;
956
1056
  lines.push(o.status === "passed"
957
- ? `- ✓ ${cell(f.name)} (${code(f.file)}): ${f.steps} step(s) passed`
1057
+ ? `- ✓ ${cell(f.name)} (${code(f.file)})${as}: ${f.steps} step(s) passed`
958
1058
  : o.status === "refused"
959
- ? `- ⊘ ${cell(f.name)} (${code(f.file)}): could not run — step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)} _(--flow-writes ${result.settings.flowWrites})_`
960
- : `- ✗ ${cell(f.name)} (${code(f.file)}): step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)}`);
1059
+ ? `- ⊘ ${cell(f.name)} (${code(f.file)})${as}: could not run — step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)} _(--flow-writes ${result.settings.flowWrites})_`
1060
+ : `- ✗ ${cell(f.name)} (${code(f.file)})${as}: step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)}`);
961
1061
  if (f.refusedBackground.length > 0)
962
1062
  lines.push(` - refused background requests (beacons and pings, not charged to a step): ${f.refusedBackground.map(code).join(", ")}`);
963
1063
  if (f.websockets.length > 0)
@@ -1023,6 +1123,7 @@ export function toSummaryJson(result, toolVersion) {
1023
1123
  controls: r.elements,
1024
1124
  ...(r.auditError !== undefined ? { designNotMeasured: r.auditError } : {}),
1025
1125
  })),
1126
+ resources: result.resources ?? [],
1026
1127
  unvisited: result.unvisited,
1027
1128
  ...(result.timeBudget ? { timeBudget: result.timeBudget } : {}),
1028
1129
  ignored: result.ignored,
@@ -1030,6 +1131,7 @@ export function toSummaryJson(result, toolVersion) {
1030
1131
  flows: result.flows.map((f) => ({
1031
1132
  name: f.name,
1032
1133
  file: f.file,
1134
+ ...(f.role === undefined ? {} : { role: f.role }),
1033
1135
  steps: f.steps,
1034
1136
  status: f.outcome.status,
1035
1137
  ...(f.outcome.status === "passed" ? {} : { step: f.outcome.step, did: f.outcome.did, reason: f.outcome.reason, path: f.outcome.path }),
@@ -1042,5 +1144,7 @@ export function toSummaryJson(result, toolVersion) {
1042
1144
  issues: result.issues,
1043
1145
  // Apart from `issues` and `counts`, which the gate reads: none of these is counted or gated.
1044
1146
  worthALook: result.worthALook,
1147
+ // Routes a check with no session was sent to sign-in from: a coverage gap. Absent when the check had a session.
1148
+ ...(result.needsSignIn ? { needsSignIn: result.needsSignIn } : {}),
1045
1149
  };
1046
1150
  }
@@ -39,6 +39,25 @@ export const XPATH_OF_SRC = `(el) => {
39
39
  }
40
40
  return "/html/" + parts.join("/");
41
41
  }`;
42
+ /**
43
+ * What one descendant of a control says toward the control's name, as
44
+ * page-side source, in the order the accessible-name computation reads it
45
+ * when a name comes from content: its aria-label; for an <img>, its alt text,
46
+ * and when that is empty or missing, its title (an avatar with alt="" and a
47
+ * title names the link around it); for an <svg>, its <title>. Anything else
48
+ * says nothing. Kept apart from NAME_FACTS_SRC so the order can be
49
+ * table-tested without a browser (contentSays).
50
+ */
51
+ export const CONTENT_SAYS_SRC = `(d) => {
52
+ if (d.ariaLabel && d.ariaLabel.trim()) return d.ariaLabel;
53
+ if (d.tag === "img") return d.alt && d.alt.trim() ? d.alt : d.title;
54
+ if (d.tag === "svg") return d.svgTitle;
55
+ return "";
56
+ }`;
57
+ /** CONTENT_SAYS_SRC run outside a page, from the same source the page runs. */
58
+ export function contentSays(facts) {
59
+ return new Function(`return (${CONTENT_SAYS_SRC});`)()(facts);
60
+ }
42
61
  /**
43
62
  * What an element's accessible name is read from, as page-side source: every
44
63
  * candidate source, read from the DOM and nothing decided. PICK_NAME_SRC
@@ -48,31 +67,45 @@ export const XPATH_OF_SRC = `(el) => {
48
67
  * holds the control, and a select's options or a textarea's contents are not
49
68
  * its label.
50
69
  *
51
- * `content` is what the element's image content says, which the text cannot
52
- * hold: the alt text of the first descendant <img>, else the aria-label or
53
- * <title> of a descendant <svg> or role="img", in document order, skipping
54
- * anything inside an aria-hidden part of the element.
70
+ * `content` is what the element's content says that its text cannot hold:
71
+ * the first descendant, in document order, that CONTENT_SAYS_SRC finds a word
72
+ * in (an aria-label, an image's alt or title, an svg's <title>), skipping
73
+ * anything inside an aria-hidden part of the element. `content2` is what the
74
+ * same content said under name rule 2 (memory.ts NAME_RULE), which read only
75
+ * an <img>'s alt and an <svg>'s or role="img"'s aria-label or <title>: it is
76
+ * read so a control renamed since keeps its coverage.
55
77
  */
56
78
  export const NAME_FACTS_SRC = `(el) => {
57
79
  const tag = el.tagName.toLowerCase();
58
80
  const role = el.getAttribute("role") || "";
59
81
  const inputType = tag === "input" ? el.type : "";
60
- const byId = (id) => { const n = document.getElementById(id); return n ? n.textContent || "" : ""; };
82
+ const byId = (id) => { const n = (el.ownerDocument || document).getElementById(id); return n ? n.textContent || "" : ""; };
61
83
  const labels = [];
62
84
  for (const label of Array.from(el.labels || [])) {
63
85
  const own = label.contains(el) ? el.textContent || "" : "";
64
86
  labels.push((label.textContent || "").replace(own, ""));
65
87
  }
88
+ const says = ${CONTENT_SAYS_SRC};
66
89
  let content = "";
67
- for (const d of el.querySelectorAll("img, svg, [role='img']")) {
90
+ let content2 = "";
91
+ for (const d of el.querySelectorAll("img, svg, [role='img'], [aria-label]")) {
68
92
  const hidden = d.closest('[aria-hidden="true"]');
69
93
  if (hidden && el.contains(hidden)) continue;
70
- const title = d.tagName.toLowerCase() === "svg" ? d.querySelector(":scope > title") : null;
71
- const said = (d.tagName === "IMG" ? d.getAttribute("alt") : d.getAttribute("aria-label") || (title ? title.textContent : "")) || "";
72
- if (said.trim()) {
73
- content = said;
74
- break;
94
+ const svgTitle = d.tagName.toLowerCase() === "svg" ? d.querySelector(":scope > title") : null;
95
+ const said = says({
96
+ tag: d.tagName.toLowerCase(),
97
+ ariaLabel: d.getAttribute("aria-label"),
98
+ alt: d.getAttribute("alt"),
99
+ title: d.getAttribute("title") || "",
100
+ svgTitle: svgTitle ? svgTitle.textContent || "" : "",
101
+ });
102
+ if (!content && said.trim()) content = said;
103
+ // Name rule 2, frozen: what this content said before a descendant's aria-label and an image's title counted.
104
+ if (!content2 && d.matches("img, svg, [role='img']")) {
105
+ const said2 = (d.tagName === "IMG" ? d.getAttribute("alt") : d.getAttribute("aria-label") || (svgTitle ? svgTitle.textContent : "")) || "";
106
+ if (said2.trim()) content2 = said2;
75
107
  }
108
+ if (content && content2) break;
76
109
  }
77
110
  const live = /^(status|alert|log|timer|marquee)$/.test(role) || tag === "output" ||
78
111
  (el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off");
@@ -91,6 +124,7 @@ export const NAME_FACTS_SRC = `(el) => {
91
124
  live,
92
125
  text: el.innerText || el.textContent || "",
93
126
  content,
127
+ content2,
94
128
  };
95
129
  }`;
96
130
  /**
@@ -105,15 +139,18 @@ export const NAME_FACTS_SRC = `(el) => {
105
139
  * fallback stays.
106
140
  *
107
141
  * - A select is never named by its options: their text is its value, not its name.
108
- * - Other elements are named by their text; with none, by their image content
142
+ * - Other elements are named by their text; with none, by their content
109
143
  * (a link holding only a logo with alt text, a button holding only an svg
110
- * with a <title>); and with neither, by title (an icon-only button with a
111
- * tooltip). Text and image content are not joined: a control with both is
112
- * named by its text, as it was before image content counted.
113
- * - A name taken from image content carries `prior`, the name the rule gave
114
- * before image content counted (the title, or nothing). The name is half of
115
- * the coverage key, so the key under the earlier rule is kept beside the new
116
- * one and coverage recorded under it carries over (memory.ts keyAliases).
144
+ * with a <title>, a link around an icon element with an aria-label or an
145
+ * avatar with a title); and with neither, by title (an icon-only button
146
+ * with a tooltip). Text and content are not joined: a control with both is
147
+ * named by its text, as it was before content counted.
148
+ * - A name taken from content carries `priors`, the name each earlier rule
149
+ * gave (memory.ts NAME_RULE), rule 1 first: rule 1 read no content (the
150
+ * title, or nothing), rule 2 read only image content. The name is half of
151
+ * the coverage key, so the key under each earlier rule is kept beside the
152
+ * new one and coverage recorded under it carries over (memory.ts keyAliases,
153
+ * ruleKeyAliases).
117
154
  * - A live region (status, alert, log, timer, an aria-live region, <output>)
118
155
  * is named by the text it announces, which is what matters after an action.
119
156
  * - A whitespace-only aria-label ends the name with nothing, as it always has
@@ -145,7 +182,11 @@ export const PICK_NAME_SRC = `(f) => {
145
182
  return { name: (clean(f.nameAttr) || t || f.tag).slice(0, 80), from: "fallback" };
146
183
  }
147
184
  if (clean(f.text)) return named(clean(f.text));
148
- if (clean(f.content)) return { ...named(clean(f.content)), prior: clean(f.title).slice(0, 80) };
185
+ if (clean(f.content)) {
186
+ // The name under rule 1 (no content) and under rule 2 (content2), so coverage keys can be carried across.
187
+ const title = clean(f.title).slice(0, 80);
188
+ return { ...named(clean(f.content)), priors: [title, clean(f.content2) ? clean(f.content2).slice(0, 80) : title] };
189
+ }
149
190
  return named(clean(f.title));
150
191
  }`;
151
192
  /**
@@ -656,7 +697,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
656
697
  name: named.name,
657
698
  ...policyText(el),
658
699
  nameFrom: named.from,
659
- ...(named.prior !== undefined ? { priorName: named.prior } : {}),
700
+ ...(named.priors ? { priorNames: named.priors } : {}),
660
701
  testid: el.getAttribute("data-testid"),
661
702
  interactive,
662
703
  affords: affords && (affords.tabStop || affords.clickHandler || affords.pointer) ? affords : null,
@@ -47,3 +47,40 @@ export function crawlLine(o, counts, flags) {
47
47
  (counts.unnamed ? ` · ${counts.unnamed} unnamed` : "") +
48
48
  (allFlags.length ? ` · ${allFlags.join(" ")}` : ""));
49
49
  }
50
+ /** The media types a browser renders as a page: anything else a route answers with is a file, a feed or data. */
51
+ export const PAGE_MEDIA_TYPES = ["text/html", "application/xhtml+xml"];
52
+ /**
53
+ * A Content-Type header's media type, lowercased and without its parameters
54
+ * ("text/HTML; charset=utf-8" → "text/html"); undefined when there is none.
55
+ */
56
+ export function mediaTypeOf(header) {
57
+ const type = (header ?? "").split(";")[0].trim().toLowerCase();
58
+ return type === "" ? undefined : type;
59
+ }
60
+ /**
61
+ * Whether a route's document answered as something other than a page: an RSS
62
+ * or Atom feed, XML, JSON, plain text, a PDF, an image. Decided from the
63
+ * response's content type, not the path, because a feed at `/feed`, a licence
64
+ * file or a raw-file view has no extension to give it away, and a path ending
65
+ * `.xml` can be served as HTML. Such a route has no controls by nature, so the
66
+ * page rules (a dead end among them) do not apply to it, and it is not a page
67
+ * for the route count. Only a response that succeeded counts: the body of a
68
+ * 4xx or 5xx describes the error, not the route, and the error is reported as
69
+ * the route's own. A response with no content type is read as a page, the
70
+ * behaviour before the type was consulted.
71
+ */
72
+ export function isNonPageResource(r) {
73
+ if (r.status === null || r.status >= 400)
74
+ return false;
75
+ return isFileMediaType(r.contentType);
76
+ }
77
+ /**
78
+ * Whether a response's content type names something other than a page, whatever
79
+ * its status. A browser may take such a response as a download and fail the
80
+ * navigation, so this is what says the route answered (with its status) rather
81
+ * than failed to load.
82
+ */
83
+ export function isFileMediaType(contentType) {
84
+ const type = mediaTypeOf(contentType);
85
+ return type !== undefined && !PAGE_MEDIA_TYPES.includes(type);
86
+ }
@@ -22,6 +22,32 @@
22
22
  * controls, visible keyboard focus.
23
23
  */
24
24
  import { missingName, NAME_SRC, placeholderOnly } from "./collector.js";
25
+ /**
26
+ * The control a Tab press focused, as the focus-indicator rule measures it. It runs
27
+ * page-side (probes.ts embeds its source), so it is plain JavaScript once compiled.
28
+ *
29
+ * A Tab that lands on an iframe or frame moves focus into the frame's document: the
30
+ * frame element is not the control the user sees focused, and its own outline staying
31
+ * the same says nothing. So the walk follows focus into a same-origin frame and returns
32
+ * the element focused there. It returns "frame" when focus is inside a frame it cannot
33
+ * follow (another site's, whose document is not readable) or a frame whose document has
34
+ * nothing focused: that press is skipped, not sampled. It returns null when nothing in
35
+ * the page is focused, which ends the sampling.
36
+ */
37
+ export function focusedControl(doc) {
38
+ let d = doc;
39
+ let el = d.activeElement;
40
+ while (el && /^i?frame$/i.test(el.tagName)) {
41
+ const inner = el.contentDocument ?? null;
42
+ if (!inner)
43
+ return "frame";
44
+ d = inner;
45
+ el = inner.activeElement;
46
+ }
47
+ if (!el || el === d.body || el === d.documentElement)
48
+ return d === doc ? null : "frame";
49
+ return el;
50
+ }
25
51
  /**
26
52
  * Whether some text names a filter or a facet, as page-side source: one of its
27
53
  * words, split at camelCase and punctuation, is filter(s) or facet(s). Whole
@@ -63,20 +89,47 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
63
89
  const p = parseColor(c);
64
90
  return p ? "rgba(" + p[0] + ", " + p[1] + ", " + p[2] + ", " + p[3] + ")" : "unknown";
65
91
  };
92
+ // Replaced content that paints pixels the audit cannot read: an <img>,
93
+ // <picture>, <video> or <canvas>, often absolutely positioned under a hero
94
+ // heading as a sibling or cousin of the text rather than its ancestor.
95
+ let media = null;
96
+ const mediaUnder = (el, within) => {
97
+ if (media === null) media = Array.from(document.querySelectorAll("img, picture, video, canvas")).slice(0, 300);
98
+ const r = el.getBoundingClientRect();
99
+ const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
100
+ for (const m of media) {
101
+ // The text's own subtree holds inline icons, not its background; and an
102
+ // image outside the opaque ancestor the text sits on is treated as hidden
103
+ // behind it. An opaque overlay that is NOT an ancestor (a sibling scrim)
104
+ // is not seen, so text on it over an image is skipped, not measured.
105
+ if (el.contains(m) || (within && !within.contains(m))) continue;
106
+ const mr = m.getBoundingClientRect();
107
+ if (mr.width === 0 || mr.height === 0 || cx < mr.left || cx > mr.right || cy < mr.top || cy > mr.bottom) continue;
108
+ const ms = window.getComputedStyle(m);
109
+ if (ms.visibility === "hidden" || ms.display === "none" || ms.opacity === "0") continue;
110
+ return true;
111
+ }
112
+ return false;
113
+ };
66
114
  // Effective background: COMPOSITE translucent ancestor layers (a chip with
67
115
  // rgba(...,0.12) over white is nearly white — treating it as opaque produces
68
116
  // false WCAG failures). Stops at the first opaque layer; white fallback.
117
+ // "image" when an ancestor's background-image or a media element under the
118
+ // text's centre paints behind it: no single colour stands for that, so the
119
+ // contrast check skips it rather than report a ratio against the page.
69
120
  const effBg = (el) => {
70
121
  const layers = [];
71
122
  let node = el;
123
+ let opaque = null;
72
124
  for (let i = 0; node && i < 20; i++, node = node.parentElement) {
73
125
  const s = window.getComputedStyle(node);
74
126
  if (s.backgroundImage && s.backgroundImage !== "none") return "image";
75
127
  const c = parseColor(s.backgroundColor);
76
128
  if (!c || c[3] === 0) continue;
77
129
  layers.push(c);
78
- if (c[3] >= 1) break;
130
+ if (c[3] >= 1) { opaque = node; break; }
79
131
  }
132
+ if (mediaUnder(el, opaque)) return "image";
80
133
  let base = [255, 255, 255];
81
134
  for (let i = layers.length - 1; i >= 0; i--) {
82
135
  const [r, g, b, a] = layers[i];
@@ -264,7 +317,7 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
264
317
  if (img.naturalWidth <= 1 || img.naturalHeight <= 1 || r.width < 24 || r.height < 24) continue;
265
318
  const tid = img.getAttribute("data-testid");
266
319
  const label = tid ? "[" + tid + "]" : (img.getAttribute("alt") || (img.getAttribute("src") || "").split("/").pop() || "img").slice(0, 40);
267
- images.push({ label, nw: img.naturalWidth, nh: img.naturalHeight, rw: Math.round(r.width), rh: Math.round(r.height) });
320
+ images.push({ label, nw: img.naturalWidth, nh: img.naturalHeight, rw: Math.round(r.width), rh: Math.round(r.height), fit: window.getComputedStyle(img).objectFit || "fill" });
268
321
  }
269
322
  const page = {
270
323
  scrollW: Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0),
@@ -359,6 +412,10 @@ function contrastFailures(records) {
359
412
  for (const r of records) {
360
413
  if (!r.text || r.bg === "image" || r.bg === "unknown" || r.color === "unknown")
361
414
  continue;
415
+ // Text whose own colour is fully transparent is not painted: a selectable text layer laid over a rendered
416
+ // document (a PDF viewer's), or text hidden for screen readers. Its 1:1 ratio is not what anyone sees.
417
+ if (parseRgb(r.color)?.[3] === 0)
418
+ continue;
362
419
  const ratio = contrastRatio(r.color, r.bg);
363
420
  if (ratio === null)
364
421
  continue;
@@ -574,6 +631,20 @@ export function spacedApart(r, others) {
574
631
  export function tooSmall(r, all) {
575
632
  return undersized(r) && !spacedApart(r, all);
576
633
  }
634
+ /** Past this, a stretched image reads as distorted rather than as rounding. */
635
+ export const ASPECT_TOLERANCE = 0.12;
636
+ /**
637
+ * How far an image is stretched out of its proportions, or null when it is not.
638
+ * Only `fill`, the default, stretches the picture to its box: `cover` and `contain`
639
+ * scale it uniformly and crop or letterbox, `none` draws it at its own size and
640
+ * `scale-down` is one of those two. So a mismatched box is distortion only under `fill`.
641
+ */
642
+ export function aspectDistortion(img) {
643
+ if (img.fit !== "fill")
644
+ return null;
645
+ const off = Math.abs(img.rw / img.rh / (img.nw / img.nh) - 1);
646
+ return off > ASPECT_TOLERANCE ? off : null;
647
+ }
577
648
  /**
578
649
  * Reduce raw style records + page facts to a judgeable design digest + score.
579
650
  *
@@ -884,7 +955,10 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
884
955
  if (affordances.length > 0)
885
956
  sections.push(`AFFORDANCES:\n` + affordances.map((s) => ` ${s}`).join("\n"));
886
957
  // ---- 12. IMAGES: aspect-ratio distortion (rendered box fights the source's proportions).
887
- const distorted = page.images.map((img) => ({ img, off: Math.abs(img.rw / img.rh / (img.nw / img.nh) - 1) })).filter(({ off }) => off > 0.12);
958
+ const distorted = page.images.flatMap((img) => {
959
+ const off = aspectDistortion(img);
960
+ return off === null ? [] : [{ img, off }];
961
+ });
888
962
  if (distorted.length > 0) {
889
963
  sections.push(`IMAGES (${distorted.length} distorted):\n` +
890
964
  distorted