@masup9/a11y-audit 0.1.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 +34 -0
- package/README.ja.md +128 -0
- package/README.md +130 -0
- package/dist/constants.d.ts +88 -0
- package/dist/constants.js +184 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/playwright/index.d.ts +19 -0
- package/dist/playwright/index.js +19 -0
- package/dist/playwright/runAxeAudit.d.ts +28 -0
- package/dist/playwright/runAxeAudit.js +89 -0
- package/dist/playwright/runFocusIndicatorCheck.d.ts +30 -0
- package/dist/playwright/runFocusIndicatorCheck.js +740 -0
- package/dist/playwright/runReflowCheck.d.ts +36 -0
- package/dist/playwright/runReflowCheck.js +68 -0
- package/dist/playwright/runTargetSizeCheck.d.ts +35 -0
- package/dist/playwright/runTargetSizeCheck.js +389 -0
- package/dist/schemas/index.d.ts +34 -0
- package/dist/schemas/index.js +245 -0
- package/dist/test-entries/axe-audit.d.ts +13 -0
- package/dist/test-entries/axe-audit.js +20 -0
- package/dist/test-entries/focus-indicator-check.d.ts +9 -0
- package/dist/test-entries/focus-indicator-check.js +13 -0
- package/dist/test-entries/reflow-check.d.ts +9 -0
- package/dist/test-entries/reflow-check.js +21 -0
- package/dist/test-entries/target-size-check.d.ts +8 -0
- package/dist/test-entries/target-size-check.js +15 -0
- package/dist/types.d.ts +215 -0
- package/dist/types.js +4 -0
- package/dist/utils/annotations.d.ts +67 -0
- package/dist/utils/annotations.js +110 -0
- package/dist/utils/layout.d.ts +23 -0
- package/dist/utils/layout.js +144 -0
- package/dist/utils/test-harness.d.ts +76 -0
- package/dist/utils/test-harness.js +119 -0
- package/package.json +92 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reflow Check — WCAG 1.4.10 (Reflow)
|
|
3
|
+
*
|
|
4
|
+
* Sets the viewport to a narrow width (default 320px, equivalent to 400% zoom
|
|
5
|
+
* on a 1280px viewport), then detects horizontal scrolling, overflowing
|
|
6
|
+
* elements, and clipped text.
|
|
7
|
+
*
|
|
8
|
+
* The caller is responsible for navigating the page before calling this
|
|
9
|
+
* function. This function sets the viewport size itself so the measurement is
|
|
10
|
+
* taken at the reflow width regardless of how the page was loaded.
|
|
11
|
+
*
|
|
12
|
+
* Limitations:
|
|
13
|
+
* - Cannot distinguish acceptable horizontal scroll (e.g., data tables)
|
|
14
|
+
* - Does not verify functional reflow for complex widgets
|
|
15
|
+
*/
|
|
16
|
+
import type { Page } from '@playwright/test';
|
|
17
|
+
import type { ReflowCheckResult } from '../types.js';
|
|
18
|
+
import { type OutputLocationOptions } from '../utils/test-harness.js';
|
|
19
|
+
export interface RunReflowCheckOptions extends OutputLocationOptions {
|
|
20
|
+
/** A page already navigated to the target URL. */
|
|
21
|
+
page: Page;
|
|
22
|
+
/** Viewport to measure reflow at (default: 320x256). */
|
|
23
|
+
viewport?: {
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
};
|
|
27
|
+
/** Overflow tolerance in pixels (default: 5). */
|
|
28
|
+
overflowTolerance?: number;
|
|
29
|
+
/** Whether to capture a screenshot next to the result file (default: false). */
|
|
30
|
+
screenshot?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Run the reflow check against the current page, write the result JSON
|
|
34
|
+
* (and optionally a screenshot), and return the parsed result.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runReflowCheck(options: RunReflowCheckOptions): Promise<ReflowCheckResult>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reflow Check — WCAG 1.4.10 (Reflow)
|
|
3
|
+
*
|
|
4
|
+
* Sets the viewport to a narrow width (default 320px, equivalent to 400% zoom
|
|
5
|
+
* on a 1280px viewport), then detects horizontal scrolling, overflowing
|
|
6
|
+
* elements, and clipped text.
|
|
7
|
+
*
|
|
8
|
+
* The caller is responsible for navigating the page before calling this
|
|
9
|
+
* function. This function sets the viewport size itself so the measurement is
|
|
10
|
+
* taken at the reflow width regardless of how the page was loaded.
|
|
11
|
+
*
|
|
12
|
+
* Limitations:
|
|
13
|
+
* - Cannot distinguish acceptable horizontal scroll (e.g., data tables)
|
|
14
|
+
* - Does not verify functional reflow for complex widgets
|
|
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';
|
|
17
|
+
import { createLayoutChecker } from '../utils/layout.js';
|
|
18
|
+
import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
|
|
19
|
+
/**
|
|
20
|
+
* Run the reflow check against the current page, write the result JSON
|
|
21
|
+
* (and optionally a screenshot), and return the parsed result.
|
|
22
|
+
*/
|
|
23
|
+
export async function runReflowCheck(options) {
|
|
24
|
+
const { page, viewport = REFLOW_VIEWPORT, overflowTolerance = REFLOW_OVERFLOW_TOLERANCE, screenshot = false, ...location } = options;
|
|
25
|
+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
26
|
+
const layoutResult = await page.evaluate(createLayoutChecker, {
|
|
27
|
+
viewportWidth: viewport.width,
|
|
28
|
+
overflowTolerance,
|
|
29
|
+
checkSelector: REFLOW_CHECK_SELECTOR,
|
|
30
|
+
allowedOverflowSelectors: [...REFLOW_ALLOWED_OVERFLOW_SELECTORS],
|
|
31
|
+
});
|
|
32
|
+
const result = {
|
|
33
|
+
url: page.url(),
|
|
34
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
35
|
+
...layoutResult,
|
|
36
|
+
};
|
|
37
|
+
// Output results
|
|
38
|
+
logAuditHeader('Reflow Check Results', 'WCAG 1.4.10', result.url);
|
|
39
|
+
logSummary({
|
|
40
|
+
Viewport: `${result.viewport.width}x${result.viewport.height}`,
|
|
41
|
+
'Document scroll width': `${result.documentScrollWidth}px`,
|
|
42
|
+
'Document client width': `${result.documentClientWidth}px`,
|
|
43
|
+
'Horizontal scroll': result.hasHorizontalScroll,
|
|
44
|
+
'Overflowing elements': result.overflowingElements.length,
|
|
45
|
+
'Clipped text elements': result.clippedTextElements.length,
|
|
46
|
+
});
|
|
47
|
+
logIssueList('Overflowing Elements', result.overflowingElements, (el, i) => [
|
|
48
|
+
`${i + 1}. <${el.tagName}> "${el.selector}"`,
|
|
49
|
+
` rect.right: ${el.rect.right}px (viewport: ${el.viewportWidth}px)`,
|
|
50
|
+
]);
|
|
51
|
+
logIssueList('Clipped Text Elements', result.clippedTextElements, (el, i) => [
|
|
52
|
+
`${i + 1}. <${el.tagName}> "${el.selector}"`,
|
|
53
|
+
` scrollWidth: ${el.scrollWidth}px, clientWidth: ${el.clientWidth}px`,
|
|
54
|
+
` overflow: ${el.overflow}, overflowX: ${el.overflowX}`,
|
|
55
|
+
]);
|
|
56
|
+
const resolvedPath = saveAuditResult(result, {
|
|
57
|
+
...location,
|
|
58
|
+
defaultFile: DEFAULT_REFLOW_RESULT_FILE,
|
|
59
|
+
});
|
|
60
|
+
let screenshotPath;
|
|
61
|
+
if (screenshot) {
|
|
62
|
+
screenshotPath = await takeAuditScreenshot(page, {
|
|
63
|
+
path: resolveScreenshotPath(resolvedPath, DEFAULT_REFLOW_SCREENSHOT_FILE),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
logOutputPaths(resolvedPath, screenshotPath);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target Size Check
|
|
3
|
+
*
|
|
4
|
+
* WCAG 2.5.8 (Target Size Minimum - AA): 24x24 CSS px
|
|
5
|
+
* WCAG 2.5.5 (Target Size Enhanced - AAA): 44x44 CSS px
|
|
6
|
+
*
|
|
7
|
+
* The caller is responsible for navigating the page before calling this
|
|
8
|
+
* function.
|
|
9
|
+
*
|
|
10
|
+
* Note: when `screenshot` is enabled this function appends an annotation
|
|
11
|
+
* overlay to the page DOM before capturing the screenshot.
|
|
12
|
+
*
|
|
13
|
+
* Limitations:
|
|
14
|
+
* - Essential exception requires manual review
|
|
15
|
+
* - Cannot detect all redundant target cases
|
|
16
|
+
* - CSS transform may affect measurements
|
|
17
|
+
*/
|
|
18
|
+
import type { Page } from '@playwright/test';
|
|
19
|
+
import type { TargetSizeCheckResult } from '../types.js';
|
|
20
|
+
import { type OutputLocationOptions } from '../utils/test-harness.js';
|
|
21
|
+
export interface RunTargetSizeCheckOptions extends OutputLocationOptions {
|
|
22
|
+
/** A page already navigated to the target URL. */
|
|
23
|
+
page: Page;
|
|
24
|
+
/** Minimum (AA) threshold in CSS px (default: 24). */
|
|
25
|
+
aaThreshold?: number;
|
|
26
|
+
/** Enhanced (AAA) threshold in CSS px (default: 44). */
|
|
27
|
+
aaaThreshold?: number;
|
|
28
|
+
/** Whether to capture an annotated screenshot (default: false). Mutates the page DOM. */
|
|
29
|
+
screenshot?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run the target size check against the current page, write the result JSON
|
|
33
|
+
* (and optionally an annotated screenshot), and return the parsed result.
|
|
34
|
+
*/
|
|
35
|
+
export declare function runTargetSizeCheck(options: RunTargetSizeCheckOptions): Promise<TargetSizeCheckResult>;
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target Size Check
|
|
3
|
+
*
|
|
4
|
+
* WCAG 2.5.8 (Target Size Minimum - AA): 24x24 CSS px
|
|
5
|
+
* WCAG 2.5.5 (Target Size Enhanced - AAA): 44x44 CSS px
|
|
6
|
+
*
|
|
7
|
+
* The caller is responsible for navigating the page before calling this
|
|
8
|
+
* function.
|
|
9
|
+
*
|
|
10
|
+
* Note: when `screenshot` is enabled this function appends an annotation
|
|
11
|
+
* overlay to the page DOM before capturing the screenshot.
|
|
12
|
+
*
|
|
13
|
+
* Limitations:
|
|
14
|
+
* - Essential exception requires manual review
|
|
15
|
+
* - Cannot detect all redundant target cases
|
|
16
|
+
* - CSS transform may affect measurements
|
|
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';
|
|
19
|
+
import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
|
|
20
|
+
import { addPageAnnotations } from '../utils/annotations.js';
|
|
21
|
+
/**
|
|
22
|
+
* Collect basic target information from DOM (runs in browser context).
|
|
23
|
+
*/
|
|
24
|
+
function collectBasicTargetInfo(interactiveSelector) {
|
|
25
|
+
function getUniqueSelector(element, elementIndex) {
|
|
26
|
+
if (element.id) {
|
|
27
|
+
return `#${CSS.escape(element.id)}`;
|
|
28
|
+
}
|
|
29
|
+
const path = [];
|
|
30
|
+
let current = element;
|
|
31
|
+
while (current && current !== document.body) {
|
|
32
|
+
let selector = current.tagName.toLowerCase();
|
|
33
|
+
const parent = current.parentElement;
|
|
34
|
+
if (parent) {
|
|
35
|
+
const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
|
36
|
+
if (siblings.length > 1) {
|
|
37
|
+
const index = siblings.indexOf(current) + 1;
|
|
38
|
+
selector += `:nth-of-type(${index})`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
path.unshift(selector);
|
|
42
|
+
current = parent;
|
|
43
|
+
}
|
|
44
|
+
return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
|
|
45
|
+
}
|
|
46
|
+
const targets = [];
|
|
47
|
+
const elements = document.querySelectorAll(interactiveSelector);
|
|
48
|
+
elements.forEach((element, index) => {
|
|
49
|
+
const el = element;
|
|
50
|
+
const rect = el.getBoundingClientRect();
|
|
51
|
+
// Skip invisible elements
|
|
52
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Skip elements outside viewport (likely hidden)
|
|
56
|
+
if (rect.bottom < 0 || rect.right < 0) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const computedStyle = getComputedStyle(el);
|
|
60
|
+
if (computedStyle.visibility === 'hidden' ||
|
|
61
|
+
computedStyle.display === 'none') {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const tagName = el.tagName.toLowerCase();
|
|
65
|
+
const role = el.getAttribute('role');
|
|
66
|
+
const inputType = el instanceof HTMLInputElement ? el.type : null;
|
|
67
|
+
const href = el instanceof HTMLAnchorElement ? el.href : null;
|
|
68
|
+
// Get parent info for inline exception check
|
|
69
|
+
const parent = el.parentElement;
|
|
70
|
+
const parentTag = parent ? parent.tagName.toLowerCase() : null;
|
|
71
|
+
const parentTextLength = parent ? (parent.textContent || '').length : 0;
|
|
72
|
+
targets.push({
|
|
73
|
+
selector: getUniqueSelector(element, index),
|
|
74
|
+
tagName,
|
|
75
|
+
role,
|
|
76
|
+
width: Math.round(rect.width * 100) / 100,
|
|
77
|
+
height: Math.round(rect.height * 100) / 100,
|
|
78
|
+
href,
|
|
79
|
+
inputType,
|
|
80
|
+
appearance: computedStyle.appearance,
|
|
81
|
+
parentTag,
|
|
82
|
+
parentTextLength,
|
|
83
|
+
boundingRect: {
|
|
84
|
+
left: rect.left,
|
|
85
|
+
top: rect.top,
|
|
86
|
+
right: rect.right,
|
|
87
|
+
bottom: rect.bottom,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
return targets;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Parse accessible name from ariaSnapshot output.
|
|
95
|
+
*/
|
|
96
|
+
function parseAccessibleName(snapshot) {
|
|
97
|
+
// ariaSnapshot format: "- role \"accessible name\"" or "- role \"accessible name\" [state]"
|
|
98
|
+
const match = snapshot.match(/^- \w+(?:\s+"([^"]*)")?/);
|
|
99
|
+
if (match && match[1]) {
|
|
100
|
+
return match[1];
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Check if element qualifies for inline exception.
|
|
106
|
+
*/
|
|
107
|
+
function checkInlineException(target, inlineContextTags, minTextLength) {
|
|
108
|
+
// Only links typically qualify for inline exception
|
|
109
|
+
if (target.tagName !== 'a' || !target.href) {
|
|
110
|
+
return { applies: false, details: null };
|
|
111
|
+
}
|
|
112
|
+
if (target.parentTag &&
|
|
113
|
+
inlineContextTags.includes(target.parentTag) &&
|
|
114
|
+
target.parentTextLength >= minTextLength) {
|
|
115
|
+
return {
|
|
116
|
+
applies: true,
|
|
117
|
+
details: `Inline link within <${target.parentTag}>, surrounding text: ${target.parentTextLength} chars`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return { applies: false, details: null };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Check if element qualifies for UA control exception.
|
|
124
|
+
*/
|
|
125
|
+
function checkUAControlException(target, uaControlledTypes) {
|
|
126
|
+
// Check native form controls that haven't been restyled
|
|
127
|
+
if (target.tagName === 'select') {
|
|
128
|
+
if (target.appearance !== 'none') {
|
|
129
|
+
return {
|
|
130
|
+
applies: true,
|
|
131
|
+
details: 'Native <select> element with default appearance',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (target.tagName === 'input' &&
|
|
136
|
+
target.inputType &&
|
|
137
|
+
uaControlledTypes.includes(target.inputType)) {
|
|
138
|
+
if (target.appearance !== 'none') {
|
|
139
|
+
return {
|
|
140
|
+
applies: true,
|
|
141
|
+
details: `Native <input type="${target.inputType}"> with default appearance`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { applies: false, details: null };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Check for redundant targets (same href with at least one meeting size).
|
|
149
|
+
*/
|
|
150
|
+
function findRedundantTargets(targets) {
|
|
151
|
+
const byHref = new Map();
|
|
152
|
+
for (const target of targets) {
|
|
153
|
+
if (target.href) {
|
|
154
|
+
const existing = byHref.get(target.href) || [];
|
|
155
|
+
existing.push(target);
|
|
156
|
+
byHref.set(target.href, existing);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return byHref;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Check if target has adequate spacing from adjacent targets.
|
|
163
|
+
*/
|
|
164
|
+
function checkSpacingException(target, allTargets, requiredSpacing) {
|
|
165
|
+
const targetRect = target.boundingRect;
|
|
166
|
+
for (const other of allTargets) {
|
|
167
|
+
if (other.selector === target.selector) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const otherRect = other.boundingRect;
|
|
171
|
+
// Calculate distance between edges
|
|
172
|
+
const horizontalGap = Math.max(0, Math.max(otherRect.left - targetRect.right, targetRect.left - otherRect.right));
|
|
173
|
+
const verticalGap = Math.max(0, Math.max(otherRect.top - targetRect.bottom, targetRect.top - otherRect.bottom));
|
|
174
|
+
// If adjacent (gaps are small), check if spacing is adequate
|
|
175
|
+
if (horizontalGap < requiredSpacing && verticalGap < requiredSpacing) {
|
|
176
|
+
// Not enough spacing
|
|
177
|
+
return { applies: false, details: null };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// All adjacent targets have adequate spacing
|
|
181
|
+
return {
|
|
182
|
+
applies: true,
|
|
183
|
+
details: `No adjacent targets within ${requiredSpacing}px`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Analyze targets and categorize by pass/fail level.
|
|
188
|
+
*/
|
|
189
|
+
function analyzeTargets(targets, aaThreshold, aaaThreshold) {
|
|
190
|
+
const failAA = [];
|
|
191
|
+
const failAAAOnly = [];
|
|
192
|
+
const excepted = [];
|
|
193
|
+
let passCount = 0;
|
|
194
|
+
// Build href map for redundancy check
|
|
195
|
+
const hrefMap = findRedundantTargets(targets);
|
|
196
|
+
for (const target of targets) {
|
|
197
|
+
const minDimension = Math.min(target.width, target.height);
|
|
198
|
+
// Determine level
|
|
199
|
+
let level;
|
|
200
|
+
if (minDimension >= aaaThreshold) {
|
|
201
|
+
passCount++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
else if (minDimension >= aaThreshold) {
|
|
205
|
+
level = 'fail-aaa-only';
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
level = 'fail-aa';
|
|
209
|
+
}
|
|
210
|
+
// Check exceptions (only relevant for fail-aa)
|
|
211
|
+
let exception = null;
|
|
212
|
+
let exceptionDetails = null;
|
|
213
|
+
if (level === 'fail-aa') {
|
|
214
|
+
// Check inline exception
|
|
215
|
+
const inlineCheck = checkInlineException(target, INLINE_CONTEXT_TAGS, INLINE_CONTEXT_MIN_TEXT);
|
|
216
|
+
if (inlineCheck.applies) {
|
|
217
|
+
exception = 'inline';
|
|
218
|
+
exceptionDetails = inlineCheck.details;
|
|
219
|
+
}
|
|
220
|
+
// Check UA control exception
|
|
221
|
+
if (!exception) {
|
|
222
|
+
const uaCheck = checkUAControlException(target, UA_CONTROLLED_INPUT_TYPES);
|
|
223
|
+
if (uaCheck.applies) {
|
|
224
|
+
exception = 'ua-control';
|
|
225
|
+
exceptionDetails = uaCheck.details;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Check redundant exception
|
|
229
|
+
if (!exception && target.href) {
|
|
230
|
+
const sameHrefTargets = hrefMap.get(target.href) || [];
|
|
231
|
+
const hasLargerTarget = sameHrefTargets.some((t) => t.selector !== target.selector &&
|
|
232
|
+
Math.min(t.width, t.height) >= aaThreshold);
|
|
233
|
+
if (hasLargerTarget) {
|
|
234
|
+
exception = 'redundant';
|
|
235
|
+
exceptionDetails = `Another link to same URL meets size requirement`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Check spacing exception
|
|
239
|
+
if (!exception) {
|
|
240
|
+
const spacingCheck = checkSpacingException(target, targets, aaThreshold);
|
|
241
|
+
if (spacingCheck.applies) {
|
|
242
|
+
exception = 'spacing';
|
|
243
|
+
exceptionDetails = spacingCheck.details;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const issue = {
|
|
248
|
+
selector: target.selector,
|
|
249
|
+
tagName: target.tagName,
|
|
250
|
+
role: target.role,
|
|
251
|
+
accessibleName: target.accessibleName,
|
|
252
|
+
width: target.width,
|
|
253
|
+
height: target.height,
|
|
254
|
+
minDimension: Math.round(minDimension * 100) / 100,
|
|
255
|
+
level,
|
|
256
|
+
exception,
|
|
257
|
+
exceptionDetails,
|
|
258
|
+
href: target.href,
|
|
259
|
+
};
|
|
260
|
+
if (exception) {
|
|
261
|
+
excepted.push(issue);
|
|
262
|
+
}
|
|
263
|
+
else if (level === 'fail-aa') {
|
|
264
|
+
failAA.push(issue);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
failAAAOnly.push(issue);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return { failAA, failAAAOnly, passCount, excepted };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Run the target size check against the current page, write the result JSON
|
|
274
|
+
* (and optionally an annotated screenshot), and return the parsed result.
|
|
275
|
+
*/
|
|
276
|
+
export async function runTargetSizeCheck(options) {
|
|
277
|
+
const { page, aaThreshold = TARGET_SIZE_AA, aaaThreshold = TARGET_SIZE_AAA, screenshot = false, ...location } = options;
|
|
278
|
+
// Collect basic target info from DOM
|
|
279
|
+
const basicTargets = await page.evaluate(collectBasicTargetInfo, INTERACTIVE_SELECTOR);
|
|
280
|
+
// Enhance with accessible names via ariaSnapshot()
|
|
281
|
+
const targets = [];
|
|
282
|
+
for (const basicTarget of basicTargets) {
|
|
283
|
+
let accessibleName = null;
|
|
284
|
+
try {
|
|
285
|
+
const locator = page.locator(basicTarget.selector).first();
|
|
286
|
+
const snapshot = await locator.ariaSnapshot();
|
|
287
|
+
accessibleName = parseAccessibleName(snapshot);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// If ariaSnapshot fails, accessibleName remains null
|
|
291
|
+
}
|
|
292
|
+
targets.push({
|
|
293
|
+
...basicTarget,
|
|
294
|
+
accessibleName,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// Analyze targets
|
|
298
|
+
const { failAA, failAAAOnly, passCount, excepted } = analyzeTargets(targets, aaThreshold, aaaThreshold);
|
|
299
|
+
const result = {
|
|
300
|
+
url: page.url(),
|
|
301
|
+
totalTargetsChecked: targets.length,
|
|
302
|
+
failAA,
|
|
303
|
+
failAAAOnly,
|
|
304
|
+
passedTargets: passCount,
|
|
305
|
+
exceptedTargets: excepted,
|
|
306
|
+
summary: {
|
|
307
|
+
failAACount: failAA.length,
|
|
308
|
+
failAAAOnlyCount: failAAAOnly.length,
|
|
309
|
+
passCount,
|
|
310
|
+
exceptedCount: excepted.length,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
// Output results
|
|
314
|
+
logAuditHeader('Target Size Check Results', 'WCAG 2.5.5 / 2.5.8', result.url);
|
|
315
|
+
logSummary({
|
|
316
|
+
'Total targets checked': result.totalTargetsChecked,
|
|
317
|
+
});
|
|
318
|
+
console.log('\nSummary:');
|
|
319
|
+
console.log(` Pass (>= ${aaaThreshold}px): ${result.summary.passCount}`);
|
|
320
|
+
console.log(` Fail AAA only (${aaThreshold}-${aaaThreshold - 1}px): ${result.summary.failAAAOnlyCount}`);
|
|
321
|
+
console.log(` Fail AA (< ${aaThreshold}px): ${result.summary.failAACount}`);
|
|
322
|
+
console.log(` Possible exceptions: ${result.summary.exceptedCount}`);
|
|
323
|
+
logIssueList(`Fail AA (< ${aaThreshold}px) - Requires Fix`, failAA, (el, i) => {
|
|
324
|
+
const lines = [
|
|
325
|
+
`${i + 1}. <${el.tagName}> "${el.selector}"`,
|
|
326
|
+
` Size: ${el.width}x${el.height}px (min: ${el.minDimension}px)`,
|
|
327
|
+
` Name: "${el.accessibleName || 'none'}"`,
|
|
328
|
+
];
|
|
329
|
+
if (el.exception) {
|
|
330
|
+
lines.push(` Exception: ${el.exception} - ${el.exceptionDetails}`);
|
|
331
|
+
}
|
|
332
|
+
return lines;
|
|
333
|
+
});
|
|
334
|
+
logIssueList(`Fail AAA Only (${aaThreshold}-${aaaThreshold - 1}px) - Recommended Fix`, failAAAOnly, (el, i) => [
|
|
335
|
+
`${i + 1}. <${el.tagName}> "${el.selector}"`,
|
|
336
|
+
` Size: ${el.width}x${el.height}px (min: ${el.minDimension}px)`,
|
|
337
|
+
` Name: "${el.accessibleName || 'none'}"`,
|
|
338
|
+
], 5);
|
|
339
|
+
logIssueList('Possible Exceptions (Manual Review Recommended)', excepted, (el, i) => [
|
|
340
|
+
`${i + 1}. <${el.tagName}> "${el.selector}"`,
|
|
341
|
+
` Size: ${el.width}x${el.height}px (min: ${el.minDimension}px)`,
|
|
342
|
+
` Exception: ${el.exception} - ${el.exceptionDetails}`,
|
|
343
|
+
], 5);
|
|
344
|
+
const resolvedPath = saveAuditResult(result, {
|
|
345
|
+
...location,
|
|
346
|
+
defaultFile: DEFAULT_TARGET_SIZE_RESULT_FILE,
|
|
347
|
+
});
|
|
348
|
+
let screenshotPath;
|
|
349
|
+
if (screenshot) {
|
|
350
|
+
// Build annotations for screenshot
|
|
351
|
+
const passSelectors = targets
|
|
352
|
+
.filter((t) => Math.min(t.width, t.height) >= aaaThreshold)
|
|
353
|
+
.map((t) => t.selector);
|
|
354
|
+
const annotations = [
|
|
355
|
+
...passSelectors.map((s) => ({
|
|
356
|
+
selector: s,
|
|
357
|
+
label: 'PASS',
|
|
358
|
+
colorScheme: 'pass',
|
|
359
|
+
})),
|
|
360
|
+
...failAAAOnly.map((t) => ({
|
|
361
|
+
selector: t.selector,
|
|
362
|
+
label: 'AA Pass',
|
|
363
|
+
colorScheme: 'warning',
|
|
364
|
+
})),
|
|
365
|
+
...failAA.map((t) => ({
|
|
366
|
+
selector: t.selector,
|
|
367
|
+
label: 'AA Fail',
|
|
368
|
+
colorScheme: 'fail',
|
|
369
|
+
})),
|
|
370
|
+
...excepted.map((t) => ({
|
|
371
|
+
selector: t.selector,
|
|
372
|
+
label: 'Exception',
|
|
373
|
+
colorScheme: 'info',
|
|
374
|
+
})),
|
|
375
|
+
];
|
|
376
|
+
await addPageAnnotations(page, annotations);
|
|
377
|
+
screenshotPath = await takeAuditScreenshot(page, {
|
|
378
|
+
path: resolveScreenshotPath(resolvedPath, DEFAULT_TARGET_SIZE_SCREENSHOT_FILE),
|
|
379
|
+
});
|
|
380
|
+
// Legend
|
|
381
|
+
console.log('\nLegend:');
|
|
382
|
+
console.log(` PASS (green): >= ${aaaThreshold}px - AA Pass, AAA Pass`);
|
|
383
|
+
console.log(` AA Pass (orange): ${aaThreshold}-${aaaThreshold - 1}px - AA Pass, AAA Fail`);
|
|
384
|
+
console.log(` AA Fail (red): < ${aaThreshold}px - AA Fail, AAA Fail`);
|
|
385
|
+
console.log(` Exception (blue): Possible exception (manual review needed)`);
|
|
386
|
+
}
|
|
387
|
+
logOutputPaths(resolvedPath, screenshotPath);
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result types and JSON Schemas for the audit checks.
|
|
3
|
+
*
|
|
4
|
+
* The TypeScript types are the source of truth; the JSON Schemas are
|
|
5
|
+
* hand-written for consumers that validate the result files at runtime
|
|
6
|
+
* (e.g. an issue creator that reads `*-result.json`). They are intentionally
|
|
7
|
+
* permissive (no `additionalProperties: false`) so that additive changes to a
|
|
8
|
+
* result shape do not break downstream validation.
|
|
9
|
+
*/
|
|
10
|
+
export type { AxeViolationNode, AxeViolation, AxeAuditResult, FocusRecord, OnFocusViolation, FocusCheckResult, BoundingRect, FocusObscuredOverlap, FocusObscuredIssue, ReflowIssue, ClippedTextElement, ReflowCheckResult, TargetSizeException, TargetSizeIssue, TargetSizeSummary, TargetSizeCheckResult, } from '../types.js';
|
|
11
|
+
/** Minimal JSON Schema object shape (Draft 2020-12 compatible subset). */
|
|
12
|
+
export interface JsonSchema {
|
|
13
|
+
$schema?: string;
|
|
14
|
+
$id?: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
type?: string | string[];
|
|
17
|
+
properties?: Record<string, JsonSchema>;
|
|
18
|
+
items?: JsonSchema;
|
|
19
|
+
required?: string[];
|
|
20
|
+
enum?: unknown[];
|
|
21
|
+
description?: string;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
export declare const AXE_AUDIT_RESULT_SCHEMA: JsonSchema;
|
|
25
|
+
export declare const FOCUS_CHECK_RESULT_SCHEMA: JsonSchema;
|
|
26
|
+
export declare const REFLOW_CHECK_RESULT_SCHEMA: JsonSchema;
|
|
27
|
+
export declare const TARGET_SIZE_CHECK_RESULT_SCHEMA: JsonSchema;
|
|
28
|
+
/** All result schemas keyed by check id. */
|
|
29
|
+
export declare const RESULT_SCHEMAS: {
|
|
30
|
+
readonly 'axe-audit': JsonSchema;
|
|
31
|
+
readonly 'focus-indicator-check': JsonSchema;
|
|
32
|
+
readonly 'reflow-check': JsonSchema;
|
|
33
|
+
readonly 'target-size-check': JsonSchema;
|
|
34
|
+
};
|