@a11y-skills/audit 0.1.0 → 0.3.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 (59) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.ja.md +76 -6
  3. package/README.md +78 -6
  4. package/dist/constants.d.ts +84 -0
  5. package/dist/constants.js +228 -0
  6. package/dist/detectors/index.d.ts +1 -0
  7. package/dist/detectors/index.js +1 -0
  8. package/dist/detectors/pause-control.d.ts +18 -0
  9. package/dist/detectors/pause-control.js +206 -0
  10. package/dist/index.d.ts +3 -1
  11. package/dist/index.js +3 -1
  12. package/dist/playwright/index.d.ts +7 -1
  13. package/dist/playwright/index.js +7 -1
  14. package/dist/playwright/runAutoPlayDetection.d.ts +36 -0
  15. package/dist/playwright/runAutoPlayDetection.js +143 -0
  16. package/dist/playwright/runAutocompleteAudit.d.ts +27 -0
  17. package/dist/playwright/runAutocompleteAudit.js +227 -0
  18. package/dist/playwright/runAxeAudit.d.ts +4 -0
  19. package/dist/playwright/runAxeAudit.js +26 -30
  20. package/dist/playwright/runFocusIndicatorCheck.js +55 -12
  21. package/dist/playwright/runOrientationCheck.d.ts +40 -0
  22. package/dist/playwright/runOrientationCheck.js +170 -0
  23. package/dist/playwright/runReflowCheck.js +18 -11
  24. package/dist/playwright/runTargetSizeCheck.js +42 -10
  25. package/dist/playwright/runTextSpacingCheck.d.ts +25 -0
  26. package/dist/playwright/runTextSpacingCheck.js +285 -0
  27. package/dist/playwright/runTimeLimitDetector.d.ts +31 -0
  28. package/dist/playwright/runTimeLimitDetector.js +219 -0
  29. package/dist/playwright/runZoomCheck.d.ts +42 -0
  30. package/dist/playwright/runZoomCheck.js +174 -0
  31. package/dist/schemas/index.d.ts +20 -1
  32. package/dist/schemas/index.js +404 -186
  33. package/dist/test-entries/auto-play-detection.d.ts +7 -0
  34. package/dist/test-entries/auto-play-detection.js +13 -0
  35. package/dist/test-entries/autocomplete-audit.d.ts +5 -0
  36. package/dist/test-entries/autocomplete-audit.js +11 -0
  37. package/dist/test-entries/orientation-check.d.ts +8 -0
  38. package/dist/test-entries/orientation-check.js +12 -0
  39. package/dist/test-entries/text-spacing-check.d.ts +5 -0
  40. package/dist/test-entries/text-spacing-check.js +11 -0
  41. package/dist/test-entries/time-limit-detector.d.ts +8 -0
  42. package/dist/test-entries/time-limit-detector.js +12 -0
  43. package/dist/test-entries/zoom-200-check.d.ts +5 -0
  44. package/dist/test-entries/zoom-200-check.js +11 -0
  45. package/dist/types.d.ts +275 -40
  46. package/dist/types.js +9 -0
  47. package/dist/utils/axe-format.d.ts +88 -0
  48. package/dist/utils/axe-format.js +361 -0
  49. package/dist/utils/image-compare.d.ts +24 -0
  50. package/dist/utils/image-compare.js +49 -0
  51. package/dist/utils/layout.d.ts +2 -0
  52. package/dist/utils/layout.js +20 -1
  53. package/dist/utils/recommendations.d.ts +18 -0
  54. package/dist/utils/recommendations.js +88 -0
  55. package/dist/utils/rule-registry.d.ts +216 -0
  56. package/dist/utils/rule-registry.js +220 -0
  57. package/dist/utils/test-harness.d.ts +8 -2
  58. package/dist/utils/test-harness.js +13 -6
  59. package/package.json +32 -2
@@ -0,0 +1,361 @@
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' ? buckets.violations : buckets.incomplete).push(ruleResult(key, nodes));
44
+ }
45
+ /** Build a node from element evidence, synthesizing `html` when missing. */
46
+ function toNode(el, failureSummary) {
47
+ const tag = (el.tagName ?? el.tag ?? 'element').toLowerCase();
48
+ const html = el.html && el.html.length > 0 ? el.html : `<${tag}>`;
49
+ return {
50
+ target: [el.selector],
51
+ html,
52
+ htmlTruncated: el.htmlTruncated ?? false,
53
+ failureSummary,
54
+ };
55
+ }
56
+ /** Build a page-level node (`target: ['html']`). */
57
+ function pageNode(failureSummary) {
58
+ return { target: ['html'], html: '<html>', htmlTruncated: false, failureSummary };
59
+ }
60
+ function describeElement(ref) {
61
+ return `<${ref.tag.toLowerCase()}> "${ref.name}"`;
62
+ }
63
+ // =============================================================================
64
+ // Envelope assembly
65
+ // =============================================================================
66
+ /** Assemble the common envelope from a check's details and buckets. */
67
+ export function buildAuditResult(args) {
68
+ const { source, url, details, buckets, timestamp } = args;
69
+ const summary = {
70
+ violationCount: buckets.violations.length,
71
+ incompleteCount: buckets.incomplete.length,
72
+ passCount: buckets.passes.length,
73
+ };
74
+ if (buckets.checkedNodes !== undefined) {
75
+ summary.checkedNodes = buckets.checkedNodes;
76
+ }
77
+ return {
78
+ source,
79
+ url,
80
+ timestamp: timestamp ?? new Date().toISOString(),
81
+ violations: buckets.violations,
82
+ incomplete: buckets.incomplete,
83
+ passes: buckets.passes,
84
+ inapplicable: buckets.inapplicable,
85
+ summary,
86
+ details,
87
+ disclaimer: AUDIT_DISCLAIMER,
88
+ };
89
+ }
90
+ // =============================================================================
91
+ // Per-check normalizers
92
+ // =============================================================================
93
+ export function normalizeFocusCheck(details) {
94
+ const buckets = emptyBuckets();
95
+ const applicable = details.totalFocusableElements > 0;
96
+ buckets.checkedNodes = details.totalFocusableElements;
97
+ bucketize(buckets, 'focus-visible', details.issues.map((el) => toNode(el, `${describeElement(el)} shows no computed-style change on focus ` +
98
+ '(outline, box-shadow, background-color). Verify manually whether a ' +
99
+ 'visual focus indicator exists (pseudo-elements, canvas, or parent ' +
100
+ 'changes are not detected).')), applicable);
101
+ bucketize(buckets, 'no-context-change-on-focus', details.onFocusViolations.map((v) => toNode(v.element, `Focusing ${describeElement(v.element)} triggered a ${v.changeType} ` +
102
+ `from ${v.fromUrl} to ${v.toUrl}.`)), applicable);
103
+ bucketize(buckets, 'focus-not-obscured', details.focusObscuredIssues.map((issue) => toNode(issue.element, `${describeElement(issue.element)} is ${(issue.obscuredRatio * 100).toFixed(0)}% ` +
104
+ `obscured when focused (by ${issue.overlaps
105
+ .map((o) => `<${o.obscuredBy.tag.toLowerCase()}>`)
106
+ .join(', ')}). Verify whether the focused element is hidden.`)), applicable);
107
+ return buckets;
108
+ }
109
+ export function normalizeReflowCheck(details) {
110
+ const buckets = emptyBuckets();
111
+ const overflowNodes = details.overflowingElements.map((el) => toNode(el, `<${el.tagName}> extends to ${el.rect.right}px in a ${el.viewportWidth}px ` +
112
+ `viewport (${el.reason}). Verify whether a two-dimensional layout ` +
113
+ 'exception (table, map, diagram, ...) applies.'));
114
+ if (details.hasHorizontalScroll && overflowNodes.length === 0) {
115
+ overflowNodes.push(pageNode(`Document scrolls horizontally at ${details.viewport.width}px ` +
116
+ `(scrollWidth ${details.documentScrollWidth}px > clientWidth ` +
117
+ `${details.documentClientWidth}px). Verify whether an exception applies.`));
118
+ }
119
+ bucketize(buckets, 'reflow-overflow', overflowNodes, true);
120
+ bucketize(buckets, 'reflow-clipped-text', details.clippedTextElements.map((el) => toNode(el, `<${el.tagName}> clips its text at ${details.viewport.width}px ` +
121
+ `(scrollWidth ${el.scrollWidth}px > clientWidth ${el.clientWidth}px, ` +
122
+ `overflow: ${el.overflow}). Verify whether content is lost.`)), true);
123
+ return buckets;
124
+ }
125
+ export function normalizeTargetSizeCheck(details) {
126
+ const buckets = emptyBuckets();
127
+ const applicable = details.totalTargetsChecked > 0;
128
+ buckets.checkedNodes = details.totalTargetsChecked;
129
+ const minimumFailureSummary = (issue) => {
130
+ const base = `Target is ${issue.width}x${issue.height}px ` +
131
+ `(min dimension ${issue.minDimension}px, requirement 24px).`;
132
+ if (issue.exception) {
133
+ return (`${base} Possible '${issue.exception}' exception: ` +
134
+ `${issue.exceptionDetails ?? 'see manual review notes'}. Confirm manually.`);
135
+ }
136
+ return `${base} No exception detected, but the essential exception cannot be ruled out automatically.`;
137
+ };
138
+ // Minimum (2.5.8 AA): findings are fail-aa targets, with and without
139
+ // detected exceptions. Only 'ruled-out' assessments are confirmed violations.
140
+ const minimumIssues = [...details.failAA, ...details.exceptedTargets].filter((issue) => issue.level === 'fail-aa');
141
+ const confirmed = minimumIssues.filter((i) => i.exceptionAssessment === 'ruled-out');
142
+ const needsReview = minimumIssues.filter((i) => i.exceptionAssessment !== 'ruled-out');
143
+ if (!applicable) {
144
+ buckets.inapplicable.push(ruleResult('target-size-minimum', []));
145
+ }
146
+ else if (minimumIssues.length === 0) {
147
+ buckets.passes.push(ruleResult('target-size-minimum', []));
148
+ }
149
+ else {
150
+ if (confirmed.length > 0) {
151
+ buckets.violations.push(ruleResult('target-size-minimum', confirmed.map((i) => toNode(i, minimumFailureSummary(i)))));
152
+ }
153
+ if (needsReview.length > 0) {
154
+ buckets.incomplete.push(ruleResult('target-size-minimum', needsReview.map((i) => toNode(i, minimumFailureSummary(i)))));
155
+ }
156
+ }
157
+ // Enhanced (2.5.5 AAA): targets that pass AA but miss the 44px requirement.
158
+ // Targets already failing AA are reported under target-size-minimum only.
159
+ bucketize(buckets, 'target-size-enhanced', details.failAAAOnly.map((issue) => toNode(issue, `Target is ${issue.width}x${issue.height}px ` +
160
+ `(min dimension ${issue.minDimension}px, AAA requirement 44px). ` +
161
+ 'Verify whether an SC 2.5.5 exception applies.')), applicable);
162
+ return buckets;
163
+ }
164
+ export function normalizeTextSpacingCheck(details) {
165
+ const buckets = emptyBuckets();
166
+ buckets.checkedNodes = details.totalElementsChecked;
167
+ bucketize(buckets, 'text-spacing', details.clippedElements.map((el) => toNode(el, `<${el.tagName}> clips its content (${el.issueType}) when WCAG 1.4.12 ` +
168
+ `text spacing is applied: ${el.afterMetrics.scrollWidth}x${el.afterMetrics.scrollHeight}px ` +
169
+ `content in a ${el.afterMetrics.clientWidth}x${el.afterMetrics.clientHeight}px box.`)), details.totalElementsChecked > 0);
170
+ return buckets;
171
+ }
172
+ export function normalizeZoomCheck(details) {
173
+ const buckets = emptyBuckets();
174
+ const nodes = details.clippedElements.map((el) => toNode(el, `<${el.tagName}> ${el.issueType === 'horizontal-scroll' ? 'overflows horizontally' : 'clips its content'} ` +
175
+ `at ${details.zoomFactor * 100}% zoom (scrollWidth ${el.scrollWidth}px > ` +
176
+ `clientWidth ${el.clientWidth}px). Verify whether content or ` +
177
+ 'functionality is lost.'));
178
+ if (details.hasHorizontalScroll && nodes.length === 0) {
179
+ nodes.push(pageNode(`Document scrolls horizontally at ${details.zoomFactor * 100}% zoom ` +
180
+ `(scrollWidth ${details.documentScrollWidth}px > clientWidth ` +
181
+ `${details.documentClientWidth}px). Horizontal scrolling alone does ` +
182
+ 'not fail SC 1.4.4 — verify whether text becomes unusable.'));
183
+ }
184
+ bucketize(buckets, 'resize-text', nodes, true);
185
+ return buckets;
186
+ }
187
+ export function normalizeOrientationCheck(details) {
188
+ const buckets = emptyBuckets();
189
+ const nodes = [];
190
+ if (details.hasOrientationLock) {
191
+ const state = details.lockDetectedIn === 'landscape' ? details.landscape : details.portrait;
192
+ const messagePart = state.lockMessageText
193
+ ? ` Lock message found: "${state.lockMessageText}".`
194
+ : '';
195
+ nodes.push(pageNode(`Content appears restricted to a single orientation ` +
196
+ `(detected in: ${details.lockDetectedIn}).${messagePart} Verify ` +
197
+ 'whether the essential exception (SC 1.3.4) applies.'));
198
+ }
199
+ bucketize(buckets, 'orientation-lock', nodes, true);
200
+ return buckets;
201
+ }
202
+ export function normalizeAutocompleteAudit(details) {
203
+ const buckets = emptyBuckets();
204
+ const applicable = details.totalFieldsChecked > 0;
205
+ buckets.checkedNodes = details.totalFieldsChecked;
206
+ bucketize(buckets, 'autocomplete-invalid', details.invalidAutocomplete.map((field) => toNode(field, `autocomplete="${field.currentAutocomplete}" is not a valid token. ` +
207
+ `Expected "${field.expectedToken}" (purpose matched by ${field.matchedBy}).`)), applicable);
208
+ bucketize(buckets, 'autocomplete-missing', details.missingAutocomplete.map((field) => toNode(field, `Field appears to collect "${field.expectedToken}" (matched by ` +
209
+ `${field.matchedBy}) but has ${field.currentAutocomplete === null
210
+ ? 'no autocomplete attribute'
211
+ : `autocomplete="${field.currentAutocomplete}"`}. Confirm the field purpose manually.`)), applicable);
212
+ return buckets;
213
+ }
214
+ export function normalizeTimeLimitDetector(details) {
215
+ const buckets = emptyBuckets();
216
+ bucketize(buckets, 'meta-refresh', details.metaRefresh.map((meta) => ({
217
+ target: ['meta[http-equiv="refresh"]'],
218
+ html: meta.html || `<meta http-equiv="refresh" content="${meta.content}">`,
219
+ htmlTruncated: meta.htmlTruncated ?? false,
220
+ failureSummary: `Page refreshes${meta.url ? ` to ${meta.url}` : ''} after ${meta.seconds}s. ` +
221
+ 'Verify whether the time limit can be turned off, adjusted, or extended, ' +
222
+ 'or whether an SC 2.2.1 exception (e.g. over 20 hours) applies.',
223
+ })), true);
224
+ bucketize(buckets, 'time-limit-timer', details.timers.map((timer) => pageNode(`${timer.type} with a ${timer.delayMs}ms delay detected` +
225
+ `${timer.callStack ? ` (${timer.callStack.split('\n')[0]?.trim()})` : ''}. ` +
226
+ 'Verify whether it implements a time limit and is adjustable.')), true);
227
+ bucketize(buckets, 'time-limit-countdown', details.countdownIndicators.map((indicator) => toNode(indicator, `Countdown/timeout wording found: "${indicator.text.slice(0, 80)}". ` +
228
+ 'Verify whether it indicates an adjustable time limit.')), true);
229
+ return buckets;
230
+ }
231
+ export function normalizeAutoPlayDetection(details) {
232
+ const buckets = emptyBuckets();
233
+ const nodes = [];
234
+ if (details.hasAutoPlayContent && !details.stopsWithin5Seconds) {
235
+ const controlsPart = details.pauseControls.found
236
+ ? `Pause controls found (${details.pauseControls.controls.length}); ` +
237
+ `pause verified working: ${details.pauseVerification.pauseWorked ?? 'unknown'}.`
238
+ : 'No pause controls found.';
239
+ nodes.push(pageNode(`Moving content continues past 5 seconds (pixel-diff detection). ` +
240
+ `${controlsPart} ${details.recommendation} Verify the content type ` +
241
+ 'and audio manually.'));
242
+ }
243
+ bucketize(buckets, 'auto-play', nodes, true);
244
+ return buckets;
245
+ }
246
+ function normalizeAxeRule(rule, includeNodes) {
247
+ return {
248
+ id: rule.id,
249
+ impact: (rule.impact ?? null),
250
+ description: rule.description,
251
+ help: rule.help,
252
+ helpUrl: rule.helpUrl,
253
+ tags: [...rule.tags],
254
+ nodes: includeNodes
255
+ ? rule.nodes.map((n) => {
256
+ const truncated = n.html.length > HTML_SNIPPET_MAX_LENGTH;
257
+ return {
258
+ target: n.target.map((t) => String(t)),
259
+ html: truncated ? n.html.slice(0, HTML_SNIPPET_MAX_LENGTH) : n.html,
260
+ htmlTruncated: truncated,
261
+ failureSummary: n.failureSummary ?? '',
262
+ };
263
+ })
264
+ : [],
265
+ };
266
+ }
267
+ /**
268
+ * Normalize raw axe-core results into the common buckets. Must be fed the RAW
269
+ * `AxeResults` (before any reduction) — pass/incomplete details are not
270
+ * recoverable afterwards. Node lists are kept for violations and incomplete;
271
+ * passes/inapplicable carry rule metadata only.
272
+ */
273
+ export function normalizeAxeResults(raw) {
274
+ return {
275
+ violations: raw.violations.map((r) => normalizeAxeRule(r, true)),
276
+ incomplete: raw.incomplete.map((r) => normalizeAxeRule(r, true)),
277
+ passes: raw.passes.map((r) => normalizeAxeRule(r, false)),
278
+ inapplicable: raw.inapplicable.map((r) => normalizeAxeRule(r, false)),
279
+ };
280
+ }
281
+ const BUCKETS = ['violations', 'incomplete', 'passes', 'inapplicable'];
282
+ /**
283
+ * Merge several check results for the same URL into one normalized view.
284
+ *
285
+ * - Results with differing URLs are rejected (throws).
286
+ * - The same rule id is merged into one entry; nodes are deduplicated by
287
+ * `target` + `failureSummary`. Identical selectors inside different frames
288
+ * or shadow roots are NOT distinguished.
289
+ * - A rule appearing in several buckets is placed in the highest-priority one:
290
+ * violations > incomplete > passes > inapplicable.
291
+ */
292
+ export function mergeNormalizedResults(results) {
293
+ if (results.length === 0) {
294
+ throw new Error('mergeNormalizedResults requires at least one result.');
295
+ }
296
+ const first = results[0];
297
+ const mismatch = results.find((r) => r.url !== first.url);
298
+ if (mismatch) {
299
+ throw new Error(`mergeNormalizedResults: URL mismatch — "${first.url}" vs "${mismatch.url}". ` +
300
+ 'Merge only results for the same page.');
301
+ }
302
+ const byId = new Map();
303
+ const nodeKey = (n) => `${JSON.stringify(n.target)}|${n.failureSummary}`;
304
+ for (const result of results) {
305
+ BUCKETS.forEach((bucket, bucketIndex) => {
306
+ for (const rule of result[bucket]) {
307
+ let entry = byId.get(rule.id);
308
+ if (!entry) {
309
+ entry = {
310
+ bucketIndex,
311
+ rule: { ...rule, nodes: [] },
312
+ nodeKeys: new Set(),
313
+ };
314
+ byId.set(rule.id, entry);
315
+ }
316
+ entry.bucketIndex = Math.min(entry.bucketIndex, bucketIndex);
317
+ for (const node of rule.nodes) {
318
+ const key = nodeKey(node);
319
+ if (!entry.nodeKeys.has(key)) {
320
+ entry.nodeKeys.add(key);
321
+ entry.rule.nodes.push(node);
322
+ }
323
+ }
324
+ }
325
+ });
326
+ }
327
+ const merged = {
328
+ violations: [],
329
+ incomplete: [],
330
+ passes: [],
331
+ inapplicable: [],
332
+ };
333
+ for (const entry of byId.values()) {
334
+ merged[BUCKETS[entry.bucketIndex]].push(entry.rule);
335
+ }
336
+ const latestTimestamp = results
337
+ .map((r) => r.timestamp)
338
+ .sort()
339
+ .at(-1);
340
+ const checkedNodes = results.reduce((sum, r) => {
341
+ if (r.summary.checkedNodes === undefined)
342
+ return sum;
343
+ return (sum ?? 0) + r.summary.checkedNodes;
344
+ }, undefined);
345
+ const summary = {
346
+ violationCount: merged.violations.length,
347
+ incompleteCount: merged.incomplete.length,
348
+ passCount: merged.passes.length,
349
+ };
350
+ if (checkedNodes !== undefined) {
351
+ summary.checkedNodes = checkedNodes;
352
+ }
353
+ return {
354
+ url: first.url,
355
+ timestamp: latestTimestamp,
356
+ sources: [...new Set(results.map((r) => r.source))],
357
+ ...merged,
358
+ summary,
359
+ disclaimer: AUDIT_DISCLAIMER,
360
+ };
361
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Image comparison utilities using pixelmatch.
3
+ *
4
+ * NOTE: `pixelmatch` and `pngjs` are optional peer/runtime deps (only the
5
+ * auto-play check needs them). This module top-level imports them, so it must
6
+ * only be loaded via a dynamic `import()` from `runAutoPlayDetection` — never
7
+ * statically from the package barrel. That keeps the other checks usable when
8
+ * the optional deps are absent.
9
+ */
10
+ import type { ImageDiffResult } from '../types.js';
11
+ /**
12
+ * Compare two PNG images using pixel-level diff.
13
+ *
14
+ * @returns Diff statistics
15
+ */
16
+ export declare function compareImages(img1Path: string, img2Path: string, diffOutputPath: string): ImageDiffResult;
17
+ /** Format diff percentage as string with 3 decimal places */
18
+ export declare function formatDiffPercent(diffPercent: number): string;
19
+ /** Check if change is significant based on threshold */
20
+ export declare function hasSignificantChange(diffPercent: number, threshold: number): boolean;
21
+ /** Ensure output directory exists */
22
+ export declare function ensureOutputDir(outputDir: string): void;
23
+ /** Save JSON result to file */
24
+ export declare function saveJsonResult(filePath: string, data: unknown): void;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Image comparison utilities using pixelmatch.
3
+ *
4
+ * NOTE: `pixelmatch` and `pngjs` are optional peer/runtime deps (only the
5
+ * auto-play check needs them). This module top-level imports them, so it must
6
+ * only be loaded via a dynamic `import()` from `runAutoPlayDetection` — never
7
+ * statically from the package barrel. That keeps the other checks usable when
8
+ * the optional deps are absent.
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import { PNG } from 'pngjs';
12
+ import pixelmatch from 'pixelmatch';
13
+ import { PIXELMATCH_THRESHOLD } from '../constants.js';
14
+ /**
15
+ * Compare two PNG images using pixel-level diff.
16
+ *
17
+ * @returns Diff statistics
18
+ */
19
+ export function compareImages(img1Path, img2Path, diffOutputPath) {
20
+ const img1 = PNG.sync.read(fs.readFileSync(img1Path));
21
+ const img2 = PNG.sync.read(fs.readFileSync(img2Path));
22
+ const { width, height } = img1;
23
+ const totalPixels = width * height;
24
+ const diff = new PNG({ width, height });
25
+ const diffPixels = pixelmatch(img1.data, img2.data, diff.data, width, height, {
26
+ threshold: PIXELMATCH_THRESHOLD,
27
+ });
28
+ fs.writeFileSync(diffOutputPath, PNG.sync.write(diff));
29
+ const diffPercent = (diffPixels / totalPixels) * 100;
30
+ return { diffPixels, totalPixels, diffPercent };
31
+ }
32
+ /** Format diff percentage as string with 3 decimal places */
33
+ export function formatDiffPercent(diffPercent) {
34
+ return diffPercent.toFixed(3) + '%';
35
+ }
36
+ /** Check if change is significant based on threshold */
37
+ export function hasSignificantChange(diffPercent, threshold) {
38
+ return diffPercent > threshold;
39
+ }
40
+ /** Ensure output directory exists */
41
+ export function ensureOutputDir(outputDir) {
42
+ if (!fs.existsSync(outputDir)) {
43
+ fs.mkdirSync(outputDir, { recursive: true });
44
+ }
45
+ }
46
+ /** Save JSON result to file */
47
+ export function saveJsonResult(filePath, data) {
48
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
49
+ }
@@ -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;
@@ -7,7 +7,23 @@
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 { html: `<${element.tagName.toLowerCase()}>`, htmlTruncated: false };
21
+ }
22
+ if (html.length > htmlSnippetMaxLength) {
23
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
24
+ }
25
+ return { html, htmlTruncated: false };
26
+ }
11
27
  // Helper functions (must be defined inside for browser context)
12
28
  /**
13
29
  * Generate a unique CSS selector for an element using index-based approach
@@ -76,6 +92,7 @@ export function createLayoutChecker(options) {
76
92
  overflowingElements.push({
77
93
  selector,
78
94
  tagName: element.tagName.toLowerCase(),
95
+ ...getHtmlSnippet(element),
79
96
  rect: {
80
97
  left: Math.round(rect.left),
81
98
  right: Math.round(rect.right),
@@ -91,6 +108,7 @@ export function createLayoutChecker(options) {
91
108
  overflowingElements.push({
92
109
  selector,
93
110
  tagName: element.tagName.toLowerCase(),
111
+ ...getHtmlSnippet(element),
94
112
  rect: {
95
113
  left: Math.round(rect.left),
96
114
  right: Math.round(rect.right),
@@ -123,6 +141,7 @@ export function createLayoutChecker(options) {
123
141
  clippedTextElements.push({
124
142
  selector,
125
143
  tagName: element.tagName.toLowerCase(),
144
+ ...getHtmlSnippet(element),
126
145
  scrollWidth,
127
146
  clientWidth,
128
147
  scrollHeight,
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Recommendation generation for auto-play detection.
3
+ */
4
+ import type { PauseControlInfo, PauseVerificationResult } from '../types.js';
5
+ export interface RecommendationContext {
6
+ hasAutoPlayContent: boolean;
7
+ stopsWithin5Seconds: boolean;
8
+ pauseControls: PauseControlInfo;
9
+ pauseVerification: PauseVerificationResult;
10
+ }
11
+ /**
12
+ * Generate a recommendation based on detection results.
13
+ */
14
+ export declare function generateRecommendation(ctx: RecommendationContext): string;
15
+ /**
16
+ * Print summary to console.
17
+ */
18
+ export declare function printSummary(ctx: RecommendationContext, outputDir: string): void;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Recommendation generation for auto-play detection.
3
+ */
4
+ /**
5
+ * Generate a recommendation based on detection results.
6
+ */
7
+ export function generateRecommendation(ctx) {
8
+ const { hasAutoPlayContent, stopsWithin5Seconds, pauseControls, pauseVerification } = ctx;
9
+ if (!hasAutoPlayContent) {
10
+ return 'No auto-playing content detected in viewport.';
11
+ }
12
+ if (stopsWithin5Seconds) {
13
+ return 'Auto-playing content detected but stops within 5 seconds. WCAG 2.2.2 may be satisfied, but verify no user impact.';
14
+ }
15
+ if (pauseControls.found) {
16
+ if (pauseVerification.pauseWorked === true) {
17
+ return pauseControls.hasAccessibleName
18
+ ? 'Auto-playing content detected with working pause control. Verify keyboard accessibility.'
19
+ : 'Auto-playing content detected. Pause control works but lacks accessible name (aria-label). Add accessible labels (WCAG 4.1.2).';
20
+ }
21
+ if (pauseVerification.pauseWorked === false) {
22
+ return 'Auto-playing content detected. Pause control found but does NOT stop the animation. Fix the control or add a working one (WCAG 1.4.2, 2.2.2).';
23
+ }
24
+ // Verification not conclusive
25
+ return pauseControls.hasAccessibleName
26
+ ? 'Auto-playing content detected with pause controls. Manually verify controls work and are keyboard accessible.'
27
+ : 'Auto-playing content detected. Pause control found but lacks accessible name (aria-label). Add accessible labels (WCAG 1.4.2, 2.2.2, 4.1.2).';
28
+ }
29
+ return 'Auto-playing content detected and continues beyond 5 seconds. No pause/stop controls found. Add controls (WCAG 1.4.2, 2.2.2).';
30
+ }
31
+ /**
32
+ * Print summary to console.
33
+ */
34
+ export function printSummary(ctx, outputDir) {
35
+ const { hasAutoPlayContent, stopsWithin5Seconds, pauseControls, pauseVerification } = ctx;
36
+ console.log('\n--- Summary ---');
37
+ if (!hasAutoPlayContent) {
38
+ console.log('✓ No auto-playing content detected in viewport');
39
+ return;
40
+ }
41
+ console.log('⚠ Auto-playing content detected!');
42
+ console.log(` Stops within 5 seconds: ${stopsWithin5Seconds ? 'Yes' : 'No'}`);
43
+ console.log(` Screenshots saved to: ${outputDir}`);
44
+ console.log(' Diff images generated for visual verification');
45
+ // Pause control detection
46
+ console.log('\n--- Pause Control Detection ---');
47
+ if (pauseControls.found) {
48
+ console.log(`✓ Pause controls found: ${pauseControls.controls.length}`);
49
+ pauseControls.controls.forEach((ctrl, i) => {
50
+ console.log(` ${i + 1}. <${ctrl.element}> "${ctrl.name}" (matched by: ${ctrl.matchedBy})`);
51
+ });
52
+ if (!pauseControls.hasAccessibleName) {
53
+ console.log('⚠ Warning: Pause control lacks accessible name (aria-label)');
54
+ }
55
+ }
56
+ else {
57
+ console.log('✗ No pause controls detected');
58
+ }
59
+ // Pause verification results
60
+ if (pauseVerification.attempted) {
61
+ console.log('\n--- Pause Control Verification ---');
62
+ console.log(` Control clicked: ${pauseVerification.controlClicked}`);
63
+ console.log(` Change before click: ${pauseVerification.beforeClickDiffPercent}`);
64
+ console.log(` Change after click: ${pauseVerification.afterClickDiffPercent}`);
65
+ if (pauseVerification.pauseWorked === true) {
66
+ console.log('✓ Pause control WORKS - animation stopped after clicking');
67
+ }
68
+ else if (pauseVerification.pauseWorked === false) {
69
+ console.log('✗ Pause control DOES NOT WORK - animation continues after clicking');
70
+ }
71
+ else if (pauseVerification.error) {
72
+ console.log(`⚠ Verification error: ${pauseVerification.error}`);
73
+ }
74
+ }
75
+ if (pauseControls.carouselIndicators.length > 0) {
76
+ console.log(`\nCarousel navigation controls found: ${pauseControls.carouselIndicators.length}`);
77
+ }
78
+ // Manual verification checklist
79
+ console.log('\nManual verification required:');
80
+ console.log(' - Verify pause/stop controls are keyboard accessible');
81
+ console.log(' - Check for audio auto-play (requires manual listening)');
82
+ if (!pauseControls.hasAccessibleName && pauseControls.found) {
83
+ console.log(' - Add aria-label to pause control buttons');
84
+ }
85
+ if (pauseVerification.pauseWorked === false) {
86
+ console.log(' - Fix the pause control to actually stop the animation');
87
+ }
88
+ }