@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,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Spacing Check — WCAG 1.4.12 (Text Spacing)
|
|
3
|
+
*
|
|
4
|
+
* Captures baseline metrics, injects the WCAG 1.4.12 spacing overrides, and
|
|
5
|
+
* reports elements whose text becomes clipped (overflow:hidden) under the
|
|
6
|
+
* increased spacing.
|
|
7
|
+
*
|
|
8
|
+
* The caller is responsible for navigating the page before calling this.
|
|
9
|
+
*/
|
|
10
|
+
import { TEXT_SPACING_CSS, TEXT_SPACING_CLIP_TOLERANCE, TEXT_SPACING_CHECK_SELECTOR, DEFAULT_TEXT_SPACING_RESULT_FILE, DEFAULT_TEXT_SPACING_SCREENSHOT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
|
|
11
|
+
import { buildAuditResult, normalizeTextSpacingCheck, } from '../utils/axe-format.js';
|
|
12
|
+
import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
|
|
13
|
+
/** Collect metrics for elements with hidden overflow (browser context). */
|
|
14
|
+
function collectElementMetrics(args) {
|
|
15
|
+
const { checkSelector, htmlSnippetMaxLength } = args;
|
|
16
|
+
function getHtmlSnippet(element) {
|
|
17
|
+
let html = '';
|
|
18
|
+
try {
|
|
19
|
+
html = element.outerHTML || '';
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
html = '';
|
|
23
|
+
}
|
|
24
|
+
if (!html) {
|
|
25
|
+
return { html: `<${element.tagName.toLowerCase()}>`, htmlTruncated: false };
|
|
26
|
+
}
|
|
27
|
+
if (html.length > htmlSnippetMaxLength) {
|
|
28
|
+
return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
|
|
29
|
+
}
|
|
30
|
+
return { html, htmlTruncated: false };
|
|
31
|
+
}
|
|
32
|
+
function getUniqueSelector(element, elementIndex) {
|
|
33
|
+
if (element.id) {
|
|
34
|
+
return `#${element.id}`;
|
|
35
|
+
}
|
|
36
|
+
const path = [];
|
|
37
|
+
let current = element;
|
|
38
|
+
while (current && current !== document.body) {
|
|
39
|
+
let selector = current.tagName.toLowerCase();
|
|
40
|
+
const parent = current.parentElement;
|
|
41
|
+
if (parent) {
|
|
42
|
+
const childIndex = Array.from(parent.children).indexOf(current) + 1;
|
|
43
|
+
selector += `:nth-child(${childIndex})`;
|
|
44
|
+
}
|
|
45
|
+
path.unshift(selector);
|
|
46
|
+
current = parent;
|
|
47
|
+
}
|
|
48
|
+
return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
|
|
49
|
+
}
|
|
50
|
+
function isVisible(element) {
|
|
51
|
+
const style = window.getComputedStyle(element);
|
|
52
|
+
return (style.display !== 'none' &&
|
|
53
|
+
style.visibility !== 'hidden' &&
|
|
54
|
+
parseFloat(style.opacity) > 0);
|
|
55
|
+
}
|
|
56
|
+
function hasHiddenOverflow(style) {
|
|
57
|
+
return (style.overflow === 'hidden' ||
|
|
58
|
+
style.overflow === 'clip' ||
|
|
59
|
+
style.overflowX === 'hidden' ||
|
|
60
|
+
style.overflowX === 'clip' ||
|
|
61
|
+
style.overflowY === 'hidden' ||
|
|
62
|
+
style.overflowY === 'clip');
|
|
63
|
+
}
|
|
64
|
+
const elements = document.querySelectorAll(checkSelector);
|
|
65
|
+
const metrics = [];
|
|
66
|
+
elements.forEach((element, index) => {
|
|
67
|
+
if (!isVisible(element)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const style = window.getComputedStyle(element);
|
|
71
|
+
const hasText = element.textContent && element.textContent.trim().length > 0;
|
|
72
|
+
if (hasText && hasHiddenOverflow(style)) {
|
|
73
|
+
metrics.push({
|
|
74
|
+
selector: getUniqueSelector(element, index),
|
|
75
|
+
tagName: element.tagName.toLowerCase(),
|
|
76
|
+
...getHtmlSnippet(element),
|
|
77
|
+
scrollWidth: element.scrollWidth,
|
|
78
|
+
scrollHeight: element.scrollHeight,
|
|
79
|
+
clientWidth: element.clientWidth,
|
|
80
|
+
clientHeight: element.clientHeight,
|
|
81
|
+
overflow: style.overflow,
|
|
82
|
+
overflowX: style.overflowX,
|
|
83
|
+
overflowY: style.overflowY,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return metrics;
|
|
88
|
+
}
|
|
89
|
+
/** Inject text spacing CSS and re-collect metrics (browser context). */
|
|
90
|
+
function injectSpacingAndCollect(args) {
|
|
91
|
+
const { css, checkSelector, htmlSnippetMaxLength } = args;
|
|
92
|
+
const styleEl = document.createElement('style');
|
|
93
|
+
styleEl.id = 'wcag-text-spacing-override';
|
|
94
|
+
styleEl.textContent = css;
|
|
95
|
+
document.head.appendChild(styleEl);
|
|
96
|
+
// Force reflow
|
|
97
|
+
void document.body.offsetHeight;
|
|
98
|
+
function getHtmlSnippet(element) {
|
|
99
|
+
let html = '';
|
|
100
|
+
try {
|
|
101
|
+
html = element.outerHTML || '';
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
html = '';
|
|
105
|
+
}
|
|
106
|
+
if (!html) {
|
|
107
|
+
return { html: `<${element.tagName.toLowerCase()}>`, htmlTruncated: false };
|
|
108
|
+
}
|
|
109
|
+
if (html.length > htmlSnippetMaxLength) {
|
|
110
|
+
return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
|
|
111
|
+
}
|
|
112
|
+
return { html, htmlTruncated: false };
|
|
113
|
+
}
|
|
114
|
+
function getUniqueSelector(element, elementIndex) {
|
|
115
|
+
if (element.id) {
|
|
116
|
+
return `#${element.id}`;
|
|
117
|
+
}
|
|
118
|
+
const path = [];
|
|
119
|
+
let current = element;
|
|
120
|
+
while (current && current !== document.body) {
|
|
121
|
+
let selector = current.tagName.toLowerCase();
|
|
122
|
+
const parent = current.parentElement;
|
|
123
|
+
if (parent) {
|
|
124
|
+
const childIndex = Array.from(parent.children).indexOf(current) + 1;
|
|
125
|
+
selector += `:nth-child(${childIndex})`;
|
|
126
|
+
}
|
|
127
|
+
path.unshift(selector);
|
|
128
|
+
current = parent;
|
|
129
|
+
}
|
|
130
|
+
return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
|
|
131
|
+
}
|
|
132
|
+
function isVisible(element) {
|
|
133
|
+
const style = window.getComputedStyle(element);
|
|
134
|
+
return (style.display !== 'none' &&
|
|
135
|
+
style.visibility !== 'hidden' &&
|
|
136
|
+
parseFloat(style.opacity) > 0);
|
|
137
|
+
}
|
|
138
|
+
function hasHiddenOverflow(style) {
|
|
139
|
+
return (style.overflow === 'hidden' ||
|
|
140
|
+
style.overflow === 'clip' ||
|
|
141
|
+
style.overflowX === 'hidden' ||
|
|
142
|
+
style.overflowX === 'clip' ||
|
|
143
|
+
style.overflowY === 'hidden' ||
|
|
144
|
+
style.overflowY === 'clip');
|
|
145
|
+
}
|
|
146
|
+
const elements = document.querySelectorAll(checkSelector);
|
|
147
|
+
const metrics = [];
|
|
148
|
+
elements.forEach((element, index) => {
|
|
149
|
+
if (!isVisible(element)) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const style = window.getComputedStyle(element);
|
|
153
|
+
const hasText = element.textContent && element.textContent.trim().length > 0;
|
|
154
|
+
if (hasText && hasHiddenOverflow(style)) {
|
|
155
|
+
metrics.push({
|
|
156
|
+
selector: getUniqueSelector(element, index),
|
|
157
|
+
tagName: element.tagName.toLowerCase(),
|
|
158
|
+
...getHtmlSnippet(element),
|
|
159
|
+
scrollWidth: element.scrollWidth,
|
|
160
|
+
scrollHeight: element.scrollHeight,
|
|
161
|
+
clientWidth: element.clientWidth,
|
|
162
|
+
clientHeight: element.clientHeight,
|
|
163
|
+
overflow: style.overflow,
|
|
164
|
+
overflowX: style.overflowX,
|
|
165
|
+
overflowY: style.overflowY,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return metrics;
|
|
170
|
+
}
|
|
171
|
+
function determineIssueType(hasHorizontalIssue, hasVerticalIssue) {
|
|
172
|
+
if (hasHorizontalIssue && hasVerticalIssue) {
|
|
173
|
+
return 'both';
|
|
174
|
+
}
|
|
175
|
+
if (hasVerticalIssue) {
|
|
176
|
+
return 'vertical-clip';
|
|
177
|
+
}
|
|
178
|
+
return 'horizontal-clip';
|
|
179
|
+
}
|
|
180
|
+
function detectClippingIssues(beforeMetrics, afterMetrics, tolerance) {
|
|
181
|
+
const beforeMap = new Map();
|
|
182
|
+
beforeMetrics.forEach((m) => beforeMap.set(m.selector, m));
|
|
183
|
+
const issues = [];
|
|
184
|
+
afterMetrics.forEach((after) => {
|
|
185
|
+
const before = beforeMap.get(after.selector);
|
|
186
|
+
const defaultBefore = {
|
|
187
|
+
scrollWidth: after.clientWidth,
|
|
188
|
+
scrollHeight: after.clientHeight,
|
|
189
|
+
clientWidth: after.clientWidth,
|
|
190
|
+
clientHeight: after.clientHeight,
|
|
191
|
+
};
|
|
192
|
+
const beforeData = before || defaultBefore;
|
|
193
|
+
const horizontalClipBefore = beforeData.scrollWidth > beforeData.clientWidth + tolerance;
|
|
194
|
+
const horizontalClipAfter = after.scrollWidth > after.clientWidth + tolerance;
|
|
195
|
+
const verticalClipBefore = beforeData.scrollHeight > beforeData.clientHeight + tolerance;
|
|
196
|
+
const verticalClipAfter = after.scrollHeight > after.clientHeight + tolerance;
|
|
197
|
+
const newHorizontalClip = !horizontalClipBefore && horizontalClipAfter;
|
|
198
|
+
const newVerticalClip = !verticalClipBefore && verticalClipAfter;
|
|
199
|
+
const worsenedHorizontalClip = horizontalClipBefore &&
|
|
200
|
+
horizontalClipAfter &&
|
|
201
|
+
after.scrollWidth - after.clientWidth >
|
|
202
|
+
beforeData.scrollWidth - beforeData.clientWidth + tolerance;
|
|
203
|
+
const worsenedVerticalClip = verticalClipBefore &&
|
|
204
|
+
verticalClipAfter &&
|
|
205
|
+
after.scrollHeight - after.clientHeight >
|
|
206
|
+
beforeData.scrollHeight - beforeData.clientHeight + tolerance;
|
|
207
|
+
const hasHorizontalIssue = newHorizontalClip || worsenedHorizontalClip;
|
|
208
|
+
const hasVerticalIssue = newVerticalClip || worsenedVerticalClip;
|
|
209
|
+
if (hasHorizontalIssue || hasVerticalIssue) {
|
|
210
|
+
issues.push({
|
|
211
|
+
selector: after.selector,
|
|
212
|
+
tagName: after.tagName,
|
|
213
|
+
html: after.html,
|
|
214
|
+
htmlTruncated: after.htmlTruncated,
|
|
215
|
+
beforeMetrics: {
|
|
216
|
+
scrollWidth: beforeData.scrollWidth,
|
|
217
|
+
scrollHeight: beforeData.scrollHeight,
|
|
218
|
+
clientWidth: beforeData.clientWidth,
|
|
219
|
+
clientHeight: beforeData.clientHeight,
|
|
220
|
+
},
|
|
221
|
+
afterMetrics: {
|
|
222
|
+
scrollWidth: after.scrollWidth,
|
|
223
|
+
scrollHeight: after.scrollHeight,
|
|
224
|
+
clientWidth: after.clientWidth,
|
|
225
|
+
clientHeight: after.clientHeight,
|
|
226
|
+
},
|
|
227
|
+
overflow: after.overflow,
|
|
228
|
+
overflowX: after.overflowX,
|
|
229
|
+
overflowY: after.overflowY,
|
|
230
|
+
issueType: determineIssueType(hasHorizontalIssue, hasVerticalIssue),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
return issues;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Run the text spacing check against the current page, write the result JSON
|
|
238
|
+
* (and optionally a screenshot), and return the parsed result.
|
|
239
|
+
*/
|
|
240
|
+
export async function runTextSpacingCheck(options) {
|
|
241
|
+
const { page, tolerance = TEXT_SPACING_CLIP_TOLERANCE, screenshot = false, ...location } = options;
|
|
242
|
+
const beforeMetrics = await page.evaluate(collectElementMetrics, {
|
|
243
|
+
checkSelector: TEXT_SPACING_CHECK_SELECTOR,
|
|
244
|
+
htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
|
|
245
|
+
});
|
|
246
|
+
const afterMetrics = await page.evaluate(injectSpacingAndCollect, {
|
|
247
|
+
css: TEXT_SPACING_CSS,
|
|
248
|
+
checkSelector: TEXT_SPACING_CHECK_SELECTOR,
|
|
249
|
+
htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
|
|
250
|
+
});
|
|
251
|
+
const clippedElements = detectClippingIssues(beforeMetrics, afterMetrics, tolerance);
|
|
252
|
+
const details = {
|
|
253
|
+
clippedElements,
|
|
254
|
+
totalElementsChecked: afterMetrics.length,
|
|
255
|
+
};
|
|
256
|
+
const result = buildAuditResult({
|
|
257
|
+
source: 'text-spacing-check',
|
|
258
|
+
url: page.url(),
|
|
259
|
+
details,
|
|
260
|
+
buckets: normalizeTextSpacingCheck(details),
|
|
261
|
+
});
|
|
262
|
+
logAuditHeader('Text Spacing Check Results', 'WCAG 1.4.12', result.url);
|
|
263
|
+
logSummary({
|
|
264
|
+
'Elements with overflow:hidden checked': details.totalElementsChecked,
|
|
265
|
+
'Elements with clipping issues': details.clippedElements.length,
|
|
266
|
+
});
|
|
267
|
+
logIssueList('Clipped Elements', details.clippedElements, (el, i) => [
|
|
268
|
+
`${i + 1}. <${el.tagName}> "${el.selector}"`,
|
|
269
|
+
` Issue: ${el.issueType}`,
|
|
270
|
+
` Before: ${el.beforeMetrics.scrollWidth}x${el.beforeMetrics.scrollHeight} in ${el.beforeMetrics.clientWidth}x${el.beforeMetrics.clientHeight}`,
|
|
271
|
+
` After: ${el.afterMetrics.scrollWidth}x${el.afterMetrics.scrollHeight} in ${el.afterMetrics.clientWidth}x${el.afterMetrics.clientHeight}`,
|
|
272
|
+
]);
|
|
273
|
+
const resolvedPath = saveAuditResult(result, {
|
|
274
|
+
...location,
|
|
275
|
+
defaultFile: DEFAULT_TEXT_SPACING_RESULT_FILE,
|
|
276
|
+
});
|
|
277
|
+
let screenshotPath;
|
|
278
|
+
if (screenshot) {
|
|
279
|
+
screenshotPath = await takeAuditScreenshot(page, {
|
|
280
|
+
path: resolveScreenshotPath(resolvedPath, DEFAULT_TEXT_SPACING_SCREENSHOT_FILE),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
logOutputPaths(resolvedPath, screenshotPath);
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time Limit Detector — WCAG 2.2.1 (Timing Adjustable)
|
|
3
|
+
*
|
|
4
|
+
* Hooks setTimeout/setInterval (via an init script injected BEFORE navigation),
|
|
5
|
+
* checks meta refresh tags, and scans visible text for countdown/timeout
|
|
6
|
+
* keywords.
|
|
7
|
+
*
|
|
8
|
+
* Because the timer hook must be installed before page scripts run, this
|
|
9
|
+
* function OWNS navigation: pass an un-navigated `page` and a `targetUrl`
|
|
10
|
+
* (option → `TEST_PAGE` env → required). Unlike the focus check it does not
|
|
11
|
+
* need a fresh context per attempt, so it stays page-based.
|
|
12
|
+
*/
|
|
13
|
+
import type { Page } from '@playwright/test';
|
|
14
|
+
import type { TimeLimitDetectorResult } from '../types.js';
|
|
15
|
+
import { type OutputLocationOptions } from '../utils/test-harness.js';
|
|
16
|
+
export interface RunTimeLimitDetectorOptions extends OutputLocationOptions {
|
|
17
|
+
/** An un-navigated page (this function navigates after installing the timer hook). */
|
|
18
|
+
page: Page;
|
|
19
|
+
/** Target URL. Falls back to the `TEST_PAGE` env var; required. */
|
|
20
|
+
targetUrl?: string;
|
|
21
|
+
/** Minimum timer delay to capture, ms (default: 10000). */
|
|
22
|
+
minMs?: number;
|
|
23
|
+
/** Maximum timer delay to capture, ms (default: 600000). */
|
|
24
|
+
maxMs?: number;
|
|
25
|
+
/** How long to wait after load for timers to register, ms (default: 2000). */
|
|
26
|
+
settleMs?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Run the time limit detector, write the result JSON, and return the result.
|
|
30
|
+
*/
|
|
31
|
+
export declare function runTimeLimitDetector(options: RunTimeLimitDetectorOptions): Promise<TimeLimitDetectorResult>;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time Limit Detector — WCAG 2.2.1 (Timing Adjustable)
|
|
3
|
+
*
|
|
4
|
+
* Hooks setTimeout/setInterval (via an init script injected BEFORE navigation),
|
|
5
|
+
* checks meta refresh tags, and scans visible text for countdown/timeout
|
|
6
|
+
* keywords.
|
|
7
|
+
*
|
|
8
|
+
* Because the timer hook must be installed before page scripts run, this
|
|
9
|
+
* function OWNS navigation: pass an un-navigated `page` and a `targetUrl`
|
|
10
|
+
* (option → `TEST_PAGE` env → required). Unlike the focus check it does not
|
|
11
|
+
* need a fresh context per attempt, so it stays page-based.
|
|
12
|
+
*/
|
|
13
|
+
import { TIME_LIMIT_KEYWORDS, TIME_LIMIT_THRESHOLD_MS, TIME_LIMIT_MIN_MS, DEFAULT_TIME_LIMIT_RESULT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
|
|
14
|
+
import { buildAuditResult, normalizeTimeLimitDetector, } from '../utils/axe-format.js';
|
|
15
|
+
import { saveAuditResult, requireTargetUrl, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
|
|
16
|
+
/** Timer hook injected before page load (browser context). */
|
|
17
|
+
function createTimerHookScript(args) {
|
|
18
|
+
const { minMs, maxMs } = args;
|
|
19
|
+
const capturedTimers = [];
|
|
20
|
+
const originalSetTimeout = window.setTimeout;
|
|
21
|
+
const originalSetInterval = window.setInterval;
|
|
22
|
+
window.setTimeout = function (callback, delay, ...rest) {
|
|
23
|
+
const actualDelay = delay || 0;
|
|
24
|
+
if (actualDelay >= minMs && actualDelay <= maxMs) {
|
|
25
|
+
let callStack = null;
|
|
26
|
+
try {
|
|
27
|
+
throw new Error();
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
callStack =
|
|
31
|
+
e.stack?.split('\n').slice(2, 5).join('\n') || null;
|
|
32
|
+
}
|
|
33
|
+
capturedTimers.push({ type: 'setTimeout', delayMs: actualDelay, callStack });
|
|
34
|
+
}
|
|
35
|
+
return originalSetTimeout.apply(window, [
|
|
36
|
+
callback,
|
|
37
|
+
delay,
|
|
38
|
+
...rest,
|
|
39
|
+
]);
|
|
40
|
+
};
|
|
41
|
+
window.setInterval = function (callback, delay, ...rest) {
|
|
42
|
+
const actualDelay = delay || 0;
|
|
43
|
+
if (actualDelay >= minMs && actualDelay <= maxMs) {
|
|
44
|
+
let callStack = null;
|
|
45
|
+
try {
|
|
46
|
+
throw new Error();
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
callStack =
|
|
50
|
+
e.stack?.split('\n').slice(2, 5).join('\n') || null;
|
|
51
|
+
}
|
|
52
|
+
capturedTimers.push({ type: 'setInterval', delayMs: actualDelay, callStack });
|
|
53
|
+
}
|
|
54
|
+
return originalSetInterval.apply(window, [
|
|
55
|
+
callback,
|
|
56
|
+
delay,
|
|
57
|
+
...rest,
|
|
58
|
+
]);
|
|
59
|
+
};
|
|
60
|
+
window.__capturedTimers = capturedTimers;
|
|
61
|
+
}
|
|
62
|
+
/** Detect meta refresh + countdown indicators (browser context). */
|
|
63
|
+
function detectTimeLimitIndicators(args) {
|
|
64
|
+
const { keywords, htmlSnippetMaxLength } = args;
|
|
65
|
+
function getHtmlSnippet(element) {
|
|
66
|
+
let html = '';
|
|
67
|
+
try {
|
|
68
|
+
html = element.outerHTML || '';
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
html = '';
|
|
72
|
+
}
|
|
73
|
+
if (!html) {
|
|
74
|
+
return { html: `<${element.tagName.toLowerCase()}>`, htmlTruncated: false };
|
|
75
|
+
}
|
|
76
|
+
if (html.length > htmlSnippetMaxLength) {
|
|
77
|
+
return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
|
|
78
|
+
}
|
|
79
|
+
return { html, htmlTruncated: false };
|
|
80
|
+
}
|
|
81
|
+
function getUniqueSelector(element, elementIndex) {
|
|
82
|
+
if (element.id) {
|
|
83
|
+
return `#${element.id}`;
|
|
84
|
+
}
|
|
85
|
+
const path = [];
|
|
86
|
+
let current = element;
|
|
87
|
+
while (current && current !== document.body) {
|
|
88
|
+
let selector = current.tagName.toLowerCase();
|
|
89
|
+
const parent = current.parentElement;
|
|
90
|
+
if (parent) {
|
|
91
|
+
const childIndex = Array.from(parent.children).indexOf(current) + 1;
|
|
92
|
+
selector += `:nth-child(${childIndex})`;
|
|
93
|
+
}
|
|
94
|
+
path.unshift(selector);
|
|
95
|
+
current = parent;
|
|
96
|
+
}
|
|
97
|
+
return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
|
|
98
|
+
}
|
|
99
|
+
const metaRefresh = [];
|
|
100
|
+
const metaTags = document.querySelectorAll('meta[http-equiv="refresh"]');
|
|
101
|
+
metaTags.forEach((meta) => {
|
|
102
|
+
const content = meta.getAttribute('content');
|
|
103
|
+
if (content) {
|
|
104
|
+
const trimmed = content.trim();
|
|
105
|
+
const match = trimmed.match(/^(\d+)\s*(?:;\s*url\s*=\s*(.+))?$/i);
|
|
106
|
+
if (match) {
|
|
107
|
+
metaRefresh.push({
|
|
108
|
+
content,
|
|
109
|
+
seconds: parseInt(match[1] ?? '0', 10),
|
|
110
|
+
url: match[2]?.trim() || null,
|
|
111
|
+
...getHtmlSnippet(meta),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
const countdownIndicators = [];
|
|
117
|
+
const visibleText = document.body.innerText || '';
|
|
118
|
+
const lowerText = visibleText.toLowerCase();
|
|
119
|
+
for (const keyword of keywords) {
|
|
120
|
+
if (!lowerText.includes(keyword.toLowerCase())) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
|
|
124
|
+
let node;
|
|
125
|
+
let elementIndex = 0;
|
|
126
|
+
while ((node = walker.nextNode())) {
|
|
127
|
+
if (node.textContent?.toLowerCase().includes(keyword.toLowerCase())) {
|
|
128
|
+
const parent = node.parentElement;
|
|
129
|
+
if (parent) {
|
|
130
|
+
const fullText = parent.textContent?.trim().slice(0, 150) || '';
|
|
131
|
+
const alreadyAdded = countdownIndicators.some((c) => c.text === fullText);
|
|
132
|
+
if (!alreadyAdded && fullText.length > 0) {
|
|
133
|
+
countdownIndicators.push({
|
|
134
|
+
selector: getUniqueSelector(parent, elementIndex),
|
|
135
|
+
text: fullText,
|
|
136
|
+
tagName: parent.tagName.toLowerCase(),
|
|
137
|
+
...getHtmlSnippet(parent),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
elementIndex++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { metaRefresh, countdownIndicators };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Run the time limit detector, write the result JSON, and return the result.
|
|
149
|
+
*/
|
|
150
|
+
export async function runTimeLimitDetector(options) {
|
|
151
|
+
const { page, targetUrl: targetUrlOption, minMs = TIME_LIMIT_MIN_MS, maxMs = TIME_LIMIT_THRESHOLD_MS, settleMs = 2000, ...location } = options;
|
|
152
|
+
const targetUrl = requireTargetUrl(targetUrlOption);
|
|
153
|
+
await page.addInitScript(createTimerHookScript, { minMs, maxMs });
|
|
154
|
+
await page.goto(targetUrl, { waitUntil: 'networkidle' });
|
|
155
|
+
await page.waitForTimeout(settleMs);
|
|
156
|
+
const timers = await page.evaluate(() => {
|
|
157
|
+
return (window.__capturedTimers || []);
|
|
158
|
+
});
|
|
159
|
+
const indicators = await page.evaluate(detectTimeLimitIndicators, {
|
|
160
|
+
keywords: [...TIME_LIMIT_KEYWORDS],
|
|
161
|
+
htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
|
|
162
|
+
});
|
|
163
|
+
const hasTimeLimits = indicators.metaRefresh.length > 0 ||
|
|
164
|
+
timers.length > 0 ||
|
|
165
|
+
indicators.countdownIndicators.length > 0;
|
|
166
|
+
const details = {
|
|
167
|
+
metaRefresh: indicators.metaRefresh,
|
|
168
|
+
timers,
|
|
169
|
+
countdownIndicators: indicators.countdownIndicators,
|
|
170
|
+
hasTimeLimits,
|
|
171
|
+
};
|
|
172
|
+
const result = buildAuditResult({
|
|
173
|
+
source: 'time-limit-detector',
|
|
174
|
+
url: page.url(),
|
|
175
|
+
details,
|
|
176
|
+
buckets: normalizeTimeLimitDetector(details),
|
|
177
|
+
});
|
|
178
|
+
logAuditHeader('Time Limit Detection Results', 'WCAG 2.2.1', result.url);
|
|
179
|
+
logSummary({
|
|
180
|
+
'Meta refresh tags': details.metaRefresh.length,
|
|
181
|
+
[`Timers detected (${minMs / 1000}s - ${maxMs / 1000}s)`]: details.timers.length,
|
|
182
|
+
'Countdown text indicators': details.countdownIndicators.length,
|
|
183
|
+
'Time limits detected': details.hasTimeLimits,
|
|
184
|
+
});
|
|
185
|
+
logIssueList('Meta Refresh', details.metaRefresh, (meta, i) => {
|
|
186
|
+
const lines = [
|
|
187
|
+
`${i + 1}. content="${meta.content}"`,
|
|
188
|
+
` Refresh in ${meta.seconds} seconds`,
|
|
189
|
+
];
|
|
190
|
+
if (meta.url) {
|
|
191
|
+
lines.push(` Redirects to: ${meta.url}`);
|
|
192
|
+
}
|
|
193
|
+
return lines;
|
|
194
|
+
});
|
|
195
|
+
logIssueList('Detected Timers', details.timers, (timer, i) => {
|
|
196
|
+
const lines = [
|
|
197
|
+
`${i + 1}. ${timer.type} - ${timer.delayMs}ms (${(timer.delayMs / 1000).toFixed(1)}s)`,
|
|
198
|
+
];
|
|
199
|
+
if (timer.callStack) {
|
|
200
|
+
lines.push(` Stack: ${timer.callStack.split('\n')[0]}`);
|
|
201
|
+
}
|
|
202
|
+
return lines;
|
|
203
|
+
});
|
|
204
|
+
logIssueList('Countdown Indicators', details.countdownIndicators, (indicator, i) => {
|
|
205
|
+
const truncatedText = indicator.text.length > 80
|
|
206
|
+
? indicator.text.slice(0, 80) + '...'
|
|
207
|
+
: indicator.text;
|
|
208
|
+
return [
|
|
209
|
+
`${i + 1}. <${indicator.tagName}> "${indicator.selector}"`,
|
|
210
|
+
` Text: "${truncatedText}"`,
|
|
211
|
+
];
|
|
212
|
+
}, 5);
|
|
213
|
+
const resolvedPath = saveAuditResult(result, {
|
|
214
|
+
...location,
|
|
215
|
+
defaultFile: DEFAULT_TIME_LIMIT_RESULT_FILE,
|
|
216
|
+
});
|
|
217
|
+
logOutputPaths(resolvedPath);
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zoom 200% Check — WCAG 1.4.4 (Resize Text)
|
|
3
|
+
*
|
|
4
|
+
* Sets a standard viewport (default 1280x720), applies 200% zoom via the CSS
|
|
5
|
+
* `zoom` property, then detects horizontal scrolling and elements whose content
|
|
6
|
+
* becomes clipped (overflow:hidden) under zoom.
|
|
7
|
+
*
|
|
8
|
+
* If a `targetUrl` (or the `TEST_PAGE` env var) is available, this function
|
|
9
|
+
* owns navigation: it sets the base viewport BEFORE navigating (matching the
|
|
10
|
+
* legacy script, so pages that read the viewport at load time behave the same).
|
|
11
|
+
* Otherwise it operates on the already-navigated page (just sets the viewport).
|
|
12
|
+
*
|
|
13
|
+
* Limitations:
|
|
14
|
+
* - CSS zoom is engine-specific; actual browser zoom may behave differently
|
|
15
|
+
* - Does not verify responsive breakpoint behavior
|
|
16
|
+
* - Manual verification needed for complex interactions at zoom
|
|
17
|
+
*/
|
|
18
|
+
import type { Page } from '@playwright/test';
|
|
19
|
+
import type { ZoomCheckResult } from '../types.js';
|
|
20
|
+
import { type OutputLocationOptions } from '../utils/test-harness.js';
|
|
21
|
+
export interface RunZoomCheckOptions extends OutputLocationOptions {
|
|
22
|
+
/** Page to run the check on (navigated by this function if `targetUrl`/`TEST_PAGE` is set). */
|
|
23
|
+
page: Page;
|
|
24
|
+
/**
|
|
25
|
+
* Target URL. If provided (or `TEST_PAGE` is set), the function sets the base
|
|
26
|
+
* viewport then navigates, for results identical to the legacy script. If
|
|
27
|
+
* omitted, the page is assumed already navigated.
|
|
28
|
+
*/
|
|
29
|
+
targetUrl?: string;
|
|
30
|
+
/** Base viewport applied before zoom (default: 1280x720). */
|
|
31
|
+
viewport?: {
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
};
|
|
35
|
+
/** Whether to capture a screenshot next to the result file (default: false). */
|
|
36
|
+
screenshot?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run the zoom 200% check against the current page, write the result JSON
|
|
40
|
+
* (and optionally a screenshot), and return the parsed result.
|
|
41
|
+
*/
|
|
42
|
+
export declare function runZoomCheck(options: RunZoomCheckOptions): Promise<ZoomCheckResult>;
|