@a11y-skills/audit 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.ja.md +52 -4
- package/README.md +53 -4
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +104 -21
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.js +1 -1
- package/dist/playwright/runAutoPlayDetection.js +15 -4
- package/dist/playwright/runAutocompleteAudit.js +46 -11
- package/dist/playwright/runAxeAudit.d.ts +4 -0
- package/dist/playwright/runAxeAudit.js +26 -30
- package/dist/playwright/runFocusIndicatorCheck.d.ts +7 -0
- package/dist/playwright/runFocusIndicatorCheck.js +98 -31
- package/dist/playwright/runOrientationCheck.js +13 -7
- package/dist/playwright/runReflowCheck.js +22 -12
- package/dist/playwright/runTargetSizeCheck.js +49 -12
- package/dist/playwright/runTextSpacingCheck.js +64 -10
- package/dist/playwright/runTimeLimitDetector.js +56 -25
- package/dist/playwright/runZoomCheck.js +46 -15
- package/dist/schemas/index.d.ts +8 -1
- package/dist/schemas/index.js +397 -292
- package/dist/types.d.ts +137 -53
- package/dist/types.js +9 -0
- package/dist/utils/axe-format.d.ts +88 -0
- package/dist/utils/axe-format.js +370 -0
- package/dist/utils/layout.d.ts +2 -0
- package/dist/utils/layout.js +26 -2
- package/dist/utils/recommendations.js +2 -2
- package/dist/utils/rule-registry.d.ts +216 -0
- package/dist/utils/rule-registry.js +220 -0
- package/dist/utils/test-harness.d.ts +2 -2
- package/dist/utils/test-harness.js +5 -6
- package/package.json +2 -1
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mappers from check-specific `details` to the axe-style buckets
|
|
3
|
+
* (`violations` / `incomplete` / `passes` / `inapplicable`), plus the envelope
|
|
4
|
+
* assembler and the opt-in cross-check merge.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is browser-independent: the runners gather evidence into a
|
|
7
|
+
* details object, and these functions derive the normalized view from it.
|
|
8
|
+
* Because the details objects are part of the saved JSON, the buckets can be
|
|
9
|
+
* re-derived from a result file at any time.
|
|
10
|
+
*/
|
|
11
|
+
import { AUDIT_DISCLAIMER, HTML_SNIPPET_MAX_LENGTH } from '../constants.js';
|
|
12
|
+
import { getRule } from './rule-registry.js';
|
|
13
|
+
function emptyBuckets() {
|
|
14
|
+
return { violations: [], incomplete: [], passes: [], inapplicable: [] };
|
|
15
|
+
}
|
|
16
|
+
function ruleResult(key, nodes) {
|
|
17
|
+
const meta = getRule(key);
|
|
18
|
+
return {
|
|
19
|
+
id: meta.id,
|
|
20
|
+
impact: meta.impact,
|
|
21
|
+
description: meta.description,
|
|
22
|
+
help: meta.help,
|
|
23
|
+
helpUrl: meta.helpUrl,
|
|
24
|
+
tags: [...meta.tags],
|
|
25
|
+
nodes,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Route a rule's findings into the right bucket:
|
|
30
|
+
* not applicable → `inapplicable`, no findings → `passes`, findings →
|
|
31
|
+
* the registry's classification (or an explicit override).
|
|
32
|
+
*/
|
|
33
|
+
function bucketize(buckets, key, nodes, applicable, override) {
|
|
34
|
+
if (!applicable) {
|
|
35
|
+
buckets.inapplicable.push(ruleResult(key, []));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (nodes.length === 0) {
|
|
39
|
+
buckets.passes.push(ruleResult(key, []));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const classification = override ?? getRule(key).classification;
|
|
43
|
+
(classification === 'violation'
|
|
44
|
+
? buckets.violations
|
|
45
|
+
: buckets.incomplete).push(ruleResult(key, nodes));
|
|
46
|
+
}
|
|
47
|
+
/** Build a node from element evidence, synthesizing `html` when missing. */
|
|
48
|
+
function toNode(el, failureSummary) {
|
|
49
|
+
const tag = (el.tagName ?? el.tag ?? 'element').toLowerCase();
|
|
50
|
+
const html = el.html && el.html.length > 0 ? el.html : `<${tag}>`;
|
|
51
|
+
return {
|
|
52
|
+
target: [el.selector],
|
|
53
|
+
html,
|
|
54
|
+
htmlTruncated: el.htmlTruncated ?? false,
|
|
55
|
+
failureSummary,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Build a page-level node (`target: ['html']`). */
|
|
59
|
+
function pageNode(failureSummary) {
|
|
60
|
+
return {
|
|
61
|
+
target: ['html'],
|
|
62
|
+
html: '<html>',
|
|
63
|
+
htmlTruncated: false,
|
|
64
|
+
failureSummary,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function describeElement(ref) {
|
|
68
|
+
return `<${ref.tag.toLowerCase()}> "${ref.name}"`;
|
|
69
|
+
}
|
|
70
|
+
// =============================================================================
|
|
71
|
+
// Envelope assembly
|
|
72
|
+
// =============================================================================
|
|
73
|
+
/** Assemble the common envelope from a check's details and buckets. */
|
|
74
|
+
export function buildAuditResult(args) {
|
|
75
|
+
const { source, url, details, buckets, timestamp } = args;
|
|
76
|
+
const summary = {
|
|
77
|
+
violationCount: buckets.violations.length,
|
|
78
|
+
incompleteCount: buckets.incomplete.length,
|
|
79
|
+
passCount: buckets.passes.length,
|
|
80
|
+
};
|
|
81
|
+
if (buckets.checkedNodes !== undefined) {
|
|
82
|
+
summary.checkedNodes = buckets.checkedNodes;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
source,
|
|
86
|
+
url,
|
|
87
|
+
timestamp: timestamp ?? new Date().toISOString(),
|
|
88
|
+
violations: buckets.violations,
|
|
89
|
+
incomplete: buckets.incomplete,
|
|
90
|
+
passes: buckets.passes,
|
|
91
|
+
inapplicable: buckets.inapplicable,
|
|
92
|
+
summary,
|
|
93
|
+
details,
|
|
94
|
+
disclaimer: AUDIT_DISCLAIMER,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// Per-check normalizers
|
|
99
|
+
// =============================================================================
|
|
100
|
+
export function normalizeFocusCheck(details) {
|
|
101
|
+
const buckets = emptyBuckets();
|
|
102
|
+
const applicable = details.totalFocusableElements > 0;
|
|
103
|
+
buckets.checkedNodes = details.totalFocusableElements;
|
|
104
|
+
bucketize(buckets, 'focus-visible', details.issues.map((el) => toNode(el, `${describeElement(el)} shows no computed-style change on focus ` +
|
|
105
|
+
'(outline, box-shadow, background-color). Verify manually whether a ' +
|
|
106
|
+
'visual focus indicator exists (pseudo-elements, canvas, or parent ' +
|
|
107
|
+
'changes are not detected).')), applicable);
|
|
108
|
+
bucketize(buckets, 'no-context-change-on-focus', details.onFocusViolations.map((v) => toNode(v.element, `Focusing ${describeElement(v.element)} triggered a ${v.changeType} ` +
|
|
109
|
+
`from ${v.fromUrl} to ${v.toUrl}.`)), applicable);
|
|
110
|
+
bucketize(buckets, 'focus-not-obscured', details.focusObscuredIssues.map((issue) => toNode(issue.element, `${describeElement(issue.element)} is ${(issue.obscuredRatio * 100).toFixed(0)}% ` +
|
|
111
|
+
`obscured when focused (by ${issue.overlaps
|
|
112
|
+
.map((o) => `<${o.obscuredBy.tag.toLowerCase()}>`)
|
|
113
|
+
.join(', ')}). Verify whether the focused element is hidden.`)), applicable);
|
|
114
|
+
return buckets;
|
|
115
|
+
}
|
|
116
|
+
export function normalizeReflowCheck(details) {
|
|
117
|
+
const buckets = emptyBuckets();
|
|
118
|
+
const overflowNodes = details.overflowingElements.map((el) => toNode(el, `<${el.tagName}> extends to ${el.rect.right}px in a ${el.viewportWidth}px ` +
|
|
119
|
+
`viewport (${el.reason}). Verify whether a two-dimensional layout ` +
|
|
120
|
+
'exception (table, map, diagram, ...) applies.'));
|
|
121
|
+
if (details.hasHorizontalScroll && overflowNodes.length === 0) {
|
|
122
|
+
overflowNodes.push(pageNode(`Document scrolls horizontally at ${details.viewport.width}px ` +
|
|
123
|
+
`(scrollWidth ${details.documentScrollWidth}px > clientWidth ` +
|
|
124
|
+
`${details.documentClientWidth}px). Verify whether an exception applies.`));
|
|
125
|
+
}
|
|
126
|
+
bucketize(buckets, 'reflow-overflow', overflowNodes, true);
|
|
127
|
+
bucketize(buckets, 'reflow-clipped-text', details.clippedTextElements.map((el) => toNode(el, `<${el.tagName}> clips its text at ${details.viewport.width}px ` +
|
|
128
|
+
`(scrollWidth ${el.scrollWidth}px > clientWidth ${el.clientWidth}px, ` +
|
|
129
|
+
`overflow: ${el.overflow}). Verify whether content is lost.`)), true);
|
|
130
|
+
return buckets;
|
|
131
|
+
}
|
|
132
|
+
export function normalizeTargetSizeCheck(details) {
|
|
133
|
+
const buckets = emptyBuckets();
|
|
134
|
+
const applicable = details.totalTargetsChecked > 0;
|
|
135
|
+
buckets.checkedNodes = details.totalTargetsChecked;
|
|
136
|
+
const minimumFailureSummary = (issue) => {
|
|
137
|
+
const base = `Target is ${issue.width}x${issue.height}px ` +
|
|
138
|
+
`(min dimension ${issue.minDimension}px, requirement 24px).`;
|
|
139
|
+
if (issue.exception) {
|
|
140
|
+
return (`${base} Possible '${issue.exception}' exception: ` +
|
|
141
|
+
`${issue.exceptionDetails ?? 'see manual review notes'}. Confirm manually.`);
|
|
142
|
+
}
|
|
143
|
+
return `${base} No exception detected, but the essential exception cannot be ruled out automatically.`;
|
|
144
|
+
};
|
|
145
|
+
// Minimum (2.5.8 AA): findings are fail-aa targets, with and without
|
|
146
|
+
// detected exceptions. Only 'ruled-out' assessments are confirmed violations.
|
|
147
|
+
const minimumIssues = [...details.failAA, ...details.exceptedTargets].filter((issue) => issue.level === 'fail-aa');
|
|
148
|
+
const confirmed = minimumIssues.filter((i) => i.exceptionAssessment === 'ruled-out');
|
|
149
|
+
const needsReview = minimumIssues.filter((i) => i.exceptionAssessment !== 'ruled-out');
|
|
150
|
+
if (!applicable) {
|
|
151
|
+
buckets.inapplicable.push(ruleResult('target-size-minimum', []));
|
|
152
|
+
}
|
|
153
|
+
else if (minimumIssues.length === 0) {
|
|
154
|
+
buckets.passes.push(ruleResult('target-size-minimum', []));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
if (confirmed.length > 0) {
|
|
158
|
+
buckets.violations.push(ruleResult('target-size-minimum', confirmed.map((i) => toNode(i, minimumFailureSummary(i)))));
|
|
159
|
+
}
|
|
160
|
+
if (needsReview.length > 0) {
|
|
161
|
+
buckets.incomplete.push(ruleResult('target-size-minimum', needsReview.map((i) => toNode(i, minimumFailureSummary(i)))));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Enhanced (2.5.5 AAA): targets that pass AA but miss the 44px requirement.
|
|
165
|
+
// Targets already failing AA are reported under target-size-minimum only.
|
|
166
|
+
bucketize(buckets, 'target-size-enhanced', details.failAAAOnly.map((issue) => toNode(issue, `Target is ${issue.width}x${issue.height}px ` +
|
|
167
|
+
`(min dimension ${issue.minDimension}px, AAA requirement 44px). ` +
|
|
168
|
+
'Verify whether an SC 2.5.5 exception applies.')), applicable);
|
|
169
|
+
return buckets;
|
|
170
|
+
}
|
|
171
|
+
export function normalizeTextSpacingCheck(details) {
|
|
172
|
+
const buckets = emptyBuckets();
|
|
173
|
+
buckets.checkedNodes = details.totalElementsChecked;
|
|
174
|
+
bucketize(buckets, 'text-spacing', details.clippedElements.map((el) => toNode(el, `<${el.tagName}> clips its content (${el.issueType}) when WCAG 1.4.12 ` +
|
|
175
|
+
`text spacing is applied: ${el.afterMetrics.scrollWidth}x${el.afterMetrics.scrollHeight}px ` +
|
|
176
|
+
`content in a ${el.afterMetrics.clientWidth}x${el.afterMetrics.clientHeight}px box.`)), details.totalElementsChecked > 0);
|
|
177
|
+
return buckets;
|
|
178
|
+
}
|
|
179
|
+
export function normalizeZoomCheck(details) {
|
|
180
|
+
const buckets = emptyBuckets();
|
|
181
|
+
const nodes = details.clippedElements.map((el) => toNode(el, `<${el.tagName}> ${el.issueType === 'horizontal-scroll' ? 'overflows horizontally' : 'clips its content'} ` +
|
|
182
|
+
`at ${details.zoomFactor * 100}% zoom (scrollWidth ${el.scrollWidth}px > ` +
|
|
183
|
+
`clientWidth ${el.clientWidth}px). Verify whether content or ` +
|
|
184
|
+
'functionality is lost.'));
|
|
185
|
+
if (details.hasHorizontalScroll && nodes.length === 0) {
|
|
186
|
+
nodes.push(pageNode(`Document scrolls horizontally at ${details.zoomFactor * 100}% zoom ` +
|
|
187
|
+
`(scrollWidth ${details.documentScrollWidth}px > clientWidth ` +
|
|
188
|
+
`${details.documentClientWidth}px). Horizontal scrolling alone does ` +
|
|
189
|
+
'not fail SC 1.4.4 — verify whether text becomes unusable.'));
|
|
190
|
+
}
|
|
191
|
+
bucketize(buckets, 'resize-text', nodes, true);
|
|
192
|
+
return buckets;
|
|
193
|
+
}
|
|
194
|
+
export function normalizeOrientationCheck(details) {
|
|
195
|
+
const buckets = emptyBuckets();
|
|
196
|
+
const nodes = [];
|
|
197
|
+
if (details.hasOrientationLock) {
|
|
198
|
+
const state = details.lockDetectedIn === 'landscape'
|
|
199
|
+
? details.landscape
|
|
200
|
+
: details.portrait;
|
|
201
|
+
const messagePart = state.lockMessageText
|
|
202
|
+
? ` Lock message found: "${state.lockMessageText}".`
|
|
203
|
+
: '';
|
|
204
|
+
nodes.push(pageNode(`Content appears restricted to a single orientation ` +
|
|
205
|
+
`(detected in: ${details.lockDetectedIn}).${messagePart} Verify ` +
|
|
206
|
+
'whether the essential exception (SC 1.3.4) applies.'));
|
|
207
|
+
}
|
|
208
|
+
bucketize(buckets, 'orientation-lock', nodes, true);
|
|
209
|
+
return buckets;
|
|
210
|
+
}
|
|
211
|
+
export function normalizeAutocompleteAudit(details) {
|
|
212
|
+
const buckets = emptyBuckets();
|
|
213
|
+
const applicable = details.totalFieldsChecked > 0;
|
|
214
|
+
buckets.checkedNodes = details.totalFieldsChecked;
|
|
215
|
+
bucketize(buckets, 'autocomplete-invalid', details.invalidAutocomplete.map((field) => toNode(field, `autocomplete="${field.currentAutocomplete}" is not a valid token. ` +
|
|
216
|
+
`Expected "${field.expectedToken}" (purpose matched by ${field.matchedBy}).`)), applicable);
|
|
217
|
+
bucketize(buckets, 'autocomplete-missing', details.missingAutocomplete.map((field) => toNode(field, `Field appears to collect "${field.expectedToken}" (matched by ` +
|
|
218
|
+
`${field.matchedBy}) but has ${field.currentAutocomplete === null
|
|
219
|
+
? 'no autocomplete attribute'
|
|
220
|
+
: `autocomplete="${field.currentAutocomplete}"`}. Confirm the field purpose manually.`)), applicable);
|
|
221
|
+
return buckets;
|
|
222
|
+
}
|
|
223
|
+
export function normalizeTimeLimitDetector(details) {
|
|
224
|
+
const buckets = emptyBuckets();
|
|
225
|
+
bucketize(buckets, 'meta-refresh', details.metaRefresh.map((meta) => ({
|
|
226
|
+
target: ['meta[http-equiv="refresh"]'],
|
|
227
|
+
html: meta.html || `<meta http-equiv="refresh" content="${meta.content}">`,
|
|
228
|
+
htmlTruncated: meta.htmlTruncated ?? false,
|
|
229
|
+
failureSummary: `Page refreshes${meta.url ? ` to ${meta.url}` : ''} after ${meta.seconds}s. ` +
|
|
230
|
+
'Verify whether the time limit can be turned off, adjusted, or extended, ' +
|
|
231
|
+
'or whether an SC 2.2.1 exception (e.g. over 20 hours) applies.',
|
|
232
|
+
})), true);
|
|
233
|
+
bucketize(buckets, 'time-limit-timer', details.timers.map((timer) => pageNode(`${timer.type} with a ${timer.delayMs}ms delay detected` +
|
|
234
|
+
`${timer.callStack ? ` (${timer.callStack.split('\n')[0]?.trim()})` : ''}. ` +
|
|
235
|
+
'Verify whether it implements a time limit and is adjustable.')), true);
|
|
236
|
+
bucketize(buckets, 'time-limit-countdown', details.countdownIndicators.map((indicator) => toNode(indicator, `Countdown/timeout wording found: "${indicator.text.slice(0, 80)}". ` +
|
|
237
|
+
'Verify whether it indicates an adjustable time limit.')), true);
|
|
238
|
+
return buckets;
|
|
239
|
+
}
|
|
240
|
+
export function normalizeAutoPlayDetection(details) {
|
|
241
|
+
const buckets = emptyBuckets();
|
|
242
|
+
const nodes = [];
|
|
243
|
+
if (details.hasAutoPlayContent && !details.stopsWithin5Seconds) {
|
|
244
|
+
const controlsPart = details.pauseControls.found
|
|
245
|
+
? `Pause controls found (${details.pauseControls.controls.length}); ` +
|
|
246
|
+
`pause verified working: ${details.pauseVerification.pauseWorked ?? 'unknown'}.`
|
|
247
|
+
: 'No pause controls found.';
|
|
248
|
+
nodes.push(pageNode(`Moving content continues past 5 seconds (pixel-diff detection). ` +
|
|
249
|
+
`${controlsPart} ${details.recommendation} Verify the content type ` +
|
|
250
|
+
'and audio manually.'));
|
|
251
|
+
}
|
|
252
|
+
bucketize(buckets, 'auto-play', nodes, true);
|
|
253
|
+
return buckets;
|
|
254
|
+
}
|
|
255
|
+
function normalizeAxeRule(rule, includeNodes) {
|
|
256
|
+
return {
|
|
257
|
+
id: rule.id,
|
|
258
|
+
impact: (rule.impact ?? null),
|
|
259
|
+
description: rule.description,
|
|
260
|
+
help: rule.help,
|
|
261
|
+
helpUrl: rule.helpUrl,
|
|
262
|
+
tags: [...rule.tags],
|
|
263
|
+
nodes: includeNodes
|
|
264
|
+
? rule.nodes.map((n) => {
|
|
265
|
+
const truncated = n.html.length > HTML_SNIPPET_MAX_LENGTH;
|
|
266
|
+
return {
|
|
267
|
+
target: n.target.map((t) => String(t)),
|
|
268
|
+
html: truncated ? n.html.slice(0, HTML_SNIPPET_MAX_LENGTH) : n.html,
|
|
269
|
+
htmlTruncated: truncated,
|
|
270
|
+
failureSummary: n.failureSummary ?? '',
|
|
271
|
+
};
|
|
272
|
+
})
|
|
273
|
+
: [],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Normalize raw axe-core results into the common buckets. Must be fed the RAW
|
|
278
|
+
* `AxeResults` (before any reduction) — pass/incomplete details are not
|
|
279
|
+
* recoverable afterwards. Node lists are kept for violations and incomplete;
|
|
280
|
+
* passes/inapplicable carry rule metadata only.
|
|
281
|
+
*/
|
|
282
|
+
export function normalizeAxeResults(raw) {
|
|
283
|
+
return {
|
|
284
|
+
violations: raw.violations.map((r) => normalizeAxeRule(r, true)),
|
|
285
|
+
incomplete: raw.incomplete.map((r) => normalizeAxeRule(r, true)),
|
|
286
|
+
passes: raw.passes.map((r) => normalizeAxeRule(r, false)),
|
|
287
|
+
inapplicable: raw.inapplicable.map((r) => normalizeAxeRule(r, false)),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const BUCKETS = ['violations', 'incomplete', 'passes', 'inapplicable'];
|
|
291
|
+
/**
|
|
292
|
+
* Merge several check results for the same URL into one normalized view.
|
|
293
|
+
*
|
|
294
|
+
* - Results with differing URLs are rejected (throws).
|
|
295
|
+
* - The same rule id is merged into one entry; nodes are deduplicated by
|
|
296
|
+
* `target` + `failureSummary`. Identical selectors inside different frames
|
|
297
|
+
* or shadow roots are NOT distinguished.
|
|
298
|
+
* - A rule appearing in several buckets is placed in the highest-priority one:
|
|
299
|
+
* violations > incomplete > passes > inapplicable.
|
|
300
|
+
*/
|
|
301
|
+
export function mergeNormalizedResults(results) {
|
|
302
|
+
if (results.length === 0) {
|
|
303
|
+
throw new Error('mergeNormalizedResults requires at least one result.');
|
|
304
|
+
}
|
|
305
|
+
const first = results[0];
|
|
306
|
+
const mismatch = results.find((r) => r.url !== first.url);
|
|
307
|
+
if (mismatch) {
|
|
308
|
+
throw new Error(`mergeNormalizedResults: URL mismatch — "${first.url}" vs "${mismatch.url}". ` +
|
|
309
|
+
'Merge only results for the same page.');
|
|
310
|
+
}
|
|
311
|
+
const byId = new Map();
|
|
312
|
+
const nodeKey = (n) => `${JSON.stringify(n.target)}|${n.failureSummary}`;
|
|
313
|
+
for (const result of results) {
|
|
314
|
+
BUCKETS.forEach((bucket, bucketIndex) => {
|
|
315
|
+
for (const rule of result[bucket]) {
|
|
316
|
+
let entry = byId.get(rule.id);
|
|
317
|
+
if (!entry) {
|
|
318
|
+
entry = {
|
|
319
|
+
bucketIndex,
|
|
320
|
+
rule: { ...rule, nodes: [] },
|
|
321
|
+
nodeKeys: new Set(),
|
|
322
|
+
};
|
|
323
|
+
byId.set(rule.id, entry);
|
|
324
|
+
}
|
|
325
|
+
entry.bucketIndex = Math.min(entry.bucketIndex, bucketIndex);
|
|
326
|
+
for (const node of rule.nodes) {
|
|
327
|
+
const key = nodeKey(node);
|
|
328
|
+
if (!entry.nodeKeys.has(key)) {
|
|
329
|
+
entry.nodeKeys.add(key);
|
|
330
|
+
entry.rule.nodes.push(node);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const merged = {
|
|
337
|
+
violations: [],
|
|
338
|
+
incomplete: [],
|
|
339
|
+
passes: [],
|
|
340
|
+
inapplicable: [],
|
|
341
|
+
};
|
|
342
|
+
for (const entry of byId.values()) {
|
|
343
|
+
merged[BUCKETS[entry.bucketIndex]].push(entry.rule);
|
|
344
|
+
}
|
|
345
|
+
const latestTimestamp = results
|
|
346
|
+
.map((r) => r.timestamp)
|
|
347
|
+
.sort()
|
|
348
|
+
.at(-1);
|
|
349
|
+
const checkedNodes = results.reduce((sum, r) => {
|
|
350
|
+
if (r.summary.checkedNodes === undefined)
|
|
351
|
+
return sum;
|
|
352
|
+
return (sum ?? 0) + r.summary.checkedNodes;
|
|
353
|
+
}, undefined);
|
|
354
|
+
const summary = {
|
|
355
|
+
violationCount: merged.violations.length,
|
|
356
|
+
incompleteCount: merged.incomplete.length,
|
|
357
|
+
passCount: merged.passes.length,
|
|
358
|
+
};
|
|
359
|
+
if (checkedNodes !== undefined) {
|
|
360
|
+
summary.checkedNodes = checkedNodes;
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
url: first.url,
|
|
364
|
+
timestamp: latestTimestamp,
|
|
365
|
+
sources: [...new Set(results.map((r) => r.source))],
|
|
366
|
+
...merged,
|
|
367
|
+
summary,
|
|
368
|
+
disclaimer: AUDIT_DISCLAIMER,
|
|
369
|
+
};
|
|
370
|
+
}
|
package/dist/utils/layout.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface LayoutCheckOptions {
|
|
|
8
8
|
overflowTolerance: number;
|
|
9
9
|
checkSelector: string;
|
|
10
10
|
allowedOverflowSelectors: readonly string[];
|
|
11
|
+
/** Maximum length of captured outerHTML snippets. */
|
|
12
|
+
htmlSnippetMaxLength: number;
|
|
11
13
|
}
|
|
12
14
|
export interface LayoutCheckResult {
|
|
13
15
|
hasHorizontalScroll: boolean;
|
package/dist/utils/layout.js
CHANGED
|
@@ -7,7 +7,26 @@
|
|
|
7
7
|
* This is serialized and executed in the browser context.
|
|
8
8
|
*/
|
|
9
9
|
export function createLayoutChecker(options) {
|
|
10
|
-
const { viewportWidth, overflowTolerance, checkSelector, allowedOverflowSelectors, } = options;
|
|
10
|
+
const { viewportWidth, overflowTolerance, checkSelector, allowedOverflowSelectors, htmlSnippetMaxLength, } = options;
|
|
11
|
+
function getHtmlSnippet(element) {
|
|
12
|
+
let html = '';
|
|
13
|
+
try {
|
|
14
|
+
html = element.outerHTML || '';
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
html = '';
|
|
18
|
+
}
|
|
19
|
+
if (!html) {
|
|
20
|
+
return {
|
|
21
|
+
html: `<${element.tagName.toLowerCase()}>`,
|
|
22
|
+
htmlTruncated: false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (html.length > htmlSnippetMaxLength) {
|
|
26
|
+
return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
|
|
27
|
+
}
|
|
28
|
+
return { html, htmlTruncated: false };
|
|
29
|
+
}
|
|
11
30
|
// Helper functions (must be defined inside for browser context)
|
|
12
31
|
/**
|
|
13
32
|
* Generate a unique CSS selector for an element using index-based approach
|
|
@@ -31,7 +50,9 @@ export function createLayoutChecker(options) {
|
|
|
31
50
|
current = parent;
|
|
32
51
|
}
|
|
33
52
|
// Append element index as fallback for guaranteed uniqueness
|
|
34
|
-
return path.length > 0
|
|
53
|
+
return path.length > 0
|
|
54
|
+
? path.join(' > ')
|
|
55
|
+
: `[data-index="${elementIndex}"]`;
|
|
35
56
|
}
|
|
36
57
|
function isAllowedOverflow(element) {
|
|
37
58
|
return allowedOverflowSelectors.some((selector) => {
|
|
@@ -76,6 +97,7 @@ export function createLayoutChecker(options) {
|
|
|
76
97
|
overflowingElements.push({
|
|
77
98
|
selector,
|
|
78
99
|
tagName: element.tagName.toLowerCase(),
|
|
100
|
+
...getHtmlSnippet(element),
|
|
79
101
|
rect: {
|
|
80
102
|
left: Math.round(rect.left),
|
|
81
103
|
right: Math.round(rect.right),
|
|
@@ -91,6 +113,7 @@ export function createLayoutChecker(options) {
|
|
|
91
113
|
overflowingElements.push({
|
|
92
114
|
selector,
|
|
93
115
|
tagName: element.tagName.toLowerCase(),
|
|
116
|
+
...getHtmlSnippet(element),
|
|
94
117
|
rect: {
|
|
95
118
|
left: Math.round(rect.left),
|
|
96
119
|
right: Math.round(rect.right),
|
|
@@ -123,6 +146,7 @@ export function createLayoutChecker(options) {
|
|
|
123
146
|
clippedTextElements.push({
|
|
124
147
|
selector,
|
|
125
148
|
tagName: element.tagName.toLowerCase(),
|
|
149
|
+
...getHtmlSnippet(element),
|
|
126
150
|
scrollWidth,
|
|
127
151
|
clientWidth,
|
|
128
152
|
scrollHeight,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Generate a recommendation based on detection results.
|
|
6
6
|
*/
|
|
7
7
|
export function generateRecommendation(ctx) {
|
|
8
|
-
const { hasAutoPlayContent, stopsWithin5Seconds, pauseControls, pauseVerification } = ctx;
|
|
8
|
+
const { hasAutoPlayContent, stopsWithin5Seconds, pauseControls, pauseVerification, } = ctx;
|
|
9
9
|
if (!hasAutoPlayContent) {
|
|
10
10
|
return 'No auto-playing content detected in viewport.';
|
|
11
11
|
}
|
|
@@ -32,7 +32,7 @@ export function generateRecommendation(ctx) {
|
|
|
32
32
|
* Print summary to console.
|
|
33
33
|
*/
|
|
34
34
|
export function printSummary(ctx, outputDir) {
|
|
35
|
-
const { hasAutoPlayContent, stopsWithin5Seconds, pauseControls, pauseVerification } = ctx;
|
|
35
|
+
const { hasAutoPlayContent, stopsWithin5Seconds, pauseControls, pauseVerification, } = ctx;
|
|
36
36
|
console.log('\n--- Summary ---');
|
|
37
37
|
if (!hasAutoPlayContent) {
|
|
38
38
|
console.log('✓ No auto-playing content detected in viewport');
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule metadata registry for the custom (non-axe) checks.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for rule ids, WCAG mapping, severity, and the
|
|
5
|
+
* violation/incomplete classification. The mappers in `axe-format.ts` read
|
|
6
|
+
* from here so that classification policy is reviewable in one place.
|
|
7
|
+
*
|
|
8
|
+
* Classification policy: `'violation'` is reserved for rules whose detection
|
|
9
|
+
* has no blind spot AND where no WCAG exception can apply to a finding.
|
|
10
|
+
* Heuristic detections and findings with possible exceptions are
|
|
11
|
+
* `'incomplete'` (the manual-review queue). impact is NOT derived from the
|
|
12
|
+
* WCAG conformance level; it is assigned per rule, conservatively defaulting
|
|
13
|
+
* to `moderate`.
|
|
14
|
+
*/
|
|
15
|
+
import type { NormalizedImpact } from '../types.js';
|
|
16
|
+
export interface RuleMeta {
|
|
17
|
+
/** Namespaced rule id, e.g. `a11y-skills/focus-visible`. */
|
|
18
|
+
id: string;
|
|
19
|
+
/** WCAG success criteria, e.g. `['2.4.7']`. */
|
|
20
|
+
sc: string[];
|
|
21
|
+
/**
|
|
22
|
+
* axe-style tags. The version+level tag reflects the WCAG version that
|
|
23
|
+
* introduced the SC (2.4.7 → `wcag2aa`, 1.4.10 → `wcag21aa`, ...).
|
|
24
|
+
*/
|
|
25
|
+
tags: string[];
|
|
26
|
+
impact: NormalizedImpact;
|
|
27
|
+
/** Whether findings refer to specific elements or the page as a whole. */
|
|
28
|
+
scope: 'node' | 'page';
|
|
29
|
+
description: string;
|
|
30
|
+
help: string;
|
|
31
|
+
/** W3C Understanding document URL. */
|
|
32
|
+
helpUrl: string;
|
|
33
|
+
/** Default bucket for findings of this rule. */
|
|
34
|
+
classification: 'violation' | 'incomplete';
|
|
35
|
+
}
|
|
36
|
+
export declare const RULES: {
|
|
37
|
+
readonly 'focus-visible': {
|
|
38
|
+
readonly id: "a11y-skills/focus-visible";
|
|
39
|
+
readonly sc: ["2.4.7"];
|
|
40
|
+
readonly tags: ["a11y-skills", "wcag2aa", "wcag247"];
|
|
41
|
+
readonly impact: "serious";
|
|
42
|
+
readonly scope: "node";
|
|
43
|
+
readonly description: "Ensure keyboard focus produces a visible indicator on focusable elements";
|
|
44
|
+
readonly help: "Focusable elements should have a visible focus indicator";
|
|
45
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html";
|
|
46
|
+
readonly classification: "incomplete";
|
|
47
|
+
};
|
|
48
|
+
readonly 'no-context-change-on-focus': {
|
|
49
|
+
readonly id: "a11y-skills/no-context-change-on-focus";
|
|
50
|
+
readonly sc: ["3.2.1"];
|
|
51
|
+
readonly tags: ["a11y-skills", "wcag2a", "wcag321"];
|
|
52
|
+
readonly impact: "serious";
|
|
53
|
+
readonly scope: "node";
|
|
54
|
+
readonly description: "Ensure receiving focus does not trigger a change of context (navigation, new window)";
|
|
55
|
+
readonly help: "Focusing an element must not trigger a context change";
|
|
56
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/on-focus.html";
|
|
57
|
+
readonly classification: "violation";
|
|
58
|
+
};
|
|
59
|
+
readonly 'focus-not-obscured': {
|
|
60
|
+
readonly id: "a11y-skills/focus-not-obscured";
|
|
61
|
+
readonly sc: ["2.4.11", "2.4.12"];
|
|
62
|
+
readonly tags: ["a11y-skills", "wcag22aa", "wcag2411"];
|
|
63
|
+
readonly impact: "moderate";
|
|
64
|
+
readonly scope: "node";
|
|
65
|
+
readonly description: "Ensure the focused element is not hidden by fixed or sticky content";
|
|
66
|
+
readonly help: "Focused elements should not be obscured by author-created content";
|
|
67
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html";
|
|
68
|
+
readonly classification: "incomplete";
|
|
69
|
+
};
|
|
70
|
+
readonly 'reflow-overflow': {
|
|
71
|
+
readonly id: "a11y-skills/reflow-overflow";
|
|
72
|
+
readonly sc: ["1.4.10"];
|
|
73
|
+
readonly tags: ["a11y-skills", "wcag21aa", "wcag1410"];
|
|
74
|
+
readonly impact: "serious";
|
|
75
|
+
readonly scope: "node";
|
|
76
|
+
readonly description: "Ensure content reflows at 320 CSS px width without horizontal scrolling";
|
|
77
|
+
readonly help: "Content should not require horizontal scrolling at 320px width";
|
|
78
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/reflow.html";
|
|
79
|
+
readonly classification: "incomplete";
|
|
80
|
+
};
|
|
81
|
+
readonly 'reflow-clipped-text': {
|
|
82
|
+
readonly id: "a11y-skills/reflow-clipped-text";
|
|
83
|
+
readonly sc: ["1.4.10"];
|
|
84
|
+
readonly tags: ["a11y-skills", "wcag21aa", "wcag1410"];
|
|
85
|
+
readonly impact: "moderate";
|
|
86
|
+
readonly scope: "node";
|
|
87
|
+
readonly description: "Ensure text is not clipped when content reflows at 320 CSS px";
|
|
88
|
+
readonly help: "Text should remain readable at 320px width";
|
|
89
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/reflow.html";
|
|
90
|
+
readonly classification: "incomplete";
|
|
91
|
+
};
|
|
92
|
+
readonly 'target-size-minimum': {
|
|
93
|
+
readonly id: "a11y-skills/target-size-minimum";
|
|
94
|
+
readonly sc: ["2.5.8"];
|
|
95
|
+
readonly tags: ["a11y-skills", "wcag22aa", "wcag258"];
|
|
96
|
+
readonly impact: "serious";
|
|
97
|
+
readonly scope: "node";
|
|
98
|
+
readonly description: "Ensure pointer targets are at least 24x24 CSS px (WCAG 2.5.8 AA)";
|
|
99
|
+
readonly help: "Pointer targets should be at least 24x24 CSS px";
|
|
100
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html";
|
|
101
|
+
readonly classification: "incomplete";
|
|
102
|
+
};
|
|
103
|
+
readonly 'target-size-enhanced': {
|
|
104
|
+
readonly id: "a11y-skills/target-size-enhanced";
|
|
105
|
+
readonly sc: ["2.5.5"];
|
|
106
|
+
readonly tags: ["a11y-skills", "wcag21aaa", "wcag255"];
|
|
107
|
+
readonly impact: "moderate";
|
|
108
|
+
readonly scope: "node";
|
|
109
|
+
readonly description: "Ensure pointer targets are at least 44x44 CSS px (WCAG 2.5.5 AAA)";
|
|
110
|
+
readonly help: "Pointer targets should be at least 44x44 CSS px";
|
|
111
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html";
|
|
112
|
+
readonly classification: "incomplete";
|
|
113
|
+
};
|
|
114
|
+
readonly 'text-spacing': {
|
|
115
|
+
readonly id: "a11y-skills/text-spacing";
|
|
116
|
+
readonly sc: ["1.4.12"];
|
|
117
|
+
readonly tags: ["a11y-skills", "wcag21aa", "wcag1412"];
|
|
118
|
+
readonly impact: "moderate";
|
|
119
|
+
readonly scope: "node";
|
|
120
|
+
readonly description: "Ensure no loss of content when WCAG 1.4.12 text spacing overrides are applied";
|
|
121
|
+
readonly help: "Text must not be clipped under increased text spacing";
|
|
122
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html";
|
|
123
|
+
readonly classification: "violation";
|
|
124
|
+
};
|
|
125
|
+
readonly 'resize-text': {
|
|
126
|
+
readonly id: "a11y-skills/resize-text";
|
|
127
|
+
readonly sc: ["1.4.4"];
|
|
128
|
+
readonly tags: ["a11y-skills", "wcag2aa", "wcag144"];
|
|
129
|
+
readonly impact: "moderate";
|
|
130
|
+
readonly scope: "node";
|
|
131
|
+
readonly description: "Ensure content remains usable when text is resized to 200%";
|
|
132
|
+
readonly help: "Content should not be lost or clipped at 200% zoom";
|
|
133
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html";
|
|
134
|
+
readonly classification: "incomplete";
|
|
135
|
+
};
|
|
136
|
+
readonly 'orientation-lock': {
|
|
137
|
+
readonly id: "a11y-skills/orientation-lock";
|
|
138
|
+
readonly sc: ["1.3.4"];
|
|
139
|
+
readonly tags: ["a11y-skills", "wcag21aa", "wcag134"];
|
|
140
|
+
readonly impact: "serious";
|
|
141
|
+
readonly scope: "page";
|
|
142
|
+
readonly description: "Ensure content does not restrict its view to a single display orientation";
|
|
143
|
+
readonly help: "Content should work in both portrait and landscape orientation";
|
|
144
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/orientation.html";
|
|
145
|
+
readonly classification: "incomplete";
|
|
146
|
+
};
|
|
147
|
+
readonly 'autocomplete-invalid': {
|
|
148
|
+
readonly id: "a11y-skills/autocomplete-invalid";
|
|
149
|
+
readonly sc: ["1.3.5"];
|
|
150
|
+
readonly tags: ["a11y-skills", "wcag21aa", "wcag135"];
|
|
151
|
+
readonly impact: "moderate";
|
|
152
|
+
readonly scope: "node";
|
|
153
|
+
readonly description: "Ensure autocomplete attribute values are valid tokens";
|
|
154
|
+
readonly help: "autocomplete attributes must use valid token values";
|
|
155
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html";
|
|
156
|
+
readonly classification: "violation";
|
|
157
|
+
};
|
|
158
|
+
readonly 'autocomplete-missing': {
|
|
159
|
+
readonly id: "a11y-skills/autocomplete-missing";
|
|
160
|
+
readonly sc: ["1.3.5"];
|
|
161
|
+
readonly tags: ["a11y-skills", "wcag21aa", "wcag135"];
|
|
162
|
+
readonly impact: "moderate";
|
|
163
|
+
readonly scope: "node";
|
|
164
|
+
readonly description: "Ensure fields collecting user information declare their purpose via autocomplete";
|
|
165
|
+
readonly help: "Add an autocomplete attribute matching the field purpose";
|
|
166
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html";
|
|
167
|
+
readonly classification: "incomplete";
|
|
168
|
+
};
|
|
169
|
+
readonly 'meta-refresh': {
|
|
170
|
+
readonly id: "a11y-skills/meta-refresh";
|
|
171
|
+
readonly sc: ["2.2.1"];
|
|
172
|
+
readonly tags: ["a11y-skills", "wcag2a", "wcag221"];
|
|
173
|
+
readonly impact: "serious";
|
|
174
|
+
readonly scope: "node";
|
|
175
|
+
readonly description: "Ensure meta refresh time limits can be turned off, adjusted, or extended";
|
|
176
|
+
readonly help: "Verify the meta refresh satisfies an SC 2.2.1 exception or is adjustable";
|
|
177
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html";
|
|
178
|
+
readonly classification: "incomplete";
|
|
179
|
+
};
|
|
180
|
+
readonly 'time-limit-timer': {
|
|
181
|
+
readonly id: "a11y-skills/time-limit-timer";
|
|
182
|
+
readonly sc: ["2.2.1"];
|
|
183
|
+
readonly tags: ["a11y-skills", "wcag2a", "wcag221"];
|
|
184
|
+
readonly impact: "moderate";
|
|
185
|
+
readonly scope: "page";
|
|
186
|
+
readonly description: "Detect JavaScript timers that may implement a time limit";
|
|
187
|
+
readonly help: "Verify whether detected timers implement an adjustable time limit";
|
|
188
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html";
|
|
189
|
+
readonly classification: "incomplete";
|
|
190
|
+
};
|
|
191
|
+
readonly 'time-limit-countdown': {
|
|
192
|
+
readonly id: "a11y-skills/time-limit-countdown";
|
|
193
|
+
readonly sc: ["2.2.1"];
|
|
194
|
+
readonly tags: ["a11y-skills", "wcag2a", "wcag221"];
|
|
195
|
+
readonly impact: "moderate";
|
|
196
|
+
readonly scope: "node";
|
|
197
|
+
readonly description: "Detect countdown/timeout wording in visible text";
|
|
198
|
+
readonly help: "Verify whether the countdown text indicates an adjustable time limit";
|
|
199
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html";
|
|
200
|
+
readonly classification: "incomplete";
|
|
201
|
+
};
|
|
202
|
+
readonly 'auto-play': {
|
|
203
|
+
readonly id: "a11y-skills/auto-play";
|
|
204
|
+
readonly sc: ["2.2.2", "1.4.2"];
|
|
205
|
+
readonly tags: ["a11y-skills", "wcag2a", "wcag222", "wcag142"];
|
|
206
|
+
readonly impact: "moderate";
|
|
207
|
+
readonly scope: "page";
|
|
208
|
+
readonly description: "Detect auto-playing/moving content lasting longer than 5 seconds without a working pause control";
|
|
209
|
+
readonly help: "Moving content over 5 seconds needs a pause, stop, or hide mechanism";
|
|
210
|
+
readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html";
|
|
211
|
+
readonly classification: "incomplete";
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
export type RuleKey = keyof typeof RULES;
|
|
215
|
+
/** Look up a rule's metadata. */
|
|
216
|
+
export declare function getRule(key: RuleKey): RuleMeta;
|