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.
Files changed (85) hide show
  1. package/README.md +265 -12
  2. package/index.d.ts +1 -1
  3. package/lib/browserContextFactory.js +1161 -94
  4. package/lib/browserContextFactory.js.map +1 -1
  5. package/lib/browserServerBackend.js +199 -22
  6. package/lib/browserServerBackend.js.map +1 -1
  7. package/lib/browserSessions.js +172 -0
  8. package/lib/browserSessions.js.map +1 -0
  9. package/lib/config.js +48 -7
  10. package/lib/config.js.map +1 -1
  11. package/lib/context.js +321 -71
  12. package/lib/context.js.map +1 -1
  13. package/lib/extension/cdpRelay.js +5 -0
  14. package/lib/extension/cdpRelay.js.map +1 -1
  15. package/lib/extension/extensionContextFactory.js +12 -1
  16. package/lib/extension/extensionContextFactory.js.map +1 -1
  17. package/lib/index.js +5 -1
  18. package/lib/index.js.map +1 -1
  19. package/lib/mcp/http.js +194 -44
  20. package/lib/mcp/http.js.map +1 -1
  21. package/lib/mcp/inProcessTransport.js.map +1 -1
  22. package/lib/mcp/mdb.js +7 -9
  23. package/lib/mcp/mdb.js.map +1 -1
  24. package/lib/mcp/proxyBackend.js +76 -18
  25. package/lib/mcp/proxyBackend.js.map +1 -1
  26. package/lib/mcp/server.js +70 -41
  27. package/lib/mcp/server.js.map +1 -1
  28. package/lib/mcp/sharedClientSlot.js +134 -0
  29. package/lib/mcp/sharedClientSlot.js.map +1 -0
  30. package/lib/mcp/tool.js +3 -0
  31. package/lib/mcp/tool.js.map +1 -1
  32. package/lib/networkPolicy.js +73 -0
  33. package/lib/networkPolicy.js.map +1 -0
  34. package/lib/program.js +91 -12
  35. package/lib/program.js.map +1 -1
  36. package/lib/response.js +16 -3
  37. package/lib/response.js.map +1 -1
  38. package/lib/sessionLog.js +36 -6
  39. package/lib/sessionLog.js.map +1 -1
  40. package/lib/tab.js +149 -18
  41. package/lib/tab.js.map +1 -1
  42. package/lib/tools/auditKeyboard.js +204 -4
  43. package/lib/tools/auditKeyboard.js.map +1 -1
  44. package/lib/tools/auditScreenReader.js +823 -0
  45. package/lib/tools/auditScreenReader.js.map +1 -0
  46. package/lib/tools/auditSite.js +268 -90
  47. package/lib/tools/auditSite.js.map +1 -1
  48. package/lib/tools/axe.js +479 -21
  49. package/lib/tools/axe.js.map +1 -1
  50. package/lib/tools/dialogs.js +18 -4
  51. package/lib/tools/dialogs.js.map +1 -1
  52. package/lib/tools/evaluate.js +34 -5
  53. package/lib/tools/evaluate.js.map +1 -1
  54. package/lib/tools/network.js +136 -8
  55. package/lib/tools/network.js.map +1 -1
  56. package/lib/tools/pdf.js +5 -2
  57. package/lib/tools/pdf.js.map +1 -1
  58. package/lib/tools/scanPageMatrix.js +135 -47
  59. package/lib/tools/scanPageMatrix.js.map +1 -1
  60. package/lib/tools/screenshot.js +8 -4
  61. package/lib/tools/screenshot.js.map +1 -1
  62. package/lib/tools/session.js +53 -0
  63. package/lib/tools/session.js.map +1 -0
  64. package/lib/tools/snapshot.js +266 -8
  65. package/lib/tools/snapshot.js.map +1 -1
  66. package/lib/tools/tool.js.map +1 -1
  67. package/lib/tools/utils.js +62 -6
  68. package/lib/tools/utils.js.map +1 -1
  69. package/lib/tools.js +11 -2
  70. package/lib/tools.js.map +1 -1
  71. package/lib/utils/dataUrl.js +58 -35
  72. package/lib/utils/dataUrl.js.map +1 -1
  73. package/lib/utils/fileUtils.js +35 -0
  74. package/lib/utils/fileUtils.js.map +1 -1
  75. package/lib/utils/guid.js +8 -0
  76. package/lib/utils/guid.js.map +1 -1
  77. package/lib/utils/jsSource.js +187 -0
  78. package/lib/utils/jsSource.js.map +1 -0
  79. package/lib/vscode/browserContextFactory.js +87 -0
  80. package/lib/vscode/browserContextFactory.js.map +1 -0
  81. package/lib/vscode/host.js +189 -33
  82. package/lib/vscode/host.js.map +1 -1
  83. package/lib/vscode/main.js +3 -35
  84. package/lib/vscode/main.js.map +1 -1
  85. 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