mcp-accessibility-scanner 3.0.1 → 3.2.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/README.md +265 -12
- package/index.d.ts +1 -1
- package/lib/browserContextFactory.js +1161 -94
- package/lib/browserContextFactory.js.map +1 -1
- package/lib/browserServerBackend.js +199 -22
- package/lib/browserServerBackend.js.map +1 -1
- package/lib/browserSessions.js +172 -0
- package/lib/browserSessions.js.map +1 -0
- package/lib/config.js +48 -7
- package/lib/config.js.map +1 -1
- package/lib/context.js +321 -71
- package/lib/context.js.map +1 -1
- package/lib/extension/cdpRelay.js +5 -0
- package/lib/extension/cdpRelay.js.map +1 -1
- package/lib/extension/extensionContextFactory.js +12 -1
- package/lib/extension/extensionContextFactory.js.map +1 -1
- package/lib/index.js +5 -1
- package/lib/index.js.map +1 -1
- package/lib/mcp/http.js +194 -44
- package/lib/mcp/http.js.map +1 -1
- package/lib/mcp/inProcessTransport.js.map +1 -1
- package/lib/mcp/mdb.js +7 -9
- package/lib/mcp/mdb.js.map +1 -1
- package/lib/mcp/proxyBackend.js +76 -18
- package/lib/mcp/proxyBackend.js.map +1 -1
- package/lib/mcp/server.js +70 -41
- package/lib/mcp/server.js.map +1 -1
- package/lib/mcp/sharedClientSlot.js +134 -0
- package/lib/mcp/sharedClientSlot.js.map +1 -0
- package/lib/mcp/tool.js +3 -0
- package/lib/mcp/tool.js.map +1 -1
- package/lib/networkPolicy.js +73 -0
- package/lib/networkPolicy.js.map +1 -0
- package/lib/program.js +91 -12
- package/lib/program.js.map +1 -1
- package/lib/response.js +16 -3
- package/lib/response.js.map +1 -1
- package/lib/sessionLog.js +36 -6
- package/lib/sessionLog.js.map +1 -1
- package/lib/tab.js +149 -18
- package/lib/tab.js.map +1 -1
- package/lib/tools/auditKeyboard.js +204 -4
- package/lib/tools/auditKeyboard.js.map +1 -1
- package/lib/tools/auditScreenReader.js +823 -0
- package/lib/tools/auditScreenReader.js.map +1 -0
- package/lib/tools/auditSite.js +268 -90
- package/lib/tools/auditSite.js.map +1 -1
- package/lib/tools/axe.js +479 -21
- package/lib/tools/axe.js.map +1 -1
- package/lib/tools/dialogs.js +18 -4
- package/lib/tools/dialogs.js.map +1 -1
- package/lib/tools/evaluate.js +34 -5
- package/lib/tools/evaluate.js.map +1 -1
- package/lib/tools/network.js +136 -8
- package/lib/tools/network.js.map +1 -1
- package/lib/tools/pdf.js +5 -2
- package/lib/tools/pdf.js.map +1 -1
- package/lib/tools/scanPageMatrix.js +135 -47
- package/lib/tools/scanPageMatrix.js.map +1 -1
- package/lib/tools/screenshot.js +8 -4
- package/lib/tools/screenshot.js.map +1 -1
- package/lib/tools/session.js +53 -0
- package/lib/tools/session.js.map +1 -0
- package/lib/tools/snapshot.js +266 -8
- package/lib/tools/snapshot.js.map +1 -1
- package/lib/tools/tool.js.map +1 -1
- package/lib/tools/utils.js +62 -6
- package/lib/tools/utils.js.map +1 -1
- package/lib/tools.js +11 -2
- package/lib/tools.js.map +1 -1
- package/lib/utils/dataUrl.js +58 -35
- package/lib/utils/dataUrl.js.map +1 -1
- package/lib/utils/fileUtils.js +35 -0
- package/lib/utils/fileUtils.js.map +1 -1
- package/lib/utils/guid.js +8 -0
- package/lib/utils/guid.js.map +1 -1
- package/lib/utils/jsSource.js +187 -0
- package/lib/utils/jsSource.js.map +1 -0
- package/lib/vscode/browserContextFactory.js +87 -0
- package/lib/vscode/browserContextFactory.js.map +1 -0
- package/lib/vscode/host.js +189 -33
- package/lib/vscode/host.js.map +1 -1
- package/lib/vscode/main.js +3 -35
- package/lib/vscode/main.js.map +1 -1
- package/package.json +13 -9
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { defineTabTool } from './tool.js';
|
|
4
|
+
import { safeIsoTimestampForFileName, sanitizeForFilePath } from '../utils/fileUtils.js';
|
|
5
|
+
// Roles that a screen-reader user reaches out of context, so an empty or
|
|
6
|
+
// meaningless accessible name leaves them with nothing to act on.
|
|
7
|
+
const namedRoles = new Set([
|
|
8
|
+
'link', 'button', 'checkbox', 'radio', 'switch', 'textbox', 'searchbox', 'combobox',
|
|
9
|
+
'listbox', 'slider', 'spinbutton', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
|
10
|
+
'tab', 'treeitem', 'option', 'img', 'image',
|
|
11
|
+
]);
|
|
12
|
+
// Only roles whose accessible name is expected to start with the visible label;
|
|
13
|
+
// containers are excluded because their text is the concatenation of children.
|
|
14
|
+
const labelInNameRoles = new Set([
|
|
15
|
+
'link', 'button', 'checkbox', 'radio', 'switch', 'menuitem', 'menuitemcheckbox',
|
|
16
|
+
'menuitemradio', 'tab', 'option', 'treeitem',
|
|
17
|
+
]);
|
|
18
|
+
const uninformativeNames = new Set([
|
|
19
|
+
'click here', 'click', 'here', 'this link', 'link', 'read more', 'more', 'more info',
|
|
20
|
+
'more information', 'more details', 'details', 'learn more', 'see more', 'view more',
|
|
21
|
+
'read this', 'full story', 'go', 'untitled', 'image', 'photo', 'picture', 'graphic',
|
|
22
|
+
'spacer', 'placeholder',
|
|
23
|
+
]);
|
|
24
|
+
const filenameNamePattern = /\.(jpe?g|png|gif|webp|svg|avif|bmp|tiff?|ico)$/i;
|
|
25
|
+
const cameraFileNamePattern = /^(img|dsc|dscn|pxl|screenshot|image|photo)[-_ ]?\d{3,}$/i;
|
|
26
|
+
// Refs are resolved and measured in chunks of this size.
|
|
27
|
+
const measureChunkSize = 50;
|
|
28
|
+
// Bands narrower than this are noise (sr-only clip boxes, 1px spacers).
|
|
29
|
+
const minLayoutSizePx = 2;
|
|
30
|
+
const layoutTolerancePx = 1;
|
|
31
|
+
const bandOverlapRatio = 0.5;
|
|
32
|
+
function normalizeText(value) {
|
|
33
|
+
if (!value)
|
|
34
|
+
return '';
|
|
35
|
+
return value.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
36
|
+
}
|
|
37
|
+
export function parseAriaSnapshot(snapshot) {
|
|
38
|
+
const nodes = [];
|
|
39
|
+
const stack = [];
|
|
40
|
+
for (const rawLine of snapshot.split('\n')) {
|
|
41
|
+
// Playwright YAML-quotes the whole key when the accessible name contains
|
|
42
|
+
// ": ", " #", braces or backticks, doubling any apostrophe inside it. Left
|
|
43
|
+
// quoted, such a node is dropped and its children are mis-parented.
|
|
44
|
+
const quoted = /^(\s*)- '((?:[^']|'')*)'(.*)$/.exec(rawLine);
|
|
45
|
+
const line = quoted ? `${quoted[1]}- ${quoted[2].replace(/''/g, '\'')}${quoted[3]}` : rawLine;
|
|
46
|
+
const match = /^(\s*)- ([a-zA-Z]+)(?:\s+"((?:[^"\\]|\\.)*)")?(.*)$/.exec(line);
|
|
47
|
+
if (!match)
|
|
48
|
+
continue;
|
|
49
|
+
const depth = match[1].length;
|
|
50
|
+
const rest = match[4];
|
|
51
|
+
while (stack.length && stack[stack.length - 1].depth >= depth)
|
|
52
|
+
stack.pop();
|
|
53
|
+
const levelMatch = /\[level=(\d+)\]/.exec(rest);
|
|
54
|
+
nodes.push({
|
|
55
|
+
role: match[2],
|
|
56
|
+
name: match[3] === undefined ? null : match[3].replace(/\\(.)/g, '$1'),
|
|
57
|
+
level: levelMatch ? Number(levelMatch[1]) : null,
|
|
58
|
+
ref: /\[ref=([^\]]+)\]/.exec(rest)?.[1] ?? null,
|
|
59
|
+
depth,
|
|
60
|
+
parent: stack.length ? stack[stack.length - 1].index : null,
|
|
61
|
+
});
|
|
62
|
+
stack.push({ depth, index: nodes.length - 1 });
|
|
63
|
+
}
|
|
64
|
+
return nodes;
|
|
65
|
+
}
|
|
66
|
+
function describe(node) {
|
|
67
|
+
const label = node.name ? `"${node.name}"` : node.visibleText ? `showing "${node.visibleText.slice(0, 40)}"` : 'no name';
|
|
68
|
+
return `${node.role} ${label}${node.selector ? ` (${node.selector})` : ''}`;
|
|
69
|
+
}
|
|
70
|
+
function overlapRatio(a, b, axis) {
|
|
71
|
+
const aStart = axis === 'x' ? a.x : a.y;
|
|
72
|
+
const bStart = axis === 'x' ? b.x : b.y;
|
|
73
|
+
const aSize = axis === 'x' ? a.width : a.height;
|
|
74
|
+
const bSize = axis === 'x' ? b.width : b.height;
|
|
75
|
+
const overlap = Math.min(aStart + aSize, bStart + bSize) - Math.max(aStart, bStart);
|
|
76
|
+
const smaller = Math.min(aSize, bSize);
|
|
77
|
+
return smaller <= 0 ? 0 : overlap / smaller;
|
|
78
|
+
}
|
|
79
|
+
function countBands(rects, axis) {
|
|
80
|
+
const band = rects.map((_, index) => index);
|
|
81
|
+
const rootOf = (index) => band[index] === index ? index : rootOf(band[index]);
|
|
82
|
+
for (let i = 0; i < rects.length; i++) {
|
|
83
|
+
for (let j = i + 1; j < rects.length; j++) {
|
|
84
|
+
if (overlapRatio(rects[i], rects[j], axis) >= bandOverlapRatio)
|
|
85
|
+
band[rootOf(j)] = rootOf(i);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return new Set(rects.map((_, index) => rootOf(index))).size;
|
|
89
|
+
}
|
|
90
|
+
function isLayoutRelevant(node) {
|
|
91
|
+
// Floated and fixed boxes are placed outside the normal flow on purpose (media
|
|
92
|
+
// beside a paragraph, sticky bars), so their visual position never claims to
|
|
93
|
+
// match source order.
|
|
94
|
+
if (!node.ref || !node.rect || node.positionFixed || node.floating || node.ariaHidden)
|
|
95
|
+
return false;
|
|
96
|
+
// Only text-bearing elements can change what is *read* when they move. Icon
|
|
97
|
+
// affordances next to their label (disclosure arrows, leading glyphs) are the
|
|
98
|
+
// single largest source of false reading-order alarms.
|
|
99
|
+
if (!/[\p{L}\p{N}]/u.test(node.visibleText ?? ''))
|
|
100
|
+
return false;
|
|
101
|
+
const { x, y, width, height } = node.rect;
|
|
102
|
+
// Off-canvas and clipped boxes are the standard visually-hidden techniques:
|
|
103
|
+
// they have no visual order to compare the reading order against.
|
|
104
|
+
return width >= minLayoutSizePx && height >= minLayoutSizePx && x + width > 0 && y + height > 0;
|
|
105
|
+
}
|
|
106
|
+
// An image inside a named link or button is announced through that control, so
|
|
107
|
+
// its own missing name is not a defect (icon buttons are full of these).
|
|
108
|
+
function hasNamedControlAncestor(nodes, index) {
|
|
109
|
+
for (let parent = nodes[index].parent; parent !== null; parent = nodes[parent].parent) {
|
|
110
|
+
if (nodes[parent].name?.trim() && labelInNameRoles.has(nodes[parent].role))
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
function checkAccessibleNames(nodes, push) {
|
|
116
|
+
for (const [index, node] of nodes.entries()) {
|
|
117
|
+
if (!node.ref || node.ariaHidden)
|
|
118
|
+
continue;
|
|
119
|
+
const name = node.name?.trim() ?? '';
|
|
120
|
+
const base = {
|
|
121
|
+
ref: node.ref,
|
|
122
|
+
role: node.role,
|
|
123
|
+
name: node.name,
|
|
124
|
+
selector: node.selector,
|
|
125
|
+
};
|
|
126
|
+
const isImage = node.role === 'img' || node.role === 'image';
|
|
127
|
+
if (!name && namedRoles.has(node.role) && !(isImage && hasNamedControlAncestor(nodes, index))) {
|
|
128
|
+
push({
|
|
129
|
+
...base,
|
|
130
|
+
check: 'missing-accessible-name',
|
|
131
|
+
wcag: '4.1.2 Name, Role, Value',
|
|
132
|
+
problem: `${describe(node)} exposes no accessible name, so a screen reader announces only its role.`,
|
|
133
|
+
fix: isImage
|
|
134
|
+
? 'Describe it with alt text (or a <title> child for inline SVG), or mark it decorative with alt="" and aria-hidden="true".'
|
|
135
|
+
: 'Give it visible text, an aria-label, or an aria-labelledby pointing at visible text.',
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!name)
|
|
140
|
+
continue;
|
|
141
|
+
// Both name-quality checks below judge how a control or image is announced,
|
|
142
|
+
// so they only apply to roles that carry their own name.
|
|
143
|
+
const isNamedRole = namedRoles.has(node.role);
|
|
144
|
+
if (isNamedRole && uninformativeNames.has(normalizeText(name))) {
|
|
145
|
+
push({
|
|
146
|
+
...base,
|
|
147
|
+
check: 'uninformative-accessible-name',
|
|
148
|
+
wcag: '2.4.4 Link Purpose (In Context) / 2.4.9',
|
|
149
|
+
problem: `${describe(node)} is announced as "${name}", which says nothing when read out of context in a links or controls list.`,
|
|
150
|
+
fix: 'Rename it after its destination or action (e.g. "Pricing details"), or extend it with visually hidden text.',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// Only an image is *described* by its name, so only there is a file name a
|
|
154
|
+
// defect; a link or button legitimately named after the file it downloads
|
|
155
|
+
// ("logo.png") is doing its job.
|
|
156
|
+
if (isImage && (filenameNamePattern.test(name) || cameraFileNamePattern.test(name))) {
|
|
157
|
+
push({
|
|
158
|
+
...base,
|
|
159
|
+
check: 'filename-as-accessible-name',
|
|
160
|
+
wcag: '1.1.1 Non-text Content',
|
|
161
|
+
problem: `${describe(node)} uses the file name "${name}" as its accessible name; a screen reader reads the file name aloud.`,
|
|
162
|
+
fix: 'Replace the alt text with a description of what the image shows.',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const visibleText = node.visibleText?.trim() ?? '';
|
|
166
|
+
const normalizedVisible = normalizeText(visibleText);
|
|
167
|
+
const isLeaf = node.childCount === 0;
|
|
168
|
+
if (labelInNameRoles.has(node.role) && isLeaf && normalizedVisible && visibleText.length <= 60
|
|
169
|
+
&& /[\p{L}\p{N}]/u.test(visibleText) && !normalizeText(name).includes(normalizedVisible)) {
|
|
170
|
+
push({
|
|
171
|
+
...base,
|
|
172
|
+
check: 'label-in-name-mismatch',
|
|
173
|
+
wcag: '2.5.3 Label in Name',
|
|
174
|
+
problem: `${describe(node)} shows "${visibleText}" but is announced as "${name}", so a voice-control user saying "click ${visibleText}" cannot activate it.`,
|
|
175
|
+
fix: `Make the accessible name start with the visible text, e.g. aria-label="${visibleText} ${name}".`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function checkDuplicateNames(nodes, push) {
|
|
181
|
+
const byParent = new Map();
|
|
182
|
+
for (const node of nodes) {
|
|
183
|
+
const normalized = normalizeText(node.name);
|
|
184
|
+
if (!node.ref || node.ariaHidden || !normalized || !labelInNameRoles.has(node.role))
|
|
185
|
+
continue;
|
|
186
|
+
const key = `${node.parent ?? -1}|${node.role}|${normalized}`;
|
|
187
|
+
const group = byParent.get(key);
|
|
188
|
+
if (group)
|
|
189
|
+
group.push(node);
|
|
190
|
+
else
|
|
191
|
+
byParent.set(key, [node]);
|
|
192
|
+
}
|
|
193
|
+
for (const group of byParent.values()) {
|
|
194
|
+
// Same name pointing at the same destination is allowed (WCAG 2.4.4); only
|
|
195
|
+
// siblings that do different things are ambiguous. A destination is only
|
|
196
|
+
// observable for links, so controls whose action we cannot see (two "Save"
|
|
197
|
+
// submit buttons in one form) are never claimed to differ.
|
|
198
|
+
const targets = new Set(group.map(node => node.href));
|
|
199
|
+
if (group.length < 2 || targets.has(null) || targets.size < 2)
|
|
200
|
+
continue;
|
|
201
|
+
push({
|
|
202
|
+
check: 'duplicate-accessible-name',
|
|
203
|
+
wcag: '2.4.4 Link Purpose (In Context)',
|
|
204
|
+
ref: group[0].ref,
|
|
205
|
+
role: group[0].role,
|
|
206
|
+
name: group[0].name,
|
|
207
|
+
selector: group[0].selector,
|
|
208
|
+
problem: `${group.length} sibling ${group[0].role}s share the accessible name "${group[0].name}" but lead to different targets (${[...targets].slice(0, 4).join(', ')}).`,
|
|
209
|
+
fix: 'Give each one a distinct accessible name, or append visually hidden text that names its target.',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function checkReadingOrder(nodes, push) {
|
|
214
|
+
const childrenByParent = new Map();
|
|
215
|
+
for (const node of nodes) {
|
|
216
|
+
if (node.parent === null || !isLayoutRelevant(node))
|
|
217
|
+
continue;
|
|
218
|
+
const siblings = childrenByParent.get(node.parent);
|
|
219
|
+
if (siblings)
|
|
220
|
+
siblings.push(node);
|
|
221
|
+
else
|
|
222
|
+
childrenByParent.set(node.parent, [node]);
|
|
223
|
+
}
|
|
224
|
+
for (const [parentIndex, siblings] of childrenByParent) {
|
|
225
|
+
if (siblings.length < 2)
|
|
226
|
+
continue;
|
|
227
|
+
const rects = siblings.map(node => node.rect);
|
|
228
|
+
const rows = countBands(rects, 'y');
|
|
229
|
+
const columns = countBands(rects, 'x');
|
|
230
|
+
// A true 2-D layout (grid, CSS columns, wrapped flex) has no single correct
|
|
231
|
+
// linear visual order, so comparing against DOM order there only cries wolf.
|
|
232
|
+
const horizontal = rows === 1 && columns > 1;
|
|
233
|
+
const vertical = columns === 1 && rows > 1;
|
|
234
|
+
if (!horizontal && !vertical)
|
|
235
|
+
continue;
|
|
236
|
+
// The container's own direction decides the order of its children, but an
|
|
237
|
+
// unmeasured parent has no measured direction (it defaults to ltr), and an
|
|
238
|
+
// iframe element's direction belongs to the embedding page rather than to
|
|
239
|
+
// the document inside it. Fall back to the children's inherited direction.
|
|
240
|
+
const parent = nodes[parentIndex];
|
|
241
|
+
const parentDirection = parent?.rect && parent.tagName !== 'iframe' ? parent.direction : siblings[0].direction;
|
|
242
|
+
const rtl = parentDirection === 'rtl';
|
|
243
|
+
const isInverted = (a, b) => horizontal
|
|
244
|
+
? (rtl ? a.x + a.width <= b.x + layoutTolerancePx : a.x >= b.x + b.width - layoutTolerancePx)
|
|
245
|
+
: a.y >= b.y + b.height - layoutTolerancePx;
|
|
246
|
+
let inversions = 0;
|
|
247
|
+
for (let i = 0; i < siblings.length; i++) {
|
|
248
|
+
for (let j = i + 1; j < siblings.length; j++) {
|
|
249
|
+
if (isInverted(rects[i], rects[j]))
|
|
250
|
+
inversions++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!inversions)
|
|
254
|
+
continue;
|
|
255
|
+
const sortKey = (rect) => horizontal ? (rtl ? -(rect.x + rect.width) : rect.x) : rect.y;
|
|
256
|
+
const visualOrder = [...siblings].sort((a, b) => sortKey(a.rect) - sortKey(b.rect));
|
|
257
|
+
const label = (list) => list.slice(0, 6).map(node => describe(node)).join(' -> ')
|
|
258
|
+
+ (list.length > 6 ? ` -> ... (+${list.length - 6})` : '');
|
|
259
|
+
push({
|
|
260
|
+
check: 'reading-order-mismatch',
|
|
261
|
+
wcag: '1.3.2 Meaningful Sequence',
|
|
262
|
+
ref: parent?.ref ?? siblings[0].ref,
|
|
263
|
+
role: parent?.role ?? 'generic',
|
|
264
|
+
name: parent?.name ?? null,
|
|
265
|
+
selector: parent?.selector ?? null,
|
|
266
|
+
problem: `Inside ${parent ? describe(parent) : 'the page'}, screen readers and keyboard users follow DOM order [${label(siblings)}] but the ${rtl ? 'right-to-left ' : ''}visual order is [${label(visualOrder)}].`,
|
|
267
|
+
fix: 'Reorder the source so DOM order matches the visual order; CSS order, flex-direction: row-reverse and absolute positioning move pixels but not the reading order.',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
export function analyzeScreenReader(rawNodes, options) {
|
|
272
|
+
// Playwright inlines a child frame's tree under the iframe node, but inside
|
|
273
|
+
// that document closest() cannot see the embedding <iframe aria-hidden="true">.
|
|
274
|
+
// Hidden state is inherited down the tree instead; a parent always precedes
|
|
275
|
+
// its children in snapshot order.
|
|
276
|
+
const inheritedHidden = rawNodes.map(node => node.ariaHidden);
|
|
277
|
+
const nodes = rawNodes.map((node, index) => {
|
|
278
|
+
if (node.parent !== null && inheritedHidden[node.parent])
|
|
279
|
+
inheritedHidden[index] = true;
|
|
280
|
+
return inheritedHidden[index] === node.ariaHidden ? node : { ...node, ariaHidden: true };
|
|
281
|
+
});
|
|
282
|
+
const findings = [];
|
|
283
|
+
const countByCheck = {
|
|
284
|
+
'missing-accessible-name': 0,
|
|
285
|
+
'uninformative-accessible-name': 0,
|
|
286
|
+
'filename-as-accessible-name': 0,
|
|
287
|
+
'label-in-name-mismatch': 0,
|
|
288
|
+
'duplicate-accessible-name': 0,
|
|
289
|
+
'reading-order-mismatch': 0,
|
|
290
|
+
};
|
|
291
|
+
const push = (finding) => {
|
|
292
|
+
countByCheck[finding.check]++;
|
|
293
|
+
if (countByCheck[finding.check] <= options.maxFindingsPerCheck)
|
|
294
|
+
findings.push(finding);
|
|
295
|
+
};
|
|
296
|
+
if (options.checkNames) {
|
|
297
|
+
checkAccessibleNames(nodes, push);
|
|
298
|
+
checkDuplicateNames(nodes, push);
|
|
299
|
+
}
|
|
300
|
+
if (options.checkReadingOrder)
|
|
301
|
+
checkReadingOrder(nodes, push);
|
|
302
|
+
const truncatedChecks = Object.keys(countByCheck)
|
|
303
|
+
.filter(check => countByCheck[check] > options.maxFindingsPerCheck);
|
|
304
|
+
return { findings, countByCheck, truncatedChecks };
|
|
305
|
+
}
|
|
306
|
+
// Runs inside the page: no imports, no closures over module scope.
|
|
307
|
+
export function collectElementFacts(elements) {
|
|
308
|
+
// innerText counts visually hidden (clipped) labels as visible, which makes
|
|
309
|
+
// icon-only controls look like text. Walk the subtree instead and skip the
|
|
310
|
+
// usual visually-hidden techniques; the cache keeps nested elements linear.
|
|
311
|
+
const textCache = new Map();
|
|
312
|
+
// Conditions that hide an element AND everything beneath it: display:none,
|
|
313
|
+
// full transparency, the sr-only clip patterns, and collapsed boxes that
|
|
314
|
+
// clip. CSS visibility is deliberately not here — a descendant can restore
|
|
315
|
+
// visibility:visible under a hidden ancestor and still be rendered, so it is
|
|
316
|
+
// evaluated per node in sightedText instead.
|
|
317
|
+
// A clip-path inset hides the subtree only when the region it leaves has no
|
|
318
|
+
// area — the sr-only pattern is inset(50%), every edge pulled past the
|
|
319
|
+
// midpoint. A partial inset such as inset(50% 0 0 0) keeps half the box
|
|
320
|
+
// painted, and its computed value still starts with "inset(50%", so the
|
|
321
|
+
// remaining region is computed from the inset components instead of
|
|
322
|
+
// pattern-matching the serialized prefix. Non-numeric components (calc())
|
|
323
|
+
// parse to NaN and fail every comparison, erring on the visible side.
|
|
324
|
+
const insetClipsEverything = (clipPath, element, rect, style) => {
|
|
325
|
+
const match = /^inset\(([^)]*)\)(?:\s+([a-z-]+))?/.exec(clipPath);
|
|
326
|
+
if (!match)
|
|
327
|
+
return false;
|
|
328
|
+
const args = match[1].split(' round ')[0].trim().split(/\s+/);
|
|
329
|
+
// clip-path lengths and percentages resolve against the untransformed
|
|
330
|
+
// reference box, not the transformed bounding rect — a rotated element
|
|
331
|
+
// swaps its rect's axes and a pixel inset would be compared against the
|
|
332
|
+
// wrong dimension. The reference box is the geometry-box suffix when one
|
|
333
|
+
// is given (border-box is the default): offsetWidth/offsetHeight for the
|
|
334
|
+
// border box, clientWidth/clientHeight for the padding box (scrollbars
|
|
335
|
+
// shave it — a rare sliver of over-hiding), those minus paddings for the
|
|
336
|
+
// content box, offsets plus margins for the margin box. SVG geometry
|
|
337
|
+
// boxes (fill/stroke/view) have no offset box, and for them — as for SVG
|
|
338
|
+
// elements generally — the bounding rect is the closest stand-in here.
|
|
339
|
+
const px = (value) => parseFloat(value) || 0;
|
|
340
|
+
const box = match[2] ?? 'border-box';
|
|
341
|
+
let width = rect.width;
|
|
342
|
+
let height = rect.height;
|
|
343
|
+
if (element instanceof HTMLElement) {
|
|
344
|
+
if (box === 'border-box') {
|
|
345
|
+
width = element.offsetWidth;
|
|
346
|
+
height = element.offsetHeight;
|
|
347
|
+
}
|
|
348
|
+
else if (box === 'margin-box') {
|
|
349
|
+
width = element.offsetWidth + px(style.marginLeft) + px(style.marginRight);
|
|
350
|
+
height = element.offsetHeight + px(style.marginTop) + px(style.marginBottom);
|
|
351
|
+
}
|
|
352
|
+
else if (box === 'padding-box') {
|
|
353
|
+
width = element.clientWidth;
|
|
354
|
+
height = element.clientHeight;
|
|
355
|
+
}
|
|
356
|
+
else if (box === 'content-box') {
|
|
357
|
+
width = element.clientWidth - px(style.paddingLeft) - px(style.paddingRight);
|
|
358
|
+
height = element.clientHeight - px(style.paddingTop) - px(style.paddingBottom);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Computed clip-path lengths are resolved to px; only percentages remain.
|
|
362
|
+
const toPx = (value, size) => value.endsWith('%') ? (parseFloat(value) / 100) * size : parseFloat(value);
|
|
363
|
+
const top = toPx(args[0], height);
|
|
364
|
+
const right = toPx(args[1] ?? args[0], width);
|
|
365
|
+
const bottom = toPx(args[2] ?? args[0], height);
|
|
366
|
+
const left = toPx(args[3] ?? args[1] ?? args[0], width);
|
|
367
|
+
return height - top - bottom <= 0 || width - left - right <= 0;
|
|
368
|
+
};
|
|
369
|
+
// A transform whose linear part is singular in exact arithmetic (scale(0),
|
|
370
|
+
// scaleX(0), an edge-on rotateY(90deg), …) collapses the painted area to a
|
|
371
|
+
// line or point — overflow included — so unlike an ordinary collapsed box
|
|
372
|
+
// it hides the subtree even with overflow: visible; a composed singular
|
|
373
|
+
// matrix stays singular, so no descendant transform can counter it. A
|
|
374
|
+
// merely small determinant is not the same thing: a parent scaled by
|
|
375
|
+
// 0.0001 (det 1e-8) is countered by a descendant scaled by 10000, which
|
|
376
|
+
// renders a full-size label the audit must not prune. The two are told
|
|
377
|
+
// apart relative to the components: float noise on an exact-zero
|
|
378
|
+
// determinant is tiny next to the components' magnitude (Blink serializes
|
|
379
|
+
// cos(90deg) as ~6e-17 beside a sin of exactly 1), while a genuine small
|
|
380
|
+
// determinant sits on the order of its own components squared
|
|
381
|
+
// (scale(0.0001): det 1e-8 against components of 1e-4). Computed
|
|
382
|
+
// transforms serialize as matrix()/matrix3d(); a NaN determinant
|
|
383
|
+
// (unparseable value) fails the comparison and errs on the visible side.
|
|
384
|
+
const transformCollapses = (element, transform) => {
|
|
385
|
+
const singular = (a, b, c, d) => {
|
|
386
|
+
const det = a * d - b * c;
|
|
387
|
+
const component = Math.max(Math.abs(a), Math.abs(b), Math.abs(c), Math.abs(d));
|
|
388
|
+
return Math.abs(det) <= 1e-12 * component * component;
|
|
389
|
+
};
|
|
390
|
+
const matrix = /^matrix\(([^)]*)\)/.exec(transform);
|
|
391
|
+
if (matrix) {
|
|
392
|
+
const v = matrix[1].split(',').map(parseFloat);
|
|
393
|
+
return singular(v[0], v[1], v[2], v[3]);
|
|
394
|
+
}
|
|
395
|
+
const matrix3d = /^matrix3d\(([^)]*)\)/.exec(transform);
|
|
396
|
+
if (matrix3d) {
|
|
397
|
+
const v = matrix3d[1].split(',').map(parseFloat);
|
|
398
|
+
// Perspective makes the screen projection nonlinear: an edge-on plane
|
|
399
|
+
// that sits off the perspective origin still projects to a quadrilateral
|
|
400
|
+
// with positive area, so the linear-part test is sound only for affine
|
|
401
|
+
// matrices (no perspective components).
|
|
402
|
+
if (v[3] !== 0 || v[7] !== 0 || v[11] !== 0 || v[15] !== 1)
|
|
403
|
+
return false;
|
|
404
|
+
// The determinant of the x/y linear part: scaleZ(0) alone leaves flat
|
|
405
|
+
// content rendered, so only the projected plane matters here.
|
|
406
|
+
if (!singular(v[0], v[1], v[4], v[5]))
|
|
407
|
+
return false;
|
|
408
|
+
// A parent's perspective property is not serialized into this matrix.
|
|
409
|
+
// It can give an otherwise edge-on child positive painted area. A
|
|
410
|
+
// preserve-3d ancestor can likewise let a descendant counter-rotate.
|
|
411
|
+
for (let node = parentInComposedTree(element); node; node = parentInComposedTree(node)) {
|
|
412
|
+
const style = window.getComputedStyle(node);
|
|
413
|
+
if (style.perspective !== 'none' || style.transformStyle === 'preserve-3d')
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
return false;
|
|
419
|
+
};
|
|
420
|
+
// Hiding conditions that ignore box geometry; null means undecided — the
|
|
421
|
+
// geometric checks (clip-path insets, collapsed clipping boxes) still apply.
|
|
422
|
+
const nonGeometricHidden = (element, style) => {
|
|
423
|
+
if (style.display === 'none' || style.opacity === '0')
|
|
424
|
+
return true;
|
|
425
|
+
// display:contents generates no box of its own, but its text and children
|
|
426
|
+
// render as if lifted into the parent — a 0x0 rect there hides nothing,
|
|
427
|
+
// and with no box there is nothing for either clip property to clip nor a
|
|
428
|
+
// transform to apply to, so it is settled before those checks.
|
|
429
|
+
if (style.display === 'contents')
|
|
430
|
+
return false;
|
|
431
|
+
if (style.transformStyle !== 'preserve-3d' && transformCollapses(element, style.transform))
|
|
432
|
+
return true;
|
|
433
|
+
// The legacy clip property only applies to absolutely positioned boxes; on
|
|
434
|
+
// a static element it is inert and the content renders normally.
|
|
435
|
+
if ((style.position === 'absolute' || style.position === 'fixed') && style.clip === 'rect(0px, 0px, 0px, 0px)')
|
|
436
|
+
return true;
|
|
437
|
+
return null;
|
|
438
|
+
};
|
|
439
|
+
// A collapsed box hides its subtree only when it also clips (the classic
|
|
440
|
+
// sr-only pattern is 1x1 with overflow:hidden). With visible overflow the
|
|
441
|
+
// content renders outside the box.
|
|
442
|
+
const collapsedClippingBox = (style, rect) => (rect.width <= 1 || rect.height <= 1) && style.overflow !== 'visible';
|
|
443
|
+
const subtreeHidden = (element) => {
|
|
444
|
+
const style = window.getComputedStyle(element);
|
|
445
|
+
const decided = nonGeometricHidden(element, style);
|
|
446
|
+
if (decided !== null)
|
|
447
|
+
return decided;
|
|
448
|
+
const rect = element.getBoundingClientRect();
|
|
449
|
+
if (insetClipsEverything(style.clipPath, element, rect, style))
|
|
450
|
+
return true;
|
|
451
|
+
// Ceiling: during text descent a collapsed clipping box drops its whole
|
|
452
|
+
// subtree, including an out-of-flow descendant whose containing block
|
|
453
|
+
// lies outside the box and therefore escapes the clip. The measured
|
|
454
|
+
// element itself — where the label checks read from — is protected by
|
|
455
|
+
// the containing-block-aware ancestor walk below.
|
|
456
|
+
return collapsedClippingBox(style, rect);
|
|
457
|
+
};
|
|
458
|
+
// visibility: collapse renders exactly like hidden outside table
|
|
459
|
+
// rows/columns (and hides the row either way), so both values count.
|
|
460
|
+
const visibilityHidden = (element) => {
|
|
461
|
+
const visibility = window.getComputedStyle(element).visibility;
|
|
462
|
+
return visibility === 'hidden' || visibility === 'collapse';
|
|
463
|
+
};
|
|
464
|
+
function parentInComposedTree(element) {
|
|
465
|
+
// A slotted element renders where its assigned <slot> sits, so its
|
|
466
|
+
// flat-tree parent is the slot; parentElement is the light-DOM host and
|
|
467
|
+
// following it would skip a hidden wrapper around the slot inside the
|
|
468
|
+
// shadow tree. (A closed shadow root reports no assignedSlot — there the
|
|
469
|
+
// host is the closest reachable ancestor.)
|
|
470
|
+
if (element.assignedSlot)
|
|
471
|
+
return element.assignedSlot;
|
|
472
|
+
if (element.parentElement)
|
|
473
|
+
return element.parentElement;
|
|
474
|
+
const root = element.getRootNode();
|
|
475
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
476
|
+
}
|
|
477
|
+
// Not every subtree-hiding condition surfaces in a descendant's own computed
|
|
478
|
+
// style: opacity is not inherited, and a clipping or collapsed ancestor
|
|
479
|
+
// leaves the descendant's rect untouched. Ancestors of the measured element
|
|
480
|
+
// are walked (through shadow hosts) so a control inside an opacity:0 or
|
|
481
|
+
// sr-only wrapper is not reported as showing text nobody can see.
|
|
482
|
+
//
|
|
483
|
+
// Overflow clips bind only descendants whose containing block sits at or
|
|
484
|
+
// below the clipping box: an absolutely positioned control whose containing
|
|
485
|
+
// block is a positioned wrapper OUTSIDE a collapsed 1px box renders in full
|
|
486
|
+
// despite it. While walking up, track whether the content below rides an
|
|
487
|
+
// out-of-flow position whose containing block has not been reached yet —
|
|
488
|
+
// collapsed-box clips on the nodes in between do not bind it. Containing
|
|
489
|
+
// blocks are approximated as any positioned or transformed ancestor for
|
|
490
|
+
// absolute descendants, but only transformed/projected/filtered ancestors
|
|
491
|
+
// for fixed ones.
|
|
492
|
+
// Every other hiding condition applies regardless: display:none by
|
|
493
|
+
// inheritance, opacity by compositing, clip-path through its stacking
|
|
494
|
+
// context, and a singular transform because a transformed ancestor is the
|
|
495
|
+
// containing block anyway.
|
|
496
|
+
const ancestorSubtreeHidden = (element) => {
|
|
497
|
+
const outOfFlow = (style) => style.position === 'absolute' || style.position === 'fixed' ? style.position : null;
|
|
498
|
+
let escapingPosition = outOfFlow(window.getComputedStyle(element));
|
|
499
|
+
for (let node = parentInComposedTree(element); node; node = parentInComposedTree(node)) {
|
|
500
|
+
const style = window.getComputedStyle(node);
|
|
501
|
+
const decided = nonGeometricHidden(node, style);
|
|
502
|
+
if (decided === true)
|
|
503
|
+
return true;
|
|
504
|
+
if (decided === null) {
|
|
505
|
+
const rect = node.getBoundingClientRect();
|
|
506
|
+
if (insetClipsEverything(style.clipPath, node, rect, style))
|
|
507
|
+
return true;
|
|
508
|
+
const containingBlock = style.position !== 'static' || style.transform !== 'none';
|
|
509
|
+
const fixedContainingBlock = style.transform !== 'none' || style.perspective !== 'none' || style.filter !== 'none';
|
|
510
|
+
const bindsEscape = escapingPosition === 'fixed' ? fixedContainingBlock : containingBlock;
|
|
511
|
+
if (collapsedClippingBox(style, rect) && (!escapingPosition || bindsEscape))
|
|
512
|
+
return true;
|
|
513
|
+
// A boxless (display:contents) node neither carries nor anchors
|
|
514
|
+
// positioning, so the escape state only moves on box-generating nodes.
|
|
515
|
+
if (outOfFlow(style))
|
|
516
|
+
escapingPosition = outOfFlow(style);
|
|
517
|
+
else if (bindsEscape)
|
|
518
|
+
escapingPosition = null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return false;
|
|
522
|
+
};
|
|
523
|
+
// clipState mirrors the ancestor walk's containing-block model during the
|
|
524
|
+
// descent: 'none' collects normally; 'escapable' means a collapsed static
|
|
525
|
+
// clip box is above — its in-flow content is clipped (text suppressed), but
|
|
526
|
+
// an absolutely/fixed positioned descendant whose containing block sits
|
|
527
|
+
// outside the box escapes and collects normally again. Meeting a containing
|
|
528
|
+
// block (positioned or transformed) while suppressed binds everything below
|
|
529
|
+
// it inside the clip, so that branch prunes. A positioned collapsed box (the
|
|
530
|
+
// classic sr-only shape) is its own containing block and prunes outright.
|
|
531
|
+
const sightedText = (element, clipState = 'none') => {
|
|
532
|
+
const cached = clipState === 'none' ? textCache.get(element) : undefined;
|
|
533
|
+
if (cached !== undefined)
|
|
534
|
+
return cached;
|
|
535
|
+
let text = '';
|
|
536
|
+
// An element's own text nodes render only while its computed visibility is
|
|
537
|
+
// visible; child elements are walked regardless, because unlike the
|
|
538
|
+
// subtreeHidden conditions, visibility can be restored further down.
|
|
539
|
+
const ownTextVisible = clipState === 'none' && !visibilityHidden(element);
|
|
540
|
+
// A web component renders its visible label in its shadow root; walking only
|
|
541
|
+
// light-DOM children makes such a host look like an icon-only control. The
|
|
542
|
+
// shadow tree replaces the host's light children entirely — light nodes
|
|
543
|
+
// render only where a <slot> assigns them, so slots contribute their
|
|
544
|
+
// assigned nodes (or their own fallback content when nothing is assigned)
|
|
545
|
+
// and unassigned light children contribute nothing.
|
|
546
|
+
const children = element.shadowRoot
|
|
547
|
+
? element.shadowRoot.childNodes
|
|
548
|
+
: element instanceof HTMLSlotElement
|
|
549
|
+
? element.assignedNodes({ flatten: true })
|
|
550
|
+
: element.childNodes;
|
|
551
|
+
for (const child of children) {
|
|
552
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
553
|
+
if (ownTextVisible)
|
|
554
|
+
text += child.nodeValue ?? '';
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (child.nodeType !== Node.ELEMENT_NODE)
|
|
558
|
+
continue;
|
|
559
|
+
const childElement = child;
|
|
560
|
+
const style = window.getComputedStyle(childElement);
|
|
561
|
+
const decided = nonGeometricHidden(childElement, style);
|
|
562
|
+
if (decided === true)
|
|
563
|
+
continue;
|
|
564
|
+
let childState = clipState;
|
|
565
|
+
if (decided === null) {
|
|
566
|
+
const rect = childElement.getBoundingClientRect();
|
|
567
|
+
if (insetClipsEverything(style.clipPath, childElement, rect, style))
|
|
568
|
+
continue;
|
|
569
|
+
const outOfFlow = style.position === 'absolute' || style.position === 'fixed';
|
|
570
|
+
const containingBlock = style.position !== 'static' || style.transform !== 'none';
|
|
571
|
+
if (clipState === 'escapable') {
|
|
572
|
+
if (outOfFlow)
|
|
573
|
+
childState = 'none';
|
|
574
|
+
else if (containingBlock)
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (childState === 'none' && collapsedClippingBox(style, rect)) {
|
|
578
|
+
if (containingBlock)
|
|
579
|
+
continue;
|
|
580
|
+
childState = 'escapable';
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const childText = sightedText(childElement, childState);
|
|
584
|
+
if (childText)
|
|
585
|
+
text += ` ${childText}`;
|
|
586
|
+
}
|
|
587
|
+
const value = text.replace(/\s+/g, ' ').trim();
|
|
588
|
+
if (clipState === 'none')
|
|
589
|
+
textCache.set(element, value);
|
|
590
|
+
return value;
|
|
591
|
+
};
|
|
592
|
+
// Button-like inputs render their label from `value` and have no child nodes,
|
|
593
|
+
// so without this they look like icon-only controls to every text check.
|
|
594
|
+
const buttonInputTypes = ['submit', 'button', 'reset'];
|
|
595
|
+
return elements.map(element => {
|
|
596
|
+
const rect = element.getBoundingClientRect();
|
|
597
|
+
const style = window.getComputedStyle(element);
|
|
598
|
+
// The visibility predicate must include the element itself, not only its
|
|
599
|
+
// descendants: a control hidden with opacity:0 shows no text at all, and
|
|
600
|
+
// treating its child text as visible raises label-in-name mismatches
|
|
601
|
+
// against a label nobody can see. Button-like inputs render no children,
|
|
602
|
+
// so for them visibility:hidden is as final as the subtree conditions.
|
|
603
|
+
const text = subtreeHidden(element) || ancestorSubtreeHidden(element)
|
|
604
|
+
? ''
|
|
605
|
+
: element instanceof HTMLInputElement && buttonInputTypes.includes(element.type)
|
|
606
|
+
? (visibilityHidden(element) ? '' : element.value.trim())
|
|
607
|
+
: sightedText(element);
|
|
608
|
+
return {
|
|
609
|
+
tagName: element.tagName.toLowerCase(),
|
|
610
|
+
selector: `${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ''}${element.classList[0] ? `.${element.classList[0]}` : ''}`,
|
|
611
|
+
visibleText: text ? text.slice(0, 200) : null,
|
|
612
|
+
// The resolved URL, so that "/help" and "https://site/help" are recognised
|
|
613
|
+
// as the same destination rather than reported as ambiguous links.
|
|
614
|
+
href: element instanceof HTMLAnchorElement && element.hasAttribute('href') ? element.href : null,
|
|
615
|
+
rect: {
|
|
616
|
+
x: rect.x + window.scrollX,
|
|
617
|
+
y: rect.y + window.scrollY,
|
|
618
|
+
width: rect.width,
|
|
619
|
+
height: rect.height,
|
|
620
|
+
},
|
|
621
|
+
direction: style.direction === 'rtl' ? 'rtl' : 'ltr',
|
|
622
|
+
positionFixed: style.position === 'fixed',
|
|
623
|
+
floating: style.float !== 'none',
|
|
624
|
+
// Playwright's snapshot still lists aria-hidden elements, but a screen
|
|
625
|
+
// reader never reaches them, so nothing about them is a defect. ARIA
|
|
626
|
+
// enumerated tokens are ASCII case-insensitive, so aria-hidden="TRUE"
|
|
627
|
+
// hides exactly like "true". closest() stops at the shadow boundary,
|
|
628
|
+
// so the composed-tree walker is used instead — a host (or a host's
|
|
629
|
+
// ancestor) carrying aria-hidden removes its whole shadow tree from
|
|
630
|
+
// the accessibility tree too.
|
|
631
|
+
ariaHidden: (() => {
|
|
632
|
+
for (let node = element; node; node = parentInComposedTree(node)) {
|
|
633
|
+
if (node.getAttribute('aria-hidden')?.toLowerCase() === 'true')
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
return false;
|
|
637
|
+
})(),
|
|
638
|
+
};
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const auditScreenReaderSchema = z.object({
|
|
642
|
+
checkNames: z.boolean().default(true).describe('Check accessible name quality (missing, generic, filename, label-in-name, duplicate sibling names).'),
|
|
643
|
+
checkReadingOrder: z.boolean().default(true).describe('Compare accessibility tree order against visual position to find reading-order mismatches.'),
|
|
644
|
+
maxElements: z.number().int().min(1).max(2000).default(400).describe('Maximum accessibility tree elements to analyze; extra elements are reported as truncated.'),
|
|
645
|
+
maxFindingsPerCheck: z.number().int().min(1).max(200).default(20).describe('Maximum findings kept per check; the full count is still reported.'),
|
|
646
|
+
reportFile: z.string().optional().describe('Output JSON report file name.'),
|
|
647
|
+
});
|
|
648
|
+
const auditScreenReader = defineTabTool({
|
|
649
|
+
capability: 'core',
|
|
650
|
+
schema: {
|
|
651
|
+
name: 'audit_screen_reader',
|
|
652
|
+
title: 'Audit screen reader experience',
|
|
653
|
+
description: 'Audit accessible name quality and reading order using the browser accessibility tree and element geometry.',
|
|
654
|
+
inputSchema: auditScreenReaderSchema,
|
|
655
|
+
type: 'readOnly',
|
|
656
|
+
},
|
|
657
|
+
handle: async (tab, params, response) => {
|
|
658
|
+
const ariaNodes = parseAriaSnapshot(await tab.page.ariaSnapshot({ mode: 'ai' }));
|
|
659
|
+
const refIndexes = ariaNodes.map((node, index) => node.ref ? index : -1).filter(index => index >= 0);
|
|
660
|
+
const childCounts = new Map();
|
|
661
|
+
for (const node of ariaNodes) {
|
|
662
|
+
if (node.parent !== null)
|
|
663
|
+
childCounts.set(node.parent, (childCounts.get(node.parent) ?? 0) + 1);
|
|
664
|
+
}
|
|
665
|
+
const onlyFrame = tab.page.frames().length === 1 ? tab.page.mainFrame() : null;
|
|
666
|
+
const factsByIndex = new Map();
|
|
667
|
+
const analyzedIndexes = [];
|
|
668
|
+
let resolvedCount = 0;
|
|
669
|
+
let reachable = 0;
|
|
670
|
+
// maxElements budgets the elements a screen reader can actually reach: the
|
|
671
|
+
// AI snapshot also refs aria-hidden subtrees, and slicing the raw ref list
|
|
672
|
+
// let those spend the whole budget and leave every visible control below
|
|
673
|
+
// them unmeasured. The ceiling keeps a page made mostly of hidden refs
|
|
674
|
+
// bounded. Refs are resolved a chunk at a time because a ref that went
|
|
675
|
+
// stale costs a full timeout, and one timeout per element serially would
|
|
676
|
+
// stall a large audit for minutes.
|
|
677
|
+
const measureCeiling = Math.min(refIndexes.length, params.maxElements * 2);
|
|
678
|
+
for (let start = 0; start < measureCeiling && reachable < params.maxElements;) {
|
|
679
|
+
// Never take more than the remaining budget, or a maxElements that is not
|
|
680
|
+
// a multiple of the chunk size would analyze a whole extra chunk.
|
|
681
|
+
const size = Math.min(measureChunkSize, params.maxElements - reachable, measureCeiling - start);
|
|
682
|
+
const chunk = refIndexes.slice(start, start + size);
|
|
683
|
+
start += size;
|
|
684
|
+
const handles = await Promise.all(chunk.map(index => tab.page.locator(`aria-ref=${ariaNodes[index].ref}`).elementHandle({ timeout: 1000 }).catch(() => null)));
|
|
685
|
+
const byFrame = new Map();
|
|
686
|
+
for (const [position, handle] of handles.entries()) {
|
|
687
|
+
const frame = handle ? onlyFrame ?? await handle.ownerFrame().catch(() => null) : null;
|
|
688
|
+
if (!handle || !frame)
|
|
689
|
+
continue;
|
|
690
|
+
const batch = byFrame.get(frame);
|
|
691
|
+
if (batch)
|
|
692
|
+
batch.push({ index: chunk[position], handle });
|
|
693
|
+
else
|
|
694
|
+
byFrame.set(frame, [{ index: chunk[position], handle }]);
|
|
695
|
+
}
|
|
696
|
+
for (const [frame, batch] of byFrame) {
|
|
697
|
+
const facts = await frame.evaluate(collectElementFacts, batch.map(entry => entry.handle)).catch(() => null);
|
|
698
|
+
if (facts) {
|
|
699
|
+
batch.forEach((entry, position) => factsByIndex.set(entry.index, facts[position]));
|
|
700
|
+
resolvedCount += batch.length;
|
|
701
|
+
reachable += facts.filter(fact => !fact.ariaHidden).length;
|
|
702
|
+
}
|
|
703
|
+
await Promise.all(batch.map(entry => entry.handle.dispose().catch(() => undefined)));
|
|
704
|
+
}
|
|
705
|
+
analyzedIndexes.push(...chunk);
|
|
706
|
+
await response.reportProgress({
|
|
707
|
+
progress: analyzedIndexes.length,
|
|
708
|
+
total: measureCeiling,
|
|
709
|
+
message: `Measured ${analyzedIndexes.length} accessibility tree elements (${Math.min(reachable, params.maxElements)}/${params.maxElements} screen-reader-reachable)`,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
// A ref goes stale when the page rerenders between ariaSnapshot() and
|
|
713
|
+
// locator resolution. Such elements have no facts, every check skips them,
|
|
714
|
+
// and with nothing resolved at all the audit would otherwise report
|
|
715
|
+
// "Findings: 0" for a page it never actually evaluated.
|
|
716
|
+
const unresolvedCount = analyzedIndexes.length - resolvedCount;
|
|
717
|
+
if (analyzedIndexes.length && !resolvedCount)
|
|
718
|
+
throw new Error(`None of the ${analyzedIndexes.length} accessibility tree elements could be resolved to DOM nodes — the page re-rendered between the snapshot and measurement, so nothing was evaluated. Wait for the page to settle (or trigger the rerender first) and run audit_screen_reader again.`);
|
|
719
|
+
const emptyFacts = {
|
|
720
|
+
tagName: null,
|
|
721
|
+
selector: null,
|
|
722
|
+
visibleText: null,
|
|
723
|
+
href: null,
|
|
724
|
+
rect: null,
|
|
725
|
+
direction: 'ltr',
|
|
726
|
+
positionFixed: false,
|
|
727
|
+
floating: false,
|
|
728
|
+
ariaHidden: false,
|
|
729
|
+
};
|
|
730
|
+
const nodes = ariaNodes.map((node, index) => ({
|
|
731
|
+
...node,
|
|
732
|
+
...(factsByIndex.get(index) ?? emptyFacts),
|
|
733
|
+
ref: factsByIndex.has(index) ? node.ref : null,
|
|
734
|
+
childCount: childCounts.get(index) ?? 0,
|
|
735
|
+
}));
|
|
736
|
+
const result = analyzeScreenReader(nodes, {
|
|
737
|
+
checkNames: params.checkNames,
|
|
738
|
+
checkReadingOrder: params.checkReadingOrder,
|
|
739
|
+
maxFindingsPerCheck: params.maxFindingsPerCheck,
|
|
740
|
+
});
|
|
741
|
+
const truncatedElements = refIndexes.length - analyzedIndexes.length;
|
|
742
|
+
const elementCountSuffix = truncatedElements > 0
|
|
743
|
+
? ` (truncated: analyzed the first ${analyzedIndexes.length} of ${refIndexes.length}; raise maxElements to see the rest)`
|
|
744
|
+
: '';
|
|
745
|
+
const totalFindings = Object.values(result.countByCheck).reduce((sum, count) => sum + count, 0);
|
|
746
|
+
const report = {
|
|
747
|
+
version: 'v1',
|
|
748
|
+
metadata: {
|
|
749
|
+
url: tab.page.url(),
|
|
750
|
+
options: params,
|
|
751
|
+
generatedAt: new Date().toISOString(),
|
|
752
|
+
},
|
|
753
|
+
elements: {
|
|
754
|
+
total: refIndexes.length,
|
|
755
|
+
analyzed: analyzedIndexes.length,
|
|
756
|
+
unresolved: analyzedIndexes.length - resolvedCount,
|
|
757
|
+
truncated: truncatedElements > 0,
|
|
758
|
+
},
|
|
759
|
+
countByCheck: result.countByCheck,
|
|
760
|
+
totalFindings,
|
|
761
|
+
truncatedChecks: result.truncatedChecks,
|
|
762
|
+
findings: result.findings,
|
|
763
|
+
};
|
|
764
|
+
const reportFileName = sanitizeForFilePath(params.reportFile ?? `audit-screen-reader-${safeIsoTimestampForFileName()}.json`);
|
|
765
|
+
const reportPath = await tab.context.outputFile(reportFileName);
|
|
766
|
+
await fs.promises.writeFile(reportPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
767
|
+
const reportResourceLink = response.addFileResourceLink(reportPath, {
|
|
768
|
+
name: 'audit-screen-reader-report',
|
|
769
|
+
title: 'Audit screen reader JSON report',
|
|
770
|
+
description: 'JSON report for accessible name quality and reading order findings.',
|
|
771
|
+
mimeType: 'application/json',
|
|
772
|
+
});
|
|
773
|
+
response.setStructuredContent({
|
|
774
|
+
kind: 'audit_screen_reader',
|
|
775
|
+
report: {
|
|
776
|
+
path: reportPath,
|
|
777
|
+
uri: reportResourceLink.uri,
|
|
778
|
+
name: reportResourceLink.name,
|
|
779
|
+
title: reportResourceLink.title ?? null,
|
|
780
|
+
mimeType: reportResourceLink.mimeType ?? null,
|
|
781
|
+
},
|
|
782
|
+
page: {
|
|
783
|
+
url: tab.page.url(),
|
|
784
|
+
},
|
|
785
|
+
summary: {
|
|
786
|
+
elementsTotal: refIndexes.length,
|
|
787
|
+
elementsAnalyzed: analyzedIndexes.length,
|
|
788
|
+
elementsUnresolved: unresolvedCount,
|
|
789
|
+
elementsTruncated: truncatedElements,
|
|
790
|
+
totalFindings,
|
|
791
|
+
countByCheck: result.countByCheck,
|
|
792
|
+
truncatedChecks: result.truncatedChecks,
|
|
793
|
+
},
|
|
794
|
+
findings: result.findings,
|
|
795
|
+
reportUri: reportResourceLink.uri,
|
|
796
|
+
});
|
|
797
|
+
const findingLines = result.findings.map(finding => (`- [${finding.check}] WCAG ${finding.wcag} — ${finding.problem}\n Fix: ${finding.fix}${finding.ref ? `\n Ref: ${finding.ref}` : ''}`));
|
|
798
|
+
response.addCode('// Read the accessibility tree with page.ariaSnapshot() and compared names and geometry against reading order.');
|
|
799
|
+
response.addResult([
|
|
800
|
+
`Elements analyzed: ${analyzedIndexes.length}${elementCountSuffix}`,
|
|
801
|
+
// Unresolved elements were skipped by every check, so a clean result
|
|
802
|
+
// covering only part of the page must say so rather than read as clean.
|
|
803
|
+
...(unresolvedCount > 0
|
|
804
|
+
? [`WARNING: ${unresolvedCount} of these went stale before measurement (the page re-rendered mid-audit) and were not evaluated; findings may be incomplete. Re-run once the page is stable.`]
|
|
805
|
+
: []),
|
|
806
|
+
`Findings: ${totalFindings}`,
|
|
807
|
+
'Check | Findings',
|
|
808
|
+
'--- | ---',
|
|
809
|
+
...Object.keys(result.countByCheck).map(check => `${check} | ${result.countByCheck[check]}`),
|
|
810
|
+
...(result.truncatedChecks.length
|
|
811
|
+
? ['', `Showing at most ${params.maxFindingsPerCheck} findings per check; truncated: ${result.truncatedChecks.join(', ')}`]
|
|
812
|
+
: []),
|
|
813
|
+
'',
|
|
814
|
+
...(findingLines.length ? findingLines : ['- No screen-reader-level issues detected.']),
|
|
815
|
+
'',
|
|
816
|
+
`JSON report: ${reportPath}`,
|
|
817
|
+
].join('\n'));
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
export default [
|
|
821
|
+
auditScreenReader,
|
|
822
|
+
];
|
|
823
|
+
//# sourceMappingURL=auditScreenReader.js.map
|