scenescout 3.16.0 → 3.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +13 -7
- package/dist/check-run.js +15 -7
- package/dist/cli.js +3 -4
- package/dist/engine/browser.js +108 -32
- package/dist/engine/check.js +152 -8
- package/dist/engine/collector.js +62 -21
- package/dist/engine/crawl.js +37 -0
- package/dist/engine/design.js +73 -3
- package/dist/engine/fingerprint.js +16 -0
- package/dist/engine/memory.js +74 -20
- package/dist/engine/policy.js +31 -0
- package/dist/engine/probes.js +75 -16
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -0
- package/dist/first-run.js +8 -1
- package/dist/installer.js +111 -5
- package/dist/intake.js +161 -0
- package/dist/mcp-server.js +135 -9
- package/dist/playbook.js +1 -1
- package/dist/prompts.js +106 -0
- package/package.json +3 -3
- package/skills/scenescout/SKILL.md +2 -2
package/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": {
|
|
@@ -210,14 +236,27 @@ export function geometryRule(line) {
|
|
|
210
236
|
* dedup, and their fingerprint comes from the target, not the evidence: the
|
|
211
237
|
* evidence carries this run's percentage, and the same target changing by a
|
|
212
238
|
* different amount is still the same alert.
|
|
239
|
+
*
|
|
240
|
+
* `--ignore-path` drops a fact on the route it names, or one rule there,
|
|
241
|
+
* before the fact is filed. A fact also seen on a route that is not exempted
|
|
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.
|
|
213
249
|
*/
|
|
214
|
-
export function checkFindings(routes, origin, ignore = [], flows = [], baselines = null) {
|
|
250
|
+
export function checkFindings(routes, origin, ignore = [], flows = [], baselines = null, ignorePaths = [], signedIn = true) {
|
|
251
|
+
const needsSignIn = [];
|
|
215
252
|
const byKey = new Map();
|
|
216
253
|
const looks = new Map();
|
|
254
|
+
/** A route (and, when named, one rule on it) that --ignore-path exempts. */
|
|
255
|
+
const exempt = (rule, route) => ignorePaths.some((e) => e.path === route && (e.rule === undefined || e.rule === rule));
|
|
217
256
|
/** Evidence as it is written: no origin, no secret, and bounded. */
|
|
218
257
|
const cleanEvidence = (evidence) => redactSecrets(withoutOrigin(evidence, origin)).slice(0, 300);
|
|
219
258
|
const add = (rule, evidence, route, opts = {}) => {
|
|
220
|
-
if (ignore.includes(rule))
|
|
259
|
+
if (ignore.includes(rule) || exempt(rule, route))
|
|
221
260
|
return;
|
|
222
261
|
const clean = cleanEvidence(evidence);
|
|
223
262
|
const key = `${rule}\u0000${clean}`;
|
|
@@ -248,6 +287,10 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
248
287
|
};
|
|
249
288
|
for (const r of routes) {
|
|
250
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;
|
|
251
294
|
if (r.loadError !== undefined) {
|
|
252
295
|
add("route-load-failed", `${route}: ${r.loadError}`, route);
|
|
253
296
|
continue;
|
|
@@ -256,7 +299,12 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
256
299
|
add("route-server-error", `${route} → HTTP ${r.status}`, route);
|
|
257
300
|
else if (r.status !== null && r.status >= 400)
|
|
258
301
|
add("route-client-error", `${route} → HTTP ${r.status}`, route);
|
|
259
|
-
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)
|
|
260
308
|
add("auth-redirect", `${route} → ${withoutOrigin(r.url, origin) || "/"}`, route);
|
|
261
309
|
else if (r.elements === 0 && (r.status === null || r.status < 400))
|
|
262
310
|
add("dead-end", `${route}: 0 controls`, route);
|
|
@@ -287,6 +335,8 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
287
335
|
const visual = [];
|
|
288
336
|
if (baselines && !ignore.includes(VISUAL_RULE)) {
|
|
289
337
|
for (const r of baselines.results) {
|
|
338
|
+
if (exempt(VISUAL_RULE, r.path))
|
|
339
|
+
continue;
|
|
290
340
|
const evidence = baselineEvidence(r, baselines);
|
|
291
341
|
if (evidence === null)
|
|
292
342
|
continue;
|
|
@@ -302,11 +352,12 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
302
352
|
return {
|
|
303
353
|
issues: [...byKey.values(), ...visual].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.rule.localeCompare(b.rule)),
|
|
304
354
|
worthALook: [...looks.values()].sort((a, b) => a.rule.localeCompare(b.rule)),
|
|
355
|
+
needsSignIn,
|
|
305
356
|
};
|
|
306
357
|
}
|
|
307
358
|
/** The defect tier of `checkFindings`: what counts, and what the gate reads. */
|
|
308
|
-
export function issuesFromRoutes(routes, origin, ignore = [], flows = []) {
|
|
309
|
-
return checkFindings(routes, origin, ignore, flows).issues;
|
|
359
|
+
export function issuesFromRoutes(routes, origin, ignore = [], flows = [], ignorePaths = [], signedIn = true) {
|
|
360
|
+
return checkFindings(routes, origin, ignore, flows, null, ignorePaths, signedIn).issues;
|
|
310
361
|
}
|
|
311
362
|
/**
|
|
312
363
|
* Why a check has nothing to give a verdict on, or null when it measured the
|
|
@@ -413,6 +464,7 @@ export const CHECK_OPTION_NAMES = [
|
|
|
413
464
|
"max-routes",
|
|
414
465
|
"paths",
|
|
415
466
|
"ignore",
|
|
467
|
+
"ignore-path",
|
|
416
468
|
"flows",
|
|
417
469
|
"retest",
|
|
418
470
|
"flow-writes",
|
|
@@ -507,6 +559,50 @@ export function parseCheckArgs(args, cwd) {
|
|
|
507
559
|
const unknownRules = ignore.filter((r) => !CHECK_RULE_IDS.includes(r));
|
|
508
560
|
if (unknownRules.length > 0)
|
|
509
561
|
return { ok: false, error: `unknown rule(s) in --ignore: ${unknownRules.join(", ")}. Rules: ${CHECK_RULE_IDS.join(", ")}` };
|
|
562
|
+
const ignorePathList = list(flags.get("ignore-path"));
|
|
563
|
+
if (ignorePathList && ignorePathList.length === 0)
|
|
564
|
+
return { ok: false, error: "--ignore-path is empty" };
|
|
565
|
+
const ignorePaths = [];
|
|
566
|
+
const unknownPathRules = [];
|
|
567
|
+
const badPathEntries = [];
|
|
568
|
+
const ruleAfterPath = [];
|
|
569
|
+
for (const entry of ignorePathList ?? []) {
|
|
570
|
+
if (entry.startsWith("/")) {
|
|
571
|
+
// `/path:rule` would otherwise be a path that never matches. A colon is
|
|
572
|
+
// still allowed in a path when what follows it is not a rule id.
|
|
573
|
+
const suffix = entry.slice(entry.lastIndexOf(":") + 1);
|
|
574
|
+
if (entry.includes(":") && CHECK_RULE_IDS.includes(suffix)) {
|
|
575
|
+
ruleAfterPath.push(entry);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
ignorePaths.push({ path: entry });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const colon = entry.indexOf(":");
|
|
582
|
+
const rule = colon > 0 ? entry.slice(0, colon) : "";
|
|
583
|
+
const path = colon > 0 ? entry.slice(colon + 1) : "";
|
|
584
|
+
if (path.startsWith("/") && CHECK_RULE_IDS.includes(rule)) {
|
|
585
|
+
ignorePaths.push({ path, rule: rule });
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (path.startsWith("/") && rule) {
|
|
589
|
+
unknownPathRules.push(rule);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
badPathEntries.push(entry);
|
|
593
|
+
}
|
|
594
|
+
if (unknownPathRules.length > 0) {
|
|
595
|
+
return { ok: false, error: `unknown rule(s) in --ignore-path: ${unknownPathRules.join(", ")}. Rules: ${CHECK_RULE_IDS.join(", ")}` };
|
|
596
|
+
}
|
|
597
|
+
if (ruleAfterPath.length > 0) {
|
|
598
|
+
return { ok: false, error: `--ignore-path names a rule as rule:/path (got ${ruleAfterPath.join(", ")})` };
|
|
599
|
+
}
|
|
600
|
+
if (badPathEntries.length > 0) {
|
|
601
|
+
return {
|
|
602
|
+
ok: false,
|
|
603
|
+
error: `--ignore-path entries are paths starting with /, or one rule on a path as rule:/path (got ${badPathEntries.join(", ")})`,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
510
606
|
const retest = flags.get("retest") ?? "on";
|
|
511
607
|
if (retest !== "on" && retest !== "off")
|
|
512
608
|
return { ok: false, error: "--retest must be on or off" };
|
|
@@ -558,6 +654,7 @@ export function parseCheckArgs(args, cwd) {
|
|
|
558
654
|
maxRoutes,
|
|
559
655
|
...(paths ? { paths } : {}),
|
|
560
656
|
ignore: ignore,
|
|
657
|
+
ignorePaths,
|
|
561
658
|
...(flows !== undefined ? { flows: flows === "off" ? "off" : resolve(flows) } : {}),
|
|
562
659
|
retest: retest === "on",
|
|
563
660
|
flowWrites,
|
|
@@ -862,6 +959,42 @@ export function routesTable(result) {
|
|
|
862
959
|
}
|
|
863
960
|
return lines;
|
|
864
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
|
+
}
|
|
865
998
|
/** The routes known and not visited, as one line; nothing when every known route was visited. */
|
|
866
999
|
export function unvisitedLine(unvisited, why) {
|
|
867
1000
|
if (unvisited.length === 0)
|
|
@@ -888,12 +1021,15 @@ export function formatCheck(result) {
|
|
|
888
1021
|
: `${couldNotRun > 0 ? "failed" : "**FAILED**"} — ${failingText} · ${counts.high} high · ${counts.medium} medium · ${counts.low} low`) +
|
|
889
1022
|
(unaudited > 0 ? ` · design not measured on ${unaudited} route(s)` : "") +
|
|
890
1023
|
(uncompared > 0 ? ` · ${uncompared} visual target(s) not compared: no baseline yet` : "") +
|
|
891
|
-
(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` : ""));
|
|
892
1026
|
// Right under the verdict: what a green check was allowed to do is part of what it means.
|
|
893
1027
|
lines.push("", `Settings — ${describeSettings(result)}`);
|
|
894
1028
|
lines.push(...issueSections(result.issues));
|
|
895
1029
|
lines.push(...worthALookSection(result.worthALook));
|
|
1030
|
+
lines.push(...needsSignInSection(result));
|
|
896
1031
|
lines.push(...routesTable(result));
|
|
1032
|
+
lines.push(...resourcesTable(result.resources));
|
|
897
1033
|
lines.push(...unvisitedLine(result.unvisited, "over --max-routes"));
|
|
898
1034
|
if (result.flows.length > 0 || result.skippedFlows.length > 0) {
|
|
899
1035
|
lines.push("", `## Flows (${result.flows.length})`, "");
|
|
@@ -940,6 +1076,10 @@ export function formatCheck(result) {
|
|
|
940
1076
|
}
|
|
941
1077
|
if (result.ignored.length > 0)
|
|
942
1078
|
lines.push("", `Rules ignored by --ignore: ${result.ignored.join(", ")}`);
|
|
1079
|
+
if (result.ignoredPaths.length > 0) {
|
|
1080
|
+
const shown = result.ignoredPaths.map((e) => (e.rule ? `${e.rule}:${e.path}` : e.path));
|
|
1081
|
+
lines.push("", `Paths exempted by --ignore-path: ${shown.join(", ")}`);
|
|
1082
|
+
}
|
|
943
1083
|
lines.push("", result.flows.length > 0
|
|
944
1084
|
? "_A check visits pages, measures what loads and replays the flows saved for it. It does not explore, fill forms of its own accord or compare roles; an exploratory run does that._"
|
|
945
1085
|
: "_A check visits pages and measures what loads. It does not fill forms, click through flows or compare roles; an exploratory run does that. Flows saved in .scenescout/flows are replayed._");
|
|
@@ -965,9 +1105,11 @@ export function toSummaryJson(result, toolVersion) {
|
|
|
965
1105
|
controls: r.elements,
|
|
966
1106
|
...(r.auditError !== undefined ? { designNotMeasured: r.auditError } : {}),
|
|
967
1107
|
})),
|
|
1108
|
+
resources: result.resources ?? [],
|
|
968
1109
|
unvisited: result.unvisited,
|
|
969
1110
|
...(result.timeBudget ? { timeBudget: result.timeBudget } : {}),
|
|
970
1111
|
ignored: result.ignored,
|
|
1112
|
+
ignoredPaths: result.ignoredPaths,
|
|
971
1113
|
flows: result.flows.map((f) => ({
|
|
972
1114
|
name: f.name,
|
|
973
1115
|
file: f.file,
|
|
@@ -983,5 +1125,7 @@ export function toSummaryJson(result, toolVersion) {
|
|
|
983
1125
|
issues: result.issues,
|
|
984
1126
|
// Apart from `issues` and `counts`, which the gate reads: none of these is counted or gated.
|
|
985
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 } : {}),
|
|
986
1130
|
};
|
|
987
1131
|
}
|
package/dist/engine/collector.js
CHANGED
|
@@ -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
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
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
|
|
71
|
-
const said = (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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))
|
|
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.
|
|
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,
|
package/dist/engine/crawl.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/engine/design.js
CHANGED
|
@@ -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),
|
|
@@ -574,6 +627,20 @@ export function spacedApart(r, others) {
|
|
|
574
627
|
export function tooSmall(r, all) {
|
|
575
628
|
return undersized(r) && !spacedApart(r, all);
|
|
576
629
|
}
|
|
630
|
+
/** Past this, a stretched image reads as distorted rather than as rounding. */
|
|
631
|
+
export const ASPECT_TOLERANCE = 0.12;
|
|
632
|
+
/**
|
|
633
|
+
* How far an image is stretched out of its proportions, or null when it is not.
|
|
634
|
+
* Only `fill`, the default, stretches the picture to its box: `cover` and `contain`
|
|
635
|
+
* scale it uniformly and crop or letterbox, `none` draws it at its own size and
|
|
636
|
+
* `scale-down` is one of those two. So a mismatched box is distortion only under `fill`.
|
|
637
|
+
*/
|
|
638
|
+
export function aspectDistortion(img) {
|
|
639
|
+
if (img.fit !== "fill")
|
|
640
|
+
return null;
|
|
641
|
+
const off = Math.abs(img.rw / img.rh / (img.nw / img.nh) - 1);
|
|
642
|
+
return off > ASPECT_TOLERANCE ? off : null;
|
|
643
|
+
}
|
|
577
644
|
/**
|
|
578
645
|
* Reduce raw style records + page facts to a judgeable design digest + score.
|
|
579
646
|
*
|
|
@@ -884,7 +951,10 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
884
951
|
if (affordances.length > 0)
|
|
885
952
|
sections.push(`AFFORDANCES:\n` + affordances.map((s) => ` ${s}`).join("\n"));
|
|
886
953
|
// ---- 12. IMAGES: aspect-ratio distortion (rendered box fights the source's proportions).
|
|
887
|
-
const distorted = page.images.
|
|
954
|
+
const distorted = page.images.flatMap((img) => {
|
|
955
|
+
const off = aspectDistortion(img);
|
|
956
|
+
return off === null ? [] : [{ img, off }];
|
|
957
|
+
});
|
|
888
958
|
if (distorted.length > 0) {
|
|
889
959
|
sections.push(`IMAGES (${distorted.length} distorted):\n` +
|
|
890
960
|
distorted
|
|
@@ -177,6 +177,22 @@ export function keyAliases(bases) {
|
|
|
177
177
|
});
|
|
178
178
|
return out;
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* keyAliases for every step between name rules, from each element's base key
|
|
182
|
+
* under each rule, rule 1 first and the current rule last: rule r (from 2)
|
|
183
|
+
* → the step's aliases, current key under r → key under r - 1. A step that
|
|
184
|
+
* renamed nothing is left out.
|
|
185
|
+
*/
|
|
186
|
+
export function keyAliasSteps(bases) {
|
|
187
|
+
const rules = bases.length > 0 ? bases[0].byRule.length : 0;
|
|
188
|
+
const out = {};
|
|
189
|
+
for (let i = 1; i < rules; i++) {
|
|
190
|
+
const step = keyAliases(bases.map((b) => ({ base: b.byRule[i], prior: b.byRule[i - 1], tracked: b.tracked })));
|
|
191
|
+
if (Object.keys(step).length > 0)
|
|
192
|
+
out[i + 1] = step;
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
180
196
|
/**
|
|
181
197
|
* Fingerprint a UI state: normalized route + hash of the set of interactable
|
|
182
198
|
* element classes. Same page with different data → same fingerprint; a page
|