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.
Files changed (187) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE +9 -0
  3. package/README.md +100 -0
  4. package/SKILL.md +65 -0
  5. package/agent/prebuilt/darwin-universal/qa-agent +0 -0
  6. package/agent/prebuilt/linux-arm64/qa-agent +0 -0
  7. package/agent/prebuilt/linux-x64/qa-agent +0 -0
  8. package/agent/release-manifest.json +31 -0
  9. package/bin/autonomous-cache.mjs +2 -0
  10. package/bin/bench-generate.mjs +232 -0
  11. package/bin/bench-graph-query.mjs +2 -0
  12. package/bin/bench-score.mjs +10 -0
  13. package/bin/classify.mjs +15 -0
  14. package/bin/coherence-playwright.mjs +1 -0
  15. package/bin/coherence-runner.mjs +6 -0
  16. package/bin/crud-runner.mjs +12 -0
  17. package/bin/experiment-executor.mjs +10 -0
  18. package/bin/experiment-runner.mjs +3 -0
  19. package/bin/inventory.mjs +17 -0
  20. package/bin/probe.js +879 -0
  21. package/bin/qa.mjs +16 -0
  22. package/bin/rules.mjs +5 -0
  23. package/bin/run-benchmarks.mjs +2 -0
  24. package/bin/runtime-packs.mjs +3 -0
  25. package/bin/semantic-explorer.mjs +3 -0
  26. package/bin/shadow-collector.mjs +3 -0
  27. package/bin/shadow-replay.mjs +6 -0
  28. package/bin/soak-marathon.mjs +15 -0
  29. package/bin/sweep.mjs +43 -0
  30. package/bin/validate-priors.mjs +3 -0
  31. package/bin/verify-artifacts.mjs +3 -0
  32. package/bin/verify-manifest.mjs +2 -0
  33. package/install-skill.sh +45 -0
  34. package/lib/qa/agent-availability.mjs +1 -0
  35. package/lib/qa/agent-result.mjs +1 -0
  36. package/lib/qa/analysis.mjs +5 -0
  37. package/lib/qa/atlas-coverage.mjs +11 -0
  38. package/lib/qa/atlas-v1.mjs +1 -0
  39. package/lib/qa/atlas.mjs +2 -0
  40. package/lib/qa/auth/coordinator.mjs +1 -0
  41. package/lib/qa/auth/launcher.mjs +3 -0
  42. package/lib/qa/auth/lease.mjs +1 -0
  43. package/lib/qa/auth/oracle.mjs +1 -0
  44. package/lib/qa/auth/personas.mjs +3 -0
  45. package/lib/qa/autonomous-cache.mjs +3 -0
  46. package/lib/qa/block-ledger.mjs +1 -0
  47. package/lib/qa/capture-report.mjs +63 -0
  48. package/lib/qa/ci.mjs +11 -0
  49. package/lib/qa/cli.mjs +538 -0
  50. package/lib/qa/coherence/analyze.mjs +1 -0
  51. package/lib/qa/coherence/cache-key.mjs +6 -0
  52. package/lib/qa/coherence/causes.mjs +1 -0
  53. package/lib/qa/coherence/concurrency.mjs +1 -0
  54. package/lib/qa/coherence/contracts.mjs +1 -0
  55. package/lib/qa/coherence/entity-aliases.mjs +1 -0
  56. package/lib/qa/coherence/enums.mjs +1 -0
  57. package/lib/qa/coherence/family-report.mjs +1 -0
  58. package/lib/qa/coherence/flow-pool.mjs +1 -0
  59. package/lib/qa/coherence/graph.mjs +1 -0
  60. package/lib/qa/coherence/identity.mjs +1 -0
  61. package/lib/qa/coherence/ir.mjs +4 -0
  62. package/lib/qa/coherence/navigation.mjs +1 -0
  63. package/lib/qa/coherence/observe.mjs +2 -0
  64. package/lib/qa/coherence/offline.mjs +1 -0
  65. package/lib/qa/coherence/plan-join.mjs +1 -0
  66. package/lib/qa/coherence/provenance.mjs +2 -0
  67. package/lib/qa/coherence/report.mjs +1 -0
  68. package/lib/qa/coherence/representations.mjs +1 -0
  69. package/lib/qa/coherence/risk.mjs +2 -0
  70. package/lib/qa/coherence/structural-js.mjs +11 -0
  71. package/lib/qa/coherence/symbols.mjs +6 -0
  72. package/lib/qa/coherence/tanstack.mjs +4 -0
  73. package/lib/qa/coherence/views.mjs +1 -0
  74. package/lib/qa/coherence-planner.mjs +1 -0
  75. package/lib/qa/command-catalog.mjs +2 -0
  76. package/lib/qa/commands/dev.mjs +1 -0
  77. package/lib/qa/commands/fix.mjs +1 -0
  78. package/lib/qa/commands/impl/analysis.mjs +1 -0
  79. package/lib/qa/commands/impl/atlas.mjs +1 -0
  80. package/lib/qa/commands/impl/coherence.mjs +1 -0
  81. package/lib/qa/commands/impl/crud.mjs +2 -0
  82. package/lib/qa/commands/impl/doctor.mjs +1 -0
  83. package/lib/qa/commands/impl/experiments.mjs +1 -0
  84. package/lib/qa/commands/impl/fp.mjs +1 -0
  85. package/lib/qa/commands/impl/graph.mjs +1 -0
  86. package/lib/qa/commands/impl/invariants.mjs +1 -0
  87. package/lib/qa/commands/impl/lanes.mjs +7 -0
  88. package/lib/qa/commands/impl/ledger.mjs +11 -0
  89. package/lib/qa/commands/impl/loop.mjs +1 -0
  90. package/lib/qa/commands/impl/marathon.mjs +1 -0
  91. package/lib/qa/commands/impl/next.mjs +38 -0
  92. package/lib/qa/commands/impl/report.mjs +4 -0
  93. package/lib/qa/commands/impl/research.mjs +4 -0
  94. package/lib/qa/commands/impl/selftest.mjs +1 -0
  95. package/lib/qa/commands/impl/serve.mjs +1 -0
  96. package/lib/qa/commands/impl/shadow.mjs +1 -0
  97. package/lib/qa/commands/impl/stability.mjs +1 -0
  98. package/lib/qa/commands/impl/triage.mjs +1 -0
  99. package/lib/qa/commands/impl/validate.mjs +4 -0
  100. package/lib/qa/commands/impl/version.mjs +1 -0
  101. package/lib/qa/commands/impl/watch.mjs +1 -0
  102. package/lib/qa/commands/init.mjs +6 -0
  103. package/lib/qa/commands/measure.mjs +1 -0
  104. package/lib/qa/commands/run.mjs +1 -0
  105. package/lib/qa/commands/show.mjs +1 -0
  106. package/lib/qa/commands/verify.mjs +6 -0
  107. package/lib/qa/deepening.mjs +1 -0
  108. package/lib/qa/denominators.mjs +1 -0
  109. package/lib/qa/depth.mjs +4 -0
  110. package/lib/qa/doctor.mjs +2 -0
  111. package/lib/qa/experiments-default.mjs +1 -0
  112. package/lib/qa/fix-lanes.mjs +3 -0
  113. package/lib/qa/forced-states.mjs +1 -0
  114. package/lib/qa/fp.mjs +2 -0
  115. package/lib/qa/fs.mjs +6 -0
  116. package/lib/qa/graph-schema.mjs +2 -0
  117. package/lib/qa/graph.mjs +3 -0
  118. package/lib/qa/harness-plan.mjs +3 -0
  119. package/lib/qa/init.mjs +5 -0
  120. package/lib/qa/integrity.mjs +1 -0
  121. package/lib/qa/invariants.mjs +84 -0
  122. package/lib/qa/jcs.mjs +1 -0
  123. package/lib/qa/lanes.mjs +1 -0
  124. package/lib/qa/loop.mjs +7 -0
  125. package/lib/qa/marathon-coherence.mjs +1 -0
  126. package/lib/qa/marathon-phases.mjs +1 -0
  127. package/lib/qa/marathon-wait.mjs +3 -0
  128. package/lib/qa/marathon.mjs +5 -0
  129. package/lib/qa/mode-coverage.mjs +1 -0
  130. package/lib/qa/mutation-policy.mjs +1 -0
  131. package/lib/qa/pattern-ledger.mjs +3 -0
  132. package/lib/qa/pending.mjs +1 -0
  133. package/lib/qa/proc.mjs +4 -0
  134. package/lib/qa/recommend.mjs +1 -0
  135. package/lib/qa/release-manifest.mjs +1 -0
  136. package/lib/qa/report-html.mjs +178 -0
  137. package/lib/qa/reports.mjs +2 -0
  138. package/lib/qa/request-evidence.mjs +1 -0
  139. package/lib/qa/research.mjs +20 -0
  140. package/lib/qa/routing.mjs +1 -0
  141. package/lib/qa/runs.mjs +1 -0
  142. package/lib/qa/schema.mjs +1 -0
  143. package/lib/qa/serve.mjs +4 -0
  144. package/lib/qa/server.mjs +1 -0
  145. package/lib/qa/source-snapshot.mjs +3 -0
  146. package/lib/qa/stability.mjs +3 -0
  147. package/lib/qa/state-evidence.mjs +1 -0
  148. package/lib/qa/sweep-incremental.mjs +1 -0
  149. package/lib/qa/triage.mjs +2 -0
  150. package/lib/qa/verdict.mjs +1 -0
  151. package/lib/qa/watch.mjs +2 -0
  152. package/package.json +62 -0
  153. package/references/ATLAS-COVERAGE.md +231 -0
  154. package/references/atlas-v1.md +318 -0
  155. package/references/atlas.md +338 -0
  156. package/references/verify.crud.example.json +1 -0
  157. package/references/verify.impact.example.json +1 -0
  158. package/references/verify.shadow.example.json +1 -0
  159. package/schemas/agent-result.schema.json +135 -0
  160. package/schemas/analysis.schema.json +15 -0
  161. package/schemas/classify.schema.json +61 -0
  162. package/schemas/coherence-ir.schema.json +1585 -0
  163. package/schemas/coherence-observed.schema.json +1171 -0
  164. package/schemas/coherence-plan.schema.json +77 -0
  165. package/schemas/coherence-risk.schema.json +46 -0
  166. package/schemas/crud-observed.schema.json +118 -0
  167. package/schemas/experiment-execution.schema.json +82 -0
  168. package/schemas/graph-edge.schema.json +22 -0
  169. package/schemas/graph-node.schema.json +81 -0
  170. package/schemas/graph.schema.json +2493 -0
  171. package/schemas/identity-oracle.schema.json +19 -0
  172. package/schemas/inventory.schema.json +2167 -0
  173. package/schemas/lanes-plan.schema.json +52 -0
  174. package/schemas/next.schema.json +54 -0
  175. package/schemas/personas.schema.json +52 -0
  176. package/schemas/research.schema.json +192 -0
  177. package/schemas/run.schema.json +160 -0
  178. package/schemas/shadow-replay.schema.json +68 -0
  179. package/schemas/stability.schema.json +92 -0
  180. package/schemas/sweep.schema.json +239 -0
  181. package/schemas/v1/agent-result.schema.json +106 -0
  182. package/schemas/v1/run.schema.json +96 -0
  183. package/schemas/verify-crud.schema.json +37 -0
  184. package/schemas/verify-read-only.schema.json +37 -0
  185. package/templates/github-workflow.yml +80 -0
  186. package/templates/pre-commit.sample +22 -0
  187. 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
+ }