@a11y-skills/audit 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.ja.md +52 -4
- package/README.md +53 -4
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +104 -21
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.js +1 -1
- package/dist/playwright/runAutoPlayDetection.js +15 -4
- package/dist/playwright/runAutocompleteAudit.js +46 -11
- package/dist/playwright/runAxeAudit.d.ts +4 -0
- package/dist/playwright/runAxeAudit.js +26 -30
- package/dist/playwright/runFocusIndicatorCheck.d.ts +7 -0
- package/dist/playwright/runFocusIndicatorCheck.js +98 -31
- package/dist/playwright/runOrientationCheck.js +13 -7
- package/dist/playwright/runReflowCheck.js +22 -12
- package/dist/playwright/runTargetSizeCheck.js +49 -12
- package/dist/playwright/runTextSpacingCheck.js +64 -10
- package/dist/playwright/runTimeLimitDetector.js +56 -25
- package/dist/playwright/runZoomCheck.js +46 -15
- package/dist/schemas/index.d.ts +8 -1
- package/dist/schemas/index.js +397 -292
- package/dist/types.d.ts +137 -53
- package/dist/types.js +9 -0
- package/dist/utils/axe-format.d.ts +88 -0
- package/dist/utils/axe-format.js +370 -0
- package/dist/utils/layout.d.ts +2 -0
- package/dist/utils/layout.js +26 -2
- package/dist/utils/recommendations.js +2 -2
- package/dist/utils/rule-registry.d.ts +216 -0
- package/dist/utils/rule-registry.js +220 -0
- package/dist/utils/test-harness.d.ts +2 -2
- package/dist/utils/test-harness.js +5 -6
- package/package.json +2 -1
|
@@ -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;
|
|
@@ -22,6 +22,13 @@ export interface RunFocusIndicatorCheckOptions extends OutputLocationOptions {
|
|
|
22
22
|
screenshot?: boolean;
|
|
23
23
|
/** Options forwarded to `browser.newContext()` (locale, viewport, storageState, ...). */
|
|
24
24
|
contextOptions?: BrowserContextOptions;
|
|
25
|
+
/**
|
|
26
|
+
* Milliseconds to wait after each Tab press for a focus-triggered navigation
|
|
27
|
+
* to surface (default: 50). A navigation that fires after this window may be
|
|
28
|
+
* missed or attributed to a neighboring element — raise this value when
|
|
29
|
+
* auditing pages with debounced or otherwise delayed focus handlers.
|
|
30
|
+
*/
|
|
31
|
+
navigationSettleMs?: number;
|
|
25
32
|
}
|
|
26
33
|
/**
|
|
27
34
|
* Run the focus indicator check, write the result JSON (and optionally a
|
|
@@ -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_NAVIGATION_SETTLE_MS, 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
|
|
@@ -72,7 +73,7 @@ const MAX_RETRIES = 5;
|
|
|
72
73
|
* screenshot), and return the parsed result.
|
|
73
74
|
*/
|
|
74
75
|
export async function runFocusIndicatorCheck(options) {
|
|
75
|
-
const { browser, targetUrl: targetUrlOption, screenshot = false, contextOptions, ...location } = options;
|
|
76
|
+
const { browser, targetUrl: targetUrlOption, screenshot = false, contextOptions, navigationSettleMs = DEFAULT_NAVIGATION_SETTLE_MS, ...location } = options;
|
|
76
77
|
const targetUrl = requireTargetUrl(targetUrlOption);
|
|
77
78
|
const resolvedResultPath = resolveOutputPath({
|
|
78
79
|
...location,
|
|
@@ -94,6 +95,18 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
94
95
|
// Create a fresh context for each attempt to reset init scripts
|
|
95
96
|
context = await browser.newContext(contextOptions);
|
|
96
97
|
const page = await context.newPage();
|
|
98
|
+
// Track main-frame navigations (WCAG 3.2.1). Catches URL changes that
|
|
99
|
+
// commit and revert within a single settle window (which the before/after
|
|
100
|
+
// URL diff alone would miss). A navigation landing between iterations is
|
|
101
|
+
// absorbed into the next urlBeforeTab and is NOT caught by this listener —
|
|
102
|
+
// raise navigationSettleMs for pages with slow focus-triggered handlers.
|
|
103
|
+
// Reset after the initial goto; checked alongside the URL diff.
|
|
104
|
+
let lateNavigationUrl = null;
|
|
105
|
+
page.on('framenavigated', (frame) => {
|
|
106
|
+
if (frame === page.mainFrame()) {
|
|
107
|
+
lateNavigationUrl = frame.url();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
97
110
|
const focusHistory = [];
|
|
98
111
|
let lastFocusedElement = null;
|
|
99
112
|
let navigationDetected = false;
|
|
@@ -106,6 +119,8 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
106
119
|
name: data.name,
|
|
107
120
|
selector: data.selector ||
|
|
108
121
|
`${data.tag.toLowerCase()}:nth-of-type(${data.id + 1})`,
|
|
122
|
+
html: data.html,
|
|
123
|
+
htmlTruncated: data.htmlTruncated,
|
|
109
124
|
};
|
|
110
125
|
});
|
|
111
126
|
// Expose function to receive focus obscured reports (WCAG 2.4.12)
|
|
@@ -118,6 +133,7 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
118
133
|
styleProperties: [...FOCUS_STYLE_PROPERTIES],
|
|
119
134
|
warningStyles: WARNING_STYLES,
|
|
120
135
|
skipSelectors: [...skipSelectors],
|
|
136
|
+
htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
|
|
121
137
|
obscuredConfig: {
|
|
122
138
|
minOverlapRatio: FOCUS_OBSCURED_MIN_OVERLAP_RATIO,
|
|
123
139
|
minOverlapPx: FOCUS_OBSCURED_MIN_OVERLAP_PX,
|
|
@@ -156,6 +172,9 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
156
172
|
});
|
|
157
173
|
}, skipSelectors);
|
|
158
174
|
}
|
|
175
|
+
// Reset the late-navigation flag so the initial goto and skip-element
|
|
176
|
+
// setup above don't produce false positives in the Tab loop.
|
|
177
|
+
lateNavigationUrl = null;
|
|
159
178
|
// Tab through all elements with navigation detection
|
|
160
179
|
for (let i = 0; i < count + EXTRA_TAB_ITERATIONS; i++) {
|
|
161
180
|
const urlBeforeTab = page.url();
|
|
@@ -163,7 +182,7 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
163
182
|
// Get currently focused element IMMEDIATELY after Tab
|
|
164
183
|
let currentFocusedElement = null;
|
|
165
184
|
try {
|
|
166
|
-
currentFocusedElement = await page.evaluate(() => {
|
|
185
|
+
currentFocusedElement = await page.evaluate((htmlSnippetMaxLength) => {
|
|
167
186
|
const el = document.activeElement;
|
|
168
187
|
if (!el || el === document.body)
|
|
169
188
|
return null;
|
|
@@ -180,6 +199,17 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
180
199
|
const index = siblings.indexOf(element) + 1;
|
|
181
200
|
return `${getSelector(parent)} > ${tag}:nth-of-type(${index})`;
|
|
182
201
|
};
|
|
202
|
+
let html = '';
|
|
203
|
+
try {
|
|
204
|
+
html = el.outerHTML || '';
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
html = '';
|
|
208
|
+
}
|
|
209
|
+
if (!html) {
|
|
210
|
+
html = `<${el.tagName.toLowerCase()}>`;
|
|
211
|
+
}
|
|
212
|
+
const htmlTruncated = html.length > htmlSnippetMaxLength;
|
|
183
213
|
return {
|
|
184
214
|
tag: el.tagName,
|
|
185
215
|
role: el.getAttribute('role'),
|
|
@@ -187,26 +217,34 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
187
217
|
el.textContent?.slice(0, 30) ||
|
|
188
218
|
'',
|
|
189
219
|
selector: getSelector(el),
|
|
220
|
+
html: htmlTruncated
|
|
221
|
+
? html.slice(0, htmlSnippetMaxLength)
|
|
222
|
+
: html,
|
|
223
|
+
htmlTruncated,
|
|
190
224
|
};
|
|
191
|
-
});
|
|
225
|
+
}, HTML_SNIPPET_MAX_LENGTH);
|
|
192
226
|
}
|
|
193
227
|
catch {
|
|
194
228
|
// Page might have navigated, use lastFocusedElement as fallback
|
|
195
229
|
currentFocusedElement = lastFocusedElement;
|
|
196
230
|
}
|
|
197
231
|
// Small wait to allow any navigation to start
|
|
198
|
-
await page.waitForTimeout(
|
|
199
|
-
// Check if navigation occurred
|
|
232
|
+
await page.waitForTimeout(navigationSettleMs);
|
|
233
|
+
// Check if navigation occurred (either within the settle window or late)
|
|
200
234
|
const urlAfterTab = page.url();
|
|
201
|
-
|
|
235
|
+
const lateNav = lateNavigationUrl !== null && lateNavigationUrl !== urlBeforeTab;
|
|
236
|
+
if (urlAfterTab !== urlBeforeTab || lateNav) {
|
|
202
237
|
// 3.2.1 violation detected!
|
|
203
238
|
navigationDetected = true;
|
|
204
239
|
const culprit = currentFocusedElement || lastFocusedElement;
|
|
205
240
|
if (culprit && !skipSelectors.includes(culprit.selector)) {
|
|
241
|
+
const toUrl = urlAfterTab !== urlBeforeTab
|
|
242
|
+
? urlAfterTab
|
|
243
|
+
: (lateNavigationUrl ?? urlAfterTab);
|
|
206
244
|
onFocusViolations.push({
|
|
207
245
|
element: culprit,
|
|
208
246
|
fromUrl: urlBeforeTab,
|
|
209
|
-
toUrl
|
|
247
|
+
toUrl,
|
|
210
248
|
changeType: 'navigation',
|
|
211
249
|
});
|
|
212
250
|
skipSelectors.push(culprit.selector);
|
|
@@ -214,9 +252,10 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
214
252
|
console.warn(` Element: <${culprit.tag}> "${culprit.name}"`);
|
|
215
253
|
console.warn(` Selector: ${culprit.selector}`);
|
|
216
254
|
console.warn(` From: ${urlBeforeTab}`);
|
|
217
|
-
console.warn(` To: ${
|
|
255
|
+
console.warn(` To: ${toUrl}`);
|
|
218
256
|
console.warn(` Restarting test with this element skipped...`);
|
|
219
257
|
}
|
|
258
|
+
lateNavigationUrl = null;
|
|
220
259
|
break;
|
|
221
260
|
}
|
|
222
261
|
}
|
|
@@ -248,8 +287,7 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
248
287
|
console.warn('Elements without visible focus indicator:', elementsWithoutFocusStyle);
|
|
249
288
|
}
|
|
250
289
|
// Build result
|
|
251
|
-
const
|
|
252
|
-
url: finalPage.url(),
|
|
290
|
+
const details = {
|
|
253
291
|
totalFocusableElements: finalFocusHistory.length,
|
|
254
292
|
elementsWithFocusStyle: finalFocusHistory.length - elementsWithoutFocusStyle.length,
|
|
255
293
|
elementsWithoutFocusStyle: elementsWithoutFocusStyle.length,
|
|
@@ -257,6 +295,9 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
257
295
|
tag: el.tag,
|
|
258
296
|
role: el.role,
|
|
259
297
|
name: el.name,
|
|
298
|
+
selector: el.selector,
|
|
299
|
+
html: el.html,
|
|
300
|
+
htmlTruncated: el.htmlTruncated,
|
|
260
301
|
})),
|
|
261
302
|
onFocusViolations,
|
|
262
303
|
focusObscuredIssues,
|
|
@@ -265,12 +306,18 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
265
306
|
interrupted: false,
|
|
266
307
|
screenshotPath: screenshot ? resolvedScreenshotPath : '',
|
|
267
308
|
};
|
|
309
|
+
const result = buildAuditResult({
|
|
310
|
+
source: 'focus-indicator-check',
|
|
311
|
+
url: finalPage.url(),
|
|
312
|
+
details,
|
|
313
|
+
buckets: normalizeFocusCheck(details),
|
|
314
|
+
});
|
|
268
315
|
// 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: ${
|
|
316
|
+
logAuditHeader('Focus Indicator Check Results', 'WCAG 2.4.7 / 2.4.11 / 3.2.1', result.url);
|
|
317
|
+
console.log(`Total focusable elements: ${details.totalFocusableElements}`);
|
|
318
|
+
console.log(`Elements with focus style: ${details.elementsWithFocusStyle}`);
|
|
319
|
+
console.log(`Elements WITHOUT focus style: ${details.elementsWithoutFocusStyle}`);
|
|
320
|
+
console.log(`Elements with OBSCURED focus: ${details.elementsWithObscuredFocus}`);
|
|
274
321
|
if (retryCount > 0) {
|
|
275
322
|
console.log(`\nTest restarted ${retryCount} time(s) due to navigation violations`);
|
|
276
323
|
}
|
|
@@ -328,7 +375,23 @@ export async function runFocusIndicatorCheck(options) {
|
|
|
328
375
|
// Browser-injected script factory
|
|
329
376
|
// =============================================================================
|
|
330
377
|
function createFocusTrackerScript(args) {
|
|
331
|
-
const { focusableSelector, styleProperties, warningStyles, skipSelectors, obscuredConfig } = args;
|
|
378
|
+
const { focusableSelector, styleProperties, warningStyles, skipSelectors, htmlSnippetMaxLength, obscuredConfig, } = args;
|
|
379
|
+
const getHtmlSnippet = (el) => {
|
|
380
|
+
let html = '';
|
|
381
|
+
try {
|
|
382
|
+
html = el.outerHTML || '';
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
html = '';
|
|
386
|
+
}
|
|
387
|
+
if (!html) {
|
|
388
|
+
return { html: `<${el.tagName.toLowerCase()}>`, htmlTruncated: false };
|
|
389
|
+
}
|
|
390
|
+
if (html.length > htmlSnippetMaxLength) {
|
|
391
|
+
return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
|
|
392
|
+
}
|
|
393
|
+
return { html, htmlTruncated: false };
|
|
394
|
+
};
|
|
332
395
|
// Add warning styles
|
|
333
396
|
const styleSheet = new CSSStyleSheet();
|
|
334
397
|
document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet];
|
|
@@ -646,7 +709,8 @@ function createFocusTrackerScript(args) {
|
|
|
646
709
|
// Clamp ratio to max 1.0 to handle overlapping obscurers covering same region
|
|
647
710
|
const obscuredRatio = Math.min(totalOverlapArea / focusedArea, 1.0);
|
|
648
711
|
// Only report if obscured ratio exceeds threshold
|
|
649
|
-
if (obscuredRatio >= obscuredConfig.minOverlapRatio &&
|
|
712
|
+
if (obscuredRatio >= obscuredConfig.minOverlapRatio &&
|
|
713
|
+
overlaps.length > 0) {
|
|
650
714
|
// Add visual annotation
|
|
651
715
|
addAnnotationBox(focusedEl, `⚠ 2.4.12 Obscured (${(obscuredRatio * 100).toFixed(0)}%)`, 'focus-obscured');
|
|
652
716
|
// Report to test
|
|
@@ -658,6 +722,7 @@ function createFocusTrackerScript(args) {
|
|
|
658
722
|
focusedEl.textContent?.slice(0, 30) ||
|
|
659
723
|
'',
|
|
660
724
|
selector: getSelector(focusedEl),
|
|
725
|
+
...getHtmlSnippet(focusedEl),
|
|
661
726
|
},
|
|
662
727
|
elementRect: {
|
|
663
728
|
left: focusedRect.left,
|
|
@@ -673,18 +738,19 @@ function createFocusTrackerScript(args) {
|
|
|
673
738
|
/**
|
|
674
739
|
* Initialize focus tracker - called after page load
|
|
675
740
|
*/
|
|
676
|
-
window.initFocusTracker =
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
.
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
741
|
+
window.initFocusTracker =
|
|
742
|
+
() => {
|
|
743
|
+
// Create overlay container
|
|
744
|
+
createOverlay();
|
|
745
|
+
const elements = [...document.querySelectorAll(focusableSelector)]
|
|
746
|
+
.filter(isVisible)
|
|
747
|
+
.filter((el) => !shouldSkip(el));
|
|
748
|
+
elements.forEach((el) => {
|
|
749
|
+
baseStyles.set(el, captureStyle(el));
|
|
750
|
+
elementSelectors.set(el, getSelector(el));
|
|
751
|
+
});
|
|
752
|
+
return elements.length;
|
|
753
|
+
};
|
|
688
754
|
/**
|
|
689
755
|
* Handle focus events
|
|
690
756
|
*/
|
|
@@ -729,10 +795,11 @@ function createFocusTrackerScript(args) {
|
|
|
729
795
|
id,
|
|
730
796
|
tag: el.tagName,
|
|
731
797
|
role: el.getAttribute('role'),
|
|
732
|
-
name: el.getAttribute('aria-label') || el.textContent?.slice(0, 30),
|
|
798
|
+
name: el.getAttribute('aria-label') || el.textContent?.slice(0, 30) || '',
|
|
733
799
|
hasFocusStyle,
|
|
734
800
|
diff,
|
|
735
801
|
selector: elementSelectors.get(el) || getSelector(el),
|
|
802
|
+
...getHtmlSnippet(el),
|
|
736
803
|
});
|
|
737
804
|
// Check for WCAG 2.4.12 - focus obscured by fixed/sticky elements
|
|
738
805
|
checkFocusObscured(el);
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - Manual verification needed for exceptions (e.g., camera apps)
|
|
17
17
|
*/
|
|
18
18
|
import { ORIENTATION_VIEWPORTS, ORIENTATION_LOCK_KEYWORDS, MAIN_CONTENT_SELECTORS, DEFAULT_ORIENTATION_RESULT_FILE, DEFAULT_ORIENTATION_PORTRAIT_SCREENSHOT_FILE, DEFAULT_ORIENTATION_LANDSCAPE_SCREENSHOT_FILE, } from '../constants.js';
|
|
19
|
+
import { buildAuditResult, normalizeOrientationCheck, } from '../utils/axe-format.js';
|
|
19
20
|
import { saveAuditResult, resolveOutputPath, takeAuditScreenshot, resolveScreenshotPath, requireTargetUrl, logAuditHeader, logOutputPaths, } from '../utils/test-harness.js';
|
|
20
21
|
/** Capture orientation state in browser context. */
|
|
21
22
|
function captureOrientationState(args) {
|
|
@@ -137,20 +138,25 @@ export async function runOrientationCheck(options) {
|
|
|
137
138
|
const landscapeHasLock = landscapeState.lockMessageFound || landscapeState.mainContentHidden;
|
|
138
139
|
const hasOrientationLock = portraitHasLock || landscapeHasLock;
|
|
139
140
|
const lockDetectedIn = determineLockLocation(portraitHasLock, landscapeHasLock);
|
|
140
|
-
const
|
|
141
|
-
url: page.url(),
|
|
141
|
+
const details = {
|
|
142
142
|
portrait: portraitState,
|
|
143
143
|
landscape: landscapeState,
|
|
144
144
|
hasOrientationLock,
|
|
145
145
|
lockDetectedIn,
|
|
146
146
|
};
|
|
147
|
+
const result = buildAuditResult({
|
|
148
|
+
source: 'orientation-check',
|
|
149
|
+
url: page.url(),
|
|
150
|
+
details,
|
|
151
|
+
buckets: normalizeOrientationCheck(details),
|
|
152
|
+
});
|
|
147
153
|
// Output results
|
|
148
154
|
logAuditHeader('Orientation Check Results', 'WCAG 1.3.4', result.url);
|
|
149
|
-
logOrientationState('Portrait', ORIENTATION_VIEWPORTS.portrait,
|
|
150
|
-
logOrientationState('Landscape', ORIENTATION_VIEWPORTS.landscape,
|
|
151
|
-
console.log(`\nOrientation lock detected: ${
|
|
152
|
-
if (
|
|
153
|
-
console.log(`Lock detected in: ${
|
|
155
|
+
logOrientationState('Portrait', ORIENTATION_VIEWPORTS.portrait, details.portrait);
|
|
156
|
+
logOrientationState('Landscape', ORIENTATION_VIEWPORTS.landscape, details.landscape);
|
|
157
|
+
console.log(`\nOrientation lock detected: ${details.hasOrientationLock ? 'YES' : 'No'}`);
|
|
158
|
+
if (details.hasOrientationLock) {
|
|
159
|
+
console.log(`Lock detected in: ${details.lockDetectedIn}`);
|
|
154
160
|
}
|
|
155
161
|
const writtenPath = saveAuditResult(result, {
|
|
156
162
|
...location,
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* - Cannot distinguish acceptable horizontal scroll (e.g., data tables)
|
|
14
14
|
* - Does not verify functional reflow for complex widgets
|
|
15
15
|
*/
|
|
16
|
-
import { REFLOW_VIEWPORT, REFLOW_OVERFLOW_TOLERANCE, REFLOW_CHECK_SELECTOR, REFLOW_ALLOWED_OVERFLOW_SELECTORS, DEFAULT_REFLOW_RESULT_FILE, DEFAULT_REFLOW_SCREENSHOT_FILE, } from '../constants.js';
|
|
16
|
+
import { REFLOW_VIEWPORT, REFLOW_OVERFLOW_TOLERANCE, REFLOW_CHECK_SELECTOR, REFLOW_ALLOWED_OVERFLOW_SELECTORS, DEFAULT_REFLOW_RESULT_FILE, DEFAULT_REFLOW_SCREENSHOT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
|
|
17
17
|
import { createLayoutChecker } from '../utils/layout.js';
|
|
18
|
+
import { buildAuditResult, normalizeReflowCheck } from '../utils/axe-format.js';
|
|
18
19
|
import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
|
|
19
20
|
/**
|
|
20
21
|
* Run the reflow check against the current page, write the result JSON
|
|
@@ -22,33 +23,42 @@ import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHe
|
|
|
22
23
|
*/
|
|
23
24
|
export async function runReflowCheck(options) {
|
|
24
25
|
const { page, viewport = REFLOW_VIEWPORT, overflowTolerance = REFLOW_OVERFLOW_TOLERANCE, screenshot = false, ...location } = options;
|
|
25
|
-
await page.setViewportSize({
|
|
26
|
+
await page.setViewportSize({
|
|
27
|
+
width: viewport.width,
|
|
28
|
+
height: viewport.height,
|
|
29
|
+
});
|
|
26
30
|
const layoutResult = await page.evaluate(createLayoutChecker, {
|
|
27
31
|
viewportWidth: viewport.width,
|
|
28
32
|
overflowTolerance,
|
|
29
33
|
checkSelector: REFLOW_CHECK_SELECTOR,
|
|
30
34
|
allowedOverflowSelectors: [...REFLOW_ALLOWED_OVERFLOW_SELECTORS],
|
|
35
|
+
htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
|
|
31
36
|
});
|
|
32
|
-
const
|
|
33
|
-
url: page.url(),
|
|
37
|
+
const details = {
|
|
34
38
|
viewport: { width: viewport.width, height: viewport.height },
|
|
35
39
|
...layoutResult,
|
|
36
40
|
};
|
|
41
|
+
const result = buildAuditResult({
|
|
42
|
+
source: 'reflow-check',
|
|
43
|
+
url: page.url(),
|
|
44
|
+
details,
|
|
45
|
+
buckets: normalizeReflowCheck(details),
|
|
46
|
+
});
|
|
37
47
|
// Output results
|
|
38
48
|
logAuditHeader('Reflow Check Results', 'WCAG 1.4.10', result.url);
|
|
39
49
|
logSummary({
|
|
40
|
-
Viewport: `${
|
|
41
|
-
'Document scroll width': `${
|
|
42
|
-
'Document client width': `${
|
|
43
|
-
'Horizontal scroll':
|
|
44
|
-
'Overflowing elements':
|
|
45
|
-
'Clipped text elements':
|
|
50
|
+
Viewport: `${details.viewport.width}x${details.viewport.height}`,
|
|
51
|
+
'Document scroll width': `${details.documentScrollWidth}px`,
|
|
52
|
+
'Document client width': `${details.documentClientWidth}px`,
|
|
53
|
+
'Horizontal scroll': details.hasHorizontalScroll,
|
|
54
|
+
'Overflowing elements': details.overflowingElements.length,
|
|
55
|
+
'Clipped text elements': details.clippedTextElements.length,
|
|
46
56
|
});
|
|
47
|
-
logIssueList('Overflowing Elements',
|
|
57
|
+
logIssueList('Overflowing Elements', details.overflowingElements, (el, i) => [
|
|
48
58
|
`${i + 1}. <${el.tagName}> "${el.selector}"`,
|
|
49
59
|
` rect.right: ${el.rect.right}px (viewport: ${el.viewportWidth}px)`,
|
|
50
60
|
]);
|
|
51
|
-
logIssueList('Clipped Text Elements',
|
|
61
|
+
logIssueList('Clipped Text Elements', details.clippedTextElements, (el, i) => [
|
|
52
62
|
`${i + 1}. <${el.tagName}> "${el.selector}"`,
|
|
53
63
|
` scrollWidth: ${el.scrollWidth}px, clientWidth: ${el.clientWidth}px`,
|
|
54
64
|
` overflow: ${el.overflow}, overflowX: ${el.overflowX}`,
|
|
@@ -15,13 +15,34 @@
|
|
|
15
15
|
* - Cannot detect all redundant target cases
|
|
16
16
|
* - CSS transform may affect measurements
|
|
17
17
|
*/
|
|
18
|
-
import { INTERACTIVE_SELECTOR, TARGET_SIZE_AA, TARGET_SIZE_AAA, INLINE_CONTEXT_TAGS, UA_CONTROLLED_INPUT_TYPES, INLINE_CONTEXT_MIN_TEXT, DEFAULT_TARGET_SIZE_RESULT_FILE, DEFAULT_TARGET_SIZE_SCREENSHOT_FILE, } from '../constants.js';
|
|
18
|
+
import { INTERACTIVE_SELECTOR, TARGET_SIZE_AA, TARGET_SIZE_AAA, INLINE_CONTEXT_TAGS, UA_CONTROLLED_INPUT_TYPES, INLINE_CONTEXT_MIN_TEXT, DEFAULT_TARGET_SIZE_RESULT_FILE, DEFAULT_TARGET_SIZE_SCREENSHOT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
|
|
19
|
+
import { buildAuditResult, normalizeTargetSizeCheck, } from '../utils/axe-format.js';
|
|
19
20
|
import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
|
|
20
|
-
import { addPageAnnotations } from '../utils/annotations.js';
|
|
21
|
+
import { addPageAnnotations, } from '../utils/annotations.js';
|
|
21
22
|
/**
|
|
22
23
|
* Collect basic target information from DOM (runs in browser context).
|
|
23
24
|
*/
|
|
24
|
-
function collectBasicTargetInfo(
|
|
25
|
+
function collectBasicTargetInfo(args) {
|
|
26
|
+
const { interactiveSelector, htmlSnippetMaxLength } = args;
|
|
27
|
+
function getHtmlSnippet(element) {
|
|
28
|
+
let html = '';
|
|
29
|
+
try {
|
|
30
|
+
html = element.outerHTML || '';
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
html = '';
|
|
34
|
+
}
|
|
35
|
+
if (!html) {
|
|
36
|
+
return {
|
|
37
|
+
html: `<${element.tagName.toLowerCase()}>`,
|
|
38
|
+
htmlTruncated: false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (html.length > htmlSnippetMaxLength) {
|
|
42
|
+
return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
|
|
43
|
+
}
|
|
44
|
+
return { html, htmlTruncated: false };
|
|
45
|
+
}
|
|
25
46
|
function getUniqueSelector(element, elementIndex) {
|
|
26
47
|
if (element.id) {
|
|
27
48
|
return `#${CSS.escape(element.id)}`;
|
|
@@ -41,7 +62,9 @@ function collectBasicTargetInfo(interactiveSelector) {
|
|
|
41
62
|
path.unshift(selector);
|
|
42
63
|
current = parent;
|
|
43
64
|
}
|
|
44
|
-
return path.length > 0
|
|
65
|
+
return path.length > 0
|
|
66
|
+
? path.join(' > ')
|
|
67
|
+
: `[data-index="${elementIndex}"]`;
|
|
45
68
|
}
|
|
46
69
|
const targets = [];
|
|
47
70
|
const elements = document.querySelectorAll(interactiveSelector);
|
|
@@ -72,6 +95,7 @@ function collectBasicTargetInfo(interactiveSelector) {
|
|
|
72
95
|
targets.push({
|
|
73
96
|
selector: getUniqueSelector(element, index),
|
|
74
97
|
tagName,
|
|
98
|
+
...getHtmlSnippet(element),
|
|
75
99
|
role,
|
|
76
100
|
width: Math.round(rect.width * 100) / 100,
|
|
77
101
|
height: Math.round(rect.height * 100) / 100,
|
|
@@ -247,6 +271,8 @@ function analyzeTargets(targets, aaThreshold, aaaThreshold) {
|
|
|
247
271
|
const issue = {
|
|
248
272
|
selector: target.selector,
|
|
249
273
|
tagName: target.tagName,
|
|
274
|
+
html: target.html,
|
|
275
|
+
htmlTruncated: target.htmlTruncated,
|
|
250
276
|
role: target.role,
|
|
251
277
|
accessibleName: target.accessibleName,
|
|
252
278
|
width: target.width,
|
|
@@ -255,6 +281,9 @@ function analyzeTargets(targets, aaThreshold, aaaThreshold) {
|
|
|
255
281
|
level,
|
|
256
282
|
exception,
|
|
257
283
|
exceptionDetails,
|
|
284
|
+
// the heuristics can detect exceptions but can never rule out the
|
|
285
|
+
// essential exception, so findings are at best 'possible'/'not-assessed'.
|
|
286
|
+
exceptionAssessment: exception ? 'possible' : 'not-assessed',
|
|
258
287
|
href: target.href,
|
|
259
288
|
};
|
|
260
289
|
if (exception) {
|
|
@@ -276,7 +305,10 @@ function analyzeTargets(targets, aaThreshold, aaaThreshold) {
|
|
|
276
305
|
export async function runTargetSizeCheck(options) {
|
|
277
306
|
const { page, aaThreshold = TARGET_SIZE_AA, aaaThreshold = TARGET_SIZE_AAA, screenshot = false, ...location } = options;
|
|
278
307
|
// Collect basic target info from DOM
|
|
279
|
-
const basicTargets = await page.evaluate(collectBasicTargetInfo,
|
|
308
|
+
const basicTargets = await page.evaluate(collectBasicTargetInfo, {
|
|
309
|
+
interactiveSelector: INTERACTIVE_SELECTOR,
|
|
310
|
+
htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
|
|
311
|
+
});
|
|
280
312
|
// Enhance with accessible names via ariaSnapshot()
|
|
281
313
|
const targets = [];
|
|
282
314
|
for (const basicTarget of basicTargets) {
|
|
@@ -296,8 +328,7 @@ export async function runTargetSizeCheck(options) {
|
|
|
296
328
|
}
|
|
297
329
|
// Analyze targets
|
|
298
330
|
const { failAA, failAAAOnly, passCount, excepted } = analyzeTargets(targets, aaThreshold, aaaThreshold);
|
|
299
|
-
const
|
|
300
|
-
url: page.url(),
|
|
331
|
+
const details = {
|
|
301
332
|
totalTargetsChecked: targets.length,
|
|
302
333
|
failAA,
|
|
303
334
|
failAAAOnly,
|
|
@@ -310,16 +341,22 @@ export async function runTargetSizeCheck(options) {
|
|
|
310
341
|
exceptedCount: excepted.length,
|
|
311
342
|
},
|
|
312
343
|
};
|
|
344
|
+
const result = buildAuditResult({
|
|
345
|
+
source: 'target-size-check',
|
|
346
|
+
url: page.url(),
|
|
347
|
+
details,
|
|
348
|
+
buckets: normalizeTargetSizeCheck(details),
|
|
349
|
+
});
|
|
313
350
|
// Output results
|
|
314
351
|
logAuditHeader('Target Size Check Results', 'WCAG 2.5.5 / 2.5.8', result.url);
|
|
315
352
|
logSummary({
|
|
316
|
-
'Total targets checked':
|
|
353
|
+
'Total targets checked': details.totalTargetsChecked,
|
|
317
354
|
});
|
|
318
355
|
console.log('\nSummary:');
|
|
319
|
-
console.log(` Pass (>= ${aaaThreshold}px): ${
|
|
320
|
-
console.log(` Fail AAA only (${aaThreshold}-${aaaThreshold - 1}px): ${
|
|
321
|
-
console.log(` Fail AA (< ${aaThreshold}px): ${
|
|
322
|
-
console.log(` Possible exceptions: ${
|
|
356
|
+
console.log(` Pass (>= ${aaaThreshold}px): ${details.summary.passCount}`);
|
|
357
|
+
console.log(` Fail AAA only (${aaThreshold}-${aaaThreshold - 1}px): ${details.summary.failAAAOnlyCount}`);
|
|
358
|
+
console.log(` Fail AA (< ${aaThreshold}px): ${details.summary.failAACount}`);
|
|
359
|
+
console.log(` Possible exceptions: ${details.summary.exceptedCount}`);
|
|
323
360
|
logIssueList(`Fail AA (< ${aaThreshold}px) - Requires Fix`, failAA, (el, i) => {
|
|
324
361
|
const lines = [
|
|
325
362
|
`${i + 1}. <${el.tagName}> "${el.selector}"`,
|