@a11y-skills/audit 0.2.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.
@@ -16,6 +16,7 @@
16
16
  * - Manual verification needed for exceptions (e.g., camera apps)
17
17
  */
18
18
  import { ORIENTATION_VIEWPORTS, ORIENTATION_LOCK_KEYWORDS, MAIN_CONTENT_SELECTORS, DEFAULT_ORIENTATION_RESULT_FILE, DEFAULT_ORIENTATION_PORTRAIT_SCREENSHOT_FILE, DEFAULT_ORIENTATION_LANDSCAPE_SCREENSHOT_FILE, } from '../constants.js';
19
+ import { buildAuditResult, normalizeOrientationCheck, } from '../utils/axe-format.js';
19
20
  import { saveAuditResult, resolveOutputPath, takeAuditScreenshot, resolveScreenshotPath, requireTargetUrl, logAuditHeader, logOutputPaths, } from '../utils/test-harness.js';
20
21
  /** Capture orientation state in browser context. */
21
22
  function captureOrientationState(args) {
@@ -137,20 +138,25 @@ export async function runOrientationCheck(options) {
137
138
  const landscapeHasLock = landscapeState.lockMessageFound || landscapeState.mainContentHidden;
138
139
  const hasOrientationLock = portraitHasLock || landscapeHasLock;
139
140
  const lockDetectedIn = determineLockLocation(portraitHasLock, landscapeHasLock);
140
- const result = {
141
- url: page.url(),
141
+ const details = {
142
142
  portrait: portraitState,
143
143
  landscape: landscapeState,
144
144
  hasOrientationLock,
145
145
  lockDetectedIn,
146
146
  };
147
+ const result = buildAuditResult({
148
+ source: 'orientation-check',
149
+ url: page.url(),
150
+ details,
151
+ buckets: normalizeOrientationCheck(details),
152
+ });
147
153
  // Output results
148
154
  logAuditHeader('Orientation Check Results', 'WCAG 1.3.4', result.url);
149
- logOrientationState('Portrait', ORIENTATION_VIEWPORTS.portrait, 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}`);
155
+ logOrientationState('Portrait', ORIENTATION_VIEWPORTS.portrait, details.portrait);
156
+ logOrientationState('Landscape', ORIENTATION_VIEWPORTS.landscape, details.landscape);
157
+ console.log(`\nOrientation lock detected: ${details.hasOrientationLock ? 'YES' : 'No'}`);
158
+ if (details.hasOrientationLock) {
159
+ console.log(`Lock detected in: ${details.lockDetectedIn}`);
154
160
  }
155
161
  const writtenPath = saveAuditResult(result, {
156
162
  ...location,
@@ -13,8 +13,9 @@
13
13
  * - Cannot distinguish acceptable horizontal scroll (e.g., data tables)
14
14
  * - Does not verify functional reflow for complex widgets
15
15
  */
16
- import { REFLOW_VIEWPORT, REFLOW_OVERFLOW_TOLERANCE, REFLOW_CHECK_SELECTOR, REFLOW_ALLOWED_OVERFLOW_SELECTORS, DEFAULT_REFLOW_RESULT_FILE, DEFAULT_REFLOW_SCREENSHOT_FILE, } from '../constants.js';
16
+ import { REFLOW_VIEWPORT, REFLOW_OVERFLOW_TOLERANCE, REFLOW_CHECK_SELECTOR, REFLOW_ALLOWED_OVERFLOW_SELECTORS, DEFAULT_REFLOW_RESULT_FILE, DEFAULT_REFLOW_SCREENSHOT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
17
17
  import { createLayoutChecker } from '../utils/layout.js';
18
+ import { buildAuditResult, normalizeReflowCheck } from '../utils/axe-format.js';
18
19
  import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
19
20
  /**
20
21
  * Run the reflow check against the current page, write the result JSON
@@ -28,27 +29,33 @@ export async function runReflowCheck(options) {
28
29
  overflowTolerance,
29
30
  checkSelector: REFLOW_CHECK_SELECTOR,
30
31
  allowedOverflowSelectors: [...REFLOW_ALLOWED_OVERFLOW_SELECTORS],
32
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
31
33
  });
32
- const result = {
33
- url: page.url(),
34
+ const details = {
34
35
  viewport: { width: viewport.width, height: viewport.height },
35
36
  ...layoutResult,
36
37
  };
38
+ const result = buildAuditResult({
39
+ source: 'reflow-check',
40
+ url: page.url(),
41
+ details,
42
+ buckets: normalizeReflowCheck(details),
43
+ });
37
44
  // Output results
38
45
  logAuditHeader('Reflow Check Results', 'WCAG 1.4.10', result.url);
39
46
  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,
47
+ Viewport: `${details.viewport.width}x${details.viewport.height}`,
48
+ 'Document scroll width': `${details.documentScrollWidth}px`,
49
+ 'Document client width': `${details.documentClientWidth}px`,
50
+ 'Horizontal scroll': details.hasHorizontalScroll,
51
+ 'Overflowing elements': details.overflowingElements.length,
52
+ 'Clipped text elements': details.clippedTextElements.length,
46
53
  });
47
- logIssueList('Overflowing Elements', result.overflowingElements, (el, i) => [
54
+ logIssueList('Overflowing Elements', details.overflowingElements, (el, i) => [
48
55
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
49
56
  ` rect.right: ${el.rect.right}px (viewport: ${el.viewportWidth}px)`,
50
57
  ]);
51
- logIssueList('Clipped Text Elements', result.clippedTextElements, (el, i) => [
58
+ logIssueList('Clipped Text Elements', details.clippedTextElements, (el, i) => [
52
59
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
53
60
  ` scrollWidth: ${el.scrollWidth}px, clientWidth: ${el.clientWidth}px`,
54
61
  ` overflow: ${el.overflow}, overflowX: ${el.overflowX}`,
@@ -15,13 +15,31 @@
15
15
  * - Cannot detect all redundant target cases
16
16
  * - CSS transform may affect measurements
17
17
  */
18
- import { INTERACTIVE_SELECTOR, TARGET_SIZE_AA, TARGET_SIZE_AAA, INLINE_CONTEXT_TAGS, UA_CONTROLLED_INPUT_TYPES, INLINE_CONTEXT_MIN_TEXT, DEFAULT_TARGET_SIZE_RESULT_FILE, DEFAULT_TARGET_SIZE_SCREENSHOT_FILE, } from '../constants.js';
18
+ import { INTERACTIVE_SELECTOR, TARGET_SIZE_AA, TARGET_SIZE_AAA, INLINE_CONTEXT_TAGS, UA_CONTROLLED_INPUT_TYPES, INLINE_CONTEXT_MIN_TEXT, DEFAULT_TARGET_SIZE_RESULT_FILE, DEFAULT_TARGET_SIZE_SCREENSHOT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
19
+ import { buildAuditResult, normalizeTargetSizeCheck, } from '../utils/axe-format.js';
19
20
  import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
20
21
  import { addPageAnnotations } from '../utils/annotations.js';
21
22
  /**
22
23
  * Collect basic target information from DOM (runs in browser context).
23
24
  */
24
- function collectBasicTargetInfo(interactiveSelector) {
25
+ function collectBasicTargetInfo(args) {
26
+ const { interactiveSelector, htmlSnippetMaxLength } = args;
27
+ function getHtmlSnippet(element) {
28
+ let html = '';
29
+ try {
30
+ html = element.outerHTML || '';
31
+ }
32
+ catch {
33
+ html = '';
34
+ }
35
+ if (!html) {
36
+ return { html: `<${element.tagName.toLowerCase()}>`, htmlTruncated: false };
37
+ }
38
+ if (html.length > htmlSnippetMaxLength) {
39
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
40
+ }
41
+ return { html, htmlTruncated: false };
42
+ }
25
43
  function getUniqueSelector(element, elementIndex) {
26
44
  if (element.id) {
27
45
  return `#${CSS.escape(element.id)}`;
@@ -72,6 +90,7 @@ function collectBasicTargetInfo(interactiveSelector) {
72
90
  targets.push({
73
91
  selector: getUniqueSelector(element, index),
74
92
  tagName,
93
+ ...getHtmlSnippet(element),
75
94
  role,
76
95
  width: Math.round(rect.width * 100) / 100,
77
96
  height: Math.round(rect.height * 100) / 100,
@@ -247,6 +266,8 @@ function analyzeTargets(targets, aaThreshold, aaaThreshold) {
247
266
  const issue = {
248
267
  selector: target.selector,
249
268
  tagName: target.tagName,
269
+ html: target.html,
270
+ htmlTruncated: target.htmlTruncated,
250
271
  role: target.role,
251
272
  accessibleName: target.accessibleName,
252
273
  width: target.width,
@@ -255,6 +276,9 @@ function analyzeTargets(targets, aaThreshold, aaaThreshold) {
255
276
  level,
256
277
  exception,
257
278
  exceptionDetails,
279
+ // the heuristics can detect exceptions but can never rule out the
280
+ // essential exception, so findings are at best 'possible'/'not-assessed'.
281
+ exceptionAssessment: exception ? 'possible' : 'not-assessed',
258
282
  href: target.href,
259
283
  };
260
284
  if (exception) {
@@ -276,7 +300,10 @@ function analyzeTargets(targets, aaThreshold, aaaThreshold) {
276
300
  export async function runTargetSizeCheck(options) {
277
301
  const { page, aaThreshold = TARGET_SIZE_AA, aaaThreshold = TARGET_SIZE_AAA, screenshot = false, ...location } = options;
278
302
  // Collect basic target info from DOM
279
- const basicTargets = await page.evaluate(collectBasicTargetInfo, INTERACTIVE_SELECTOR);
303
+ const basicTargets = await page.evaluate(collectBasicTargetInfo, {
304
+ interactiveSelector: INTERACTIVE_SELECTOR,
305
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
306
+ });
280
307
  // Enhance with accessible names via ariaSnapshot()
281
308
  const targets = [];
282
309
  for (const basicTarget of basicTargets) {
@@ -296,8 +323,7 @@ export async function runTargetSizeCheck(options) {
296
323
  }
297
324
  // Analyze targets
298
325
  const { failAA, failAAAOnly, passCount, excepted } = analyzeTargets(targets, aaThreshold, aaaThreshold);
299
- const result = {
300
- url: page.url(),
326
+ const details = {
301
327
  totalTargetsChecked: targets.length,
302
328
  failAA,
303
329
  failAAAOnly,
@@ -310,16 +336,22 @@ export async function runTargetSizeCheck(options) {
310
336
  exceptedCount: excepted.length,
311
337
  },
312
338
  };
339
+ const result = buildAuditResult({
340
+ source: 'target-size-check',
341
+ url: page.url(),
342
+ details,
343
+ buckets: normalizeTargetSizeCheck(details),
344
+ });
313
345
  // Output results
314
346
  logAuditHeader('Target Size Check Results', 'WCAG 2.5.5 / 2.5.8', result.url);
315
347
  logSummary({
316
- 'Total targets checked': result.totalTargetsChecked,
348
+ 'Total targets checked': details.totalTargetsChecked,
317
349
  });
318
350
  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}`);
351
+ console.log(` Pass (>= ${aaaThreshold}px): ${details.summary.passCount}`);
352
+ console.log(` Fail AAA only (${aaThreshold}-${aaaThreshold - 1}px): ${details.summary.failAAAOnlyCount}`);
353
+ console.log(` Fail AA (< ${aaThreshold}px): ${details.summary.failAACount}`);
354
+ console.log(` Possible exceptions: ${details.summary.exceptedCount}`);
323
355
  logIssueList(`Fail AA (< ${aaThreshold}px) - Requires Fix`, failAA, (el, i) => {
324
356
  const lines = [
325
357
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
@@ -7,11 +7,28 @@
7
7
  *
8
8
  * The caller is responsible for navigating the page before calling this.
9
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';
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';
11
12
  import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
12
13
  /** Collect metrics for elements with hidden overflow (browser context). */
13
14
  function collectElementMetrics(args) {
14
- const { checkSelector } = 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
+ }
15
32
  function getUniqueSelector(element, elementIndex) {
16
33
  if (element.id) {
17
34
  return `#${element.id}`;
@@ -56,6 +73,7 @@ function collectElementMetrics(args) {
56
73
  metrics.push({
57
74
  selector: getUniqueSelector(element, index),
58
75
  tagName: element.tagName.toLowerCase(),
76
+ ...getHtmlSnippet(element),
59
77
  scrollWidth: element.scrollWidth,
60
78
  scrollHeight: element.scrollHeight,
61
79
  clientWidth: element.clientWidth,
@@ -70,13 +88,29 @@ function collectElementMetrics(args) {
70
88
  }
71
89
  /** Inject text spacing CSS and re-collect metrics (browser context). */
72
90
  function injectSpacingAndCollect(args) {
73
- const { css, checkSelector } = args;
91
+ const { css, checkSelector, htmlSnippetMaxLength } = args;
74
92
  const styleEl = document.createElement('style');
75
93
  styleEl.id = 'wcag-text-spacing-override';
76
94
  styleEl.textContent = css;
77
95
  document.head.appendChild(styleEl);
78
96
  // Force reflow
79
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
+ }
80
114
  function getUniqueSelector(element, elementIndex) {
81
115
  if (element.id) {
82
116
  return `#${element.id}`;
@@ -121,6 +155,7 @@ function injectSpacingAndCollect(args) {
121
155
  metrics.push({
122
156
  selector: getUniqueSelector(element, index),
123
157
  tagName: element.tagName.toLowerCase(),
158
+ ...getHtmlSnippet(element),
124
159
  scrollWidth: element.scrollWidth,
125
160
  scrollHeight: element.scrollHeight,
126
161
  clientWidth: element.clientWidth,
@@ -175,6 +210,8 @@ function detectClippingIssues(beforeMetrics, afterMetrics, tolerance) {
175
210
  issues.push({
176
211
  selector: after.selector,
177
212
  tagName: after.tagName,
213
+ html: after.html,
214
+ htmlTruncated: after.htmlTruncated,
178
215
  beforeMetrics: {
179
216
  scrollWidth: beforeData.scrollWidth,
180
217
  scrollHeight: beforeData.scrollHeight,
@@ -204,23 +241,30 @@ export async function runTextSpacingCheck(options) {
204
241
  const { page, tolerance = TEXT_SPACING_CLIP_TOLERANCE, screenshot = false, ...location } = options;
205
242
  const beforeMetrics = await page.evaluate(collectElementMetrics, {
206
243
  checkSelector: TEXT_SPACING_CHECK_SELECTOR,
244
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
207
245
  });
208
246
  const afterMetrics = await page.evaluate(injectSpacingAndCollect, {
209
247
  css: TEXT_SPACING_CSS,
210
248
  checkSelector: TEXT_SPACING_CHECK_SELECTOR,
249
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
211
250
  });
212
251
  const clippedElements = detectClippingIssues(beforeMetrics, afterMetrics, tolerance);
213
- const result = {
214
- url: page.url(),
252
+ const details = {
215
253
  clippedElements,
216
254
  totalElementsChecked: afterMetrics.length,
217
255
  };
256
+ const result = buildAuditResult({
257
+ source: 'text-spacing-check',
258
+ url: page.url(),
259
+ details,
260
+ buckets: normalizeTextSpacingCheck(details),
261
+ });
218
262
  logAuditHeader('Text Spacing Check Results', 'WCAG 1.4.12', result.url);
219
263
  logSummary({
220
- 'Elements with overflow:hidden checked': result.totalElementsChecked,
221
- 'Elements with clipping issues': result.clippedElements.length,
264
+ 'Elements with overflow:hidden checked': details.totalElementsChecked,
265
+ 'Elements with clipping issues': details.clippedElements.length,
222
266
  });
223
- logIssueList('Clipped Elements', result.clippedElements, (el, i) => [
267
+ logIssueList('Clipped Elements', details.clippedElements, (el, i) => [
224
268
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
225
269
  ` Issue: ${el.issueType}`,
226
270
  ` Before: ${el.beforeMetrics.scrollWidth}x${el.beforeMetrics.scrollHeight} in ${el.beforeMetrics.clientWidth}x${el.beforeMetrics.clientHeight}`,
@@ -10,7 +10,8 @@
10
10
  * (option → `TEST_PAGE` env → required). Unlike the focus check it does not
11
11
  * need a fresh context per attempt, so it stays page-based.
12
12
  */
13
- import { TIME_LIMIT_KEYWORDS, TIME_LIMIT_THRESHOLD_MS, TIME_LIMIT_MIN_MS, DEFAULT_TIME_LIMIT_RESULT_FILE, } from '../constants.js';
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';
14
15
  import { saveAuditResult, requireTargetUrl, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
15
16
  /** Timer hook injected before page load (browser context). */
16
17
  function createTimerHookScript(args) {
@@ -60,7 +61,23 @@ function createTimerHookScript(args) {
60
61
  }
61
62
  /** Detect meta refresh + countdown indicators (browser context). */
62
63
  function detectTimeLimitIndicators(args) {
63
- const { keywords } = 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
+ }
64
81
  function getUniqueSelector(element, elementIndex) {
65
82
  if (element.id) {
66
83
  return `#${element.id}`;
@@ -91,6 +108,7 @@ function detectTimeLimitIndicators(args) {
91
108
  content,
92
109
  seconds: parseInt(match[1] ?? '0', 10),
93
110
  url: match[2]?.trim() || null,
111
+ ...getHtmlSnippet(meta),
94
112
  });
95
113
  }
96
114
  }
@@ -116,6 +134,7 @@ function detectTimeLimitIndicators(args) {
116
134
  selector: getUniqueSelector(parent, elementIndex),
117
135
  text: fullText,
118
136
  tagName: parent.tagName.toLowerCase(),
137
+ ...getHtmlSnippet(parent),
119
138
  });
120
139
  }
121
140
  }
@@ -139,25 +158,31 @@ export async function runTimeLimitDetector(options) {
139
158
  });
140
159
  const indicators = await page.evaluate(detectTimeLimitIndicators, {
141
160
  keywords: [...TIME_LIMIT_KEYWORDS],
161
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
142
162
  });
143
163
  const hasTimeLimits = indicators.metaRefresh.length > 0 ||
144
164
  timers.length > 0 ||
145
165
  indicators.countdownIndicators.length > 0;
146
- const result = {
147
- url: page.url(),
166
+ const details = {
148
167
  metaRefresh: indicators.metaRefresh,
149
168
  timers,
150
169
  countdownIndicators: indicators.countdownIndicators,
151
170
  hasTimeLimits,
152
171
  };
172
+ const result = buildAuditResult({
173
+ source: 'time-limit-detector',
174
+ url: page.url(),
175
+ details,
176
+ buckets: normalizeTimeLimitDetector(details),
177
+ });
153
178
  logAuditHeader('Time Limit Detection Results', 'WCAG 2.2.1', result.url);
154
179
  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,
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,
159
184
  });
160
- logIssueList('Meta Refresh', result.metaRefresh, (meta, i) => {
185
+ logIssueList('Meta Refresh', details.metaRefresh, (meta, i) => {
161
186
  const lines = [
162
187
  `${i + 1}. content="${meta.content}"`,
163
188
  ` Refresh in ${meta.seconds} seconds`,
@@ -167,7 +192,7 @@ export async function runTimeLimitDetector(options) {
167
192
  }
168
193
  return lines;
169
194
  });
170
- logIssueList('Detected Timers', result.timers, (timer, i) => {
195
+ logIssueList('Detected Timers', details.timers, (timer, i) => {
171
196
  const lines = [
172
197
  `${i + 1}. ${timer.type} - ${timer.delayMs}ms (${(timer.delayMs / 1000).toFixed(1)}s)`,
173
198
  ];
@@ -176,7 +201,7 @@ export async function runTimeLimitDetector(options) {
176
201
  }
177
202
  return lines;
178
203
  });
179
- logIssueList('Countdown Indicators', result.countdownIndicators, (indicator, i) => {
204
+ logIssueList('Countdown Indicators', details.countdownIndicators, (indicator, i) => {
180
205
  const truncatedText = indicator.text.length > 80
181
206
  ? indicator.text.slice(0, 80) + '...'
182
207
  : indicator.text;
@@ -15,11 +15,28 @@
15
15
  * - Does not verify responsive breakpoint behavior
16
16
  * - Manual verification needed for complex interactions at zoom
17
17
  */
18
- import { ZOOM_FACTOR, ZOOM_BASE_VIEWPORT, ZOOM_CLIP_TOLERANCE, REFLOW_CHECK_SELECTOR, DEFAULT_ZOOM_RESULT_FILE, DEFAULT_ZOOM_SCREENSHOT_FILE, } from '../constants.js';
18
+ import { ZOOM_FACTOR, ZOOM_BASE_VIEWPORT, ZOOM_CLIP_TOLERANCE, REFLOW_CHECK_SELECTOR, DEFAULT_ZOOM_RESULT_FILE, DEFAULT_ZOOM_SCREENSHOT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
19
+ import { buildAuditResult, normalizeZoomCheck } from '../utils/axe-format.js';
19
20
  import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
20
21
  /** Apply zoom and detect issues in browser context. */
21
22
  function applyZoomAndCheck(args) {
22
- const { checkSelector, tolerance } = args;
23
+ const { checkSelector, tolerance, htmlSnippetMaxLength } = args;
24
+ function getHtmlSnippet(element) {
25
+ let html = '';
26
+ try {
27
+ html = element.outerHTML || '';
28
+ }
29
+ catch {
30
+ html = '';
31
+ }
32
+ if (!html) {
33
+ return { html: `<${element.tagName.toLowerCase()}>`, htmlTruncated: false };
34
+ }
35
+ if (html.length > htmlSnippetMaxLength) {
36
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
37
+ }
38
+ return { html, htmlTruncated: false };
39
+ }
23
40
  // Apply CSS zoom
24
41
  document.documentElement.style.zoom =
25
42
  '200%';
@@ -83,6 +100,7 @@ function applyZoomAndCheck(args) {
83
100
  clippedElements.push({
84
101
  selector: getUniqueSelector(element, index),
85
102
  tagName: element.tagName.toLowerCase(),
103
+ ...getHtmlSnippet(element),
86
104
  scrollWidth,
87
105
  clientWidth,
88
106
  scrollHeight,
@@ -113,24 +131,30 @@ export async function runZoomCheck(options) {
113
131
  const zoomResult = await page.evaluate(applyZoomAndCheck, {
114
132
  checkSelector: REFLOW_CHECK_SELECTOR,
115
133
  tolerance: ZOOM_CLIP_TOLERANCE,
134
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
116
135
  });
117
- const result = {
118
- url: page.url(),
136
+ const details = {
119
137
  zoomFactor: ZOOM_FACTOR,
120
138
  viewport: { width: viewport.width, height: viewport.height },
121
139
  ...zoomResult,
122
140
  };
141
+ const result = buildAuditResult({
142
+ source: 'zoom-200-check',
143
+ url: page.url(),
144
+ details,
145
+ buckets: normalizeZoomCheck(details),
146
+ });
123
147
  // Output results
124
148
  logAuditHeader('Zoom 200% Check Results', 'WCAG 1.4.4', result.url);
125
149
  logSummary({
126
- 'Zoom factor': `${result.zoomFactor}x`,
127
- 'Base viewport': `${result.viewport.width}x${result.viewport.height}`,
128
- 'Document scroll width': `${result.documentScrollWidth}px`,
129
- 'Document client width': `${result.documentClientWidth}px`,
130
- 'Horizontal scroll': result.hasHorizontalScroll,
131
- 'Clipped elements': result.clippedElements.length,
150
+ 'Zoom factor': `${details.zoomFactor}x`,
151
+ 'Base viewport': `${details.viewport.width}x${details.viewport.height}`,
152
+ 'Document scroll width': `${details.documentScrollWidth}px`,
153
+ 'Document client width': `${details.documentClientWidth}px`,
154
+ 'Horizontal scroll': details.hasHorizontalScroll,
155
+ 'Clipped elements': details.clippedElements.length,
132
156
  });
133
- logIssueList('Clipped Elements', result.clippedElements, (el, i) => [
157
+ logIssueList('Clipped Elements', details.clippedElements, (el, i) => [
134
158
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
135
159
  ` scrollWidth: ${el.scrollWidth}px, clientWidth: ${el.clientWidth}px`,
136
160
  ` Issue: ${el.issueType}`,
@@ -6,18 +6,25 @@
6
6
  * (e.g. an issue creator that reads `*-result.json`). They are intentionally
7
7
  * permissive (no `additionalProperties: false`) so that additive changes to a
8
8
  * result shape do not break downstream validation.
9
+ *
10
+ * Every check shares the same envelope (`source` / `url` / `timestamp` / the
11
+ * four normalized buckets / `summary` / `details` / `disclaimer`); the common
12
+ * pieces live in `$defs` and each check schema defines its own `details`.
9
13
  */
10
- export type { AxeViolationNode, AxeViolation, AxeAuditResult, FocusRecord, OnFocusViolation, FocusCheckResult, BoundingRect, FocusObscuredOverlap, FocusObscuredIssue, ReflowIssue, ClippedTextElement, ReflowCheckResult, TargetSizeException, TargetSizeIssue, TargetSizeSummary, TargetSizeCheckResult, TextSpacingIssue, TextSpacingCheckResult, ZoomIssue, ZoomCheckResult, OrientationState, OrientationCheckResult, AutocompleteIssue, AutocompleteAuditResult, MetaRefreshInfo, TimerInfo, CountdownIndicator, TimeLimitDetectorResult, ScreenshotRecord, ComparisonResult, ImageDiffResult, PauseControl, CarouselIndicator, PauseControlInfo, PauseVerificationResult, AutoPlayDetectionResult, } from '../types.js';
14
+ export type { AuditCheckResult, AuditResultSummary, CheckSource, NormalizedImpact, NormalizedNode, NormalizedRuleResult, AxeAuditDetails, AxeAuditResult, FocusRecord, FocusElementRef, OnFocusViolation, FocusCheckDetails, FocusCheckResult, BoundingRect, FocusObscuredOverlap, FocusObscuredIssue, ReflowIssue, ClippedTextElement, ReflowCheckDetails, ReflowCheckResult, TargetSizeException, TargetSizeExceptionAssessment, TargetSizeIssue, TargetSizeSummary, TargetSizeCheckDetails, TargetSizeCheckResult, TextSpacingIssue, TextSpacingCheckDetails, TextSpacingCheckResult, ZoomIssue, ZoomCheckDetails, ZoomCheckResult, OrientationState, OrientationCheckDetails, OrientationCheckResult, AutocompleteIssue, AutocompleteAuditDetails, AutocompleteAuditResult, MetaRefreshInfo, TimerInfo, CountdownIndicator, TimeLimitDetectorDetails, TimeLimitDetectorResult, ScreenshotRecord, ComparisonResult, ImageDiffResult, PauseControl, CarouselIndicator, PauseControlInfo, PauseVerificationResult, AutoPlayDetectionDetails, AutoPlayDetectionResult, } from '../types.js';
11
15
  /** Minimal JSON Schema object shape (Draft 2020-12 compatible subset). */
12
16
  export interface JsonSchema {
13
17
  $schema?: string;
14
18
  $id?: string;
19
+ $ref?: string;
20
+ $defs?: Record<string, JsonSchema>;
15
21
  title?: string;
16
22
  type?: string | string[];
17
23
  properties?: Record<string, JsonSchema>;
18
24
  items?: JsonSchema;
19
25
  required?: string[];
20
26
  enum?: unknown[];
27
+ const?: unknown;
21
28
  description?: string;
22
29
  [key: string]: unknown;
23
30
  }