@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,206 @@
1
+ /**
2
+ * Pause/Stop control detection for auto-playing content.
3
+ * WCAG 1.4.2 (Audio Control) / 2.2.2 (Pause, Stop, Hide)
4
+ */
5
+ import * as path from 'node:path';
6
+ import { compareImages, formatDiffPercent, hasSignificantChange, } from '../utils/image-compare.js';
7
+ import { PAUSE_KEYWORDS, CONTROL_CLASS_PATTERNS, CAROUSEL_PATTERNS, NAV_KEYWORDS, SVG_METADATA_PATTERNS, MAX_PARENT_LEVELS, PAUSE_CLICK_WAIT, SCREENSHOT_COMPARISON_WAIT, } from '../constants.js';
8
+ /**
9
+ * Detect pause/stop controls in the page.
10
+ */
11
+ export async function detectPauseControls(page) {
12
+ const config = {
13
+ pauseKeywords: [...PAUSE_KEYWORDS],
14
+ controlClassPatterns: [...CONTROL_CLASS_PATTERNS],
15
+ carouselPatterns: [...CAROUSEL_PATTERNS],
16
+ navKeywords: [...NAV_KEYWORDS],
17
+ svgMetadataPatterns: [...SVG_METADATA_PATTERNS],
18
+ maxParentLevels: MAX_PARENT_LEVELS,
19
+ };
20
+ return await page.evaluate((cfg) => {
21
+ const controls = [];
22
+ const carouselIndicators = [];
23
+ let hasAccessibleName = false;
24
+ const getSelector = (el) => {
25
+ if (el.id)
26
+ return `#${el.id}`;
27
+ if (el.className) {
28
+ const classes = el.className
29
+ .toString()
30
+ .split(' ')
31
+ .filter((c) => c)
32
+ .join('.');
33
+ if (classes)
34
+ return `${el.tagName.toLowerCase()}.${classes}`;
35
+ }
36
+ return el.tagName.toLowerCase();
37
+ };
38
+ const interactiveElements = document.querySelectorAll('button, [role="button"], input[type="button"], [tabindex="0"]');
39
+ interactiveElements.forEach((el) => {
40
+ const element = el;
41
+ const tagName = element.tagName.toLowerCase();
42
+ const ariaLabel = element.getAttribute('aria-label') || '';
43
+ const textContent = element.textContent?.trim() || '';
44
+ const className = element.className?.toString() || '';
45
+ let accessibleName = ariaLabel || textContent;
46
+ const isSvgMetadata = cfg.svgMetadataPatterns.some((pattern) => accessibleName.toLowerCase().includes(pattern));
47
+ if (isSvgMetadata) {
48
+ accessibleName = '';
49
+ }
50
+ const lowerName = accessibleName.toLowerCase();
51
+ const lowerClass = className.toLowerCase();
52
+ const nameMatch = cfg.pauseKeywords.some((kw) => lowerName.includes(kw.toLowerCase()));
53
+ const classMatch = cfg.controlClassPatterns.some((pattern) => lowerClass.includes(pattern));
54
+ const isNearCarousel = cfg.carouselPatterns.some((pattern) => {
55
+ if (lowerClass.includes(pattern))
56
+ return true;
57
+ let parent = element.parentElement;
58
+ for (let i = 0; i < cfg.maxParentLevels && parent; i++) {
59
+ if (parent.className?.toString().toLowerCase().includes(pattern)) {
60
+ return true;
61
+ }
62
+ parent = parent.parentElement;
63
+ }
64
+ return false;
65
+ });
66
+ const hasSvg = element.querySelector('svg');
67
+ let hasPauseIconPattern = false;
68
+ if (hasSvg) {
69
+ const rects = hasSvg.querySelectorAll('rect, path');
70
+ if (rects.length === 2) {
71
+ hasPauseIconPattern = true;
72
+ }
73
+ }
74
+ if (nameMatch) {
75
+ controls.push({
76
+ element: tagName,
77
+ name: accessibleName || `[class: ${className.slice(0, 50)}]`,
78
+ matchedBy: 'accessible-name',
79
+ selector: getSelector(element),
80
+ });
81
+ hasAccessibleName = true;
82
+ }
83
+ else if (classMatch && isNearCarousel) {
84
+ controls.push({
85
+ element: tagName,
86
+ name: accessibleName || `[class: ${className.slice(0, 50)}]`,
87
+ matchedBy: 'class-name-near-carousel',
88
+ selector: getSelector(element),
89
+ });
90
+ if (accessibleName)
91
+ hasAccessibleName = true;
92
+ }
93
+ else if (hasPauseIconPattern && isNearCarousel) {
94
+ controls.push({
95
+ element: tagName,
96
+ name: accessibleName || `[class: ${className.slice(0, 50)}]`,
97
+ matchedBy: 'svg-icon-pattern',
98
+ selector: getSelector(element),
99
+ });
100
+ if (accessibleName)
101
+ hasAccessibleName = true;
102
+ }
103
+ const isNavControl = cfg.navKeywords.some((kw) => lowerName.includes(kw) || lowerClass.includes(kw));
104
+ if (isNavControl && isNearCarousel) {
105
+ carouselIndicators.push({
106
+ element: tagName,
107
+ name: accessibleName || `[class: ${className.slice(0, 50)}]`,
108
+ });
109
+ }
110
+ });
111
+ return {
112
+ found: controls.length > 0,
113
+ controls,
114
+ carouselIndicators,
115
+ hasAccessibleName,
116
+ };
117
+ }, config);
118
+ }
119
+ /**
120
+ * Verify if clicking the pause control actually stops the auto-play.
121
+ */
122
+ export async function verifyPauseControl(page, pauseControls, outputDir, changeThreshold) {
123
+ const control = pauseControls.controls[0];
124
+ if (!pauseControls.found || !control) {
125
+ return {
126
+ attempted: false,
127
+ controlClicked: null,
128
+ beforeClickDiffPercent: null,
129
+ afterClickDiffPercent: null,
130
+ pauseWorked: null,
131
+ error: 'No pause controls found to verify',
132
+ };
133
+ }
134
+ const selector = control.selector;
135
+ if (!selector) {
136
+ return {
137
+ attempted: false,
138
+ controlClicked: null,
139
+ beforeClickDiffPercent: null,
140
+ afterClickDiffPercent: null,
141
+ pauseWorked: null,
142
+ error: 'No selector available for pause control',
143
+ };
144
+ }
145
+ try {
146
+ const beforePath1 = path.join(outputDir, 'verify-before-1.png');
147
+ const beforePath2 = path.join(outputDir, 'verify-before-2.png');
148
+ await page.screenshot({ path: beforePath1, fullPage: false });
149
+ await page.waitForTimeout(SCREENSHOT_COMPARISON_WAIT);
150
+ await page.screenshot({ path: beforePath2, fullPage: false });
151
+ const beforeDiff = compareImages(beforePath1, beforePath2, path.join(outputDir, 'verify-before-diff.png'));
152
+ const element = await page.$(selector);
153
+ if (!element) {
154
+ return {
155
+ attempted: true,
156
+ controlClicked: selector,
157
+ beforeClickDiffPercent: formatDiffPercent(beforeDiff.diffPercent),
158
+ afterClickDiffPercent: null,
159
+ pauseWorked: null,
160
+ error: `Could not find element with selector: ${selector}`,
161
+ };
162
+ }
163
+ await element.click();
164
+ await page.waitForTimeout(PAUSE_CLICK_WAIT);
165
+ const afterPath1 = path.join(outputDir, 'verify-after-1.png');
166
+ const afterPath2 = path.join(outputDir, 'verify-after-2.png');
167
+ await page.screenshot({ path: afterPath1, fullPage: false });
168
+ await page.waitForTimeout(SCREENSHOT_COMPARISON_WAIT);
169
+ await page.screenshot({ path: afterPath2, fullPage: false });
170
+ const afterDiff = compareImages(afterPath1, afterPath2, path.join(outputDir, 'verify-after-diff.png'));
171
+ const hadChangeBefore = hasSignificantChange(beforeDiff.diffPercent, changeThreshold);
172
+ const hasChangeAfter = hasSignificantChange(afterDiff.diffPercent, changeThreshold);
173
+ const pauseWorked = hadChangeBefore && !hasChangeAfter;
174
+ return {
175
+ attempted: true,
176
+ controlClicked: selector,
177
+ beforeClickDiffPercent: formatDiffPercent(beforeDiff.diffPercent),
178
+ afterClickDiffPercent: formatDiffPercent(afterDiff.diffPercent),
179
+ pauseWorked,
180
+ error: null,
181
+ };
182
+ }
183
+ catch (err) {
184
+ return {
185
+ attempted: true,
186
+ controlClicked: selector,
187
+ beforeClickDiffPercent: null,
188
+ afterClickDiffPercent: null,
189
+ pauseWorked: null,
190
+ error: `Error during verification: ${err instanceof Error ? err.message : String(err)}`,
191
+ };
192
+ }
193
+ }
194
+ /**
195
+ * Create a skipped verification result.
196
+ */
197
+ export function createSkippedVerification(reason) {
198
+ return {
199
+ attempted: false,
200
+ controlClicked: null,
201
+ beforeClickDiffPercent: null,
202
+ afterClickDiffPercent: null,
203
+ pauseWorked: null,
204
+ error: reason,
205
+ };
206
+ }
@@ -16,4 +16,10 @@ export { runAxeAudit, type RunAxeAuditOptions } from './runAxeAudit.js';
16
16
  export { runFocusIndicatorCheck, type RunFocusIndicatorCheckOptions, } from './runFocusIndicatorCheck.js';
17
17
  export { runReflowCheck, type RunReflowCheckOptions } from './runReflowCheck.js';
18
18
  export { runTargetSizeCheck, type RunTargetSizeCheckOptions, } from './runTargetSizeCheck.js';
19
- export { resolveOutputPath, type OutputLocationOptions, } from '../utils/test-harness.js';
19
+ export { runTextSpacingCheck, type RunTextSpacingCheckOptions, } from './runTextSpacingCheck.js';
20
+ export { runZoomCheck, type RunZoomCheckOptions } from './runZoomCheck.js';
21
+ export { runOrientationCheck, type RunOrientationCheckOptions, } from './runOrientationCheck.js';
22
+ export { runAutocompleteAudit, type RunAutocompleteAuditOptions, } from './runAutocompleteAudit.js';
23
+ export { runTimeLimitDetector, type RunTimeLimitDetectorOptions, } from './runTimeLimitDetector.js';
24
+ export { runAutoPlayDetection, type RunAutoPlayDetectionOptions, } from './runAutoPlayDetection.js';
25
+ export { resolveOutputPath, getTargetUrl, requireTargetUrl, type OutputLocationOptions, } from '../utils/test-harness.js';
@@ -16,4 +16,10 @@ export { runAxeAudit } from './runAxeAudit.js';
16
16
  export { runFocusIndicatorCheck, } from './runFocusIndicatorCheck.js';
17
17
  export { runReflowCheck } from './runReflowCheck.js';
18
18
  export { runTargetSizeCheck, } from './runTargetSizeCheck.js';
19
- export { resolveOutputPath, } from '../utils/test-harness.js';
19
+ export { runTextSpacingCheck, } from './runTextSpacingCheck.js';
20
+ export { runZoomCheck } from './runZoomCheck.js';
21
+ export { runOrientationCheck, } from './runOrientationCheck.js';
22
+ export { runAutocompleteAudit, } from './runAutocompleteAudit.js';
23
+ export { runTimeLimitDetector, } from './runTimeLimitDetector.js';
24
+ export { runAutoPlayDetection, } from './runAutoPlayDetection.js';
25
+ export { resolveOutputPath, getTargetUrl, requireTargetUrl, } from '../utils/test-harness.js';
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Auto-play Content Detection — WCAG 1.4.2 (Audio Control) / 2.2.2 (Pause, Stop, Hide)
3
+ *
4
+ * Takes screenshots at 2s intervals (0/2/4/6s), pixel-diffs consecutive frames,
5
+ * detects whether visual content continues past 5s, finds pause/stop controls,
6
+ * and verifies whether they work.
7
+ *
8
+ * The caller is responsible for navigating the page before calling this.
9
+ *
10
+ * IMPORTANT: this is the only check that needs `pixelmatch` + `pngjs` (declared
11
+ * as optionalDependencies). To keep them out of the package barrel, the modules
12
+ * that pull them (`utils/image-compare`, `detectors`) are imported LAZILY here,
13
+ * so importing `@a11y-skills/audit/playwright` never requires the optional deps
14
+ * unless `runAutoPlayDetection` is actually called.
15
+ */
16
+ import type { Page } from '@playwright/test';
17
+ import type { AutoPlayDetectionResult } from '../types.js';
18
+ export interface RunAutoPlayDetectionOptions {
19
+ /** A page already navigated to the target URL. */
20
+ page: Page;
21
+ /**
22
+ * Directory for screenshots, diffs, and the result JSON. Defaults to
23
+ * `<A11Y_OUTPUT_DIR | cwd>/auto-play-screenshots`.
24
+ */
25
+ outputDir?: string;
26
+ /** Significant-change threshold in percent (default: 0.1). */
27
+ changeThreshold?: number;
28
+ }
29
+ /**
30
+ * Run auto-play detection against the current page. Writes screenshots, diff
31
+ * images, and `detection-result.json` into the output directory; returns the
32
+ * result.
33
+ *
34
+ * @throws if the optional `pixelmatch` / `pngjs` deps are not installed.
35
+ */
36
+ export declare function runAutoPlayDetection(options: RunAutoPlayDetectionOptions): Promise<AutoPlayDetectionResult>;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Auto-play Content Detection — WCAG 1.4.2 (Audio Control) / 2.2.2 (Pause, Stop, Hide)
3
+ *
4
+ * Takes screenshots at 2s intervals (0/2/4/6s), pixel-diffs consecutive frames,
5
+ * detects whether visual content continues past 5s, finds pause/stop controls,
6
+ * and verifies whether they work.
7
+ *
8
+ * The caller is responsible for navigating the page before calling this.
9
+ *
10
+ * IMPORTANT: this is the only check that needs `pixelmatch` + `pngjs` (declared
11
+ * as optionalDependencies). To keep them out of the package barrel, the modules
12
+ * that pull them (`utils/image-compare`, `detectors`) are imported LAZILY here,
13
+ * so importing `@a11y-skills/audit/playwright` never requires the optional deps
14
+ * unless `runAutoPlayDetection` is actually called.
15
+ */
16
+ import * as path from 'node:path';
17
+ import { SCREENSHOT_INTERVALS, CHANGE_THRESHOLD, DEFAULT_AUTO_PLAY_OUTPUT_DIR, DETECTION_RESULT_FILENAME, } from '../constants.js';
18
+ import { generateRecommendation, printSummary } from '../utils/recommendations.js';
19
+ /** Capture screenshots at configured intervals. */
20
+ async function captureScreenshots(page, outputDir) {
21
+ const screenshots = [];
22
+ for (let i = 0; i < SCREENSHOT_INTERVALS.length; i++) {
23
+ const current = SCREENSHOT_INTERVALS[i] ?? 0;
24
+ if (i > 0) {
25
+ const previous = SCREENSHOT_INTERVALS[i - 1] ?? 0;
26
+ await page.waitForTimeout(current - previous);
27
+ }
28
+ const timeLabel = `${current / 1000}s`;
29
+ const filename = `screenshot-${timeLabel}.png`;
30
+ const filepath = path.join(outputDir, filename);
31
+ await page.screenshot({ path: filepath, fullPage: false });
32
+ screenshots.push({ time: timeLabel, path: filepath });
33
+ }
34
+ return screenshots;
35
+ }
36
+ /**
37
+ * Run auto-play detection against the current page. Writes screenshots, diff
38
+ * images, and `detection-result.json` into the output directory; returns the
39
+ * result.
40
+ *
41
+ * @throws if the optional `pixelmatch` / `pngjs` deps are not installed.
42
+ */
43
+ export async function runAutoPlayDetection(options) {
44
+ const { page, changeThreshold = CHANGE_THRESHOLD } = options;
45
+ const outputDir = options.outputDir ??
46
+ path.join(process.env.A11Y_OUTPUT_DIR ?? process.cwd(), DEFAULT_AUTO_PLAY_OUTPUT_DIR);
47
+ // Lazy-load the modules that depend on the optional pixelmatch/pngjs deps.
48
+ let imageCompare;
49
+ let detectors;
50
+ try {
51
+ imageCompare = await import('../utils/image-compare.js');
52
+ detectors = await import('../detectors/index.js');
53
+ }
54
+ catch (err) {
55
+ const msg = err instanceof Error ? err.message : String(err);
56
+ const code = err.code;
57
+ // Only translate a genuine missing-optional-dep error; re-throw anything
58
+ // else (e.g. a real bug inside image-compare/detectors) unchanged.
59
+ if (code === 'ERR_MODULE_NOT_FOUND' || /pixelmatch|pngjs/.test(msg)) {
60
+ throw new Error('runAutoPlayDetection requires the optional dependencies `pixelmatch` and `pngjs`. ' +
61
+ 'Install them: `npm install pixelmatch pngjs`.\n' +
62
+ `Original error: ${msg}`);
63
+ }
64
+ throw err;
65
+ }
66
+ const { compareImages, formatDiffPercent, hasSignificantChange, ensureOutputDir, saveJsonResult, } = imageCompare;
67
+ const { detectPauseControls, verifyPauseControl, createSkippedVerification } = detectors;
68
+ ensureOutputDir(outputDir);
69
+ // Take screenshots at intervals.
70
+ const screenshots = await captureScreenshots(page, outputDir);
71
+ // Compare consecutive screenshots.
72
+ const comparisons = [];
73
+ let hasAnyChange = false;
74
+ let hasChangeAfter5s = false;
75
+ for (let i = 1; i < screenshots.length; i++) {
76
+ const prev = screenshots[i - 1];
77
+ const curr = screenshots[i];
78
+ if (!prev || !curr)
79
+ continue;
80
+ const diffPath = path.join(outputDir, `diff-${prev.time}-vs-${curr.time}.png`);
81
+ const { diffPixels, totalPixels, diffPercent } = compareImages(prev.path, curr.path, diffPath);
82
+ const hasChange = hasSignificantChange(diffPercent, changeThreshold);
83
+ if (hasChange) {
84
+ hasAnyChange = true;
85
+ if ((SCREENSHOT_INTERVALS[i] ?? 0) > 5000) {
86
+ hasChangeAfter5s = true;
87
+ }
88
+ }
89
+ comparisons.push({
90
+ compare: `${prev.time} vs ${curr.time}`,
91
+ diffPixels,
92
+ totalPixels,
93
+ diffPercent: formatDiffPercent(diffPercent),
94
+ hasChange,
95
+ });
96
+ }
97
+ const stopsWithin5Seconds = hasAnyChange && !hasChangeAfter5s;
98
+ // Detect and verify pause controls.
99
+ const pauseControls = await detectPauseControls(page);
100
+ let pauseVerification;
101
+ if (hasAnyChange && !stopsWithin5Seconds && pauseControls.found) {
102
+ pauseVerification = await verifyPauseControl(page, pauseControls, outputDir, changeThreshold);
103
+ }
104
+ else {
105
+ let reason;
106
+ if (!hasAnyChange) {
107
+ reason = 'No auto-play detected';
108
+ }
109
+ else if (stopsWithin5Seconds) {
110
+ reason = 'Content stops within 5 seconds';
111
+ }
112
+ else {
113
+ reason = 'No pause controls found';
114
+ }
115
+ pauseVerification = createSkippedVerification(reason);
116
+ }
117
+ const result = {
118
+ url: page.url(),
119
+ screenshotRecords: screenshots,
120
+ comparisons,
121
+ hasAutoPlayContent: hasAnyChange,
122
+ stopsWithin5Seconds,
123
+ pauseControls,
124
+ pauseVerification,
125
+ recommendation: generateRecommendation({
126
+ hasAutoPlayContent: hasAnyChange,
127
+ stopsWithin5Seconds,
128
+ pauseControls,
129
+ pauseVerification,
130
+ }),
131
+ };
132
+ console.log('\n=== Auto-play Detection Results ===\n');
133
+ console.log(JSON.stringify(result, null, 2));
134
+ saveJsonResult(path.join(outputDir, DETECTION_RESULT_FILENAME), result);
135
+ printSummary({ hasAutoPlayContent: hasAnyChange, stopsWithin5Seconds, pauseControls, pauseVerification }, outputDir);
136
+ return result;
137
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Autocomplete Audit — WCAG 1.3.5 (Identify Input Purpose)
3
+ *
4
+ * Finds all form fields (input/select/textarea), uses Playwright's
5
+ * `ariaSnapshot()` to compute accessible names (following the ARIA naming
6
+ * algorithm), matches field names/ids/labels/placeholders to expected
7
+ * autocomplete tokens, and reports fields that are missing or have invalid
8
+ * autocomplete values.
9
+ *
10
+ * The caller is responsible for navigating the page before calling this.
11
+ *
12
+ * Limitations:
13
+ * - Cannot confirm actual field purpose; pattern matching is heuristic
14
+ * - Manual verification needed for edge cases
15
+ */
16
+ import type { Page } from '@playwright/test';
17
+ import type { AutocompleteAuditResult } from '../types.js';
18
+ import { type OutputLocationOptions } from '../utils/test-harness.js';
19
+ export interface RunAutocompleteAuditOptions extends OutputLocationOptions {
20
+ /** A page already navigated to the target URL. */
21
+ page: Page;
22
+ }
23
+ /**
24
+ * Run the autocomplete audit against the current page, write the result JSON,
25
+ * and return the parsed result.
26
+ */
27
+ export declare function runAutocompleteAudit(options: RunAutocompleteAuditOptions): Promise<AutocompleteAuditResult>;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Autocomplete Audit — WCAG 1.3.5 (Identify Input Purpose)
3
+ *
4
+ * Finds all form fields (input/select/textarea), uses Playwright's
5
+ * `ariaSnapshot()` to compute accessible names (following the ARIA naming
6
+ * algorithm), matches field names/ids/labels/placeholders to expected
7
+ * autocomplete tokens, and reports fields that are missing or have invalid
8
+ * autocomplete values.
9
+ *
10
+ * The caller is responsible for navigating the page before calling this.
11
+ *
12
+ * Limitations:
13
+ * - Cannot confirm actual field purpose; pattern matching is heuristic
14
+ * - Manual verification needed for edge cases
15
+ */
16
+ import { AUTOCOMPLETE_FIELD_PATTERNS, VALID_AUTOCOMPLETE_TOKENS, DEFAULT_AUTOCOMPLETE_RESULT_FILE, } from '../constants.js';
17
+ import { saveAuditResult, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
18
+ /**
19
+ * Collect basic form field information in browser context.
20
+ * Accessible names are retrieved separately via ariaSnapshot().
21
+ */
22
+ function collectBasicFieldInfo() {
23
+ function getUniqueSelector(element, elementIndex) {
24
+ if (element.id) {
25
+ return `#${element.id}`;
26
+ }
27
+ const path = [];
28
+ let current = element;
29
+ while (current && current !== document.body) {
30
+ let selector = current.tagName.toLowerCase();
31
+ const parent = current.parentElement;
32
+ if (parent) {
33
+ const childIndex = Array.from(parent.children).indexOf(current) + 1;
34
+ selector += `:nth-child(${childIndex})`;
35
+ }
36
+ path.unshift(selector);
37
+ current = parent;
38
+ }
39
+ return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
40
+ }
41
+ const skipTypes = ['hidden', 'submit', 'reset', 'button', 'image', 'file'];
42
+ const fields = [];
43
+ const elements = document.querySelectorAll('input, select, textarea');
44
+ elements.forEach((element, index) => {
45
+ const el = element;
46
+ if (el instanceof HTMLInputElement && skipTypes.includes(el.type)) {
47
+ return;
48
+ }
49
+ const inputType = el instanceof HTMLInputElement ? el.type : el.tagName.toLowerCase();
50
+ const autocompleteAttr = el.getAttribute('autocomplete');
51
+ fields.push({
52
+ selector: getUniqueSelector(element, index),
53
+ tagName: el.tagName.toLowerCase(),
54
+ inputType,
55
+ name: el.name || null,
56
+ id: el.id || null,
57
+ placeholder: el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
58
+ ? el.placeholder || null
59
+ : null,
60
+ autocomplete: autocompleteAttr,
61
+ });
62
+ });
63
+ return fields;
64
+ }
65
+ /**
66
+ * Extract accessible name from ariaSnapshot output.
67
+ * ariaSnapshot returns YAML-like format: "- role \"accessible name\"".
68
+ */
69
+ function parseAccessibleName(snapshot) {
70
+ // ariaSnapshot format: "- textbox \"Email address\"" or "- textbox \"Email address\" [focused]"
71
+ const match = snapshot.match(/^- \w+(?:\s+"([^"]*)")?/);
72
+ if (match && match[1]) {
73
+ return match[1];
74
+ }
75
+ return null;
76
+ }
77
+ /** Find pattern match for a field across name, id, label, and placeholder. */
78
+ function findPatternMatch(field, patterns) {
79
+ for (const [token, pattern] of patterns) {
80
+ if (field.name && pattern.test(field.name)) {
81
+ return { token, matchedBy: 'name' };
82
+ }
83
+ if (field.id && pattern.test(field.id)) {
84
+ return { token, matchedBy: 'id' };
85
+ }
86
+ if (field.labelText && pattern.test(field.labelText)) {
87
+ return { token, matchedBy: 'label' };
88
+ }
89
+ if (field.placeholder && pattern.test(field.placeholder)) {
90
+ return { token, matchedBy: 'placeholder' };
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+ /** Analyze fields for autocomplete issues. */
96
+ function analyzeFields(fields, patterns, validTokens) {
97
+ const missing = [];
98
+ const invalid = [];
99
+ for (const field of fields) {
100
+ const match = findPatternMatch(field, patterns);
101
+ if (!match) {
102
+ continue;
103
+ }
104
+ const { token: expectedToken, matchedBy } = match;
105
+ if (!field.autocomplete || field.autocomplete === 'off') {
106
+ missing.push({
107
+ selector: field.selector,
108
+ tagName: field.tagName,
109
+ inputType: field.inputType,
110
+ name: field.name,
111
+ id: field.id,
112
+ labelText: field.labelText,
113
+ currentAutocomplete: field.autocomplete,
114
+ expectedToken,
115
+ matchedBy,
116
+ issueType: 'missing',
117
+ });
118
+ continue;
119
+ }
120
+ const autocompleteTokens = field.autocomplete.toLowerCase().split(/\s+/);
121
+ const mainToken = autocompleteTokens[autocompleteTokens.length - 1];
122
+ if (mainToken === undefined ||
123
+ !validTokens.includes(mainToken)) {
124
+ invalid.push({
125
+ selector: field.selector,
126
+ tagName: field.tagName,
127
+ inputType: field.inputType,
128
+ name: field.name,
129
+ id: field.id,
130
+ labelText: field.labelText,
131
+ currentAutocomplete: field.autocomplete,
132
+ expectedToken,
133
+ matchedBy,
134
+ issueType: 'invalid',
135
+ });
136
+ }
137
+ }
138
+ return { missing, invalid };
139
+ }
140
+ /**
141
+ * Run the autocomplete audit against the current page, write the result JSON,
142
+ * and return the parsed result.
143
+ */
144
+ export async function runAutocompleteAudit(options) {
145
+ const { page, ...location } = options;
146
+ // Collect basic field info from DOM
147
+ const basicFields = await page.evaluate(collectBasicFieldInfo);
148
+ // Enhance with accessible names via ariaSnapshot()
149
+ const fields = [];
150
+ for (const basicField of basicFields) {
151
+ const locator = page.locator(basicField.selector);
152
+ let labelText = null;
153
+ try {
154
+ const snapshot = await locator.ariaSnapshot();
155
+ labelText = parseAccessibleName(snapshot);
156
+ }
157
+ catch {
158
+ // If ariaSnapshot fails, labelText remains null
159
+ }
160
+ fields.push({
161
+ ...basicField,
162
+ labelText,
163
+ });
164
+ }
165
+ const patterns = Object.entries(AUTOCOMPLETE_FIELD_PATTERNS);
166
+ const { missing, invalid } = analyzeFields(fields, patterns, VALID_AUTOCOMPLETE_TOKENS);
167
+ const result = {
168
+ url: page.url(),
169
+ totalFieldsChecked: fields.length,
170
+ missingAutocomplete: missing,
171
+ invalidAutocomplete: invalid,
172
+ };
173
+ // Output results
174
+ logAuditHeader('Autocomplete Audit Results', 'WCAG 1.3.5', result.url);
175
+ logSummary({
176
+ 'Total form fields': result.totalFieldsChecked,
177
+ 'Fields missing autocomplete': result.missingAutocomplete.length,
178
+ 'Fields with invalid autocomplete': result.invalidAutocomplete.length,
179
+ });
180
+ logIssueList('Missing Autocomplete', result.missingAutocomplete, (el, i) => [
181
+ `${i + 1}. <${el.tagName}> "${el.selector}"`,
182
+ ` name: ${el.name || 'none'}, id: ${el.id || 'none'}`,
183
+ ` label: "${el.labelText || 'none'}"`,
184
+ ` Expected: autocomplete="${el.expectedToken}" (matched by ${el.matchedBy})`,
185
+ ]);
186
+ logIssueList('Invalid Autocomplete', result.invalidAutocomplete, (el, i) => [
187
+ `${i + 1}. <${el.tagName}> "${el.selector}"`,
188
+ ` Current: autocomplete="${el.currentAutocomplete}"`,
189
+ ` Expected: autocomplete="${el.expectedToken}"`,
190
+ ]);
191
+ const resolvedPath = saveAuditResult(result, {
192
+ ...location,
193
+ defaultFile: DEFAULT_AUTOCOMPLETE_RESULT_FILE,
194
+ });
195
+ logOutputPaths(resolvedPath);
196
+ return result;
197
+ }
@@ -0,0 +1,40 @@
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 type { Page } from '@playwright/test';
19
+ import type { OrientationCheckResult } from '../types.js';
20
+ import { type OutputLocationOptions } from '../utils/test-harness.js';
21
+ export interface RunOrientationCheckOptions extends OutputLocationOptions {
22
+ /**
23
+ * A page to drive. This check navigates the page itself (once per
24
+ * orientation), so the page does not need to be pre-navigated.
25
+ */
26
+ page: Page;
27
+ /** Target URL to audit. Falls back to the `TEST_PAGE` env var. */
28
+ targetUrl?: string;
29
+ /**
30
+ * Whether to capture portrait/landscape screenshots next to the result file
31
+ * (default: false).
32
+ */
33
+ screenshot?: boolean;
34
+ }
35
+ /**
36
+ * Run the orientation check, navigating the page in both portrait and landscape,
37
+ * write the result JSON (and optionally screenshots), and return the parsed
38
+ * result.
39
+ */
40
+ export declare function runOrientationCheck(options: RunOrientationCheckOptions): Promise<OrientationCheckResult>;