@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.
- package/CHANGELOG.md +65 -0
- package/README.ja.md +76 -6
- package/README.md +78 -6
- package/dist/constants.d.ts +84 -0
- package/dist/constants.js +228 -0
- package/dist/detectors/index.d.ts +1 -0
- package/dist/detectors/index.js +1 -0
- package/dist/detectors/pause-control.d.ts +18 -0
- package/dist/detectors/pause-control.js +206 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/playwright/index.d.ts +7 -1
- package/dist/playwright/index.js +7 -1
- package/dist/playwright/runAutoPlayDetection.d.ts +36 -0
- package/dist/playwright/runAutoPlayDetection.js +143 -0
- package/dist/playwright/runAutocompleteAudit.d.ts +27 -0
- package/dist/playwright/runAutocompleteAudit.js +227 -0
- package/dist/playwright/runAxeAudit.d.ts +4 -0
- package/dist/playwright/runAxeAudit.js +26 -30
- package/dist/playwright/runFocusIndicatorCheck.js +55 -12
- package/dist/playwright/runOrientationCheck.d.ts +40 -0
- package/dist/playwright/runOrientationCheck.js +170 -0
- package/dist/playwright/runReflowCheck.js +18 -11
- package/dist/playwright/runTargetSizeCheck.js +42 -10
- package/dist/playwright/runTextSpacingCheck.d.ts +25 -0
- package/dist/playwright/runTextSpacingCheck.js +285 -0
- package/dist/playwright/runTimeLimitDetector.d.ts +31 -0
- package/dist/playwright/runTimeLimitDetector.js +219 -0
- package/dist/playwright/runZoomCheck.d.ts +42 -0
- package/dist/playwright/runZoomCheck.js +174 -0
- package/dist/schemas/index.d.ts +20 -1
- package/dist/schemas/index.js +404 -186
- package/dist/test-entries/auto-play-detection.d.ts +7 -0
- package/dist/test-entries/auto-play-detection.js +13 -0
- package/dist/test-entries/autocomplete-audit.d.ts +5 -0
- package/dist/test-entries/autocomplete-audit.js +11 -0
- package/dist/test-entries/orientation-check.d.ts +8 -0
- package/dist/test-entries/orientation-check.js +12 -0
- package/dist/test-entries/text-spacing-check.d.ts +5 -0
- package/dist/test-entries/text-spacing-check.js +11 -0
- package/dist/test-entries/time-limit-detector.d.ts +8 -0
- package/dist/test-entries/time-limit-detector.js +12 -0
- package/dist/test-entries/zoom-200-check.d.ts +5 -0
- package/dist/test-entries/zoom-200-check.js +11 -0
- package/dist/types.d.ts +275 -40
- package/dist/types.js +9 -0
- package/dist/utils/axe-format.d.ts +88 -0
- package/dist/utils/axe-format.js +361 -0
- package/dist/utils/image-compare.d.ts +24 -0
- package/dist/utils/image-compare.js +49 -0
- package/dist/utils/layout.d.ts +2 -0
- package/dist/utils/layout.js +20 -1
- package/dist/utils/recommendations.d.ts +18 -0
- package/dist/utils/recommendations.js +88 -0
- package/dist/utils/rule-registry.d.ts +216 -0
- package/dist/utils/rule-registry.js +220 -0
- package/dist/utils/test-harness.d.ts +8 -2
- package/dist/utils/test-harness.js +13 -6
- 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 {
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
53
|
-
'Incomplete (needs review)': result.
|
|
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
|
|
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.
|
|
270
|
-
console.log(`Total focusable elements: ${
|
|
271
|
-
console.log(`Elements with focus style: ${
|
|
272
|
-
console.log(`Elements WITHOUT focus style: ${
|
|
273
|
-
console.log(`Elements with OBSCURED focus: ${
|
|
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);
|