automated-qa 0.11.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/LICENSE +22 -0
- package/NOTICE +9 -0
- package/README.md +100 -0
- package/SKILL.md +65 -0
- package/agent/prebuilt/darwin-universal/qa-agent +0 -0
- package/agent/prebuilt/linux-arm64/qa-agent +0 -0
- package/agent/prebuilt/linux-x64/qa-agent +0 -0
- package/agent/release-manifest.json +31 -0
- package/bin/autonomous-cache.mjs +2 -0
- package/bin/bench-generate.mjs +232 -0
- package/bin/bench-graph-query.mjs +2 -0
- package/bin/bench-score.mjs +10 -0
- package/bin/classify.mjs +15 -0
- package/bin/coherence-playwright.mjs +1 -0
- package/bin/coherence-runner.mjs +6 -0
- package/bin/crud-runner.mjs +12 -0
- package/bin/experiment-executor.mjs +10 -0
- package/bin/experiment-runner.mjs +3 -0
- package/bin/inventory.mjs +17 -0
- package/bin/probe.js +879 -0
- package/bin/qa.mjs +16 -0
- package/bin/rules.mjs +5 -0
- package/bin/run-benchmarks.mjs +2 -0
- package/bin/runtime-packs.mjs +3 -0
- package/bin/semantic-explorer.mjs +3 -0
- package/bin/shadow-collector.mjs +3 -0
- package/bin/shadow-replay.mjs +6 -0
- package/bin/soak-marathon.mjs +15 -0
- package/bin/sweep.mjs +43 -0
- package/bin/validate-priors.mjs +3 -0
- package/bin/verify-artifacts.mjs +3 -0
- package/bin/verify-manifest.mjs +2 -0
- package/install-skill.sh +45 -0
- package/lib/qa/agent-availability.mjs +1 -0
- package/lib/qa/agent-result.mjs +1 -0
- package/lib/qa/analysis.mjs +5 -0
- package/lib/qa/atlas-coverage.mjs +11 -0
- package/lib/qa/atlas-v1.mjs +1 -0
- package/lib/qa/atlas.mjs +2 -0
- package/lib/qa/auth/coordinator.mjs +1 -0
- package/lib/qa/auth/launcher.mjs +3 -0
- package/lib/qa/auth/lease.mjs +1 -0
- package/lib/qa/auth/oracle.mjs +1 -0
- package/lib/qa/auth/personas.mjs +3 -0
- package/lib/qa/autonomous-cache.mjs +3 -0
- package/lib/qa/block-ledger.mjs +1 -0
- package/lib/qa/capture-report.mjs +63 -0
- package/lib/qa/ci.mjs +11 -0
- package/lib/qa/cli.mjs +538 -0
- package/lib/qa/coherence/analyze.mjs +1 -0
- package/lib/qa/coherence/cache-key.mjs +6 -0
- package/lib/qa/coherence/causes.mjs +1 -0
- package/lib/qa/coherence/concurrency.mjs +1 -0
- package/lib/qa/coherence/contracts.mjs +1 -0
- package/lib/qa/coherence/entity-aliases.mjs +1 -0
- package/lib/qa/coherence/enums.mjs +1 -0
- package/lib/qa/coherence/family-report.mjs +1 -0
- package/lib/qa/coherence/flow-pool.mjs +1 -0
- package/lib/qa/coherence/graph.mjs +1 -0
- package/lib/qa/coherence/identity.mjs +1 -0
- package/lib/qa/coherence/ir.mjs +4 -0
- package/lib/qa/coherence/navigation.mjs +1 -0
- package/lib/qa/coherence/observe.mjs +2 -0
- package/lib/qa/coherence/offline.mjs +1 -0
- package/lib/qa/coherence/plan-join.mjs +1 -0
- package/lib/qa/coherence/provenance.mjs +2 -0
- package/lib/qa/coherence/report.mjs +1 -0
- package/lib/qa/coherence/representations.mjs +1 -0
- package/lib/qa/coherence/risk.mjs +2 -0
- package/lib/qa/coherence/structural-js.mjs +11 -0
- package/lib/qa/coherence/symbols.mjs +6 -0
- package/lib/qa/coherence/tanstack.mjs +4 -0
- package/lib/qa/coherence/views.mjs +1 -0
- package/lib/qa/coherence-planner.mjs +1 -0
- package/lib/qa/command-catalog.mjs +2 -0
- package/lib/qa/commands/dev.mjs +1 -0
- package/lib/qa/commands/fix.mjs +1 -0
- package/lib/qa/commands/impl/analysis.mjs +1 -0
- package/lib/qa/commands/impl/atlas.mjs +1 -0
- package/lib/qa/commands/impl/coherence.mjs +1 -0
- package/lib/qa/commands/impl/crud.mjs +2 -0
- package/lib/qa/commands/impl/doctor.mjs +1 -0
- package/lib/qa/commands/impl/experiments.mjs +1 -0
- package/lib/qa/commands/impl/fp.mjs +1 -0
- package/lib/qa/commands/impl/graph.mjs +1 -0
- package/lib/qa/commands/impl/invariants.mjs +1 -0
- package/lib/qa/commands/impl/lanes.mjs +7 -0
- package/lib/qa/commands/impl/ledger.mjs +11 -0
- package/lib/qa/commands/impl/loop.mjs +1 -0
- package/lib/qa/commands/impl/marathon.mjs +1 -0
- package/lib/qa/commands/impl/next.mjs +38 -0
- package/lib/qa/commands/impl/report.mjs +4 -0
- package/lib/qa/commands/impl/research.mjs +4 -0
- package/lib/qa/commands/impl/selftest.mjs +1 -0
- package/lib/qa/commands/impl/serve.mjs +1 -0
- package/lib/qa/commands/impl/shadow.mjs +1 -0
- package/lib/qa/commands/impl/stability.mjs +1 -0
- package/lib/qa/commands/impl/triage.mjs +1 -0
- package/lib/qa/commands/impl/validate.mjs +4 -0
- package/lib/qa/commands/impl/version.mjs +1 -0
- package/lib/qa/commands/impl/watch.mjs +1 -0
- package/lib/qa/commands/init.mjs +6 -0
- package/lib/qa/commands/measure.mjs +1 -0
- package/lib/qa/commands/run.mjs +1 -0
- package/lib/qa/commands/show.mjs +1 -0
- package/lib/qa/commands/verify.mjs +6 -0
- package/lib/qa/deepening.mjs +1 -0
- package/lib/qa/denominators.mjs +1 -0
- package/lib/qa/depth.mjs +4 -0
- package/lib/qa/doctor.mjs +2 -0
- package/lib/qa/experiments-default.mjs +1 -0
- package/lib/qa/fix-lanes.mjs +3 -0
- package/lib/qa/forced-states.mjs +1 -0
- package/lib/qa/fp.mjs +2 -0
- package/lib/qa/fs.mjs +6 -0
- package/lib/qa/graph-schema.mjs +2 -0
- package/lib/qa/graph.mjs +3 -0
- package/lib/qa/harness-plan.mjs +3 -0
- package/lib/qa/init.mjs +5 -0
- package/lib/qa/integrity.mjs +1 -0
- package/lib/qa/invariants.mjs +84 -0
- package/lib/qa/jcs.mjs +1 -0
- package/lib/qa/lanes.mjs +1 -0
- package/lib/qa/loop.mjs +7 -0
- package/lib/qa/marathon-coherence.mjs +1 -0
- package/lib/qa/marathon-phases.mjs +1 -0
- package/lib/qa/marathon-wait.mjs +3 -0
- package/lib/qa/marathon.mjs +5 -0
- package/lib/qa/mode-coverage.mjs +1 -0
- package/lib/qa/mutation-policy.mjs +1 -0
- package/lib/qa/pattern-ledger.mjs +3 -0
- package/lib/qa/pending.mjs +1 -0
- package/lib/qa/proc.mjs +4 -0
- package/lib/qa/recommend.mjs +1 -0
- package/lib/qa/release-manifest.mjs +1 -0
- package/lib/qa/report-html.mjs +178 -0
- package/lib/qa/reports.mjs +2 -0
- package/lib/qa/request-evidence.mjs +1 -0
- package/lib/qa/research.mjs +20 -0
- package/lib/qa/routing.mjs +1 -0
- package/lib/qa/runs.mjs +1 -0
- package/lib/qa/schema.mjs +1 -0
- package/lib/qa/serve.mjs +4 -0
- package/lib/qa/server.mjs +1 -0
- package/lib/qa/source-snapshot.mjs +3 -0
- package/lib/qa/stability.mjs +3 -0
- package/lib/qa/state-evidence.mjs +1 -0
- package/lib/qa/sweep-incremental.mjs +1 -0
- package/lib/qa/triage.mjs +2 -0
- package/lib/qa/verdict.mjs +1 -0
- package/lib/qa/watch.mjs +2 -0
- package/package.json +62 -0
- package/references/ATLAS-COVERAGE.md +231 -0
- package/references/atlas-v1.md +318 -0
- package/references/atlas.md +338 -0
- package/references/verify.crud.example.json +1 -0
- package/references/verify.impact.example.json +1 -0
- package/references/verify.shadow.example.json +1 -0
- package/schemas/agent-result.schema.json +135 -0
- package/schemas/analysis.schema.json +15 -0
- package/schemas/classify.schema.json +61 -0
- package/schemas/coherence-ir.schema.json +1585 -0
- package/schemas/coherence-observed.schema.json +1171 -0
- package/schemas/coherence-plan.schema.json +77 -0
- package/schemas/coherence-risk.schema.json +46 -0
- package/schemas/crud-observed.schema.json +118 -0
- package/schemas/experiment-execution.schema.json +82 -0
- package/schemas/graph-edge.schema.json +22 -0
- package/schemas/graph-node.schema.json +81 -0
- package/schemas/graph.schema.json +2493 -0
- package/schemas/identity-oracle.schema.json +19 -0
- package/schemas/inventory.schema.json +2167 -0
- package/schemas/lanes-plan.schema.json +52 -0
- package/schemas/next.schema.json +54 -0
- package/schemas/personas.schema.json +52 -0
- package/schemas/research.schema.json +192 -0
- package/schemas/run.schema.json +160 -0
- package/schemas/shadow-replay.schema.json +68 -0
- package/schemas/stability.schema.json +92 -0
- package/schemas/sweep.schema.json +239 -0
- package/schemas/v1/agent-result.schema.json +106 -0
- package/schemas/v1/run.schema.json +96 -0
- package/schemas/verify-crud.schema.json +37 -0
- package/schemas/verify-read-only.schema.json +37 -0
- package/templates/github-workflow.yml +80 -0
- package/templates/pre-commit.sample +22 -0
- package/verify.sh +385 -0
package/bin/probe.js
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
// frontend-verify in-page probe. Deterministic runtime invariants.
|
|
2
|
+
//
|
|
3
|
+
// Returns { findings: [{kind, severity, detail, at}], metrics: {...} }.
|
|
4
|
+
//
|
|
5
|
+
// HARD CONSTRAINT: no backticks, no dollar signs anywhere in this file. It is
|
|
6
|
+
// passed through a double-quoted shell argument by playwright-cli; the shell
|
|
7
|
+
// eats both. Use string concatenation. (Same rule as ui-stress/probe.js.)
|
|
8
|
+
//
|
|
9
|
+
// Everything here is decidable without vision. Screenshots are for the residual.
|
|
10
|
+
|
|
11
|
+
async page => {
|
|
12
|
+
// Focus-visible depends on trusted keyboard modality, including after a journey clicked.
|
|
13
|
+
await page.keyboard.press('Tab');
|
|
14
|
+
const out = await page.evaluate(async () => {
|
|
15
|
+
// That opening Tab is the probe's own input, and it leaves a control
|
|
16
|
+
// FOCUSED before a single pixel is measured. A correct reveal-on-focus
|
|
17
|
+
// skip link then paints a 200x50 panel over the header, and the geometry
|
|
18
|
+
// rules below read an overlay the application never showed anyone --
|
|
19
|
+
// "clicks land on a.skip instead", a P1 against correct markup. Park the
|
|
20
|
+
// focus for the in-page pass and restore it before the Tab walk, which
|
|
21
|
+
// needs both the keyboard modality and the sequential-focus origin. #231
|
|
22
|
+
const probeFocused = document.activeElement;
|
|
23
|
+
if (probeFocused && probeFocused.blur) probeFocused.blur();
|
|
24
|
+
const V = [];
|
|
25
|
+
const add = (kind, severity, detail, at) => V.push({ kind, severity, detail, at: at || '(page)' });
|
|
26
|
+
|
|
27
|
+
const sel = el => {
|
|
28
|
+
if (!el || el.nodeType !== 1) return '(page)';
|
|
29
|
+
const t = el.getAttribute && el.getAttribute('data-testid');
|
|
30
|
+
if (t) return '[data-testid=' + t + ']';
|
|
31
|
+
if (el.id) return '#' + el.id;
|
|
32
|
+
const c = (el.className && String(el.className).split(/\s+/)[0]) || '';
|
|
33
|
+
return el.tagName.toLowerCase() + (c ? '.' + c : '');
|
|
34
|
+
};
|
|
35
|
+
const vis = el => {
|
|
36
|
+
if (!el || el.nodeType !== 1) return false;
|
|
37
|
+
const s = getComputedStyle(el);
|
|
38
|
+
if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) < 0.02) return false;
|
|
39
|
+
if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;
|
|
40
|
+
const r = el.getBoundingClientRect();
|
|
41
|
+
return r.width > 0 && r.height > 0;
|
|
42
|
+
};
|
|
43
|
+
// Screen-reader-only elements are 1x1 by design. Measuring them as tap
|
|
44
|
+
// targets is how a ticket claims 103 failures and a browser finds one.
|
|
45
|
+
const srOnly = el => {
|
|
46
|
+
const s = getComputedStyle(el);
|
|
47
|
+
const r = el.getBoundingClientRect();
|
|
48
|
+
return (r.width <= 2 && r.height <= 2) || s.clip === 'rect(0px, 0px, 0px, 0px)' || s.clipPath === 'inset(50%)';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/* -- L5: values that leaked through formatting ------------------------ */
|
|
52
|
+
|
|
53
|
+
// A literal dollar sign is never typed in this file -- it is built at
|
|
54
|
+
// runtime instead, because probe.js is injected through a shell argument
|
|
55
|
+
// and a literal dollar sign or backtick here gets eaten before the
|
|
56
|
+
// browser ever sees it.
|
|
57
|
+
const DOLLAR = String.fromCharCode(36);
|
|
58
|
+
const TEMPLATE_VAR_RE = new RegExp('\\{\\{[\\w.]+\\}\\}|\\' + DOLLAR + '\\{[\\w.]+\\}');
|
|
59
|
+
|
|
60
|
+
// [object Object], a raw JS error, and an unresolved template variable are
|
|
61
|
+
// checked against the whole rendered page text, as before -- these
|
|
62
|
+
// multi-character shapes essentially never collide with ordinary prose.
|
|
63
|
+
const BODY_LEAKS = [
|
|
64
|
+
[/\[object Object\]/, '[object Object] rendered as text'],
|
|
65
|
+
[/\bTypeError\b|\bReferenceError\b|is not a function|Cannot read propert/, 'raw JS error text in the DOM'],
|
|
66
|
+
[TEMPLATE_VAR_RE, 'unresolved template variable in the DOM'],
|
|
67
|
+
];
|
|
68
|
+
const bodyText = (document.body && document.body.innerText) || '';
|
|
69
|
+
for (const pair of BODY_LEAKS) {
|
|
70
|
+
const hit = bodyText.match(pair[0]);
|
|
71
|
+
if (!hit) continue;
|
|
72
|
+
const at = Math.max(0, (hit.index || 0) - 50);
|
|
73
|
+
add('value.leak', 'P1', pair[1] + ' -- ...' + bodyText.slice(at, at + 140).replace(/\s+/g, ' ') + '...');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// null/undefined/NaN/Invalid Date collide with ordinary prose on a bare
|
|
77
|
+
// word-boundary regex ("the null hypothesis"). These four are walked per
|
|
78
|
+
// text node and only counted when they look like a RENDERED VALUE rather
|
|
79
|
+
// than a word in a sentence: either the token is the entire trimmed text
|
|
80
|
+
// of the leaf element that holds it, or it is immediately preceded by a
|
|
81
|
+
// colon, equals sign, or hyphen -- the punctuation a template leaves
|
|
82
|
+
// right before a value.
|
|
83
|
+
const VALUE_TOKENS = [
|
|
84
|
+
[/\bNaN\b/, 'NaN rendered as text'],
|
|
85
|
+
[/\bundefined\b/, 'undefined rendered as text'],
|
|
86
|
+
[/\bInvalid Date\b/, 'Invalid Date rendered as text'],
|
|
87
|
+
[/\bnull\b/, 'the string "null" rendered as text'],
|
|
88
|
+
];
|
|
89
|
+
const valueLeaksFound = new Set();
|
|
90
|
+
{
|
|
91
|
+
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
92
|
+
let n;
|
|
93
|
+
while ((n = w.nextNode())) {
|
|
94
|
+
const p = n.parentElement;
|
|
95
|
+
if (!p || p.closest('script,style,noscript,code,pre,kbd,samp')) continue;
|
|
96
|
+
if (!vis(p) || srOnly(p)) continue;
|
|
97
|
+
const text = n.textContent;
|
|
98
|
+
const leafText = p.children.length === 0 ? p.textContent.trim() : null;
|
|
99
|
+
for (const pair of VALUE_TOKENS) {
|
|
100
|
+
if (valueLeaksFound.has(pair[1])) continue;
|
|
101
|
+
const m = pair[0].exec(text);
|
|
102
|
+
if (!m) continue;
|
|
103
|
+
const isWholeLeaf = leafText !== null && leafText === m[0];
|
|
104
|
+
const before = text.slice(0, m.index).trimEnd();
|
|
105
|
+
const lastChar = before.slice(-1);
|
|
106
|
+
const isPunctuated = lastChar === ':' || lastChar === '=' || lastChar === '-';
|
|
107
|
+
if (!isWholeLeaf && !isPunctuated) continue;
|
|
108
|
+
valueLeaksFound.add(pair[1]);
|
|
109
|
+
const at = Math.max(0, m.index - 50);
|
|
110
|
+
add('value.leak', 'P1', pair[1] + ' -- ...' + text.slice(at, at + 140).trim().replace(/\s+/g, ' ') + '...', sel(p));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* -- L4: states that were never built --------------------------------- */
|
|
116
|
+
|
|
117
|
+
const root = document.querySelector('#root,#__next,[data-reactroot],main') || document.body;
|
|
118
|
+
if (!root || (root.innerText || '').trim().length < 2)
|
|
119
|
+
add('render.empty', 'P0', 'page root rendered with no text content');
|
|
120
|
+
|
|
121
|
+
// A spinner still mounted after the settle loop is a query that never
|
|
122
|
+
// resolved -- the other half of "the data did not load".
|
|
123
|
+
// ARIA state is unconditional; a bare class name also catches decorative
|
|
124
|
+
// dots (an 8x8 animate-pulse status indicator is not a stuck loader), so
|
|
125
|
+
// those require a real spinner-sized box before they count.
|
|
126
|
+
const ariaSpinners = [...document.querySelectorAll('[aria-busy="true"],[role="progressbar"]:not([aria-valuenow])')].filter(vis);
|
|
127
|
+
const classSpinners = [...document.querySelectorAll('.spinner,.loading,.skeleton,[class*="skeleton" i],[class*="animate-pulse"]')].filter((el) => {
|
|
128
|
+
if (!vis(el)) return false;
|
|
129
|
+
const r = el.getBoundingClientRect();
|
|
130
|
+
return Math.min(r.width, r.height) >= 16;
|
|
131
|
+
});
|
|
132
|
+
const spinners = [...new Set(ariaSpinners.concat(classSpinners))];
|
|
133
|
+
if (spinners.length)
|
|
134
|
+
add('render.stuck-loading', 'P1', spinners.length + ' loading indicator(s) still visible after settle', sel(spinners[0]));
|
|
135
|
+
|
|
136
|
+
// An empty list with no empty state is indistinguishable from a failed fetch.
|
|
137
|
+
// NOT filtered by vis(): an empty list is zero-height by definition, so a
|
|
138
|
+
// visibility filter excludes precisely the case this rule exists to catch.
|
|
139
|
+
// Layout participation (offsetParent, or a fixed/sticky ancestor) is the
|
|
140
|
+
// right test -- it excludes display:none subtrees and keeps empty lists.
|
|
141
|
+
const inLayout = el => el.offsetParent !== null || getComputedStyle(el).position === 'fixed';
|
|
142
|
+
// Toast/notification regions are legitimately empty on almost every route:
|
|
143
|
+
// sonner and Radix mount an off-screen toaster/ToastViewport list even
|
|
144
|
+
// when nothing is toasting, and inLayout() keeps position:fixed elements
|
|
145
|
+
// on purpose. "Is or is inside" via closest() excludes the region and
|
|
146
|
+
// everything it contains, not just the region element itself.
|
|
147
|
+
const NOTICE_SEL = '[data-sonner-toaster],[data-radix-toast-viewport],[role="region"][aria-live],[aria-label*="notification" i],[role="status"],[aria-live]';
|
|
148
|
+
const lists = [...document.querySelectorAll('ul,ol,tbody,[role="list"],[role="table"],[role="grid"]')]
|
|
149
|
+
.filter(inLayout)
|
|
150
|
+
.filter(l => !l.closest(NOTICE_SEL));
|
|
151
|
+
for (const l of lists) {
|
|
152
|
+
const rows = [...l.children].filter(vis);
|
|
153
|
+
if (rows.length) continue;
|
|
154
|
+
const near = ((l.parentElement && l.parentElement.innerText) || '').toLowerCase();
|
|
155
|
+
if (!/no |empty|none |nothing|0 result|get started|add your first/.test(near))
|
|
156
|
+
add('render.empty-list-no-state', 'P1', 'list rendered with zero rows and no empty-state message', sel(l));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* -- L8: layout under the real viewport ------------------------------- */
|
|
160
|
+
|
|
161
|
+
if (document.documentElement.scrollWidth > window.innerWidth + 1) {
|
|
162
|
+
const wide = [...document.querySelectorAll('body *')].filter(el => {
|
|
163
|
+
const r = el.getBoundingClientRect();
|
|
164
|
+
return r.width > 0 && (r.right > window.innerWidth + 1 || r.left < -1) && vis(el);
|
|
165
|
+
});
|
|
166
|
+
add('layout.h-scroll', 'P1',
|
|
167
|
+
'page scrolls horizontally at ' + window.innerWidth + 'px (content ' + document.documentElement.scrollWidth + 'px)',
|
|
168
|
+
wide.length ? sel(wide[0]) : '(page)');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const INTERACTIVE = 'a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"]';
|
|
172
|
+
// WCAG 2.5.8 exempts an inline target sitting inside a run of text -- a
|
|
173
|
+
// link in the middle of a sentence is sized by the surrounding text, not
|
|
174
|
+
// a deliberately small tap target, so it does not get measured as one.
|
|
175
|
+
const inlineInSentence = (el) => {
|
|
176
|
+
if (el.tagName !== 'A') return false;
|
|
177
|
+
if (getComputedStyle(el).display !== 'inline') return false;
|
|
178
|
+
const p = el.parentElement;
|
|
179
|
+
return !!p && [...p.childNodes].some((k) => k.nodeType === 3 && k.textContent.trim());
|
|
180
|
+
};
|
|
181
|
+
const small = [...document.querySelectorAll(INTERACTIVE)].filter(el => {
|
|
182
|
+
if (!vis(el) || srOnly(el) || inlineInSentence(el)) return false;
|
|
183
|
+
const r = el.getBoundingClientRect();
|
|
184
|
+
return Math.min(r.width, r.height) < 24;
|
|
185
|
+
});
|
|
186
|
+
if (small.length)
|
|
187
|
+
add('a11y.tap-target', 'P2', small.length + ' interactive element(s) under 24px (sr-only excluded)', sel(small[0]));
|
|
188
|
+
|
|
189
|
+
/* -- L6: a control that cannot be clicked ------------------------------ */
|
|
190
|
+
|
|
191
|
+
// elementFromPoint at the control's center must return the control or one
|
|
192
|
+
// of its own descendants/ancestors; anything else is sitting on top and
|
|
193
|
+
// eats the click. elementFromPoint already skips pointer-events:none, so a
|
|
194
|
+
// deliberate click-through overlay never fires this. An OPEN dialog
|
|
195
|
+
// legitimately occludes everything behind it, so any modal signal
|
|
196
|
+
// suppresses the whole rule for this capture rather than flagging the
|
|
197
|
+
// entire background surface.
|
|
198
|
+
const modalOpen = !!document.querySelector('dialog[open],[role="dialog"],[aria-modal="true"]');
|
|
199
|
+
if (!modalOpen) {
|
|
200
|
+
const occluded = [];
|
|
201
|
+
for (const el of document.querySelectorAll('a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"]')) {
|
|
202
|
+
if (!vis(el) || srOnly(el)) continue;
|
|
203
|
+
// A collapsed details lays its content out -- getBoundingClientRect
|
|
204
|
+
// keeps real width/height -- but content-visibility: hidden on its
|
|
205
|
+
// internal box skips it for paint AND hit-testing, so elementFromPoint
|
|
206
|
+
// at the control's own centre returns whatever the browser painted
|
|
207
|
+
// there instead: an ancestor cell, or a neighbouring row's own
|
|
208
|
+
// summary. That occluder sits in a subtree unrelated to the control,
|
|
209
|
+
// so a contains() guard cannot reach it. The details' OWN summary
|
|
210
|
+
// stays exempt -- it is always rendered and is exactly what a person
|
|
211
|
+
// clicks to reveal the rest. #81.
|
|
212
|
+
const closedDetails = el.closest('details:not([open])');
|
|
213
|
+
if (closedDetails && el !== closedDetails.querySelector('summary')) continue;
|
|
214
|
+
const r = el.getBoundingClientRect();
|
|
215
|
+
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
216
|
+
if (cx < 0 || cy < 0 || cx > window.innerWidth || cy > window.innerHeight) continue;
|
|
217
|
+
// Scrolled out of its own clipping ancestor is NOT occluded. A nav with
|
|
218
|
+
// overflow-y:auto still reports a layout rect for items below the fold,
|
|
219
|
+
// so elementFromPoint at that coordinate returns whatever is PAINTED
|
|
220
|
+
// there -- typically the footer sitting under the scroll box. The user
|
|
221
|
+
// scrolls and clicks the control fine.
|
|
222
|
+
//
|
|
223
|
+
// Measured, not theorised: a sidebar nav item one row past its
|
|
224
|
+
// container's bottom edge produced this on 37 of 51 routes in one run,
|
|
225
|
+
// every one reading "clicks land on span.block instead" -- the account
|
|
226
|
+
// name in the sign-out block below the nav. 37 P1s that are all one
|
|
227
|
+
// scrollable list is exactly the noise that stops a report being read.
|
|
228
|
+
let clipped = false;
|
|
229
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
230
|
+
const s = getComputedStyle(a);
|
|
231
|
+
if (!/auto|scroll|hidden|clip/.test(s.overflowY + s.overflowX)) continue;
|
|
232
|
+
const ar = a.getBoundingClientRect();
|
|
233
|
+
if (cx < ar.left || cx > ar.right || cy < ar.top || cy > ar.bottom) { clipped = true; break; }
|
|
234
|
+
}
|
|
235
|
+
if (clipped) continue;
|
|
236
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
237
|
+
if (!hit || hit === el || el.contains(hit) || hit.contains(el)) continue;
|
|
238
|
+
// A label wrapping its input is how custom checkboxes are built.
|
|
239
|
+
if (hit.closest && hit.closest('label') && hit.closest('label').contains(el)) continue;
|
|
240
|
+
// Some surfaces put a full-cover clause over a control and deliberately
|
|
241
|
+
// forward the click from the clause or one of its ancestors. A passive
|
|
242
|
+
// probe cannot click: that could submit a form or mutate production
|
|
243
|
+
// state. Keep the geometry rule, but recognize only an explicit native
|
|
244
|
+
// or React click handler on the actual hit path.
|
|
245
|
+
let forwardsClick = false;
|
|
246
|
+
const targetAncestors = new Set();
|
|
247
|
+
for (let a = el; a; a = a.parentElement) targetAncestors.add(a);
|
|
248
|
+
let sharedAncestor = hit;
|
|
249
|
+
while (sharedAncestor && !targetAncestors.has(sharedAncestor)) sharedAncestor = sharedAncestor.parentElement;
|
|
250
|
+
for (let a = hit; a; a = a.parentElement) {
|
|
251
|
+
if (a.hasAttribute && a.hasAttribute('onclick')) { forwardsClick = true; break; }
|
|
252
|
+
for (const key of Object.getOwnPropertyNames(a)) {
|
|
253
|
+
if (!new RegExp('^__reactProps\\' + DOLLAR).test(key)) continue;
|
|
254
|
+
const descriptor = Object.getOwnPropertyDescriptor(a, key);
|
|
255
|
+
if (!descriptor || !('value' in descriptor)) continue;
|
|
256
|
+
const props = descriptor.value;
|
|
257
|
+
if ((typeof props !== 'object' || props === null) && typeof props !== 'function') continue;
|
|
258
|
+
const onClick = Object.getOwnPropertyDescriptor(props, 'onClick');
|
|
259
|
+
if (onClick && 'value' in onClick && typeof onClick.value === 'function') {
|
|
260
|
+
forwardsClick = true; break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (forwardsClick) break;
|
|
264
|
+
if (a === sharedAncestor) break;
|
|
265
|
+
}
|
|
266
|
+
if (forwardsClick) continue;
|
|
267
|
+
occluded.push([el, hit]);
|
|
268
|
+
}
|
|
269
|
+
if (occluded.length)
|
|
270
|
+
add('interact.click-occluded', 'P1', occluded.length + ' interactive element(s) whose center is covered by another element -- clicks land on '
|
|
271
|
+
+ sel(occluded[0][1]) + ' instead', sel(occluded[0][0]));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const noAria = el => !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby') && !el.getAttribute('title');
|
|
275
|
+
const unlabeled = [...document.querySelectorAll('button,[role="button"],a[href]')].filter(el =>
|
|
276
|
+
vis(el) && !(el.innerText || '').trim() && noAria(el)
|
|
277
|
+
&& ![...el.querySelectorAll('img[alt],svg > title')].some((child) =>
|
|
278
|
+
!child.closest('[aria-hidden="true"]') && (child.getAttribute('alt') || child.textContent || '').trim()));
|
|
279
|
+
// Form fields get a name from a <label> (wrapping or for=), aria, or title.
|
|
280
|
+
// A placeholder is accepted here only because browsers fall back to it in
|
|
281
|
+
// accname computation -- it is still bad practice, but not nameless.
|
|
282
|
+
// Radix keeps a native select as a 1x1 aria-hidden tabindex=-1 form
|
|
283
|
+
// control behind its visible combobox. It is intentionally absent from
|
|
284
|
+
// the accessibility tree; visible or focusable controls still count.
|
|
285
|
+
const hiddenNativeSelect = el => el.tagName === 'SELECT'
|
|
286
|
+
&& el.getAttribute('aria-hidden') === 'true'
|
|
287
|
+
&& el.tabIndex === -1 && srOnly(el);
|
|
288
|
+
const unlabeledFields = [...document.querySelectorAll('input,select,textarea')].filter(el =>
|
|
289
|
+
vis(el) && el.type !== 'hidden' && noAria(el)
|
|
290
|
+
&& !hiddenNativeSelect(el)
|
|
291
|
+
&& !(el.labels && el.labels.length) && !el.closest('label') && !el.getAttribute('placeholder'));
|
|
292
|
+
const nameless = unlabeled.concat(unlabeledFields);
|
|
293
|
+
if (nameless.length)
|
|
294
|
+
add('a11y.unlabeled-control', 'P2', nameless.length + ' control(s) with no accessible name', sel(nameless[0]));
|
|
295
|
+
|
|
296
|
+
// L9.88's other half. The app DID write a visible label; a duplicate id
|
|
297
|
+
// broke the association. A responsive layout keeps the desktop copy of a
|
|
298
|
+
// form mounted at display:none and mounts a second copy for mobile, so
|
|
299
|
+
// both controls carry id="email". HTMLLabelElement.control resolves to
|
|
300
|
+
// the FIRST element in tree order with that id, which is the hidden one,
|
|
301
|
+
// and the visible field ends up with zero labels -- getByLabel() cannot
|
|
302
|
+
// reach it and a screen reader announces nothing. The placeholder the
|
|
303
|
+
// check above accepts is an example value, not the field's name, so that
|
|
304
|
+
// check stays silent here. Only a collision that ACTUALLY breaks the
|
|
305
|
+
// relationship counts: the label is visible, its resolved control is not
|
|
306
|
+
// (or does not exist), and a visible labelable control with no labels of
|
|
307
|
+
// its own carries the same id. #240
|
|
308
|
+
const byId = new Map();
|
|
309
|
+
for (const el of document.querySelectorAll('[id]')) {
|
|
310
|
+
const list = byId.get(el.id);
|
|
311
|
+
if (list) list.push(el); else byId.set(el.id, [el]);
|
|
312
|
+
}
|
|
313
|
+
const brokenLabels = [];
|
|
314
|
+
for (const label of document.querySelectorAll('label[for]')) {
|
|
315
|
+
if (!vis(label) || !(label.innerText || '').trim()) continue;
|
|
316
|
+
const target = label.control;
|
|
317
|
+
if (target && vis(target)) continue;
|
|
318
|
+
const orphan = (byId.get(label.htmlFor) || []).find((el) => el !== target
|
|
319
|
+
&& vis(el) && !srOnly(el) && el.labels && el.labels.length === 0);
|
|
320
|
+
if (orphan) brokenLabels.push([label, orphan]);
|
|
321
|
+
}
|
|
322
|
+
if (brokenLabels.length)
|
|
323
|
+
add('a11y.label-association-broken', 'P3', brokenLabels.length
|
|
324
|
+
+ ' visible label(s) whose for= resolves past a duplicate id to a hidden or absent control while a visible unlabelled control carries that id: '
|
|
325
|
+
+ brokenLabels.slice(0, 5).map((p) => sel(p[0]) + ' -> ' + sel(p[1])).join(', '),
|
|
326
|
+
sel(brokenLabels[0][1]));
|
|
327
|
+
|
|
328
|
+
/* -- L9.94: an ARIA state attribute the element's role does not support --
|
|
329
|
+
A shared button/chip component emitting BOTH aria-pressed and
|
|
330
|
+
aria-selected is the reported shape (#287): aria-selected is not in the
|
|
331
|
+
button role's supported set, so assistive technology drops it and the
|
|
332
|
+
control's selected state is announced to nobody. Axe calls this
|
|
333
|
+
aria-allowed-attr.
|
|
334
|
+
|
|
335
|
+
Two things keep this precise rather than noisy. Only the two state
|
|
336
|
+
attributes whose supported-role sets are small and unambiguous are
|
|
337
|
+
measured -- a "selection attribute" is exactly what the report warns
|
|
338
|
+
must not be flagged everywhere. And a role is only judged when it is
|
|
339
|
+
KNOWN: an explicit role=, or a native element with one unambiguous
|
|
340
|
+
implicit role. An unmapped element is unmeasured, never guessed.
|
|
341
|
+
ponytail: two attributes, not the full aria-allowed-attr table --
|
|
342
|
+
widen it when a real run produces a class this misses, not before. */
|
|
343
|
+
const ARIA_ROLE_SUPPORT = [
|
|
344
|
+
['aria-selected', ['columnheader', 'gridcell', 'option', 'row', 'rowheader', 'tab', 'treeitem']],
|
|
345
|
+
['aria-pressed', ['button']],
|
|
346
|
+
];
|
|
347
|
+
const INPUT_ROLE = { button: 'button', checkbox: 'checkbox', image: 'button', radio: 'radio',
|
|
348
|
+
range: 'slider', reset: 'button', submit: 'button' };
|
|
349
|
+
const implicitRole = (el) => {
|
|
350
|
+
const tag = el.tagName.toLowerCase();
|
|
351
|
+
if (tag === 'button') return 'button';
|
|
352
|
+
if (tag === 'a' || tag === 'area') return el.hasAttribute('href') ? 'link' : null;
|
|
353
|
+
if (tag === 'option') return 'option';
|
|
354
|
+
if (tag === 'select') return (el.multiple || el.size > 1) ? 'listbox' : 'combobox';
|
|
355
|
+
if (tag === 'textarea') return 'textbox';
|
|
356
|
+
if (tag !== 'input') return null;
|
|
357
|
+
return INPUT_ROLE[(el.getAttribute('type') || 'text').toLowerCase()] || 'textbox';
|
|
358
|
+
};
|
|
359
|
+
const ariaMisuse = [];
|
|
360
|
+
for (const el of document.querySelectorAll('[aria-selected],[aria-pressed]')) {
|
|
361
|
+
if (!vis(el) || el.getAttribute('aria-hidden') === 'true' || el.closest('[aria-hidden="true"]')) continue;
|
|
362
|
+
const declared = (el.getAttribute('role') || '').trim().split(/\s+/)[0].toLowerCase();
|
|
363
|
+
const role = declared || implicitRole(el);
|
|
364
|
+
if (!role) continue;
|
|
365
|
+
for (const pair of ARIA_ROLE_SUPPORT)
|
|
366
|
+
if (el.hasAttribute(pair[0]) && pair[1].indexOf(role) === -1) ariaMisuse.push([el, pair[0], role]);
|
|
367
|
+
}
|
|
368
|
+
if (ariaMisuse.length)
|
|
369
|
+
add('a11y.aria-attr-not-allowed', 'P3', ariaMisuse.length
|
|
370
|
+
+ ' ARIA state attribute(s) the element role does not support: '
|
|
371
|
+
+ ariaMisuse.slice(0, 5).map((t) => t[1] + ' on role=' + t[2] + ' (' + sel(t[0]) + ')').join(', '),
|
|
372
|
+
sel(ariaMisuse[0][0]));
|
|
373
|
+
|
|
374
|
+
/* -- L5: data arrived, page rendered none of it ------------------------ */
|
|
375
|
+
|
|
376
|
+
// The cardinality defect's strongest decidable form. The in-page network
|
|
377
|
+
// recorder captured a same-origin JSON array; if EVERY list on the page has
|
|
378
|
+
// zero visible rows, the data arrived and was never rendered. Requiring all
|
|
379
|
+
// lists empty (a populated nav suppresses it) keeps this near zero noise.
|
|
380
|
+
const net = (window.__fv_net || []).filter(r =>
|
|
381
|
+
r.method === 'GET' && r.ok && typeof r.arrayLen === 'number'
|
|
382
|
+
&& (!/^https?:/.test(r.url) || r.url.indexOf(location.origin) === 0));
|
|
383
|
+
const maxLen = net.reduce((n, r) => Math.max(n, r.arrayLen), 0);
|
|
384
|
+
const totalRows = lists.reduce((n, l) => n + [...l.children].filter(vis).length, 0);
|
|
385
|
+
if (maxLen > 0 && lists.length && totalRows === 0)
|
|
386
|
+
add('data.rendered-zero-of-n', 'P1', 'a same-origin API response carried ' + maxLen
|
|
387
|
+
+ ' record(s) but every list on the page rendered zero rows -- the data arrived and was never rendered');
|
|
388
|
+
|
|
389
|
+
/* -- L10: what the main thread actually did --------------------------- */
|
|
390
|
+
|
|
391
|
+
const metrics = { longTasks: 0, longestTaskMs: 0, cls: 0, lcpMs: null, forcedReflows: 0, resources: 0, transferKB: 0, perfObserved: false, contrastEligible: 0, contrastUnmeasured: 0 };
|
|
392
|
+
|
|
393
|
+
// longtask / layout-shift / LCP are ONLY visible to observers installed
|
|
394
|
+
// before navigation (the sweep's init script fills window.__fv_perf);
|
|
395
|
+
// performance.getEntriesByType returns nothing for them in Chromium. When
|
|
396
|
+
// the init script is absent (probe run standalone), the perf gates are
|
|
397
|
+
// marked unmeasured rather than silently reporting clean zeros.
|
|
398
|
+
const P = window.__fv_perf;
|
|
399
|
+
if (P) {
|
|
400
|
+
if (P.flush) try { P.flush(); } catch (e) { /* drained what we could */ }
|
|
401
|
+
metrics.perfObserved = true;
|
|
402
|
+
metrics.longTasks = P.longTasks; metrics.longestTaskMs = P.longestTaskMs;
|
|
403
|
+
metrics.cls = P.cls; metrics.lcpMs = P.lcpMs; metrics.forcedReflows = P.thrashReads;
|
|
404
|
+
}
|
|
405
|
+
try { performance.getEntriesByType('resource').forEach(e => { metrics.resources++; metrics.transferKB += (e.transferSize || 0) / 1024; }); } catch (e) { /* unsupported */ }
|
|
406
|
+
metrics.cls = Math.round(metrics.cls * 1000) / 1000;
|
|
407
|
+
metrics.transferKB = Math.round(metrics.transferKB);
|
|
408
|
+
|
|
409
|
+
// Thresholds are the published Core Web Vitals "needs improvement" line, so
|
|
410
|
+
// a finding here means something a real user feels, not a preference. All
|
|
411
|
+
// gated on perfObserved: an unmeasured metric must never read as a clean one.
|
|
412
|
+
if (metrics.perfObserved) {
|
|
413
|
+
if (metrics.longestTaskMs > 200)
|
|
414
|
+
add('perf.long-task', 'P2', 'longest main-thread task ' + metrics.longestTaskMs + 'ms (' + metrics.longTasks + ' over 50ms) -- input is blocked for that whole window');
|
|
415
|
+
if (metrics.cls > 0.1)
|
|
416
|
+
add('perf.layout-shift', 'P2', 'cumulative layout shift ' + metrics.cls + ' (over the 0.1 threshold) -- content moves under the cursor');
|
|
417
|
+
if (metrics.lcpMs !== null && metrics.lcpMs > 2500)
|
|
418
|
+
add('perf.lcp', 'P2', 'largest contentful paint at ' + metrics.lcpMs + 'ms (over 2500ms)');
|
|
419
|
+
// Forced synchronous layout, counted since before app JS ran: a geometry
|
|
420
|
+
// read after a DOM write in the same frame makes the browser lay out again.
|
|
421
|
+
if (metrics.forcedReflows > 20)
|
|
422
|
+
add('perf.layout-thrash', 'P2', metrics.forcedReflows + ' geometry reads interleaved with DOM writes in one frame -- forced synchronous layout');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* -- L4: hydration and boundaries ------------------------------------- */
|
|
426
|
+
|
|
427
|
+
// React marks a hydration-mismatched root; the visible symptom is content
|
|
428
|
+
// that renders then silently swaps or disappears.
|
|
429
|
+
if (document.querySelector('[data-nextjs-error],[data-nextjs-dialog]'))
|
|
430
|
+
add('render.dev-overlay', 'P0', 'a framework error overlay is present on the page');
|
|
431
|
+
|
|
432
|
+
/* -- L23 probe atlas classes -------------------------------------------
|
|
433
|
+
Eleven new runtime invariants. Each excludes sr-only elements and
|
|
434
|
+
display:none/visibility:hidden ancestors the same way every check
|
|
435
|
+
above does (via vis()/srOnly()), and never guesses at a value it did
|
|
436
|
+
not actually observe -- an unresolvable background or an unreadable
|
|
437
|
+
color skips the node rather than reporting a fabricated ratio. */
|
|
438
|
+
|
|
439
|
+
/* -- L8.83: broken image, no fallback dimension ------------------------ */
|
|
440
|
+
const brokenImages = [...document.querySelectorAll('img[src]')].filter((img) => {
|
|
441
|
+
if (!img.src || !img.complete || img.naturalWidth !== 0) return false;
|
|
442
|
+
if (!vis(img) || srOnly(img)) return false;
|
|
443
|
+
if (img.getAttribute('aria-hidden') === 'true' || (img.closest && img.closest('[aria-hidden="true"]'))) return false;
|
|
444
|
+
return true;
|
|
445
|
+
});
|
|
446
|
+
if (brokenImages.length)
|
|
447
|
+
add('render.broken-image', 'P2', brokenImages.length + ' image(s) whose src failed to load (naturalWidth 0)', sel(brokenImages[0]));
|
|
448
|
+
|
|
449
|
+
/* -- L4.44: a virtualised container rendering zero rows -----------------
|
|
450
|
+
Two ways a list identifies itself as virtualised: an explicit marker
|
|
451
|
+
(data-virtualized, a testid/class naming the library), or role=grid/
|
|
452
|
+
list with an inner absolutely-positioned spacer taller than the
|
|
453
|
+
container -- the standard react-window/react-virtuoso shape. "Zero
|
|
454
|
+
rendered children" for the spacer shape excludes the spacer itself
|
|
455
|
+
and looks inside it, or every virtualised list would trivially count
|
|
456
|
+
its own sizer div as a rendered row. */
|
|
457
|
+
const virtFindings = [];
|
|
458
|
+
for (const el of document.querySelectorAll('[data-virtualized],[data-testid*="virtual" i],[class*="virtual" i],[class*="react-window" i],[class*="react-virtuoso" i]')) {
|
|
459
|
+
if (!vis(el) || srOnly(el)) continue;
|
|
460
|
+
const r = el.getBoundingClientRect();
|
|
461
|
+
if (r.height <= 100) continue;
|
|
462
|
+
if ([...el.children].filter(vis).length === 0) virtFindings.push(el);
|
|
463
|
+
}
|
|
464
|
+
for (const el of document.querySelectorAll('[role="grid"],[role="list"]')) {
|
|
465
|
+
if (!vis(el) || srOnly(el)) continue;
|
|
466
|
+
const r = el.getBoundingClientRect();
|
|
467
|
+
if (r.height <= 100) continue;
|
|
468
|
+
const spacer = [...el.children].find((k) => {
|
|
469
|
+
const ks = getComputedStyle(k);
|
|
470
|
+
return ks.position === 'absolute' && k.getBoundingClientRect().height > r.height;
|
|
471
|
+
});
|
|
472
|
+
if (!spacer) continue;
|
|
473
|
+
const others = [...el.children].filter((k) => k !== spacer).filter(vis);
|
|
474
|
+
const spacerRows = [...spacer.children].filter(vis);
|
|
475
|
+
if (others.length === 0 && spacerRows.length === 0) virtFindings.push(el);
|
|
476
|
+
}
|
|
477
|
+
const virtCapped = [...new Set(virtFindings)];
|
|
478
|
+
if (virtCapped.length)
|
|
479
|
+
add('render.virtualized-empty', 'P2', virtCapped.length + ' virtualised-looking container(s) taller than 100px rendering zero children', sel(virtCapped[0]));
|
|
480
|
+
|
|
481
|
+
/* -- L5.56: truncated with no way to read the rest ---------------------- */
|
|
482
|
+
const truncFindings = [...document.querySelectorAll('body *')].filter((el) => {
|
|
483
|
+
if (!vis(el) || srOnly(el)) return false;
|
|
484
|
+
if (!(el.textContent || '').trim()) return false;
|
|
485
|
+
const s = getComputedStyle(el);
|
|
486
|
+
if (s.overflow !== 'hidden' && s.overflowX !== 'hidden') return false;
|
|
487
|
+
if (el.scrollWidth <= el.clientWidth + 2) return false;
|
|
488
|
+
if (s.textOverflow === 'ellipsis') return false;
|
|
489
|
+
if (el.getAttribute('title') || el.getAttribute('aria-label')) return false;
|
|
490
|
+
return true;
|
|
491
|
+
}).slice(0, 10);
|
|
492
|
+
if (truncFindings.length)
|
|
493
|
+
add('layout.truncated-no-title', 'P3', truncFindings.length + ' element(s) truncated by overflow:hidden with no ellipsis styling and no title/aria-label', sel(truncFindings[0]));
|
|
494
|
+
|
|
495
|
+
/* -- L8.86: a fixed pixel height clipping real content ------------------
|
|
496
|
+
"Explicit" is read from the inline style only -- a class-based height
|
|
497
|
+
cannot be told apart from an incidental one without walking every
|
|
498
|
+
stylesheet rule, and a miss here is cheaper than guessing wrong. */
|
|
499
|
+
const fixedClipFindings = [...document.querySelectorAll('body *')].filter((el) => {
|
|
500
|
+
if (!vis(el) || srOnly(el)) return false;
|
|
501
|
+
const styleHeight = el.style && el.style.height;
|
|
502
|
+
// No trailing anchor: an end-of-string dollar sign is itself banned
|
|
503
|
+
// from this file, so a full-string match is proven by length instead.
|
|
504
|
+
const pxMatch = styleHeight && /^\d+(\.\d+)?px/.exec(styleHeight);
|
|
505
|
+
if (!pxMatch || pxMatch[0].length !== styleHeight.length) return false;
|
|
506
|
+
const s = getComputedStyle(el);
|
|
507
|
+
if (s.overflow !== 'hidden' && s.overflowY !== 'hidden') return false;
|
|
508
|
+
if (el.scrollHeight <= el.clientHeight + 4) return false;
|
|
509
|
+
if (!(el.textContent || '').trim()) return false;
|
|
510
|
+
return true;
|
|
511
|
+
}).slice(0, 10);
|
|
512
|
+
if (fixedClipFindings.length)
|
|
513
|
+
add('layout.fixed-height-clip', 'P3', fixedClipFindings.length + ' element(s) with an explicit pixel height and overflow:hidden clipping their own text content', sel(fixedClipFindings[0]));
|
|
514
|
+
|
|
515
|
+
/* -- L6.67: disabled-looking control that is not actually disabled ------ */
|
|
516
|
+
const disabledNotConveyed = [...document.querySelectorAll('button,a[href],[role="button"]')].filter((el) => {
|
|
517
|
+
if (!vis(el) || srOnly(el)) return false;
|
|
518
|
+
if (el.disabled) return false;
|
|
519
|
+
if (el.getAttribute('aria-disabled') === 'true') return false;
|
|
520
|
+
const s = getComputedStyle(el);
|
|
521
|
+
return s.pointerEvents === 'none' || Number(s.opacity) < 0.5;
|
|
522
|
+
}).slice(0, 10);
|
|
523
|
+
if (disabledNotConveyed.length)
|
|
524
|
+
add('interact.disabled-not-conveyed', 'P3', disabledNotConveyed.length + ' control(s) styled as disabled (pointer-events:none or opacity < 0.5) without a disabled or aria-disabled attribute', sel(disabledNotConveyed[0]));
|
|
525
|
+
|
|
526
|
+
/* -- L5.57: a raw snake_case enum rendered without display formatting --- */
|
|
527
|
+
// No trailing anchor here either, for the same reason as the pixel-height
|
|
528
|
+
// check above: full-string match is proven by length, not a dollar sign.
|
|
529
|
+
const rawEnumRe = /^[a-z]+(?:_[a-z]+)+/;
|
|
530
|
+
const isRawEnum = (text) => { const m = rawEnumRe.exec(text); return !!m && m[0].length === text.length; };
|
|
531
|
+
const enumFindings = [];
|
|
532
|
+
{
|
|
533
|
+
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
534
|
+
let n;
|
|
535
|
+
while ((n = w.nextNode())) {
|
|
536
|
+
const text = n.textContent.trim();
|
|
537
|
+
if (!isRawEnum(text)) continue;
|
|
538
|
+
const p = n.parentElement;
|
|
539
|
+
if (!p) continue;
|
|
540
|
+
if (!['TD', 'LI', 'SPAN', 'DIV'].includes(p.tagName)) continue;
|
|
541
|
+
if (p.closest('script,style,noscript,code,pre,kbd,samp')) continue;
|
|
542
|
+
if (p.closest('input,textarea,select,option')) continue;
|
|
543
|
+
if (!vis(p) || srOnly(p)) continue;
|
|
544
|
+
const words = (p.textContent || '').trim().split(/\s+/).filter(Boolean);
|
|
545
|
+
if (words.length > 3) continue;
|
|
546
|
+
enumFindings.push(p);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const enumCapped = [...new Set(enumFindings)].slice(0, 10);
|
|
550
|
+
if (enumCapped.length)
|
|
551
|
+
add('value.raw-enum', 'P3', enumCapped.length + ' element(s) rendering a raw snake_case value with no display formatting', sel(enumCapped[0]));
|
|
552
|
+
|
|
553
|
+
/* -- L9.91: focusing a control produces no visible change ---------------
|
|
554
|
+
Programmatic .focus()/.blur() is enough here -- :focus applies
|
|
555
|
+
regardless of how focus arrived, unlike the real Tab walk below which
|
|
556
|
+
needs a trusted input event to drive native focus advancement. */
|
|
557
|
+
const FOCUSABLE_SEL = 'a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[tabindex]:not([tabindex="-1"])';
|
|
558
|
+
const focusables = [...document.querySelectorAll(FOCUSABLE_SEL)].filter((el) => vis(el) && !srOnly(el) && !el.disabled);
|
|
559
|
+
if (focusables.length >= 3) {
|
|
560
|
+
const sample = focusables.slice(0, 25);
|
|
561
|
+
const noRing = [];
|
|
562
|
+
const prevActive = document.activeElement;
|
|
563
|
+
// Real-repo evidence: a search input styled with a border-color change
|
|
564
|
+
// on focus (no outline, no box-shadow) is a legitimate indicator, and
|
|
565
|
+
// reading only outline/box-shadow filed it as having none. Compare
|
|
566
|
+
// every property browsers commonly use for a focus indicator; only
|
|
567
|
+
// "every one of these is unchanged" earns the finding.
|
|
568
|
+
const focusKey = (s) => [s.outlineStyle, s.outlineWidth, s.outlineColor, s.boxShadow, s.borderColor, s.borderStyle, s.backgroundColor].join('|');
|
|
569
|
+
let measured = 0;
|
|
570
|
+
const probeStyle = document.createElement('style');
|
|
571
|
+
probeStyle.textContent = '*,*::before,*::after{transition:none!important}';
|
|
572
|
+
(document.head || document.documentElement).appendChild(probeStyle);
|
|
573
|
+
try {
|
|
574
|
+
for (const el of sample) {
|
|
575
|
+
try {
|
|
576
|
+
if (!el.isConnected) continue;
|
|
577
|
+
el.blur();
|
|
578
|
+
const beforeKey = focusKey(getComputedStyle(el));
|
|
579
|
+
el.focus({ preventScroll: true });
|
|
580
|
+
if (document.activeElement !== el || !el.isConnected) continue;
|
|
581
|
+
const afterKey = focusKey(getComputedStyle(el));
|
|
582
|
+
el.blur();
|
|
583
|
+
measured++;
|
|
584
|
+
if (beforeKey === afterKey) noRing.push(el);
|
|
585
|
+
} catch (e) { /* not focusable in practice */ }
|
|
586
|
+
}
|
|
587
|
+
} finally {
|
|
588
|
+
probeStyle.remove();
|
|
589
|
+
try { if (prevActive && prevActive !== document.body && prevActive.focus) prevActive.focus({ preventScroll: true }); } catch (e) { /* best effort restore */ }
|
|
590
|
+
}
|
|
591
|
+
if (noRing.length)
|
|
592
|
+
add('a11y.no-focus-ring', 'P2', noRing.length + ' of ' + measured + ' focused control(s) showed no visible outline, shadow, border, or background change: ' + noRing.slice(0, 5).map(sel).join(', '));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/* -- L9.87: text contrast against a provably opaque background ----------
|
|
596
|
+
"Provably" is load-bearing: the effective background is the first
|
|
597
|
+
ancestor with an opaque solid background-color, and any image or
|
|
598
|
+
gradient in the way -- on any element, at any point in the chain --
|
|
599
|
+
aborts the search for that node rather than assuming white. A node
|
|
600
|
+
under an opacity < 1 ancestor is skipped the same way; a rendered
|
|
601
|
+
ratio there would be a guess, not a measurement. */
|
|
602
|
+
const relLum = (c) => {
|
|
603
|
+
const chan = (v) => { v = v / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
|
|
604
|
+
return 0.2126 * chan(c.r) + 0.7152 * chan(c.g) + 0.0722 * chan(c.b);
|
|
605
|
+
};
|
|
606
|
+
const contrastRatio = (fg, bg) => {
|
|
607
|
+
const l1 = relLum(fg), l2 = relLum(bg);
|
|
608
|
+
const lighter = Math.max(l1, l2), darker = Math.min(l1, l2);
|
|
609
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
610
|
+
};
|
|
611
|
+
// Canvas rasterisation is the browser's color engine: unlike reading
|
|
612
|
+
// ctx.fillStyle, drawing an oklch()/lab()/color() value produces sRGB
|
|
613
|
+
// pixels and applies alpha compositing. Unknown CSS colors stay unmeasured.
|
|
614
|
+
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1;
|
|
615
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
616
|
+
const drawColor = (layers) => {
|
|
617
|
+
if (!ctx) return null;
|
|
618
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
619
|
+
for (const color of layers) {
|
|
620
|
+
if (!color) continue;
|
|
621
|
+
if (!globalThis.CSS?.supports?.('color', color)) return null;
|
|
622
|
+
ctx.fillStyle = color;
|
|
623
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
624
|
+
}
|
|
625
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
626
|
+
return { r, g, b, a: a / 255 };
|
|
627
|
+
};
|
|
628
|
+
const findOpaqueBg = (el) => {
|
|
629
|
+
const chain = [];
|
|
630
|
+
for (let a = el; a; a = a.parentElement) {
|
|
631
|
+
const s = getComputedStyle(a);
|
|
632
|
+
if (s.backgroundImage && s.backgroundImage !== 'none') return null;
|
|
633
|
+
chain.unshift(s.backgroundColor);
|
|
634
|
+
// Stop at the nearest FULLY OPAQUE background-color: nothing painted
|
|
635
|
+
// behind it can reach the text, so a gradient further out is not a
|
|
636
|
+
// reason to abandon the sample. Walking past it lost a measurable
|
|
637
|
+
// 1.09 ratio on an opaque white card because the body had a gradient.
|
|
638
|
+
// The image check above still runs first, so an image on this same
|
|
639
|
+
// element (which paints over its own color) keeps aborting. #277
|
|
640
|
+
const solid = drawColor([s.backgroundColor]);
|
|
641
|
+
if (solid && solid.a >= 0.999) break;
|
|
642
|
+
if (a === document.documentElement) break;
|
|
643
|
+
}
|
|
644
|
+
const bg = drawColor(chain);
|
|
645
|
+
return bg && bg.a >= 0.999 ? bg : null;
|
|
646
|
+
};
|
|
647
|
+
const opacityChainOk = (el) => {
|
|
648
|
+
for (let a = el; a; a = a.parentElement) {
|
|
649
|
+
if (Number(getComputedStyle(a).opacity) < 1) return false;
|
|
650
|
+
if (a === document.documentElement) break;
|
|
651
|
+
}
|
|
652
|
+
return true;
|
|
653
|
+
};
|
|
654
|
+
const contrastHits = [];
|
|
655
|
+
const contrastSeen = new Set();
|
|
656
|
+
{
|
|
657
|
+
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
658
|
+
let n;
|
|
659
|
+
while ((n = w.nextNode())) {
|
|
660
|
+
const text = n.textContent.trim();
|
|
661
|
+
if (text.length < 3) continue;
|
|
662
|
+
const p = n.parentElement;
|
|
663
|
+
if (!p || contrastSeen.has(p)) continue;
|
|
664
|
+
contrastSeen.add(p);
|
|
665
|
+
if (!vis(p) || srOnly(p)) continue;
|
|
666
|
+
if (p.closest('script,style,noscript,code,pre')) continue;
|
|
667
|
+
metrics.contrastEligible++;
|
|
668
|
+
try {
|
|
669
|
+
if (!opacityChainOk(p)) { metrics.contrastUnmeasured++; continue; }
|
|
670
|
+
const bg = findOpaqueBg(p);
|
|
671
|
+
if (!bg) { metrics.contrastUnmeasured++; continue; }
|
|
672
|
+
const s = getComputedStyle(p);
|
|
673
|
+
const fg = drawColor(['rgb(' + bg.r + ' ' + bg.g + ' ' + bg.b + ')', s.color]);
|
|
674
|
+
if (!fg) { metrics.contrastUnmeasured++; continue; }
|
|
675
|
+
const fontSize = parseFloat(s.fontSize) || 16;
|
|
676
|
+
const weight = parseInt(s.fontWeight, 10) || 400;
|
|
677
|
+
const large = fontSize >= 24 || (fontSize >= 18.66 && weight >= 700);
|
|
678
|
+
const ratio = contrastRatio(fg, bg);
|
|
679
|
+
const threshold = large ? 3 : 4.5;
|
|
680
|
+
if (ratio < threshold) { contrastHits.push({ el: p, ratio }); contrastSeen.add(p); }
|
|
681
|
+
} catch (e) { metrics.contrastUnmeasured++; /* color unreadable: skip rather than guess */ }
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (contrastHits.length) {
|
|
685
|
+
contrastHits.sort((a, b) => a.ratio - b.ratio);
|
|
686
|
+
add('a11y.contrast', 'P3', contrastHits.length + ' text node(s) below the WCAG contrast threshold, worst ratio '
|
|
687
|
+
+ contrastHits[0].ratio.toFixed(2) + ': ' + contrastHits.slice(0, 10).map((h) => sel(h.el)).join(', '));
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/* -- L8.80: text overflowing its nearest overflow:hidden ancestor ------- */
|
|
691
|
+
const isScrollable = (el) => { const s = getComputedStyle(el); return /auto|scroll/.test(s.overflowX + s.overflowY); };
|
|
692
|
+
const clippedText = [];
|
|
693
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
694
|
+
if (!vis(el) || srOnly(el)) continue;
|
|
695
|
+
if (!(el.textContent || '').trim()) continue;
|
|
696
|
+
if (isScrollable(el)) continue;
|
|
697
|
+
const r = el.getBoundingClientRect();
|
|
698
|
+
let blockedX = false, blockedY = false;
|
|
699
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
700
|
+
const s = getComputedStyle(a);
|
|
701
|
+
const ar = a.getBoundingClientRect();
|
|
702
|
+
const outsideX = r.left < ar.left - 4 || r.right > ar.right + 4;
|
|
703
|
+
const outsideY = r.top < ar.top - 4 || r.bottom > ar.bottom + 4;
|
|
704
|
+
const scrollX = /auto|scroll/.test(s.overflowX) && outsideX;
|
|
705
|
+
const scrollY = /auto|scroll/.test(s.overflowY) && outsideY;
|
|
706
|
+
blockedX ||= scrollX; blockedY ||= scrollY;
|
|
707
|
+
const hiddenX = (s.overflow === 'hidden' || s.overflowX === 'hidden') && outsideX && !blockedX;
|
|
708
|
+
const hiddenY = (s.overflow === 'hidden' || s.overflowY === 'hidden') && outsideY && !blockedY;
|
|
709
|
+
if (hiddenX || hiddenY) {
|
|
710
|
+
clippedText.push(el);
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
if (blockedX && blockedY) break;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const clippedCapped = clippedText.slice(0, 10);
|
|
717
|
+
if (clippedCapped.length)
|
|
718
|
+
add('layout.clipped-text', 'P3', clippedCapped.length + ' text element(s) extending beyond an overflow boundary by more than 4px', sel(clippedCapped[0]));
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
if (probeFocused && probeFocused.isConnected && probeFocused !== document.body && probeFocused.focus)
|
|
722
|
+
probeFocused.focus({ preventScroll: true });
|
|
723
|
+
} catch (e) { /* best effort restore */ }
|
|
724
|
+
return { findings: V, metrics };
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// -- L9.89 / L9.90: a real Tab-key walk. Focus advancement on Tab is native
|
|
728
|
+
// input behaviour -- Chromium does not move focus for a script-dispatched
|
|
729
|
+
// KeyboardEvent, only for a trusted one -- so this drives page.keyboard
|
|
730
|
+
// directly instead of staying inside the evaluate() above. Both findings
|
|
731
|
+
// come from the same 60-press walk, so they share one pass.
|
|
732
|
+
try {
|
|
733
|
+
const focusCount = await page.evaluate(() => {
|
|
734
|
+
const vis = (el) => {
|
|
735
|
+
if (!el || el.nodeType !== 1) return false;
|
|
736
|
+
const s = getComputedStyle(el);
|
|
737
|
+
if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) < 0.02) return false;
|
|
738
|
+
const r = el.getBoundingClientRect();
|
|
739
|
+
return r.width > 0 && r.height > 0;
|
|
740
|
+
};
|
|
741
|
+
const SEL = 'a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[tabindex]:not([tabindex="-1"])';
|
|
742
|
+
return [...document.querySelectorAll(SEL)].filter((el) => vis(el) && !el.disabled).length;
|
|
743
|
+
});
|
|
744
|
+
if (focusCount >= 3) {
|
|
745
|
+
// Read per stop: a DISPLAY selector for reporting, an INDEX into the
|
|
746
|
+
// live focusable-element query for IDENTITY, and whether focus sits
|
|
747
|
+
// inside a widget that is allowed to hold Tab on purpose. Real-repo
|
|
748
|
+
// evidence (Profectus, a shadcn/Tailwind app): comparing the display
|
|
749
|
+
// selector for identity reads a Tab walk through N different icon
|
|
750
|
+
// buttons that all share one class (button.inline-flex) as "cycling
|
|
751
|
+
// between 2 elements" -- the string collides, the elements do not.
|
|
752
|
+
// The index into a live query is not that; two different nodes can
|
|
753
|
+
// never share a position in the same NodeList.
|
|
754
|
+
const readStop = () => page.evaluate(() => {
|
|
755
|
+
const el = document.activeElement;
|
|
756
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
757
|
+
const SEL = 'a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[tabindex]:not([tabindex="-1"])';
|
|
758
|
+
const idx = [...document.querySelectorAll(SEL)].indexOf(el);
|
|
759
|
+
const r = el.getBoundingClientRect();
|
|
760
|
+
const t = el.getAttribute && el.getAttribute('data-testid');
|
|
761
|
+
const c = (el.className && String(el.className).split(/\s+/)[0]) || '';
|
|
762
|
+
const s = t ? ('[data-testid=' + t + ']') : (el.id ? ('#' + el.id) : el.tagName.toLowerCase() + (c ? '.' + c : ''));
|
|
763
|
+
// A roving-tabindex widget (menu/toolbar/tablist), an open dialog, an
|
|
764
|
+
// expanded combobox/listbox, or anything marked open is CORRECTLY
|
|
765
|
+
// allowed to hold Tab -- that is the widget doing its job, not a trap.
|
|
766
|
+
const openWidget = !!el.closest('[role="dialog"],[aria-modal="true"],[role="menu"],[role="listbox"],[role="combobox"][aria-expanded="true"],[data-state="open"],[popover]');
|
|
767
|
+
// Page coordinates, not viewport: focusing an element below the fold
|
|
768
|
+
// scrolls it into view, so its viewport top is routinely SMALLER than
|
|
769
|
+
// the previous stop's -- and a perfectly ordered page read as "jumped
|
|
770
|
+
// 300px above" on every route long enough to scroll. A fixed/sticky
|
|
771
|
+
// element has no page position of its own (it rides the viewport), so
|
|
772
|
+
// it keeps viewport coordinates; otherwise the fixed menu button at the
|
|
773
|
+
// top-left of a scrolled page lands "below" everything.
|
|
774
|
+
//
|
|
775
|
+
// window.scrollY does that job only for stops the WINDOW scrolls. An
|
|
776
|
+
// element inside an overflow-y:auto pane is positioned by that pane's
|
|
777
|
+
// scrollTop instead -- on an app shell whose body never scrolls,
|
|
778
|
+
// scrollY stays 0 and adding it changes nothing, so every stop reads
|
|
779
|
+
// as wherever the pane happens to have scrolled it. Adding the
|
|
780
|
+
// scrollTop of each scrolling ancestor puts those stops back in the
|
|
781
|
+
// pane's own content space, and the nearest such ancestor is recorded
|
|
782
|
+
// so two stops in DIFFERENT panes are never subtracted from each
|
|
783
|
+
// other -- that is two coordinate spaces, not a jump. #284
|
|
784
|
+
let pinned = false, scrollTop = 0, scrollLeft = 0, frame = -1;
|
|
785
|
+
for (let n = el; n && n !== document.body; n = n.parentElement) {
|
|
786
|
+
const st = getComputedStyle(n);
|
|
787
|
+
if (st.position === 'fixed' || st.position === 'sticky') { pinned = true; break; }
|
|
788
|
+
if (n === el || !/auto|scroll/.test(st.overflowY + st.overflowX)) continue;
|
|
789
|
+
if (frame === -1) frame = [...document.querySelectorAll('*')].indexOf(n);
|
|
790
|
+
scrollTop += n.scrollTop; scrollLeft += n.scrollLeft;
|
|
791
|
+
}
|
|
792
|
+
const top = (pinned ? r.top : r.top + (window.scrollY || 0)) + scrollTop;
|
|
793
|
+
const left = (pinned ? r.left : r.left + (window.scrollX || 0)) + scrollLeft;
|
|
794
|
+
return { sel: s, top, left, idx, openWidget, pinned, frame };
|
|
795
|
+
});
|
|
796
|
+
// The probe's opening trusted Tab establishes :focus-visible and may
|
|
797
|
+
// already be the first stop. Preserve it: blurring here loses the
|
|
798
|
+
// browser's sequential-focus origin, and a later keyboard trap can make
|
|
799
|
+
// that first -> second ordering edge impossible to observe again.
|
|
800
|
+
const initialStop = await readStop();
|
|
801
|
+
const stops = initialStop ? [initialStop] : [];
|
|
802
|
+
for (let i = stops.length; i < 60; i++) {
|
|
803
|
+
await page.keyboard.press('Tab');
|
|
804
|
+
stops.push(await readStop());
|
|
805
|
+
}
|
|
806
|
+
// A keyboard trap: the LAST 10 presses of the walk drawn from 2 or
|
|
807
|
+
// fewer distinct elements -- requiring the tail (not any window) means
|
|
808
|
+
// this is still stuck when the walk ends, not a stall it recovered
|
|
809
|
+
// from, and keeps the verification below acting on the actual current
|
|
810
|
+
// state rather than a moment already left behind.
|
|
811
|
+
const tail = stops.slice(-10);
|
|
812
|
+
let trapWin = null;
|
|
813
|
+
if (tail.length === 10 && !tail.some((x) => !x)) {
|
|
814
|
+
const uniq = new Set(tail.map((x) => x.idx));
|
|
815
|
+
if (uniq.size <= 2 && !tail.some((x) => x.openWidget)) trapWin = tail;
|
|
816
|
+
}
|
|
817
|
+
if (trapWin) {
|
|
818
|
+
const cyclingIdx = new Set(trapWin.map((x) => x.idx));
|
|
819
|
+
const reachedIdx = new Set(stops.filter(Boolean).map((x) => x.idx));
|
|
820
|
+
const neverReached = focusCount - reachedIdx.size;
|
|
821
|
+
// Require the trap to have actually cost something: at least 3 other
|
|
822
|
+
// focusables on the page were never reached across the whole walk.
|
|
823
|
+
// A brief stall a user tabs straight through reaches the rest of the
|
|
824
|
+
// page fine and fails this gate.
|
|
825
|
+
if (neverReached >= 3) {
|
|
826
|
+
// Final check, cheap and decisive: press Escape once, then Tab
|
|
827
|
+
// again. A widget legitimately holding focus (menu, combobox,
|
|
828
|
+
// popover without the ARIA markers above) releases it on Escape;
|
|
829
|
+
// a real trap does not move.
|
|
830
|
+
await page.keyboard.press('Escape');
|
|
831
|
+
await page.keyboard.press('Tab');
|
|
832
|
+
const afterIdx = await page.evaluate(() => {
|
|
833
|
+
const el = document.activeElement;
|
|
834
|
+
if (!el || el === document.body || el === document.documentElement) return -1;
|
|
835
|
+
const SEL = 'a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[tabindex]:not([tabindex="-1"])';
|
|
836
|
+
return [...document.querySelectorAll(SEL)].indexOf(el);
|
|
837
|
+
});
|
|
838
|
+
const escaped = afterIdx === -1 || !cyclingIdx.has(afterIdx);
|
|
839
|
+
if (!escaped)
|
|
840
|
+
out.findings.push({ kind: 'a11y.keyboard-trap', severity: 'P2', at: trapWin[trapWin.length - 1].sel,
|
|
841
|
+
detail: 'keyboard focus cycled among 2 or fewer elements for 10 consecutive Tab presses and did not release on Escape -- a keyboard user cannot reach the rest of the page' });
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Focus order: a stop landing more than 300px ABOVE the previous one in
|
|
846
|
+
// roughly the same column. The column check ("previous was not the last
|
|
847
|
+
// element of its row") is what keeps a normal row-wrap -- rightmost item
|
|
848
|
+
// of one row, next stop starts a new row well to the left -- silent;
|
|
849
|
+
// that shape moves DOWN and sideways, not straight up.
|
|
850
|
+
const pairs = [];
|
|
851
|
+
const firstStop = stops.find(Boolean);
|
|
852
|
+
for (let i = 1; i < stops.length && pairs.length < 5; i++) {
|
|
853
|
+
const prev = stops[i - 1], cur = stops[i];
|
|
854
|
+
if (!prev || !cur) continue;
|
|
855
|
+
// Focus moving BACKWARDS in DOM order is the walk wrapping from the
|
|
856
|
+
// last focusable element back to the FIRST one (through the browser
|
|
857
|
+
// UI), or a roving widget -- neither is the page's tab order. Only
|
|
858
|
+
// those two are skipped: a blanket "backwards in DOM order" skip also
|
|
859
|
+
// hid a positive tabindex sending focus backwards, which is the
|
|
860
|
+
// classic focus-order defect this rule exists to catch. #284
|
|
861
|
+
if (cur.idx <= prev.idx && (!firstStop || cur.idx === firstStop.idx || prev.openWidget || cur.openWidget)) continue;
|
|
862
|
+
// Different coordinate spaces are not comparable: a stop pinned to the
|
|
863
|
+
// viewport, or two stops inside different scrolling panes.
|
|
864
|
+
if (prev.pinned || cur.pinned || prev.frame !== cur.frame) continue;
|
|
865
|
+
// The 60-press walk laps a short page several times, so one defective
|
|
866
|
+
// transition is met once per lap. Counting it once keeps the reported
|
|
867
|
+
// number of jumps equal to the number of defects.
|
|
868
|
+
const pair = prev.sel + ' -> ' + cur.sel;
|
|
869
|
+
if (prev.top - cur.top > 300 && Math.abs(cur.left - prev.left) < 150 && !pairs.includes(pair))
|
|
870
|
+
pairs.push(pair);
|
|
871
|
+
}
|
|
872
|
+
if (pairs.length)
|
|
873
|
+
out.findings.push({ kind: 'a11y.focus-order', severity: 'P3', at: '(page)',
|
|
874
|
+
detail: pairs.length + ' Tab stop(s) jumped more than 300px above the previous stop in roughly the same column: ' + pairs.join(', ') });
|
|
875
|
+
}
|
|
876
|
+
} catch (e) { /* keyboard walk unavailable in this context */ }
|
|
877
|
+
|
|
878
|
+
return out;
|
|
879
|
+
}
|