@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,143 @@
1
+ /**
2
+ * Auto-play Content Detection — WCAG 1.4.2 (Audio Control) / 2.2.2 (Pause, Stop, Hide)
3
+ *
4
+ * Takes screenshots at 2s intervals (0/2/4/6s), pixel-diffs consecutive frames,
5
+ * detects whether visual content continues past 5s, finds pause/stop controls,
6
+ * and verifies whether they work.
7
+ *
8
+ * The caller is responsible for navigating the page before calling this.
9
+ *
10
+ * IMPORTANT: this is the only check that needs `pixelmatch` + `pngjs` (declared
11
+ * as optionalDependencies). To keep them out of the package barrel, the modules
12
+ * that pull them (`utils/image-compare`, `detectors`) are imported LAZILY here,
13
+ * so importing `@a11y-skills/audit/playwright` never requires the optional deps
14
+ * unless `runAutoPlayDetection` is actually called.
15
+ */
16
+ import * as path from 'node:path';
17
+ import { SCREENSHOT_INTERVALS, CHANGE_THRESHOLD, DEFAULT_AUTO_PLAY_OUTPUT_DIR, DETECTION_RESULT_FILENAME, } from '../constants.js';
18
+ import { buildAuditResult, normalizeAutoPlayDetection, } from '../utils/axe-format.js';
19
+ import { generateRecommendation, printSummary } from '../utils/recommendations.js';
20
+ /** Capture screenshots at configured intervals. */
21
+ async function captureScreenshots(page, outputDir) {
22
+ const screenshots = [];
23
+ for (let i = 0; i < SCREENSHOT_INTERVALS.length; i++) {
24
+ const current = SCREENSHOT_INTERVALS[i] ?? 0;
25
+ if (i > 0) {
26
+ const previous = SCREENSHOT_INTERVALS[i - 1] ?? 0;
27
+ await page.waitForTimeout(current - previous);
28
+ }
29
+ const timeLabel = `${current / 1000}s`;
30
+ const filename = `screenshot-${timeLabel}.png`;
31
+ const filepath = path.join(outputDir, filename);
32
+ await page.screenshot({ path: filepath, fullPage: false });
33
+ screenshots.push({ time: timeLabel, path: filepath });
34
+ }
35
+ return screenshots;
36
+ }
37
+ /**
38
+ * Run auto-play detection against the current page. Writes screenshots, diff
39
+ * images, and `detection-result.json` into the output directory; returns the
40
+ * result.
41
+ *
42
+ * @throws if the optional `pixelmatch` / `pngjs` deps are not installed.
43
+ */
44
+ export async function runAutoPlayDetection(options) {
45
+ const { page, changeThreshold = CHANGE_THRESHOLD } = options;
46
+ const outputDir = options.outputDir ??
47
+ path.join(process.env.A11Y_OUTPUT_DIR ?? process.cwd(), DEFAULT_AUTO_PLAY_OUTPUT_DIR);
48
+ // Lazy-load the modules that depend on the optional pixelmatch/pngjs deps.
49
+ let imageCompare;
50
+ let detectors;
51
+ try {
52
+ imageCompare = await import('../utils/image-compare.js');
53
+ detectors = await import('../detectors/index.js');
54
+ }
55
+ catch (err) {
56
+ const msg = err instanceof Error ? err.message : String(err);
57
+ const code = err.code;
58
+ // Only translate a genuine missing-optional-dep error; re-throw anything
59
+ // else (e.g. a real bug inside image-compare/detectors) unchanged.
60
+ if (code === 'ERR_MODULE_NOT_FOUND' || /pixelmatch|pngjs/.test(msg)) {
61
+ throw new Error('runAutoPlayDetection requires the optional dependencies `pixelmatch` and `pngjs`. ' +
62
+ 'Install them: `npm install pixelmatch pngjs`.\n' +
63
+ `Original error: ${msg}`);
64
+ }
65
+ throw err;
66
+ }
67
+ const { compareImages, formatDiffPercent, hasSignificantChange, ensureOutputDir, saveJsonResult, } = imageCompare;
68
+ const { detectPauseControls, verifyPauseControl, createSkippedVerification } = detectors;
69
+ ensureOutputDir(outputDir);
70
+ // Take screenshots at intervals.
71
+ const screenshots = await captureScreenshots(page, outputDir);
72
+ // Compare consecutive screenshots.
73
+ const comparisons = [];
74
+ let hasAnyChange = false;
75
+ let hasChangeAfter5s = false;
76
+ for (let i = 1; i < screenshots.length; i++) {
77
+ const prev = screenshots[i - 1];
78
+ const curr = screenshots[i];
79
+ if (!prev || !curr)
80
+ continue;
81
+ const diffPath = path.join(outputDir, `diff-${prev.time}-vs-${curr.time}.png`);
82
+ const { diffPixels, totalPixels, diffPercent } = compareImages(prev.path, curr.path, diffPath);
83
+ const hasChange = hasSignificantChange(diffPercent, changeThreshold);
84
+ if (hasChange) {
85
+ hasAnyChange = true;
86
+ if ((SCREENSHOT_INTERVALS[i] ?? 0) > 5000) {
87
+ hasChangeAfter5s = true;
88
+ }
89
+ }
90
+ comparisons.push({
91
+ compare: `${prev.time} vs ${curr.time}`,
92
+ diffPixels,
93
+ totalPixels,
94
+ diffPercent: formatDiffPercent(diffPercent),
95
+ hasChange,
96
+ });
97
+ }
98
+ const stopsWithin5Seconds = hasAnyChange && !hasChangeAfter5s;
99
+ // Detect and verify pause controls.
100
+ const pauseControls = await detectPauseControls(page);
101
+ let pauseVerification;
102
+ if (hasAnyChange && !stopsWithin5Seconds && pauseControls.found) {
103
+ pauseVerification = await verifyPauseControl(page, pauseControls, outputDir, changeThreshold);
104
+ }
105
+ else {
106
+ let reason;
107
+ if (!hasAnyChange) {
108
+ reason = 'No auto-play detected';
109
+ }
110
+ else if (stopsWithin5Seconds) {
111
+ reason = 'Content stops within 5 seconds';
112
+ }
113
+ else {
114
+ reason = 'No pause controls found';
115
+ }
116
+ pauseVerification = createSkippedVerification(reason);
117
+ }
118
+ const details = {
119
+ screenshotRecords: screenshots,
120
+ comparisons,
121
+ hasAutoPlayContent: hasAnyChange,
122
+ stopsWithin5Seconds,
123
+ pauseControls,
124
+ pauseVerification,
125
+ recommendation: generateRecommendation({
126
+ hasAutoPlayContent: hasAnyChange,
127
+ stopsWithin5Seconds,
128
+ pauseControls,
129
+ pauseVerification,
130
+ }),
131
+ };
132
+ const result = buildAuditResult({
133
+ source: 'auto-play-detection',
134
+ url: page.url(),
135
+ details,
136
+ buckets: normalizeAutoPlayDetection(details),
137
+ });
138
+ console.log('\n=== Auto-play Detection Results ===\n');
139
+ console.log(JSON.stringify(result, null, 2));
140
+ saveJsonResult(path.join(outputDir, DETECTION_RESULT_FILENAME), result);
141
+ printSummary({ hasAutoPlayContent: hasAnyChange, stopsWithin5Seconds, pauseControls, pauseVerification }, outputDir);
142
+ return result;
143
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Autocomplete Audit — WCAG 1.3.5 (Identify Input Purpose)
3
+ *
4
+ * Finds all form fields (input/select/textarea), uses Playwright's
5
+ * `ariaSnapshot()` to compute accessible names (following the ARIA naming
6
+ * algorithm), matches field names/ids/labels/placeholders to expected
7
+ * autocomplete tokens, and reports fields that are missing or have invalid
8
+ * autocomplete values.
9
+ *
10
+ * The caller is responsible for navigating the page before calling this.
11
+ *
12
+ * Limitations:
13
+ * - Cannot confirm actual field purpose; pattern matching is heuristic
14
+ * - Manual verification needed for edge cases
15
+ */
16
+ import type { Page } from '@playwright/test';
17
+ import type { AutocompleteAuditResult } from '../types.js';
18
+ import { type OutputLocationOptions } from '../utils/test-harness.js';
19
+ export interface RunAutocompleteAuditOptions extends OutputLocationOptions {
20
+ /** A page already navigated to the target URL. */
21
+ page: Page;
22
+ }
23
+ /**
24
+ * Run the autocomplete audit against the current page, write the result JSON,
25
+ * and return the parsed result.
26
+ */
27
+ export declare function runAutocompleteAudit(options: RunAutocompleteAuditOptions): Promise<AutocompleteAuditResult>;
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Autocomplete Audit — WCAG 1.3.5 (Identify Input Purpose)
3
+ *
4
+ * Finds all form fields (input/select/textarea), uses Playwright's
5
+ * `ariaSnapshot()` to compute accessible names (following the ARIA naming
6
+ * algorithm), matches field names/ids/labels/placeholders to expected
7
+ * autocomplete tokens, and reports fields that are missing or have invalid
8
+ * autocomplete values.
9
+ *
10
+ * The caller is responsible for navigating the page before calling this.
11
+ *
12
+ * Limitations:
13
+ * - Cannot confirm actual field purpose; pattern matching is heuristic
14
+ * - Manual verification needed for edge cases
15
+ */
16
+ import { AUTOCOMPLETE_FIELD_PATTERNS, VALID_AUTOCOMPLETE_TOKENS, DEFAULT_AUTOCOMPLETE_RESULT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
17
+ import { buildAuditResult, normalizeAutocompleteAudit, } from '../utils/axe-format.js';
18
+ import { saveAuditResult, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
19
+ /**
20
+ * Collect basic form field information in browser context.
21
+ * Accessible names are retrieved separately via ariaSnapshot().
22
+ */
23
+ function collectBasicFieldInfo(args) {
24
+ const { htmlSnippetMaxLength } = args;
25
+ function getHtmlSnippet(element) {
26
+ let html = '';
27
+ try {
28
+ html = element.outerHTML || '';
29
+ }
30
+ catch {
31
+ html = '';
32
+ }
33
+ if (!html) {
34
+ return { html: `<${element.tagName.toLowerCase()}>`, htmlTruncated: false };
35
+ }
36
+ if (html.length > htmlSnippetMaxLength) {
37
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
38
+ }
39
+ return { html, htmlTruncated: false };
40
+ }
41
+ function getUniqueSelector(element, elementIndex) {
42
+ if (element.id) {
43
+ return `#${element.id}`;
44
+ }
45
+ const path = [];
46
+ let current = element;
47
+ while (current && current !== document.body) {
48
+ let selector = current.tagName.toLowerCase();
49
+ const parent = current.parentElement;
50
+ if (parent) {
51
+ const childIndex = Array.from(parent.children).indexOf(current) + 1;
52
+ selector += `:nth-child(${childIndex})`;
53
+ }
54
+ path.unshift(selector);
55
+ current = parent;
56
+ }
57
+ return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
58
+ }
59
+ const skipTypes = ['hidden', 'submit', 'reset', 'button', 'image', 'file'];
60
+ const fields = [];
61
+ const elements = document.querySelectorAll('input, select, textarea');
62
+ elements.forEach((element, index) => {
63
+ const el = element;
64
+ if (el instanceof HTMLInputElement && skipTypes.includes(el.type)) {
65
+ return;
66
+ }
67
+ const inputType = el instanceof HTMLInputElement ? el.type : el.tagName.toLowerCase();
68
+ const autocompleteAttr = el.getAttribute('autocomplete');
69
+ fields.push({
70
+ selector: getUniqueSelector(element, index),
71
+ tagName: el.tagName.toLowerCase(),
72
+ ...getHtmlSnippet(element),
73
+ inputType,
74
+ name: el.name || null,
75
+ id: el.id || null,
76
+ placeholder: el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
77
+ ? el.placeholder || null
78
+ : null,
79
+ autocomplete: autocompleteAttr,
80
+ });
81
+ });
82
+ return fields;
83
+ }
84
+ /**
85
+ * Extract accessible name from ariaSnapshot output.
86
+ * ariaSnapshot returns YAML-like format: "- role \"accessible name\"".
87
+ */
88
+ function parseAccessibleName(snapshot) {
89
+ // ariaSnapshot format: "- textbox \"Email address\"" or "- textbox \"Email address\" [focused]"
90
+ const match = snapshot.match(/^- \w+(?:\s+"([^"]*)")?/);
91
+ if (match && match[1]) {
92
+ return match[1];
93
+ }
94
+ return null;
95
+ }
96
+ /** Find pattern match for a field across name, id, label, and placeholder. */
97
+ function findPatternMatch(field, patterns) {
98
+ for (const [token, pattern] of patterns) {
99
+ if (field.name && pattern.test(field.name)) {
100
+ return { token, matchedBy: 'name' };
101
+ }
102
+ if (field.id && pattern.test(field.id)) {
103
+ return { token, matchedBy: 'id' };
104
+ }
105
+ if (field.labelText && pattern.test(field.labelText)) {
106
+ return { token, matchedBy: 'label' };
107
+ }
108
+ if (field.placeholder && pattern.test(field.placeholder)) {
109
+ return { token, matchedBy: 'placeholder' };
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+ /** Analyze fields for autocomplete issues. */
115
+ function analyzeFields(fields, patterns, validTokens) {
116
+ const missing = [];
117
+ const invalid = [];
118
+ for (const field of fields) {
119
+ const match = findPatternMatch(field, patterns);
120
+ if (!match) {
121
+ continue;
122
+ }
123
+ const { token: expectedToken, matchedBy } = match;
124
+ if (!field.autocomplete || field.autocomplete === 'off') {
125
+ missing.push({
126
+ selector: field.selector,
127
+ tagName: field.tagName,
128
+ html: field.html,
129
+ htmlTruncated: field.htmlTruncated,
130
+ inputType: field.inputType,
131
+ name: field.name,
132
+ id: field.id,
133
+ labelText: field.labelText,
134
+ currentAutocomplete: field.autocomplete,
135
+ expectedToken,
136
+ matchedBy,
137
+ issueType: 'missing',
138
+ });
139
+ continue;
140
+ }
141
+ const autocompleteTokens = field.autocomplete.toLowerCase().split(/\s+/);
142
+ const mainToken = autocompleteTokens[autocompleteTokens.length - 1];
143
+ if (mainToken === undefined ||
144
+ !validTokens.includes(mainToken)) {
145
+ invalid.push({
146
+ selector: field.selector,
147
+ tagName: field.tagName,
148
+ html: field.html,
149
+ htmlTruncated: field.htmlTruncated,
150
+ inputType: field.inputType,
151
+ name: field.name,
152
+ id: field.id,
153
+ labelText: field.labelText,
154
+ currentAutocomplete: field.autocomplete,
155
+ expectedToken,
156
+ matchedBy,
157
+ issueType: 'invalid',
158
+ });
159
+ }
160
+ }
161
+ return { missing, invalid };
162
+ }
163
+ /**
164
+ * Run the autocomplete audit against the current page, write the result JSON,
165
+ * and return the parsed result.
166
+ */
167
+ export async function runAutocompleteAudit(options) {
168
+ const { page, ...location } = options;
169
+ // Collect basic field info from DOM
170
+ const basicFields = await page.evaluate(collectBasicFieldInfo, {
171
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
172
+ });
173
+ // Enhance with accessible names via ariaSnapshot()
174
+ const fields = [];
175
+ for (const basicField of basicFields) {
176
+ const locator = page.locator(basicField.selector);
177
+ let labelText = null;
178
+ try {
179
+ const snapshot = await locator.ariaSnapshot();
180
+ labelText = parseAccessibleName(snapshot);
181
+ }
182
+ catch {
183
+ // If ariaSnapshot fails, labelText remains null
184
+ }
185
+ fields.push({
186
+ ...basicField,
187
+ labelText,
188
+ });
189
+ }
190
+ const patterns = Object.entries(AUTOCOMPLETE_FIELD_PATTERNS);
191
+ const { missing, invalid } = analyzeFields(fields, patterns, VALID_AUTOCOMPLETE_TOKENS);
192
+ const details = {
193
+ totalFieldsChecked: fields.length,
194
+ missingAutocomplete: missing,
195
+ invalidAutocomplete: invalid,
196
+ };
197
+ const result = buildAuditResult({
198
+ source: 'autocomplete-audit',
199
+ url: page.url(),
200
+ details,
201
+ buckets: normalizeAutocompleteAudit(details),
202
+ });
203
+ // Output results
204
+ logAuditHeader('Autocomplete Audit Results', 'WCAG 1.3.5', result.url);
205
+ logSummary({
206
+ 'Total form fields': details.totalFieldsChecked,
207
+ 'Fields missing autocomplete': details.missingAutocomplete.length,
208
+ 'Fields with invalid autocomplete': details.invalidAutocomplete.length,
209
+ });
210
+ logIssueList('Missing Autocomplete', details.missingAutocomplete, (el, i) => [
211
+ `${i + 1}. <${el.tagName}> "${el.selector}"`,
212
+ ` name: ${el.name || 'none'}, id: ${el.id || 'none'}`,
213
+ ` label: "${el.labelText || 'none'}"`,
214
+ ` Expected: autocomplete="${el.expectedToken}" (matched by ${el.matchedBy})`,
215
+ ]);
216
+ logIssueList('Invalid Autocomplete', details.invalidAutocomplete, (el, i) => [
217
+ `${i + 1}. <${el.tagName}> "${el.selector}"`,
218
+ ` Current: autocomplete="${el.currentAutocomplete}"`,
219
+ ` Expected: autocomplete="${el.expectedToken}"`,
220
+ ]);
221
+ const resolvedPath = saveAuditResult(result, {
222
+ ...location,
223
+ defaultFile: DEFAULT_AUTOCOMPLETE_RESULT_FILE,
224
+ });
225
+ logOutputPaths(resolvedPath);
226
+ return result;
227
+ }
@@ -5,6 +5,10 @@
5
5
  * caller is responsible for navigating the page (e.g. `await page.goto(url)`)
6
6
  * before calling this function.
7
7
  *
8
+ * The normalized buckets are built from the RAW axe results (violations and
9
+ * incomplete keep their nodes; passes/inapplicable keep rule metadata only),
10
+ * and `details` records the execution configuration.
11
+ *
8
12
  * Axe-core cannot detect all accessibility issues — manual testing and the
9
13
  * other checks in this package are still needed for complete coverage.
10
14
  */
@@ -5,11 +5,16 @@
5
5
  * caller is responsible for navigating the page (e.g. `await page.goto(url)`)
6
6
  * before calling this function.
7
7
  *
8
+ * The normalized buckets are built from the RAW axe results (violations and
9
+ * incomplete keep their nodes; passes/inapplicable keep rule metadata only),
10
+ * and `details` records the execution configuration.
11
+ *
8
12
  * Axe-core cannot detect all accessibility issues — manual testing and the
9
13
  * other checks in this package are still needed for complete coverage.
10
14
  */
11
15
  import { AxeBuilder } from '@axe-core/playwright';
12
- import { AUDIT_DISCLAIMER, DEFAULT_AXE_TAGS, DEFAULT_AXE_RESULT_FILE, } from '../constants.js';
16
+ import { DEFAULT_AXE_TAGS, DEFAULT_AXE_RESULT_FILE } from '../constants.js';
17
+ import { buildAuditResult, normalizeAxeResults } from '../utils/axe-format.js';
13
18
  import { saveAuditResult, logAuditHeader, logSummary, logOutputPaths, } from '../utils/test-harness.js';
14
19
  /**
15
20
  * Run an axe-core audit against the current page, write the result JSON, and
@@ -22,36 +27,29 @@ export async function runAxeAudit(options) {
22
27
  builder = builder.options({ rules });
23
28
  }
24
29
  const axeResults = await builder.analyze();
25
- const result = {
26
- url: page.url(),
27
- timestamp: new Date().toISOString(),
28
- violations: axeResults.violations.map((v) => ({
29
- id: v.id,
30
- impact: v.impact ?? null,
31
- description: v.description,
32
- help: v.help,
33
- helpUrl: v.helpUrl,
34
- tags: v.tags,
35
- nodes: v.nodes.map((n) => ({
36
- html: n.html,
37
- target: n.target,
38
- failureSummary: n.failureSummary,
39
- })),
40
- })),
41
- passes: axeResults.passes.length,
42
- incomplete: axeResults.incomplete.length,
43
- inapplicable: axeResults.inapplicable.length,
44
- violationCount: axeResults.violations.length,
45
- disclaimer: AUDIT_DISCLAIMER,
30
+ const buckets = normalizeAxeResults(axeResults);
31
+ const details = {
32
+ tagsRun: [...tags],
33
+ rulesOverride: rules ?? null,
34
+ violationRuleCount: axeResults.violations.length,
35
+ passRuleCount: axeResults.passes.length,
36
+ incompleteRuleCount: axeResults.incomplete.length,
37
+ inapplicableRuleCount: axeResults.inapplicable.length,
46
38
  };
39
+ const result = buildAuditResult({
40
+ source: 'axe-audit',
41
+ url: page.url(),
42
+ details,
43
+ buckets,
44
+ });
47
45
  // Output results
48
46
  logAuditHeader('Axe-core Accessibility Audit Results', 'axe-core', result.url);
49
47
  logSummary({
50
48
  Timestamp: result.timestamp,
51
- Violations: result.violationCount,
52
- Passes: result.passes,
53
- 'Incomplete (needs review)': result.incomplete,
54
- Inapplicable: result.inapplicable,
49
+ Violations: result.summary.violationCount,
50
+ Passes: result.summary.passCount,
51
+ 'Incomplete (needs review)': result.summary.incompleteCount,
52
+ Inapplicable: result.inapplicable.length,
55
53
  });
56
54
  if (result.violations.length > 0) {
57
55
  console.log('\n--- Violations ---');
@@ -71,18 +69,16 @@ export async function runAxeAudit(options) {
71
69
  });
72
70
  }
73
71
  console.log(`\n--- Summary ---`);
74
- if (result.violationCount === 0) {
72
+ if (result.summary.violationCount === 0) {
75
73
  console.log('No violations detected by axe-core');
76
74
  }
77
75
  else {
78
76
  const totalElements = result.violations.reduce((sum, v) => sum + v.nodes.length, 0);
79
- console.log(`Found ${result.violationCount} violation type(s) affecting ${totalElements} element(s)`);
77
+ console.log(`Found ${result.summary.violationCount} violation type(s) affecting ${totalElements} element(s)`);
80
78
  }
81
- // axe results already carry the disclaimer field; don't append it again.
82
79
  const resolvedPath = saveAuditResult(result, {
83
80
  ...location,
84
81
  defaultFile: DEFAULT_AXE_RESULT_FILE,
85
- includeDisclaimer: false,
86
82
  });
87
83
  logOutputPaths(resolvedPath);
88
84
  return result;
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * The context this function creates is closed before it returns.
12
12
  */
13
- import { FOCUSABLE_SELECTOR, FOCUS_STYLE_PROPERTIES, EXTRA_TAB_ITERATIONS, DEFAULT_FOCUS_RESULT_FILE, DEFAULT_FOCUS_SCREENSHOT_FILE, FOCUS_OBSCURED_MIN_OVERLAP_RATIO, FOCUS_OBSCURED_MIN_OVERLAP_PX, FOCUS_OBSCURED_EXCLUDE_SELECTORS, } from '../constants.js';
13
+ import { FOCUSABLE_SELECTOR, FOCUS_STYLE_PROPERTIES, EXTRA_TAB_ITERATIONS, DEFAULT_FOCUS_RESULT_FILE, DEFAULT_FOCUS_SCREENSHOT_FILE, FOCUS_OBSCURED_MIN_OVERLAP_RATIO, FOCUS_OBSCURED_MIN_OVERLAP_PX, FOCUS_OBSCURED_EXCLUDE_SELECTORS, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
14
+ import { buildAuditResult, normalizeFocusCheck } from '../utils/axe-format.js';
14
15
  import { resolveOutputPath, resolveScreenshotPath, saveAuditResult, takeAuditScreenshot, requireTargetUrl, logAuditHeader, logOutputPaths, } from '../utils/test-harness.js';
15
16
  // =============================================================================
16
17
  // Browser-injected styles for marking elements
@@ -106,6 +107,8 @@ export async function runFocusIndicatorCheck(options) {
106
107
  name: data.name,
107
108
  selector: data.selector ||
108
109
  `${data.tag.toLowerCase()}:nth-of-type(${data.id + 1})`,
110
+ html: data.html,
111
+ htmlTruncated: data.htmlTruncated,
109
112
  };
110
113
  });
111
114
  // Expose function to receive focus obscured reports (WCAG 2.4.12)
@@ -118,6 +121,7 @@ export async function runFocusIndicatorCheck(options) {
118
121
  styleProperties: [...FOCUS_STYLE_PROPERTIES],
119
122
  warningStyles: WARNING_STYLES,
120
123
  skipSelectors: [...skipSelectors],
124
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
121
125
  obscuredConfig: {
122
126
  minOverlapRatio: FOCUS_OBSCURED_MIN_OVERLAP_RATIO,
123
127
  minOverlapPx: FOCUS_OBSCURED_MIN_OVERLAP_PX,
@@ -163,7 +167,7 @@ export async function runFocusIndicatorCheck(options) {
163
167
  // Get currently focused element IMMEDIATELY after Tab
164
168
  let currentFocusedElement = null;
165
169
  try {
166
- currentFocusedElement = await page.evaluate(() => {
170
+ currentFocusedElement = await page.evaluate((htmlSnippetMaxLength) => {
167
171
  const el = document.activeElement;
168
172
  if (!el || el === document.body)
169
173
  return null;
@@ -180,6 +184,17 @@ export async function runFocusIndicatorCheck(options) {
180
184
  const index = siblings.indexOf(element) + 1;
181
185
  return `${getSelector(parent)} > ${tag}:nth-of-type(${index})`;
182
186
  };
187
+ let html = '';
188
+ try {
189
+ html = el.outerHTML || '';
190
+ }
191
+ catch {
192
+ html = '';
193
+ }
194
+ if (!html) {
195
+ html = `<${el.tagName.toLowerCase()}>`;
196
+ }
197
+ const htmlTruncated = html.length > htmlSnippetMaxLength;
183
198
  return {
184
199
  tag: el.tagName,
185
200
  role: el.getAttribute('role'),
@@ -187,8 +202,10 @@ export async function runFocusIndicatorCheck(options) {
187
202
  el.textContent?.slice(0, 30) ||
188
203
  '',
189
204
  selector: getSelector(el),
205
+ html: htmlTruncated ? html.slice(0, htmlSnippetMaxLength) : html,
206
+ htmlTruncated,
190
207
  };
191
- });
208
+ }, HTML_SNIPPET_MAX_LENGTH);
192
209
  }
193
210
  catch {
194
211
  // Page might have navigated, use lastFocusedElement as fallback
@@ -248,8 +265,7 @@ export async function runFocusIndicatorCheck(options) {
248
265
  console.warn('Elements without visible focus indicator:', elementsWithoutFocusStyle);
249
266
  }
250
267
  // Build result
251
- const result = {
252
- url: finalPage.url(),
268
+ const details = {
253
269
  totalFocusableElements: finalFocusHistory.length,
254
270
  elementsWithFocusStyle: finalFocusHistory.length - elementsWithoutFocusStyle.length,
255
271
  elementsWithoutFocusStyle: elementsWithoutFocusStyle.length,
@@ -257,6 +273,9 @@ export async function runFocusIndicatorCheck(options) {
257
273
  tag: el.tag,
258
274
  role: el.role,
259
275
  name: el.name,
276
+ selector: el.selector,
277
+ html: el.html,
278
+ htmlTruncated: el.htmlTruncated,
260
279
  })),
261
280
  onFocusViolations,
262
281
  focusObscuredIssues,
@@ -265,12 +284,18 @@ export async function runFocusIndicatorCheck(options) {
265
284
  interrupted: false,
266
285
  screenshotPath: screenshot ? resolvedScreenshotPath : '',
267
286
  };
287
+ const result = buildAuditResult({
288
+ source: 'focus-indicator-check',
289
+ url: finalPage.url(),
290
+ details,
291
+ buckets: normalizeFocusCheck(details),
292
+ });
268
293
  // Output results
269
- logAuditHeader('Focus Indicator Check Results', 'WCAG 2.4.7 / 2.4.12 / 3.2.1', result.url);
270
- console.log(`Total focusable elements: ${result.totalFocusableElements}`);
271
- console.log(`Elements with focus style: ${result.elementsWithFocusStyle}`);
272
- console.log(`Elements WITHOUT focus style: ${result.elementsWithoutFocusStyle}`);
273
- console.log(`Elements with OBSCURED focus: ${result.elementsWithObscuredFocus}`);
294
+ logAuditHeader('Focus Indicator Check Results', 'WCAG 2.4.7 / 2.4.11 / 3.2.1', result.url);
295
+ console.log(`Total focusable elements: ${details.totalFocusableElements}`);
296
+ console.log(`Elements with focus style: ${details.elementsWithFocusStyle}`);
297
+ console.log(`Elements WITHOUT focus style: ${details.elementsWithoutFocusStyle}`);
298
+ console.log(`Elements with OBSCURED focus: ${details.elementsWithObscuredFocus}`);
274
299
  if (retryCount > 0) {
275
300
  console.log(`\nTest restarted ${retryCount} time(s) due to navigation violations`);
276
301
  }
@@ -328,7 +353,23 @@ export async function runFocusIndicatorCheck(options) {
328
353
  // Browser-injected script factory
329
354
  // =============================================================================
330
355
  function createFocusTrackerScript(args) {
331
- const { focusableSelector, styleProperties, warningStyles, skipSelectors, obscuredConfig } = args;
356
+ const { focusableSelector, styleProperties, warningStyles, skipSelectors, htmlSnippetMaxLength, obscuredConfig, } = args;
357
+ const getHtmlSnippet = (el) => {
358
+ let html = '';
359
+ try {
360
+ html = el.outerHTML || '';
361
+ }
362
+ catch {
363
+ html = '';
364
+ }
365
+ if (!html) {
366
+ return { html: `<${el.tagName.toLowerCase()}>`, htmlTruncated: false };
367
+ }
368
+ if (html.length > htmlSnippetMaxLength) {
369
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
370
+ }
371
+ return { html, htmlTruncated: false };
372
+ };
332
373
  // Add warning styles
333
374
  const styleSheet = new CSSStyleSheet();
334
375
  document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet];
@@ -658,6 +699,7 @@ function createFocusTrackerScript(args) {
658
699
  focusedEl.textContent?.slice(0, 30) ||
659
700
  '',
660
701
  selector: getSelector(focusedEl),
702
+ ...getHtmlSnippet(focusedEl),
661
703
  },
662
704
  elementRect: {
663
705
  left: focusedRect.left,
@@ -729,10 +771,11 @@ function createFocusTrackerScript(args) {
729
771
  id,
730
772
  tag: el.tagName,
731
773
  role: el.getAttribute('role'),
732
- name: el.getAttribute('aria-label') || el.textContent?.slice(0, 30),
774
+ name: el.getAttribute('aria-label') || el.textContent?.slice(0, 30) || '',
733
775
  hasFocusStyle,
734
776
  diff,
735
777
  selector: elementSelectors.get(el) || getSelector(el),
778
+ ...getHtmlSnippet(el),
736
779
  });
737
780
  // Check for WCAG 2.4.12 - focus obscured by fixed/sticky elements
738
781
  checkFocusObscured(el);