@a11y-skills/audit 0.2.0 → 0.3.1

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.
@@ -5,11 +5,16 @@
5
5
  * caller is responsible for navigating the page (e.g. `await page.goto(url)`)
6
6
  * before calling this function.
7
7
  *
8
+ * The normalized buckets are built from the RAW axe results (violations and
9
+ * incomplete keep their nodes; passes/inapplicable keep rule metadata only),
10
+ * and `details` records the execution configuration.
11
+ *
8
12
  * Axe-core cannot detect all accessibility issues — manual testing and the
9
13
  * other checks in this package are still needed for complete coverage.
10
14
  */
11
15
  import { AxeBuilder } from '@axe-core/playwright';
12
- import { AUDIT_DISCLAIMER, DEFAULT_AXE_TAGS, DEFAULT_AXE_RESULT_FILE, } from '../constants.js';
16
+ import { DEFAULT_AXE_TAGS, DEFAULT_AXE_RESULT_FILE } from '../constants.js';
17
+ import { buildAuditResult, normalizeAxeResults } from '../utils/axe-format.js';
13
18
  import { saveAuditResult, logAuditHeader, logSummary, logOutputPaths, } from '../utils/test-harness.js';
14
19
  /**
15
20
  * Run an axe-core audit against the current page, write the result JSON, and
@@ -22,36 +27,29 @@ export async function runAxeAudit(options) {
22
27
  builder = builder.options({ rules });
23
28
  }
24
29
  const axeResults = await builder.analyze();
25
- const result = {
26
- url: page.url(),
27
- timestamp: new Date().toISOString(),
28
- violations: axeResults.violations.map((v) => ({
29
- id: v.id,
30
- impact: v.impact ?? null,
31
- description: v.description,
32
- help: v.help,
33
- helpUrl: v.helpUrl,
34
- tags: v.tags,
35
- nodes: v.nodes.map((n) => ({
36
- html: n.html,
37
- target: n.target,
38
- failureSummary: n.failureSummary,
39
- })),
40
- })),
41
- passes: axeResults.passes.length,
42
- incomplete: axeResults.incomplete.length,
43
- inapplicable: axeResults.inapplicable.length,
44
- violationCount: axeResults.violations.length,
45
- disclaimer: AUDIT_DISCLAIMER,
30
+ const buckets = normalizeAxeResults(axeResults);
31
+ const details = {
32
+ tagsRun: [...tags],
33
+ rulesOverride: rules ?? null,
34
+ violationRuleCount: axeResults.violations.length,
35
+ passRuleCount: axeResults.passes.length,
36
+ incompleteRuleCount: axeResults.incomplete.length,
37
+ inapplicableRuleCount: axeResults.inapplicable.length,
46
38
  };
39
+ const result = buildAuditResult({
40
+ source: 'axe-audit',
41
+ url: page.url(),
42
+ details,
43
+ buckets,
44
+ });
47
45
  // Output results
48
46
  logAuditHeader('Axe-core Accessibility Audit Results', 'axe-core', result.url);
49
47
  logSummary({
50
48
  Timestamp: result.timestamp,
51
- Violations: result.violationCount,
52
- Passes: result.passes,
53
- 'Incomplete (needs review)': result.incomplete,
54
- Inapplicable: result.inapplicable,
49
+ Violations: result.summary.violationCount,
50
+ Passes: result.summary.passCount,
51
+ 'Incomplete (needs review)': result.summary.incompleteCount,
52
+ Inapplicable: result.inapplicable.length,
55
53
  });
56
54
  if (result.violations.length > 0) {
57
55
  console.log('\n--- Violations ---');
@@ -71,18 +69,16 @@ export async function runAxeAudit(options) {
71
69
  });
72
70
  }
73
71
  console.log(`\n--- Summary ---`);
74
- if (result.violationCount === 0) {
72
+ if (result.summary.violationCount === 0) {
75
73
  console.log('No violations detected by axe-core');
76
74
  }
77
75
  else {
78
76
  const totalElements = result.violations.reduce((sum, v) => sum + v.nodes.length, 0);
79
- console.log(`Found ${result.violationCount} violation type(s) affecting ${totalElements} element(s)`);
77
+ console.log(`Found ${result.summary.violationCount} violation type(s) affecting ${totalElements} element(s)`);
80
78
  }
81
- // axe results already carry the disclaimer field; don't append it again.
82
79
  const resolvedPath = saveAuditResult(result, {
83
80
  ...location,
84
81
  defaultFile: DEFAULT_AXE_RESULT_FILE,
85
- includeDisclaimer: false,
86
82
  });
87
83
  logOutputPaths(resolvedPath);
88
84
  return result;
@@ -22,6 +22,13 @@ export interface RunFocusIndicatorCheckOptions extends OutputLocationOptions {
22
22
  screenshot?: boolean;
23
23
  /** Options forwarded to `browser.newContext()` (locale, viewport, storageState, ...). */
24
24
  contextOptions?: BrowserContextOptions;
25
+ /**
26
+ * Milliseconds to wait after each Tab press for a focus-triggered navigation
27
+ * to surface (default: 50). A navigation that fires after this window may be
28
+ * missed or attributed to a neighboring element — raise this value when
29
+ * auditing pages with debounced or otherwise delayed focus handlers.
30
+ */
31
+ navigationSettleMs?: number;
25
32
  }
26
33
  /**
27
34
  * Run the focus indicator check, write the result JSON (and optionally a
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * The context this function creates is closed before it returns.
12
12
  */
13
- import { FOCUSABLE_SELECTOR, FOCUS_STYLE_PROPERTIES, EXTRA_TAB_ITERATIONS, DEFAULT_FOCUS_RESULT_FILE, DEFAULT_FOCUS_SCREENSHOT_FILE, FOCUS_OBSCURED_MIN_OVERLAP_RATIO, FOCUS_OBSCURED_MIN_OVERLAP_PX, FOCUS_OBSCURED_EXCLUDE_SELECTORS, } from '../constants.js';
13
+ import { FOCUSABLE_SELECTOR, FOCUS_STYLE_PROPERTIES, EXTRA_TAB_ITERATIONS, DEFAULT_NAVIGATION_SETTLE_MS, DEFAULT_FOCUS_RESULT_FILE, DEFAULT_FOCUS_SCREENSHOT_FILE, FOCUS_OBSCURED_MIN_OVERLAP_RATIO, FOCUS_OBSCURED_MIN_OVERLAP_PX, FOCUS_OBSCURED_EXCLUDE_SELECTORS, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
14
+ import { buildAuditResult, normalizeFocusCheck } from '../utils/axe-format.js';
14
15
  import { resolveOutputPath, resolveScreenshotPath, saveAuditResult, takeAuditScreenshot, requireTargetUrl, logAuditHeader, logOutputPaths, } from '../utils/test-harness.js';
15
16
  // =============================================================================
16
17
  // Browser-injected styles for marking elements
@@ -72,7 +73,7 @@ const MAX_RETRIES = 5;
72
73
  * screenshot), and return the parsed result.
73
74
  */
74
75
  export async function runFocusIndicatorCheck(options) {
75
- const { browser, targetUrl: targetUrlOption, screenshot = false, contextOptions, ...location } = options;
76
+ const { browser, targetUrl: targetUrlOption, screenshot = false, contextOptions, navigationSettleMs = DEFAULT_NAVIGATION_SETTLE_MS, ...location } = options;
76
77
  const targetUrl = requireTargetUrl(targetUrlOption);
77
78
  const resolvedResultPath = resolveOutputPath({
78
79
  ...location,
@@ -94,6 +95,18 @@ export async function runFocusIndicatorCheck(options) {
94
95
  // Create a fresh context for each attempt to reset init scripts
95
96
  context = await browser.newContext(contextOptions);
96
97
  const page = await context.newPage();
98
+ // Track main-frame navigations (WCAG 3.2.1). Catches URL changes that
99
+ // commit and revert within a single settle window (which the before/after
100
+ // URL diff alone would miss). A navigation landing between iterations is
101
+ // absorbed into the next urlBeforeTab and is NOT caught by this listener —
102
+ // raise navigationSettleMs for pages with slow focus-triggered handlers.
103
+ // Reset after the initial goto; checked alongside the URL diff.
104
+ let lateNavigationUrl = null;
105
+ page.on('framenavigated', (frame) => {
106
+ if (frame === page.mainFrame()) {
107
+ lateNavigationUrl = frame.url();
108
+ }
109
+ });
97
110
  const focusHistory = [];
98
111
  let lastFocusedElement = null;
99
112
  let navigationDetected = false;
@@ -106,6 +119,8 @@ export async function runFocusIndicatorCheck(options) {
106
119
  name: data.name,
107
120
  selector: data.selector ||
108
121
  `${data.tag.toLowerCase()}:nth-of-type(${data.id + 1})`,
122
+ html: data.html,
123
+ htmlTruncated: data.htmlTruncated,
109
124
  };
110
125
  });
111
126
  // Expose function to receive focus obscured reports (WCAG 2.4.12)
@@ -118,6 +133,7 @@ export async function runFocusIndicatorCheck(options) {
118
133
  styleProperties: [...FOCUS_STYLE_PROPERTIES],
119
134
  warningStyles: WARNING_STYLES,
120
135
  skipSelectors: [...skipSelectors],
136
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
121
137
  obscuredConfig: {
122
138
  minOverlapRatio: FOCUS_OBSCURED_MIN_OVERLAP_RATIO,
123
139
  minOverlapPx: FOCUS_OBSCURED_MIN_OVERLAP_PX,
@@ -156,6 +172,9 @@ export async function runFocusIndicatorCheck(options) {
156
172
  });
157
173
  }, skipSelectors);
158
174
  }
175
+ // Reset the late-navigation flag so the initial goto and skip-element
176
+ // setup above don't produce false positives in the Tab loop.
177
+ lateNavigationUrl = null;
159
178
  // Tab through all elements with navigation detection
160
179
  for (let i = 0; i < count + EXTRA_TAB_ITERATIONS; i++) {
161
180
  const urlBeforeTab = page.url();
@@ -163,7 +182,7 @@ export async function runFocusIndicatorCheck(options) {
163
182
  // Get currently focused element IMMEDIATELY after Tab
164
183
  let currentFocusedElement = null;
165
184
  try {
166
- currentFocusedElement = await page.evaluate(() => {
185
+ currentFocusedElement = await page.evaluate((htmlSnippetMaxLength) => {
167
186
  const el = document.activeElement;
168
187
  if (!el || el === document.body)
169
188
  return null;
@@ -180,6 +199,17 @@ export async function runFocusIndicatorCheck(options) {
180
199
  const index = siblings.indexOf(element) + 1;
181
200
  return `${getSelector(parent)} > ${tag}:nth-of-type(${index})`;
182
201
  };
202
+ let html = '';
203
+ try {
204
+ html = el.outerHTML || '';
205
+ }
206
+ catch {
207
+ html = '';
208
+ }
209
+ if (!html) {
210
+ html = `<${el.tagName.toLowerCase()}>`;
211
+ }
212
+ const htmlTruncated = html.length > htmlSnippetMaxLength;
183
213
  return {
184
214
  tag: el.tagName,
185
215
  role: el.getAttribute('role'),
@@ -187,26 +217,34 @@ export async function runFocusIndicatorCheck(options) {
187
217
  el.textContent?.slice(0, 30) ||
188
218
  '',
189
219
  selector: getSelector(el),
220
+ html: htmlTruncated
221
+ ? html.slice(0, htmlSnippetMaxLength)
222
+ : html,
223
+ htmlTruncated,
190
224
  };
191
- });
225
+ }, HTML_SNIPPET_MAX_LENGTH);
192
226
  }
193
227
  catch {
194
228
  // Page might have navigated, use lastFocusedElement as fallback
195
229
  currentFocusedElement = lastFocusedElement;
196
230
  }
197
231
  // Small wait to allow any navigation to start
198
- await page.waitForTimeout(50);
199
- // Check if navigation occurred
232
+ await page.waitForTimeout(navigationSettleMs);
233
+ // Check if navigation occurred (either within the settle window or late)
200
234
  const urlAfterTab = page.url();
201
- if (urlAfterTab !== urlBeforeTab) {
235
+ const lateNav = lateNavigationUrl !== null && lateNavigationUrl !== urlBeforeTab;
236
+ if (urlAfterTab !== urlBeforeTab || lateNav) {
202
237
  // 3.2.1 violation detected!
203
238
  navigationDetected = true;
204
239
  const culprit = currentFocusedElement || lastFocusedElement;
205
240
  if (culprit && !skipSelectors.includes(culprit.selector)) {
241
+ const toUrl = urlAfterTab !== urlBeforeTab
242
+ ? urlAfterTab
243
+ : (lateNavigationUrl ?? urlAfterTab);
206
244
  onFocusViolations.push({
207
245
  element: culprit,
208
246
  fromUrl: urlBeforeTab,
209
- toUrl: urlAfterTab,
247
+ toUrl,
210
248
  changeType: 'navigation',
211
249
  });
212
250
  skipSelectors.push(culprit.selector);
@@ -214,9 +252,10 @@ export async function runFocusIndicatorCheck(options) {
214
252
  console.warn(` Element: <${culprit.tag}> "${culprit.name}"`);
215
253
  console.warn(` Selector: ${culprit.selector}`);
216
254
  console.warn(` From: ${urlBeforeTab}`);
217
- console.warn(` To: ${urlAfterTab}`);
255
+ console.warn(` To: ${toUrl}`);
218
256
  console.warn(` Restarting test with this element skipped...`);
219
257
  }
258
+ lateNavigationUrl = null;
220
259
  break;
221
260
  }
222
261
  }
@@ -248,8 +287,7 @@ export async function runFocusIndicatorCheck(options) {
248
287
  console.warn('Elements without visible focus indicator:', elementsWithoutFocusStyle);
249
288
  }
250
289
  // Build result
251
- const result = {
252
- url: finalPage.url(),
290
+ const details = {
253
291
  totalFocusableElements: finalFocusHistory.length,
254
292
  elementsWithFocusStyle: finalFocusHistory.length - elementsWithoutFocusStyle.length,
255
293
  elementsWithoutFocusStyle: elementsWithoutFocusStyle.length,
@@ -257,6 +295,9 @@ export async function runFocusIndicatorCheck(options) {
257
295
  tag: el.tag,
258
296
  role: el.role,
259
297
  name: el.name,
298
+ selector: el.selector,
299
+ html: el.html,
300
+ htmlTruncated: el.htmlTruncated,
260
301
  })),
261
302
  onFocusViolations,
262
303
  focusObscuredIssues,
@@ -265,12 +306,18 @@ export async function runFocusIndicatorCheck(options) {
265
306
  interrupted: false,
266
307
  screenshotPath: screenshot ? resolvedScreenshotPath : '',
267
308
  };
309
+ const result = buildAuditResult({
310
+ source: 'focus-indicator-check',
311
+ url: finalPage.url(),
312
+ details,
313
+ buckets: normalizeFocusCheck(details),
314
+ });
268
315
  // Output results
269
- logAuditHeader('Focus Indicator Check Results', 'WCAG 2.4.7 / 2.4.12 / 3.2.1', result.url);
270
- console.log(`Total focusable elements: ${result.totalFocusableElements}`);
271
- console.log(`Elements with focus style: ${result.elementsWithFocusStyle}`);
272
- console.log(`Elements WITHOUT focus style: ${result.elementsWithoutFocusStyle}`);
273
- console.log(`Elements with OBSCURED focus: ${result.elementsWithObscuredFocus}`);
316
+ logAuditHeader('Focus Indicator Check Results', 'WCAG 2.4.7 / 2.4.11 / 3.2.1', result.url);
317
+ console.log(`Total focusable elements: ${details.totalFocusableElements}`);
318
+ console.log(`Elements with focus style: ${details.elementsWithFocusStyle}`);
319
+ console.log(`Elements WITHOUT focus style: ${details.elementsWithoutFocusStyle}`);
320
+ console.log(`Elements with OBSCURED focus: ${details.elementsWithObscuredFocus}`);
274
321
  if (retryCount > 0) {
275
322
  console.log(`\nTest restarted ${retryCount} time(s) due to navigation violations`);
276
323
  }
@@ -328,7 +375,23 @@ export async function runFocusIndicatorCheck(options) {
328
375
  // Browser-injected script factory
329
376
  // =============================================================================
330
377
  function createFocusTrackerScript(args) {
331
- const { focusableSelector, styleProperties, warningStyles, skipSelectors, obscuredConfig } = args;
378
+ const { focusableSelector, styleProperties, warningStyles, skipSelectors, htmlSnippetMaxLength, obscuredConfig, } = args;
379
+ const getHtmlSnippet = (el) => {
380
+ let html = '';
381
+ try {
382
+ html = el.outerHTML || '';
383
+ }
384
+ catch {
385
+ html = '';
386
+ }
387
+ if (!html) {
388
+ return { html: `<${el.tagName.toLowerCase()}>`, htmlTruncated: false };
389
+ }
390
+ if (html.length > htmlSnippetMaxLength) {
391
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
392
+ }
393
+ return { html, htmlTruncated: false };
394
+ };
332
395
  // Add warning styles
333
396
  const styleSheet = new CSSStyleSheet();
334
397
  document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet];
@@ -646,7 +709,8 @@ function createFocusTrackerScript(args) {
646
709
  // Clamp ratio to max 1.0 to handle overlapping obscurers covering same region
647
710
  const obscuredRatio = Math.min(totalOverlapArea / focusedArea, 1.0);
648
711
  // Only report if obscured ratio exceeds threshold
649
- if (obscuredRatio >= obscuredConfig.minOverlapRatio && overlaps.length > 0) {
712
+ if (obscuredRatio >= obscuredConfig.minOverlapRatio &&
713
+ overlaps.length > 0) {
650
714
  // Add visual annotation
651
715
  addAnnotationBox(focusedEl, `⚠ 2.4.12 Obscured (${(obscuredRatio * 100).toFixed(0)}%)`, 'focus-obscured');
652
716
  // Report to test
@@ -658,6 +722,7 @@ function createFocusTrackerScript(args) {
658
722
  focusedEl.textContent?.slice(0, 30) ||
659
723
  '',
660
724
  selector: getSelector(focusedEl),
725
+ ...getHtmlSnippet(focusedEl),
661
726
  },
662
727
  elementRect: {
663
728
  left: focusedRect.left,
@@ -673,18 +738,19 @@ function createFocusTrackerScript(args) {
673
738
  /**
674
739
  * Initialize focus tracker - called after page load
675
740
  */
676
- window.initFocusTracker = () => {
677
- // Create overlay container
678
- createOverlay();
679
- const elements = [...document.querySelectorAll(focusableSelector)]
680
- .filter(isVisible)
681
- .filter((el) => !shouldSkip(el));
682
- elements.forEach((el) => {
683
- baseStyles.set(el, captureStyle(el));
684
- elementSelectors.set(el, getSelector(el));
685
- });
686
- return elements.length;
687
- };
741
+ window.initFocusTracker =
742
+ () => {
743
+ // Create overlay container
744
+ createOverlay();
745
+ const elements = [...document.querySelectorAll(focusableSelector)]
746
+ .filter(isVisible)
747
+ .filter((el) => !shouldSkip(el));
748
+ elements.forEach((el) => {
749
+ baseStyles.set(el, captureStyle(el));
750
+ elementSelectors.set(el, getSelector(el));
751
+ });
752
+ return elements.length;
753
+ };
688
754
  /**
689
755
  * Handle focus events
690
756
  */
@@ -729,10 +795,11 @@ function createFocusTrackerScript(args) {
729
795
  id,
730
796
  tag: el.tagName,
731
797
  role: el.getAttribute('role'),
732
- name: el.getAttribute('aria-label') || el.textContent?.slice(0, 30),
798
+ name: el.getAttribute('aria-label') || el.textContent?.slice(0, 30) || '',
733
799
  hasFocusStyle,
734
800
  diff,
735
801
  selector: elementSelectors.get(el) || getSelector(el),
802
+ ...getHtmlSnippet(el),
736
803
  });
737
804
  // Check for WCAG 2.4.12 - focus obscured by fixed/sticky elements
738
805
  checkFocusObscured(el);
@@ -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
@@ -22,33 +23,42 @@ import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHe
22
23
  */
23
24
  export async function runReflowCheck(options) {
24
25
  const { page, viewport = REFLOW_VIEWPORT, overflowTolerance = REFLOW_OVERFLOW_TOLERANCE, screenshot = false, ...location } = options;
25
- await page.setViewportSize({ width: viewport.width, height: viewport.height });
26
+ await page.setViewportSize({
27
+ width: viewport.width,
28
+ height: viewport.height,
29
+ });
26
30
  const layoutResult = await page.evaluate(createLayoutChecker, {
27
31
  viewportWidth: viewport.width,
28
32
  overflowTolerance,
29
33
  checkSelector: REFLOW_CHECK_SELECTOR,
30
34
  allowedOverflowSelectors: [...REFLOW_ALLOWED_OVERFLOW_SELECTORS],
35
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
31
36
  });
32
- const result = {
33
- url: page.url(),
37
+ const details = {
34
38
  viewport: { width: viewport.width, height: viewport.height },
35
39
  ...layoutResult,
36
40
  };
41
+ const result = buildAuditResult({
42
+ source: 'reflow-check',
43
+ url: page.url(),
44
+ details,
45
+ buckets: normalizeReflowCheck(details),
46
+ });
37
47
  // Output results
38
48
  logAuditHeader('Reflow Check Results', 'WCAG 1.4.10', result.url);
39
49
  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,
50
+ Viewport: `${details.viewport.width}x${details.viewport.height}`,
51
+ 'Document scroll width': `${details.documentScrollWidth}px`,
52
+ 'Document client width': `${details.documentClientWidth}px`,
53
+ 'Horizontal scroll': details.hasHorizontalScroll,
54
+ 'Overflowing elements': details.overflowingElements.length,
55
+ 'Clipped text elements': details.clippedTextElements.length,
46
56
  });
47
- logIssueList('Overflowing Elements', result.overflowingElements, (el, i) => [
57
+ logIssueList('Overflowing Elements', details.overflowingElements, (el, i) => [
48
58
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
49
59
  ` rect.right: ${el.rect.right}px (viewport: ${el.viewportWidth}px)`,
50
60
  ]);
51
- logIssueList('Clipped Text Elements', result.clippedTextElements, (el, i) => [
61
+ logIssueList('Clipped Text Elements', details.clippedTextElements, (el, i) => [
52
62
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
53
63
  ` scrollWidth: ${el.scrollWidth}px, clientWidth: ${el.clientWidth}px`,
54
64
  ` overflow: ${el.overflow}, overflowX: ${el.overflowX}`,
@@ -15,13 +15,34 @@
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
- import { addPageAnnotations } from '../utils/annotations.js';
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 {
37
+ html: `<${element.tagName.toLowerCase()}>`,
38
+ htmlTruncated: false,
39
+ };
40
+ }
41
+ if (html.length > htmlSnippetMaxLength) {
42
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
43
+ }
44
+ return { html, htmlTruncated: false };
45
+ }
25
46
  function getUniqueSelector(element, elementIndex) {
26
47
  if (element.id) {
27
48
  return `#${CSS.escape(element.id)}`;
@@ -41,7 +62,9 @@ function collectBasicTargetInfo(interactiveSelector) {
41
62
  path.unshift(selector);
42
63
  current = parent;
43
64
  }
44
- return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
65
+ return path.length > 0
66
+ ? path.join(' > ')
67
+ : `[data-index="${elementIndex}"]`;
45
68
  }
46
69
  const targets = [];
47
70
  const elements = document.querySelectorAll(interactiveSelector);
@@ -72,6 +95,7 @@ function collectBasicTargetInfo(interactiveSelector) {
72
95
  targets.push({
73
96
  selector: getUniqueSelector(element, index),
74
97
  tagName,
98
+ ...getHtmlSnippet(element),
75
99
  role,
76
100
  width: Math.round(rect.width * 100) / 100,
77
101
  height: Math.round(rect.height * 100) / 100,
@@ -247,6 +271,8 @@ function analyzeTargets(targets, aaThreshold, aaaThreshold) {
247
271
  const issue = {
248
272
  selector: target.selector,
249
273
  tagName: target.tagName,
274
+ html: target.html,
275
+ htmlTruncated: target.htmlTruncated,
250
276
  role: target.role,
251
277
  accessibleName: target.accessibleName,
252
278
  width: target.width,
@@ -255,6 +281,9 @@ function analyzeTargets(targets, aaThreshold, aaaThreshold) {
255
281
  level,
256
282
  exception,
257
283
  exceptionDetails,
284
+ // the heuristics can detect exceptions but can never rule out the
285
+ // essential exception, so findings are at best 'possible'/'not-assessed'.
286
+ exceptionAssessment: exception ? 'possible' : 'not-assessed',
258
287
  href: target.href,
259
288
  };
260
289
  if (exception) {
@@ -276,7 +305,10 @@ function analyzeTargets(targets, aaThreshold, aaaThreshold) {
276
305
  export async function runTargetSizeCheck(options) {
277
306
  const { page, aaThreshold = TARGET_SIZE_AA, aaaThreshold = TARGET_SIZE_AAA, screenshot = false, ...location } = options;
278
307
  // Collect basic target info from DOM
279
- const basicTargets = await page.evaluate(collectBasicTargetInfo, INTERACTIVE_SELECTOR);
308
+ const basicTargets = await page.evaluate(collectBasicTargetInfo, {
309
+ interactiveSelector: INTERACTIVE_SELECTOR,
310
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
311
+ });
280
312
  // Enhance with accessible names via ariaSnapshot()
281
313
  const targets = [];
282
314
  for (const basicTarget of basicTargets) {
@@ -296,8 +328,7 @@ export async function runTargetSizeCheck(options) {
296
328
  }
297
329
  // Analyze targets
298
330
  const { failAA, failAAAOnly, passCount, excepted } = analyzeTargets(targets, aaThreshold, aaaThreshold);
299
- const result = {
300
- url: page.url(),
331
+ const details = {
301
332
  totalTargetsChecked: targets.length,
302
333
  failAA,
303
334
  failAAAOnly,
@@ -310,16 +341,22 @@ export async function runTargetSizeCheck(options) {
310
341
  exceptedCount: excepted.length,
311
342
  },
312
343
  };
344
+ const result = buildAuditResult({
345
+ source: 'target-size-check',
346
+ url: page.url(),
347
+ details,
348
+ buckets: normalizeTargetSizeCheck(details),
349
+ });
313
350
  // Output results
314
351
  logAuditHeader('Target Size Check Results', 'WCAG 2.5.5 / 2.5.8', result.url);
315
352
  logSummary({
316
- 'Total targets checked': result.totalTargetsChecked,
353
+ 'Total targets checked': details.totalTargetsChecked,
317
354
  });
318
355
  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}`);
356
+ console.log(` Pass (>= ${aaaThreshold}px): ${details.summary.passCount}`);
357
+ console.log(` Fail AAA only (${aaThreshold}-${aaaThreshold - 1}px): ${details.summary.failAAAOnlyCount}`);
358
+ console.log(` Fail AA (< ${aaThreshold}px): ${details.summary.failAACount}`);
359
+ console.log(` Possible exceptions: ${details.summary.exceptedCount}`);
323
360
  logIssueList(`Fail AA (< ${aaThreshold}px) - Requires Fix`, failAA, (el, i) => {
324
361
  const lines = [
325
362
  `${i + 1}. <${el.tagName}> "${el.selector}"`,