@a11y-skills/audit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.ja.md +24 -2
  3. package/README.md +25 -2
  4. package/dist/constants.d.ts +82 -0
  5. package/dist/constants.js +223 -0
  6. package/dist/detectors/index.d.ts +1 -0
  7. package/dist/detectors/index.js +1 -0
  8. package/dist/detectors/pause-control.d.ts +18 -0
  9. package/dist/detectors/pause-control.js +206 -0
  10. package/dist/playwright/index.d.ts +7 -1
  11. package/dist/playwright/index.js +7 -1
  12. package/dist/playwright/runAutoPlayDetection.d.ts +36 -0
  13. package/dist/playwright/runAutoPlayDetection.js +137 -0
  14. package/dist/playwright/runAutocompleteAudit.d.ts +27 -0
  15. package/dist/playwright/runAutocompleteAudit.js +197 -0
  16. package/dist/playwright/runOrientationCheck.d.ts +40 -0
  17. package/dist/playwright/runOrientationCheck.js +164 -0
  18. package/dist/playwright/runTextSpacingCheck.d.ts +25 -0
  19. package/dist/playwright/runTextSpacingCheck.js +241 -0
  20. package/dist/playwright/runTimeLimitDetector.d.ts +31 -0
  21. package/dist/playwright/runTimeLimitDetector.js +194 -0
  22. package/dist/playwright/runZoomCheck.d.ts +42 -0
  23. package/dist/playwright/runZoomCheck.js +150 -0
  24. package/dist/schemas/index.d.ts +13 -1
  25. package/dist/schemas/index.js +122 -0
  26. package/dist/test-entries/auto-play-detection.d.ts +7 -0
  27. package/dist/test-entries/auto-play-detection.js +13 -0
  28. package/dist/test-entries/autocomplete-audit.d.ts +5 -0
  29. package/dist/test-entries/autocomplete-audit.js +11 -0
  30. package/dist/test-entries/orientation-check.d.ts +8 -0
  31. package/dist/test-entries/orientation-check.js +12 -0
  32. package/dist/test-entries/text-spacing-check.d.ts +5 -0
  33. package/dist/test-entries/text-spacing-check.js +11 -0
  34. package/dist/test-entries/time-limit-detector.d.ts +8 -0
  35. package/dist/test-entries/time-limit-detector.js +12 -0
  36. package/dist/test-entries/zoom-200-check.d.ts +5 -0
  37. package/dist/test-entries/zoom-200-check.js +11 -0
  38. package/dist/types.d.ts +151 -0
  39. package/dist/utils/image-compare.d.ts +24 -0
  40. package/dist/utils/image-compare.js +49 -0
  41. package/dist/utils/recommendations.d.ts +18 -0
  42. package/dist/utils/recommendations.js +88 -0
  43. package/dist/utils/test-harness.d.ts +6 -0
  44. package/dist/utils/test-harness.js +8 -0
  45. package/package.json +31 -2
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Orientation Check — WCAG 1.3.4 (Orientation)
3
+ *
4
+ * Renders the page in portrait (375x667) and landscape (667x375), detecting
5
+ * "rotate device" messages/overlays and whether the main content is hidden in
6
+ * either orientation. Reports if the page restricts content to a specific
7
+ * orientation.
8
+ *
9
+ * This function OWNS navigation: it sets each viewport then navigates to the
10
+ * target URL for that orientation (two full navigations, not a reload), so the
11
+ * page lays out fresh for each orientation.
12
+ *
13
+ * Limitations:
14
+ * - Heuristics may miss CSS-only orientation restrictions
15
+ * - Cannot detect JavaScript-based orientation detection without visual indicators
16
+ * - Manual verification needed for exceptions (e.g., camera apps)
17
+ */
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 { saveAuditResult, resolveOutputPath, takeAuditScreenshot, resolveScreenshotPath, requireTargetUrl, logAuditHeader, logOutputPaths, } from '../utils/test-harness.js';
20
+ /** Capture orientation state in browser context. */
21
+ function captureOrientationState(args) {
22
+ const { lockKeywords, mainContentSelectors } = args;
23
+ const bodyWidth = document.body.scrollWidth;
24
+ const bodyHeight = document.body.scrollHeight;
25
+ const visibleText = document.body.innerText || '';
26
+ const visibleTextLength = visibleText.length;
27
+ const lowerText = visibleText.toLowerCase();
28
+ // Search for lock message keywords
29
+ let lockMessageFound = false;
30
+ let lockMessageText = null;
31
+ for (const keyword of lockKeywords) {
32
+ if (lowerText.includes(keyword.toLowerCase())) {
33
+ lockMessageFound = true;
34
+ const allElements = document.querySelectorAll('*');
35
+ for (const el of allElements) {
36
+ const text = el.textContent?.toLowerCase() || '';
37
+ if (text.includes(keyword.toLowerCase()) && text.length < 200) {
38
+ lockMessageText = el.textContent?.trim().slice(0, 100) || null;
39
+ break;
40
+ }
41
+ }
42
+ break;
43
+ }
44
+ }
45
+ // Check if main content is hidden
46
+ let mainContentHidden = false;
47
+ for (const selector of mainContentSelectors) {
48
+ const mainEl = document.querySelector(selector);
49
+ if (mainEl) {
50
+ const style = window.getComputedStyle(mainEl);
51
+ const isHidden = style.display === 'none' ||
52
+ style.visibility === 'hidden' ||
53
+ parseFloat(style.opacity) === 0;
54
+ if (isHidden) {
55
+ mainContentHidden = true;
56
+ break;
57
+ }
58
+ const rect = mainEl.getBoundingClientRect();
59
+ if (rect.width < 50 || rect.height < 50) {
60
+ mainContentHidden = true;
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ return {
66
+ lockMessageFound,
67
+ lockMessageText,
68
+ mainContentHidden,
69
+ bodyWidth,
70
+ bodyHeight,
71
+ visibleTextLength,
72
+ };
73
+ }
74
+ /** Determine where orientation lock was detected. */
75
+ function determineLockLocation(portraitHasLock, landscapeHasLock) {
76
+ if (portraitHasLock && landscapeHasLock) {
77
+ return 'both';
78
+ }
79
+ if (portraitHasLock) {
80
+ return 'portrait';
81
+ }
82
+ if (landscapeHasLock) {
83
+ return 'landscape';
84
+ }
85
+ return 'none';
86
+ }
87
+ /** Log orientation state for a specific orientation. */
88
+ function logOrientationState(label, viewport, state) {
89
+ console.log(`\n${label} (${viewport.width}x${viewport.height}):`);
90
+ console.log(` Lock message found: ${state.lockMessageFound ? 'YES' : 'No'}`);
91
+ if (state.lockMessageText) {
92
+ console.log(` Message: "${state.lockMessageText}"`);
93
+ }
94
+ console.log(` Main content hidden: ${state.mainContentHidden ? 'YES' : 'No'}`);
95
+ console.log(` Body size: ${state.bodyWidth}x${state.bodyHeight}`);
96
+ }
97
+ /**
98
+ * Run the orientation check, navigating the page in both portrait and landscape,
99
+ * write the result JSON (and optionally screenshots), and return the parsed
100
+ * result.
101
+ */
102
+ export async function runOrientationCheck(options) {
103
+ const { page, targetUrl, screenshot = false, ...location } = options;
104
+ const url = requireTargetUrl(targetUrl);
105
+ const checkArgs = {
106
+ lockKeywords: [...ORIENTATION_LOCK_KEYWORDS],
107
+ mainContentSelectors: [...MAIN_CONTENT_SELECTORS],
108
+ };
109
+ // Resolve where the result will be written up front so screenshots can be
110
+ // placed next to it (mirrors saveAuditResult's resolution).
111
+ const resolvedPath = resolveOutputPath({
112
+ ...location,
113
+ defaultFile: DEFAULT_ORIENTATION_RESULT_FILE,
114
+ });
115
+ // Test portrait orientation
116
+ await page.setViewportSize(ORIENTATION_VIEWPORTS.portrait);
117
+ await page.goto(url, { waitUntil: 'networkidle' });
118
+ const portraitState = await page.evaluate(captureOrientationState, checkArgs);
119
+ let portraitScreenshotPath;
120
+ if (screenshot) {
121
+ portraitScreenshotPath = await takeAuditScreenshot(page, {
122
+ path: resolveScreenshotPath(resolvedPath, DEFAULT_ORIENTATION_PORTRAIT_SCREENSHOT_FILE),
123
+ });
124
+ }
125
+ // Test landscape orientation
126
+ await page.setViewportSize(ORIENTATION_VIEWPORTS.landscape);
127
+ await page.goto(url, { waitUntil: 'networkidle' });
128
+ const landscapeState = await page.evaluate(captureOrientationState, checkArgs);
129
+ let landscapeScreenshotPath;
130
+ if (screenshot) {
131
+ landscapeScreenshotPath = await takeAuditScreenshot(page, {
132
+ path: resolveScreenshotPath(resolvedPath, DEFAULT_ORIENTATION_LANDSCAPE_SCREENSHOT_FILE),
133
+ });
134
+ }
135
+ // Determine lock status
136
+ const portraitHasLock = portraitState.lockMessageFound || portraitState.mainContentHidden;
137
+ const landscapeHasLock = landscapeState.lockMessageFound || landscapeState.mainContentHidden;
138
+ const hasOrientationLock = portraitHasLock || landscapeHasLock;
139
+ const lockDetectedIn = determineLockLocation(portraitHasLock, landscapeHasLock);
140
+ const result = {
141
+ url: page.url(),
142
+ portrait: portraitState,
143
+ landscape: landscapeState,
144
+ hasOrientationLock,
145
+ lockDetectedIn,
146
+ };
147
+ // Output results
148
+ logAuditHeader('Orientation Check Results', 'WCAG 1.3.4', result.url);
149
+ logOrientationState('Portrait', ORIENTATION_VIEWPORTS.portrait, result.portrait);
150
+ logOrientationState('Landscape', ORIENTATION_VIEWPORTS.landscape, result.landscape);
151
+ console.log(`\nOrientation lock detected: ${result.hasOrientationLock ? 'YES' : 'No'}`);
152
+ if (result.hasOrientationLock) {
153
+ console.log(`Lock detected in: ${result.lockDetectedIn}`);
154
+ }
155
+ const writtenPath = saveAuditResult(result, {
156
+ ...location,
157
+ defaultFile: DEFAULT_ORIENTATION_RESULT_FILE,
158
+ });
159
+ const screenshotPaths = [portraitScreenshotPath, landscapeScreenshotPath]
160
+ .filter((p) => p !== undefined)
161
+ .join(', ');
162
+ logOutputPaths(writtenPath, screenshotPaths || undefined);
163
+ return result;
164
+ }
@@ -0,0 +1,25 @@
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 type { Page } from '@playwright/test';
11
+ import type { TextSpacingCheckResult } from '../types.js';
12
+ import { type OutputLocationOptions } from '../utils/test-harness.js';
13
+ export interface RunTextSpacingCheckOptions extends OutputLocationOptions {
14
+ /** A page already navigated to the target URL. */
15
+ page: Page;
16
+ /** Tolerance in pixels for clip detection (default: 2). */
17
+ tolerance?: number;
18
+ /** Whether to capture a screenshot next to the result file (default: false). */
19
+ screenshot?: boolean;
20
+ }
21
+ /**
22
+ * Run the text spacing check against the current page, write the result JSON
23
+ * (and optionally a screenshot), and return the parsed result.
24
+ */
25
+ export declare function runTextSpacingCheck(options: RunTextSpacingCheckOptions): Promise<TextSpacingCheckResult>;
@@ -0,0 +1,241 @@
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, } from '../constants.js';
11
+ import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
12
+ /** Collect metrics for elements with hidden overflow (browser context). */
13
+ function collectElementMetrics(args) {
14
+ const { checkSelector } = args;
15
+ function getUniqueSelector(element, elementIndex) {
16
+ if (element.id) {
17
+ return `#${element.id}`;
18
+ }
19
+ const path = [];
20
+ let current = element;
21
+ while (current && current !== document.body) {
22
+ let selector = current.tagName.toLowerCase();
23
+ const parent = current.parentElement;
24
+ if (parent) {
25
+ const childIndex = Array.from(parent.children).indexOf(current) + 1;
26
+ selector += `:nth-child(${childIndex})`;
27
+ }
28
+ path.unshift(selector);
29
+ current = parent;
30
+ }
31
+ return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
32
+ }
33
+ function isVisible(element) {
34
+ const style = window.getComputedStyle(element);
35
+ return (style.display !== 'none' &&
36
+ style.visibility !== 'hidden' &&
37
+ parseFloat(style.opacity) > 0);
38
+ }
39
+ function hasHiddenOverflow(style) {
40
+ return (style.overflow === 'hidden' ||
41
+ style.overflow === 'clip' ||
42
+ style.overflowX === 'hidden' ||
43
+ style.overflowX === 'clip' ||
44
+ style.overflowY === 'hidden' ||
45
+ style.overflowY === 'clip');
46
+ }
47
+ const elements = document.querySelectorAll(checkSelector);
48
+ const metrics = [];
49
+ elements.forEach((element, index) => {
50
+ if (!isVisible(element)) {
51
+ return;
52
+ }
53
+ const style = window.getComputedStyle(element);
54
+ const hasText = element.textContent && element.textContent.trim().length > 0;
55
+ if (hasText && hasHiddenOverflow(style)) {
56
+ metrics.push({
57
+ selector: getUniqueSelector(element, index),
58
+ tagName: element.tagName.toLowerCase(),
59
+ scrollWidth: element.scrollWidth,
60
+ scrollHeight: element.scrollHeight,
61
+ clientWidth: element.clientWidth,
62
+ clientHeight: element.clientHeight,
63
+ overflow: style.overflow,
64
+ overflowX: style.overflowX,
65
+ overflowY: style.overflowY,
66
+ });
67
+ }
68
+ });
69
+ return metrics;
70
+ }
71
+ /** Inject text spacing CSS and re-collect metrics (browser context). */
72
+ function injectSpacingAndCollect(args) {
73
+ const { css, checkSelector } = args;
74
+ const styleEl = document.createElement('style');
75
+ styleEl.id = 'wcag-text-spacing-override';
76
+ styleEl.textContent = css;
77
+ document.head.appendChild(styleEl);
78
+ // Force reflow
79
+ void document.body.offsetHeight;
80
+ function getUniqueSelector(element, elementIndex) {
81
+ if (element.id) {
82
+ return `#${element.id}`;
83
+ }
84
+ const path = [];
85
+ let current = element;
86
+ while (current && current !== document.body) {
87
+ let selector = current.tagName.toLowerCase();
88
+ const parent = current.parentElement;
89
+ if (parent) {
90
+ const childIndex = Array.from(parent.children).indexOf(current) + 1;
91
+ selector += `:nth-child(${childIndex})`;
92
+ }
93
+ path.unshift(selector);
94
+ current = parent;
95
+ }
96
+ return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
97
+ }
98
+ function isVisible(element) {
99
+ const style = window.getComputedStyle(element);
100
+ return (style.display !== 'none' &&
101
+ style.visibility !== 'hidden' &&
102
+ parseFloat(style.opacity) > 0);
103
+ }
104
+ function hasHiddenOverflow(style) {
105
+ return (style.overflow === 'hidden' ||
106
+ style.overflow === 'clip' ||
107
+ style.overflowX === 'hidden' ||
108
+ style.overflowX === 'clip' ||
109
+ style.overflowY === 'hidden' ||
110
+ style.overflowY === 'clip');
111
+ }
112
+ const elements = document.querySelectorAll(checkSelector);
113
+ const metrics = [];
114
+ elements.forEach((element, index) => {
115
+ if (!isVisible(element)) {
116
+ return;
117
+ }
118
+ const style = window.getComputedStyle(element);
119
+ const hasText = element.textContent && element.textContent.trim().length > 0;
120
+ if (hasText && hasHiddenOverflow(style)) {
121
+ metrics.push({
122
+ selector: getUniqueSelector(element, index),
123
+ tagName: element.tagName.toLowerCase(),
124
+ scrollWidth: element.scrollWidth,
125
+ scrollHeight: element.scrollHeight,
126
+ clientWidth: element.clientWidth,
127
+ clientHeight: element.clientHeight,
128
+ overflow: style.overflow,
129
+ overflowX: style.overflowX,
130
+ overflowY: style.overflowY,
131
+ });
132
+ }
133
+ });
134
+ return metrics;
135
+ }
136
+ function determineIssueType(hasHorizontalIssue, hasVerticalIssue) {
137
+ if (hasHorizontalIssue && hasVerticalIssue) {
138
+ return 'both';
139
+ }
140
+ if (hasVerticalIssue) {
141
+ return 'vertical-clip';
142
+ }
143
+ return 'horizontal-clip';
144
+ }
145
+ function detectClippingIssues(beforeMetrics, afterMetrics, tolerance) {
146
+ const beforeMap = new Map();
147
+ beforeMetrics.forEach((m) => beforeMap.set(m.selector, m));
148
+ const issues = [];
149
+ afterMetrics.forEach((after) => {
150
+ const before = beforeMap.get(after.selector);
151
+ const defaultBefore = {
152
+ scrollWidth: after.clientWidth,
153
+ scrollHeight: after.clientHeight,
154
+ clientWidth: after.clientWidth,
155
+ clientHeight: after.clientHeight,
156
+ };
157
+ const beforeData = before || defaultBefore;
158
+ const horizontalClipBefore = beforeData.scrollWidth > beforeData.clientWidth + tolerance;
159
+ const horizontalClipAfter = after.scrollWidth > after.clientWidth + tolerance;
160
+ const verticalClipBefore = beforeData.scrollHeight > beforeData.clientHeight + tolerance;
161
+ const verticalClipAfter = after.scrollHeight > after.clientHeight + tolerance;
162
+ const newHorizontalClip = !horizontalClipBefore && horizontalClipAfter;
163
+ const newVerticalClip = !verticalClipBefore && verticalClipAfter;
164
+ const worsenedHorizontalClip = horizontalClipBefore &&
165
+ horizontalClipAfter &&
166
+ after.scrollWidth - after.clientWidth >
167
+ beforeData.scrollWidth - beforeData.clientWidth + tolerance;
168
+ const worsenedVerticalClip = verticalClipBefore &&
169
+ verticalClipAfter &&
170
+ after.scrollHeight - after.clientHeight >
171
+ beforeData.scrollHeight - beforeData.clientHeight + tolerance;
172
+ const hasHorizontalIssue = newHorizontalClip || worsenedHorizontalClip;
173
+ const hasVerticalIssue = newVerticalClip || worsenedVerticalClip;
174
+ if (hasHorizontalIssue || hasVerticalIssue) {
175
+ issues.push({
176
+ selector: after.selector,
177
+ tagName: after.tagName,
178
+ beforeMetrics: {
179
+ scrollWidth: beforeData.scrollWidth,
180
+ scrollHeight: beforeData.scrollHeight,
181
+ clientWidth: beforeData.clientWidth,
182
+ clientHeight: beforeData.clientHeight,
183
+ },
184
+ afterMetrics: {
185
+ scrollWidth: after.scrollWidth,
186
+ scrollHeight: after.scrollHeight,
187
+ clientWidth: after.clientWidth,
188
+ clientHeight: after.clientHeight,
189
+ },
190
+ overflow: after.overflow,
191
+ overflowX: after.overflowX,
192
+ overflowY: after.overflowY,
193
+ issueType: determineIssueType(hasHorizontalIssue, hasVerticalIssue),
194
+ });
195
+ }
196
+ });
197
+ return issues;
198
+ }
199
+ /**
200
+ * Run the text spacing check against the current page, write the result JSON
201
+ * (and optionally a screenshot), and return the parsed result.
202
+ */
203
+ export async function runTextSpacingCheck(options) {
204
+ const { page, tolerance = TEXT_SPACING_CLIP_TOLERANCE, screenshot = false, ...location } = options;
205
+ const beforeMetrics = await page.evaluate(collectElementMetrics, {
206
+ checkSelector: TEXT_SPACING_CHECK_SELECTOR,
207
+ });
208
+ const afterMetrics = await page.evaluate(injectSpacingAndCollect, {
209
+ css: TEXT_SPACING_CSS,
210
+ checkSelector: TEXT_SPACING_CHECK_SELECTOR,
211
+ });
212
+ const clippedElements = detectClippingIssues(beforeMetrics, afterMetrics, tolerance);
213
+ const result = {
214
+ url: page.url(),
215
+ clippedElements,
216
+ totalElementsChecked: afterMetrics.length,
217
+ };
218
+ logAuditHeader('Text Spacing Check Results', 'WCAG 1.4.12', result.url);
219
+ logSummary({
220
+ 'Elements with overflow:hidden checked': result.totalElementsChecked,
221
+ 'Elements with clipping issues': result.clippedElements.length,
222
+ });
223
+ logIssueList('Clipped Elements', result.clippedElements, (el, i) => [
224
+ `${i + 1}. <${el.tagName}> "${el.selector}"`,
225
+ ` Issue: ${el.issueType}`,
226
+ ` Before: ${el.beforeMetrics.scrollWidth}x${el.beforeMetrics.scrollHeight} in ${el.beforeMetrics.clientWidth}x${el.beforeMetrics.clientHeight}`,
227
+ ` After: ${el.afterMetrics.scrollWidth}x${el.afterMetrics.scrollHeight} in ${el.afterMetrics.clientWidth}x${el.afterMetrics.clientHeight}`,
228
+ ]);
229
+ const resolvedPath = saveAuditResult(result, {
230
+ ...location,
231
+ defaultFile: DEFAULT_TEXT_SPACING_RESULT_FILE,
232
+ });
233
+ let screenshotPath;
234
+ if (screenshot) {
235
+ screenshotPath = await takeAuditScreenshot(page, {
236
+ path: resolveScreenshotPath(resolvedPath, DEFAULT_TEXT_SPACING_SCREENSHOT_FILE),
237
+ });
238
+ }
239
+ logOutputPaths(resolvedPath, screenshotPath);
240
+ return result;
241
+ }
@@ -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,194 @@
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, } from '../constants.js';
14
+ import { saveAuditResult, requireTargetUrl, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
15
+ /** Timer hook injected before page load (browser context). */
16
+ function createTimerHookScript(args) {
17
+ const { minMs, maxMs } = args;
18
+ const capturedTimers = [];
19
+ const originalSetTimeout = window.setTimeout;
20
+ const originalSetInterval = window.setInterval;
21
+ window.setTimeout = function (callback, delay, ...rest) {
22
+ const actualDelay = delay || 0;
23
+ if (actualDelay >= minMs && actualDelay <= maxMs) {
24
+ let callStack = null;
25
+ try {
26
+ throw new Error();
27
+ }
28
+ catch (e) {
29
+ callStack =
30
+ e.stack?.split('\n').slice(2, 5).join('\n') || null;
31
+ }
32
+ capturedTimers.push({ type: 'setTimeout', delayMs: actualDelay, callStack });
33
+ }
34
+ return originalSetTimeout.apply(window, [
35
+ callback,
36
+ delay,
37
+ ...rest,
38
+ ]);
39
+ };
40
+ window.setInterval = function (callback, delay, ...rest) {
41
+ const actualDelay = delay || 0;
42
+ if (actualDelay >= minMs && actualDelay <= maxMs) {
43
+ let callStack = null;
44
+ try {
45
+ throw new Error();
46
+ }
47
+ catch (e) {
48
+ callStack =
49
+ e.stack?.split('\n').slice(2, 5).join('\n') || null;
50
+ }
51
+ capturedTimers.push({ type: 'setInterval', delayMs: actualDelay, callStack });
52
+ }
53
+ return originalSetInterval.apply(window, [
54
+ callback,
55
+ delay,
56
+ ...rest,
57
+ ]);
58
+ };
59
+ window.__capturedTimers = capturedTimers;
60
+ }
61
+ /** Detect meta refresh + countdown indicators (browser context). */
62
+ function detectTimeLimitIndicators(args) {
63
+ const { keywords } = args;
64
+ function getUniqueSelector(element, elementIndex) {
65
+ if (element.id) {
66
+ return `#${element.id}`;
67
+ }
68
+ const path = [];
69
+ let current = element;
70
+ while (current && current !== document.body) {
71
+ let selector = current.tagName.toLowerCase();
72
+ const parent = current.parentElement;
73
+ if (parent) {
74
+ const childIndex = Array.from(parent.children).indexOf(current) + 1;
75
+ selector += `:nth-child(${childIndex})`;
76
+ }
77
+ path.unshift(selector);
78
+ current = parent;
79
+ }
80
+ return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
81
+ }
82
+ const metaRefresh = [];
83
+ const metaTags = document.querySelectorAll('meta[http-equiv="refresh"]');
84
+ metaTags.forEach((meta) => {
85
+ const content = meta.getAttribute('content');
86
+ if (content) {
87
+ const trimmed = content.trim();
88
+ const match = trimmed.match(/^(\d+)\s*(?:;\s*url\s*=\s*(.+))?$/i);
89
+ if (match) {
90
+ metaRefresh.push({
91
+ content,
92
+ seconds: parseInt(match[1] ?? '0', 10),
93
+ url: match[2]?.trim() || null,
94
+ });
95
+ }
96
+ }
97
+ });
98
+ const countdownIndicators = [];
99
+ const visibleText = document.body.innerText || '';
100
+ const lowerText = visibleText.toLowerCase();
101
+ for (const keyword of keywords) {
102
+ if (!lowerText.includes(keyword.toLowerCase())) {
103
+ continue;
104
+ }
105
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
106
+ let node;
107
+ let elementIndex = 0;
108
+ while ((node = walker.nextNode())) {
109
+ if (node.textContent?.toLowerCase().includes(keyword.toLowerCase())) {
110
+ const parent = node.parentElement;
111
+ if (parent) {
112
+ const fullText = parent.textContent?.trim().slice(0, 150) || '';
113
+ const alreadyAdded = countdownIndicators.some((c) => c.text === fullText);
114
+ if (!alreadyAdded && fullText.length > 0) {
115
+ countdownIndicators.push({
116
+ selector: getUniqueSelector(parent, elementIndex),
117
+ text: fullText,
118
+ tagName: parent.tagName.toLowerCase(),
119
+ });
120
+ }
121
+ }
122
+ }
123
+ elementIndex++;
124
+ }
125
+ }
126
+ return { metaRefresh, countdownIndicators };
127
+ }
128
+ /**
129
+ * Run the time limit detector, write the result JSON, and return the result.
130
+ */
131
+ export async function runTimeLimitDetector(options) {
132
+ const { page, targetUrl: targetUrlOption, minMs = TIME_LIMIT_MIN_MS, maxMs = TIME_LIMIT_THRESHOLD_MS, settleMs = 2000, ...location } = options;
133
+ const targetUrl = requireTargetUrl(targetUrlOption);
134
+ await page.addInitScript(createTimerHookScript, { minMs, maxMs });
135
+ await page.goto(targetUrl, { waitUntil: 'networkidle' });
136
+ await page.waitForTimeout(settleMs);
137
+ const timers = await page.evaluate(() => {
138
+ return (window.__capturedTimers || []);
139
+ });
140
+ const indicators = await page.evaluate(detectTimeLimitIndicators, {
141
+ keywords: [...TIME_LIMIT_KEYWORDS],
142
+ });
143
+ const hasTimeLimits = indicators.metaRefresh.length > 0 ||
144
+ timers.length > 0 ||
145
+ indicators.countdownIndicators.length > 0;
146
+ const result = {
147
+ url: page.url(),
148
+ metaRefresh: indicators.metaRefresh,
149
+ timers,
150
+ countdownIndicators: indicators.countdownIndicators,
151
+ hasTimeLimits,
152
+ };
153
+ logAuditHeader('Time Limit Detection Results', 'WCAG 2.2.1', result.url);
154
+ logSummary({
155
+ 'Meta refresh tags': result.metaRefresh.length,
156
+ [`Timers detected (${minMs / 1000}s - ${maxMs / 1000}s)`]: result.timers.length,
157
+ 'Countdown text indicators': result.countdownIndicators.length,
158
+ 'Time limits detected': result.hasTimeLimits,
159
+ });
160
+ logIssueList('Meta Refresh', result.metaRefresh, (meta, i) => {
161
+ const lines = [
162
+ `${i + 1}. content="${meta.content}"`,
163
+ ` Refresh in ${meta.seconds} seconds`,
164
+ ];
165
+ if (meta.url) {
166
+ lines.push(` Redirects to: ${meta.url}`);
167
+ }
168
+ return lines;
169
+ });
170
+ logIssueList('Detected Timers', result.timers, (timer, i) => {
171
+ const lines = [
172
+ `${i + 1}. ${timer.type} - ${timer.delayMs}ms (${(timer.delayMs / 1000).toFixed(1)}s)`,
173
+ ];
174
+ if (timer.callStack) {
175
+ lines.push(` Stack: ${timer.callStack.split('\n')[0]}`);
176
+ }
177
+ return lines;
178
+ });
179
+ logIssueList('Countdown Indicators', result.countdownIndicators, (indicator, i) => {
180
+ const truncatedText = indicator.text.length > 80
181
+ ? indicator.text.slice(0, 80) + '...'
182
+ : indicator.text;
183
+ return [
184
+ `${i + 1}. <${indicator.tagName}> "${indicator.selector}"`,
185
+ ` Text: "${truncatedText}"`,
186
+ ];
187
+ }, 5);
188
+ const resolvedPath = saveAuditResult(result, {
189
+ ...location,
190
+ defaultFile: DEFAULT_TIME_LIMIT_RESULT_FILE,
191
+ });
192
+ logOutputPaths(resolvedPath);
193
+ return result;
194
+ }