@a11y-oracle/axe-bridge 1.0.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 (47) hide show
  1. package/README.md +254 -0
  2. package/dist/index.d.ts +26 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +27 -0
  5. package/dist/lib/axe-bridge.d.ts +33 -0
  6. package/dist/lib/axe-bridge.d.ts.map +1 -0
  7. package/dist/lib/axe-bridge.js +106 -0
  8. package/dist/lib/resolve-all.d.ts +45 -0
  9. package/dist/lib/resolve-all.d.ts.map +1 -0
  10. package/dist/lib/resolve-all.js +117 -0
  11. package/dist/lib/resolver-pipeline.d.ts +51 -0
  12. package/dist/lib/resolver-pipeline.d.ts.map +1 -0
  13. package/dist/lib/resolver-pipeline.js +94 -0
  14. package/dist/lib/resolvers/aria-hidden-focus.d.ts +31 -0
  15. package/dist/lib/resolvers/aria-hidden-focus.d.ts.map +1 -0
  16. package/dist/lib/resolvers/aria-hidden-focus.js +148 -0
  17. package/dist/lib/resolvers/content-on-hover.d.ts +27 -0
  18. package/dist/lib/resolvers/content-on-hover.d.ts.map +1 -0
  19. package/dist/lib/resolvers/content-on-hover.js +230 -0
  20. package/dist/lib/resolvers/focus-indicator.d.ts +32 -0
  21. package/dist/lib/resolvers/focus-indicator.d.ts.map +1 -0
  22. package/dist/lib/resolvers/focus-indicator.js +188 -0
  23. package/dist/lib/resolvers/frame-tested.d.ts +31 -0
  24. package/dist/lib/resolvers/frame-tested.d.ts.map +1 -0
  25. package/dist/lib/resolvers/frame-tested.js +177 -0
  26. package/dist/lib/resolvers/identical-links-same-purpose.d.ts +35 -0
  27. package/dist/lib/resolvers/identical-links-same-purpose.d.ts.map +1 -0
  28. package/dist/lib/resolvers/identical-links-same-purpose.js +117 -0
  29. package/dist/lib/resolvers/link-in-text-block.d.ts +29 -0
  30. package/dist/lib/resolvers/link-in-text-block.d.ts.map +1 -0
  31. package/dist/lib/resolvers/link-in-text-block.js +141 -0
  32. package/dist/lib/resolvers/scrollable-region-focusable.d.ts +26 -0
  33. package/dist/lib/resolvers/scrollable-region-focusable.d.ts.map +1 -0
  34. package/dist/lib/resolvers/scrollable-region-focusable.js +139 -0
  35. package/dist/lib/resolvers/skip-link.d.ts +26 -0
  36. package/dist/lib/resolvers/skip-link.d.ts.map +1 -0
  37. package/dist/lib/resolvers/skip-link.js +140 -0
  38. package/dist/lib/resolvers/target-size.d.ts +25 -0
  39. package/dist/lib/resolvers/target-size.d.ts.map +1 -0
  40. package/dist/lib/resolvers/target-size.js +125 -0
  41. package/dist/lib/types.d.ts +227 -0
  42. package/dist/lib/types.d.ts.map +1 -0
  43. package/dist/lib/types.js +8 -0
  44. package/dist/lib/wcag-thresholds.d.ts +34 -0
  45. package/dist/lib/wcag-thresholds.d.ts.map +1 -0
  46. package/dist/lib/wcag-thresholds.js +55 -0
  47. package/package.json +53 -0
package/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # @a11y-oracle/axe-bridge
2
+
3
+ Axe-core result post-processor that resolves "incomplete" (Needs Review) findings using visual analysis, keyboard interaction, and CDP inspection. Drop-in middleware between axe-core's `analyze()` and your assertion layer.
4
+
5
+ ## The Problem
6
+
7
+ axe-core marks rules as "incomplete" when they require state changes, interaction, or spatial awareness that static DOM analysis cannot reliably perform. This creates noise in CI dashboards and requires manual review for rules like color contrast, focus indicators, target sizes, and more.
8
+
9
+ ## The Solution
10
+
11
+ `resolveAllIncomplete()` takes axe-core results and a live CDP session, then pipes them through 10 specialized resolvers that promote findings from `incomplete` to `passes` or `violations`:
12
+
13
+ | # | Rule ID | WCAG SC | Technique |
14
+ |---|---------|---------|-----------|
15
+ | 1 | `color-contrast` | 1.4.3 | CSS halo heuristic + pixel-level screenshot analysis |
16
+ | 2 | `identical-links-same-purpose` | 2.4.4 | URL normalization and comparison |
17
+ | 3 | `link-in-text-block` | 1.4.1 | Default-state computed style checks |
18
+ | 4 | `target-size` | 2.5.8 | Bounding box measurements + spacing |
19
+ | 5 | `scrollable-region-focusable` | 2.1.1 | Scroll height + focusable child traversal |
20
+ | 6 | `skip-link` | 2.4.1 | Tab-focus visibility verification |
21
+ | 7 | `aria-hidden-focus` | 4.1.2 | Full keyboard Tab traversal |
22
+ | 8 | `focus-indicator` | 2.4.7 | Before/after screenshot pixel diff |
23
+ | 9 | `content-on-hover` | 1.4.13 | Hover + dismiss interaction tests |
24
+ | 10 | `frame-tested` | N/A | Cross-origin iframe axe-core injection |
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ npm install @a11y-oracle/axe-bridge
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ### Resolve All Incompletes (Recommended)
35
+
36
+ ```typescript
37
+ import { resolveAllIncomplete } from '@a11y-oracle/axe-bridge';
38
+
39
+ // After running axe-core while the page is still live:
40
+ const axeResults = await axe.run(document);
41
+ const resolved = await resolveAllIncomplete(cdpSession, axeResults);
42
+
43
+ // resolved.incomplete will have fewer (or zero) entries
44
+ // resolved.violations and resolved.passes will have the promoted entries
45
+ ```
46
+
47
+ ### With Options
48
+
49
+ ```typescript
50
+ const resolved = await resolveAllIncomplete(cdpSession, axeResults, {
51
+ wcagLevel: 'wcag22aa',
52
+ contrast: { threshold: 4.5, largeTextThreshold: 3.0 },
53
+ focusIndicator: { focusSettleDelay: 150, diffThreshold: 0.2 },
54
+ skipRules: ['frame-tested'], // skip specific resolvers
55
+ });
56
+ ```
57
+
58
+ ### Individual Resolvers
59
+
60
+ Each resolver can be used standalone:
61
+
62
+ ```typescript
63
+ import { resolveIncompleteContrast } from '@a11y-oracle/axe-bridge';
64
+
65
+ const resolved = await resolveIncompleteContrast(cdpSession, axeResults, {
66
+ wcagLevel: 'wcag22aa',
67
+ });
68
+ ```
69
+
70
+ ### With Playwright
71
+
72
+ ```typescript
73
+ import { test, expect } from '@playwright/test';
74
+ import { resolveAllIncomplete } from '@a11y-oracle/axe-bridge';
75
+
76
+ test('no unresolved accessibility issues', async ({ page }) => {
77
+ await page.goto('/my-page.html');
78
+ const cdp = await page.context().newCDPSession(page);
79
+
80
+ const axeResults = await axe.run(document);
81
+ const resolved = await resolveAllIncomplete(cdp, axeResults);
82
+
83
+ expect(resolved.violations).toHaveLength(0);
84
+ expect(resolved.incomplete).toHaveLength(0);
85
+
86
+ await cdp.detach();
87
+ });
88
+ ```
89
+
90
+ ### With Cypress
91
+
92
+ ```typescript
93
+ // Using Cypress.automation for CDP access:
94
+ cy.window().then(async (win) => {
95
+ const axeResults = await axeCore.run(win.document);
96
+
97
+ // cdpSession obtained via Cypress.automation('remote:debugger:protocol', ...)
98
+ const resolved = await resolveAllIncomplete(cdpSession, axeResults);
99
+
100
+ expect(resolved.violations).to.have.length(0);
101
+ });
102
+ ```
103
+
104
+ ## API Reference
105
+
106
+ ### `resolveAllIncomplete(cdp, axeResults, options?)`
107
+
108
+ Orchestrator that pipes results through all 10 resolvers in sequence. Each resolver receives the output of the previous one.
109
+
110
+ - **Parameters:**
111
+ - `cdp: CDPSessionLike` — CDP session connected to the page
112
+ - `axeResults: AxeResults` — Raw results from axe-core's `analyze()`
113
+ - `options?: IncompleteResolutionOptions` — Per-resolver options and `skipRules` filter
114
+ - **Returns:** `Promise<AxeResults>` — Modified copy with resolved findings
115
+ - **Pipeline order:** color-contrast → identical-links-same-purpose → link-in-text-block → target-size → scrollable-region-focusable → skip-link → aria-hidden-focus → focus-indicator → content-on-hover → frame-tested
116
+
117
+ ### Individual Resolvers
118
+
119
+ #### `resolveIncompleteContrast(cdp, axeResults, options?)`
120
+
121
+ Resolves `color-contrast` incomplete entries using CSS halo heuristics and pixel-level screenshot analysis.
122
+
123
+ - **Options:** `ContrastResolutionOptions` — `wcagLevel`, `threshold`, `largeTextThreshold`
124
+ - Automatically detects large text from axe-core's check data (>= 24px or bold >= 18.66px)
125
+
126
+ #### `resolveIdenticalLinksSamePurpose(cdp, axeResults)`
127
+
128
+ Resolves `identical-links-same-purpose` by normalizing URLs (stripping query params, hashes, resolving relative paths) and comparing destinations.
129
+
130
+ - Same destination → **Pass**, different → **Violation**
131
+
132
+ #### `resolveLinkInTextBlock(cdp, axeResults, options?)`
133
+
134
+ Resolves `link-in-text-block` by checking the **default/resting state** for non-color visual indicators.
135
+
136
+ - **Options:** `LinkInTextBlockOptions` — `linkTextContrastThreshold` (default: 3.0)
137
+ - Checks: `text-decoration: underline`, `border-bottom > 0`, `font-weight` difference from parent
138
+ - If no non-color indicator: compares link vs surrounding text color contrast
139
+
140
+ #### `resolveTargetSize(cdp, axeResults, options?)`
141
+
142
+ Resolves `target-size` by measuring bounding boxes and center-to-center spacing.
143
+
144
+ - **Options:** `TargetSizeOptions` — `minSize` (default: 24)
145
+ - Width/height >= 24px → **Pass**; undersized with 24px+ spacing → **Pass**; otherwise → **Violation**
146
+
147
+ #### `resolveScrollableRegionFocusable(cdp, axeResults, options?)`
148
+
149
+ Resolves `scrollable-region-focusable` by checking scroll height, tabindex, and focusable children.
150
+
151
+ - **Options:** `ScrollableRegionOptions` — `maxChildren` (default: 50)
152
+ - Not actually scrollable → **Pass**; has `tabindex >= 0` → **Pass**; focusable children scroll to content → **Pass**
153
+
154
+ #### `resolveSkipLink(cdp, axeResults, options?)`
155
+
156
+ Resolves `skip-link` by focusing the skip link and verifying it becomes visible.
157
+
158
+ - **Options:** `SkipLinkOptions` — `focusSettleDelay` (default: 100)
159
+ - Checks bounding box, viewport containment, opacity, clip, position
160
+
161
+ #### `resolveAriaHiddenFocus(cdp, axeResults, options?)`
162
+
163
+ Resolves `aria-hidden-focus` via a single keyboard Tab traversal across all flagged nodes.
164
+
165
+ - **Options:** `AriaHiddenFocusOptions` — `maxTabs` (default: 100)
166
+ - Tab lands on flagged element → **Violation**; traversal completes without landing → **Pass**
167
+
168
+ #### `resolveFocusIndicator(cdp, axeResults, options?)`
169
+
170
+ Resolves `focus-indicator` by pixel-diffing before/after focus screenshots.
171
+
172
+ - **Options:** `FocusIndicatorOptions` — `focusSettleDelay` (default: 100), `diffThreshold` (default: 0.1%)
173
+ - Screenshots are identical → **Violation**; pixels changed → **Pass**
174
+
175
+ #### `resolveContentOnHover(cdp, axeResults, options?)`
176
+
177
+ Resolves `content-on-hover` with hover and dismiss interaction tests.
178
+
179
+ - **Options:** `ContentOnHoverOptions` — `hoverDelay` (default: 300), `dismissDelay` (default: 200)
180
+ - Tests: content appears on hover, remains when mouse moves to content (hoverable), disappears on Escape (dismissible)
181
+
182
+ #### `resolveFrameTested(cdp, axeResults, options?)`
183
+
184
+ Resolves `frame-tested` by injecting axe-core into cross-origin iframes via CDP.
185
+
186
+ - **Options:** `FrameTestedOptions` — `axeSource` (complete axe-core script), `iframeTimeout` (default: 30000)
187
+ - Uses `Page.createIsolatedWorld` to bypass same-origin restrictions
188
+
189
+ ### Pipeline Utilities
190
+
191
+ Shared helpers used by all resolvers:
192
+
193
+ ```typescript
194
+ import {
195
+ getSelector, // Extract innermost CSS selector from axe node target
196
+ cloneResults, // Deep-clone AxeResults without mutation
197
+ ruleShell, // Create a rule shell (metadata only, no nodes)
198
+ findIncompleteRule, // Find a rule by ID in the incomplete array
199
+ applyPromotions, // Move nodes between incomplete/passes/violations
200
+ } from '@a11y-oracle/axe-bridge';
201
+ ```
202
+
203
+ ### Types
204
+
205
+ ```typescript
206
+ import type {
207
+ // Axe-core compatible types
208
+ AxeResults,
209
+ AxeRule,
210
+ AxeNode,
211
+ AxeCheck,
212
+
213
+ // WCAG level
214
+ WcagLevel,
215
+ ContrastThresholds,
216
+
217
+ // Per-resolver options
218
+ ContrastResolutionOptions,
219
+ LinkInTextBlockOptions,
220
+ TargetSizeOptions,
221
+ ScrollableRegionOptions,
222
+ SkipLinkOptions,
223
+ AriaHiddenFocusOptions,
224
+ FocusIndicatorOptions,
225
+ ContentOnHoverOptions,
226
+ FrameTestedOptions,
227
+
228
+ // Orchestrator options
229
+ IncompleteResolutionOptions,
230
+
231
+ // Pipeline utility types
232
+ PromotionResult,
233
+ } from '@a11y-oracle/axe-bridge';
234
+ ```
235
+
236
+ > **Note:** Axe-core types are locally defined for structural compatibility without requiring axe-core as a runtime dependency.
237
+
238
+ ## How It Works
239
+
240
+ Each resolver follows a common pipeline pattern:
241
+
242
+ 1. **Clone** — Deep-clone the input results (original is never mutated)
243
+ 2. **Find** — Locate the target rule in the `incomplete` array
244
+ 3. **Analyze** — For each flagged node, run the resolver's specific technique (CDP queries, screenshots, keyboard traversal, etc.)
245
+ 4. **Promote** — Move resolved nodes to `passes` or `violations`; unresolved nodes stay in `incomplete`
246
+
247
+ The `resolveAllIncomplete` orchestrator chains all resolvers in sequence, so findings accumulate through the pipeline.
248
+
249
+ ## Dependencies
250
+
251
+ - **`@a11y-oracle/visual-engine`** — Visual analysis engine (halo detection, pixel analysis, PNG decoding, CDP capture)
252
+ - **`@a11y-oracle/focus-analyzer`** — Color parsing and contrast ratio computation
253
+ - **`@a11y-oracle/keyboard-engine`** — Native CDP keyboard dispatch for skip-link, aria-hidden-focus, and content-on-hover
254
+ - **`@a11y-oracle/cdp-types`** — `CDPSessionLike` interface for framework-agnostic CDP access
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @module @a11y-oracle/axe-bridge
3
+ *
4
+ * Axe-core result post-processor that resolves "incomplete" rule
5
+ * findings using visual analysis, keyboard interaction, and CDP
6
+ * inspection. Drop-in middleware between axe-core's `analyze()`
7
+ * and your assertion layer.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export { resolveAllIncomplete } from './lib/resolve-all.js';
12
+ export { resolveIncompleteContrast } from './lib/axe-bridge.js';
13
+ export { resolveIdenticalLinksSamePurpose, normalizeUrl } from './lib/resolvers/identical-links-same-purpose.js';
14
+ export { resolveLinkInTextBlock } from './lib/resolvers/link-in-text-block.js';
15
+ export { resolveTargetSize } from './lib/resolvers/target-size.js';
16
+ export { resolveScrollableRegionFocusable } from './lib/resolvers/scrollable-region-focusable.js';
17
+ export { resolveSkipLink } from './lib/resolvers/skip-link.js';
18
+ export { resolveAriaHiddenFocus } from './lib/resolvers/aria-hidden-focus.js';
19
+ export { resolveFocusIndicator } from './lib/resolvers/focus-indicator.js';
20
+ export { resolveContentOnHover } from './lib/resolvers/content-on-hover.js';
21
+ export { resolveFrameTested } from './lib/resolvers/frame-tested.js';
22
+ export { getContrastThresholds } from './lib/wcag-thresholds.js';
23
+ export { getSelector, cloneResults, ruleShell, findIncompleteRule, applyPromotions, } from './lib/resolver-pipeline.js';
24
+ export type { PromotionResult } from './lib/resolver-pipeline.js';
25
+ export type { ContrastResolutionOptions, ContrastThresholds, WcagLevel, AxeResults, AxeRule, AxeNode, AxeCheck, LinkInTextBlockOptions, TargetSizeOptions, ScrollableRegionOptions, SkipLinkOptions, AriaHiddenFocusOptions, FocusIndicatorOptions, ContentOnHoverOptions, FrameTestedOptions, IncompleteResolutionOptions, } from './lib/types.js';
26
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAG5D,OAAO,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,gCAAgC,EAAE,YAAY,EAAE,MAAM,iDAAiD,CAAC;AACjH,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,gCAAgC,EAAE,MAAM,gDAAgD,CAAC;AAClG,OAAO,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AAC/D,OAAO,EAAE,sBAAsB,EAAE,MAAM,sCAAsC,CAAC;AAC9E,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAC;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,qCAAqC,CAAC;AAC5E,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AAGrE,OAAO,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAGjE,OAAO,EACL,WAAW,EACX,YAAY,EACZ,SAAS,EACT,kBAAkB,EAClB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAGlE,YAAY,EACV,yBAAyB,EACzB,kBAAkB,EAClB,SAAS,EACT,UAAU,EACV,OAAO,EACP,OAAO,EACP,QAAQ,EACR,sBAAsB,EACtB,iBAAiB,EACjB,uBAAuB,EACvB,eAAe,EACf,sBAAsB,EACtB,qBAAqB,EACrB,qBAAqB,EACrB,kBAAkB,EAClB,2BAA2B,GAC5B,MAAM,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @module @a11y-oracle/axe-bridge
3
+ *
4
+ * Axe-core result post-processor that resolves "incomplete" rule
5
+ * findings using visual analysis, keyboard interaction, and CDP
6
+ * inspection. Drop-in middleware between axe-core's `analyze()`
7
+ * and your assertion layer.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ // ─── Orchestrator ───────────────────────────────────────────────
12
+ export { resolveAllIncomplete } from './lib/resolve-all.js';
13
+ // ─── Individual resolvers ───────────────────────────────────────
14
+ export { resolveIncompleteContrast } from './lib/axe-bridge.js';
15
+ export { resolveIdenticalLinksSamePurpose, normalizeUrl } from './lib/resolvers/identical-links-same-purpose.js';
16
+ export { resolveLinkInTextBlock } from './lib/resolvers/link-in-text-block.js';
17
+ export { resolveTargetSize } from './lib/resolvers/target-size.js';
18
+ export { resolveScrollableRegionFocusable } from './lib/resolvers/scrollable-region-focusable.js';
19
+ export { resolveSkipLink } from './lib/resolvers/skip-link.js';
20
+ export { resolveAriaHiddenFocus } from './lib/resolvers/aria-hidden-focus.js';
21
+ export { resolveFocusIndicator } from './lib/resolvers/focus-indicator.js';
22
+ export { resolveContentOnHover } from './lib/resolvers/content-on-hover.js';
23
+ export { resolveFrameTested } from './lib/resolvers/frame-tested.js';
24
+ // ─── Threshold helpers ──────────────────────────────────────────
25
+ export { getContrastThresholds } from './lib/wcag-thresholds.js';
26
+ // ─── Pipeline utilities ─────────────────────────────────────────
27
+ export { getSelector, cloneResults, ruleShell, findIncompleteRule, applyPromotions, } from './lib/resolver-pipeline.js';
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @module axe-bridge
3
+ *
4
+ * Post-processor for axe-core results that resolves "incomplete"
5
+ * color-contrast warnings using visual pixel analysis and CSS
6
+ * halo heuristics.
7
+ */
8
+ import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
9
+ import type { AxeResults, ContrastResolutionOptions } from './types.js';
10
+ /**
11
+ * Resolve incomplete color-contrast warnings from axe-core results
12
+ * using visual pixel analysis and CSS halo heuristics.
13
+ *
14
+ * The function deep-clones the input results and returns a modified
15
+ * copy where:
16
+ * - Elements that pass worst-case contrast are promoted to `passes`
17
+ * - Elements that fail best-case contrast are promoted to `violations`
18
+ * - Ambiguous elements (split decision, dynamic content) remain in `incomplete`
19
+ *
20
+ * @param cdp - CDP session connected to the page being tested.
21
+ * @param axeResults - Raw results from axe-core's `analyze()`.
22
+ * @param options - Optional threshold overrides.
23
+ * @returns Modified axe results with resolved contrast findings.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * const axeResults = await axe.run(document);
28
+ * const cleaned = await resolveIncompleteContrast(cdp, axeResults);
29
+ * expect(cleaned.violations).toHaveLength(0);
30
+ * ```
31
+ */
32
+ export declare function resolveIncompleteContrast(cdp: CDPSessionLike, axeResults: AxeResults, options?: ContrastResolutionOptions): Promise<AxeResults>;
33
+ //# sourceMappingURL=axe-bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"axe-bridge.d.ts","sourceRoot":"","sources":["../../src/lib/axe-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EACV,UAAU,EAEV,yBAAyB,EAC1B,MAAM,YAAY,CAAC;AA2CpB;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,yBAAyB,CAC7C,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,EACtB,OAAO,CAAC,EAAE,yBAAyB,GAClC,OAAO,CAAC,UAAU,CAAC,CA8DrB"}
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @module axe-bridge
3
+ *
4
+ * Post-processor for axe-core results that resolves "incomplete"
5
+ * color-contrast warnings using visual pixel analysis and CSS
6
+ * halo heuristics.
7
+ */
8
+ import { VisualContrastAnalyzer } from '@a11y-oracle/visual-engine';
9
+ import { getContrastThresholds } from './wcag-thresholds.js';
10
+ import { getSelector, cloneResults, findIncompleteRule, applyPromotions, } from './resolver-pipeline.js';
11
+ /**
12
+ * Determine the effective contrast threshold for an axe node.
13
+ *
14
+ * axe-core stores font metadata in `node.any[0].data` for the
15
+ * color-contrast check. Large text (>= 18pt, or >= 14pt bold)
16
+ * uses the lower 3.0 threshold.
17
+ */
18
+ function getEffectiveThreshold(node, threshold, largeTextThreshold) {
19
+ try {
20
+ const checkData = node.any?.[0]?.data;
21
+ if (!checkData)
22
+ return threshold;
23
+ const fontSize = parseFloat(String(checkData['fontSize'] ?? '0'));
24
+ const fontWeight = String(checkData['fontWeight'] ?? 'normal');
25
+ const isBold = fontWeight === 'bold' ||
26
+ fontWeight === 'bolder' ||
27
+ parseInt(fontWeight, 10) >= 700;
28
+ // WCAG large text: >= 18pt (24px) or >= 14pt (18.66px) if bold
29
+ if (fontSize >= 24 || (fontSize >= 18.66 && isBold)) {
30
+ return largeTextThreshold;
31
+ }
32
+ }
33
+ catch {
34
+ // If data parsing fails, use the stricter threshold
35
+ }
36
+ return threshold;
37
+ }
38
+ /**
39
+ * Resolve incomplete color-contrast warnings from axe-core results
40
+ * using visual pixel analysis and CSS halo heuristics.
41
+ *
42
+ * The function deep-clones the input results and returns a modified
43
+ * copy where:
44
+ * - Elements that pass worst-case contrast are promoted to `passes`
45
+ * - Elements that fail best-case contrast are promoted to `violations`
46
+ * - Ambiguous elements (split decision, dynamic content) remain in `incomplete`
47
+ *
48
+ * @param cdp - CDP session connected to the page being tested.
49
+ * @param axeResults - Raw results from axe-core's `analyze()`.
50
+ * @param options - Optional threshold overrides.
51
+ * @returns Modified axe results with resolved contrast findings.
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * const axeResults = await axe.run(document);
56
+ * const cleaned = await resolveIncompleteContrast(cdp, axeResults);
57
+ * expect(cleaned.violations).toHaveLength(0);
58
+ * ```
59
+ */
60
+ export async function resolveIncompleteContrast(cdp, axeResults, options) {
61
+ const clone = cloneResults(axeResults);
62
+ // Derive thresholds: explicit values > wcagLevel > default (wcag22aa)
63
+ const levelThresholds = getContrastThresholds(options?.wcagLevel);
64
+ // Level A has no contrast requirement — skip resolution entirely
65
+ if (!levelThresholds && !options?.threshold && !options?.largeTextThreshold) {
66
+ return clone;
67
+ }
68
+ const threshold = options?.threshold ?? levelThresholds?.normalText ?? 4.5;
69
+ const largeTextThreshold = options?.largeTextThreshold ?? levelThresholds?.largeText ?? 3.0;
70
+ // Find the color-contrast rule in incomplete results
71
+ const found = findIncompleteRule(clone, 'color-contrast');
72
+ if (!found)
73
+ return clone;
74
+ const { index: ccIndex, rule: ccRule } = found;
75
+ const analyzer = new VisualContrastAnalyzer(cdp);
76
+ const passNodes = [];
77
+ const violationNodes = [];
78
+ const incompleteNodes = [];
79
+ // Analyze each incomplete node
80
+ for (const node of ccRule.nodes) {
81
+ const selector = getSelector(node);
82
+ if (!selector) {
83
+ incompleteNodes.push(node);
84
+ continue;
85
+ }
86
+ const effectiveThreshold = getEffectiveThreshold(node, threshold, largeTextThreshold);
87
+ const result = await analyzer.analyzeElement(selector, effectiveThreshold);
88
+ switch (result.category) {
89
+ case 'pass':
90
+ passNodes.push(node);
91
+ break;
92
+ case 'violation':
93
+ violationNodes.push(node);
94
+ break;
95
+ case 'incomplete':
96
+ incompleteNodes.push(node);
97
+ break;
98
+ }
99
+ }
100
+ applyPromotions(clone, ccIndex, ccRule, {
101
+ passNodes,
102
+ violationNodes,
103
+ incompleteNodes,
104
+ });
105
+ return clone;
106
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @module resolve-all
3
+ *
4
+ * Orchestrator that pipes axe-core results through all available
5
+ * incomplete rule resolvers in sequence.
6
+ *
7
+ * Each resolver receives the output of the previous one, so resolved
8
+ * findings accumulate through the pipeline.
9
+ */
10
+ import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
11
+ import type { AxeResults, IncompleteResolutionOptions } from './types.js';
12
+ /**
13
+ * Resolve all incomplete axe-core results by running each resolver
14
+ * in sequence.
15
+ *
16
+ * The pipeline processes rules in order:
17
+ * 1. `color-contrast` — visual pixel analysis
18
+ * 2. `identical-links-same-purpose` — URL normalization
19
+ * 3. `link-in-text-block` — default-state style checks
20
+ * 4. `target-size` — bounding box measurements
21
+ * 5. `scrollable-region-focusable` — scroll + focus tests
22
+ * 6. `skip-link` — focus visibility check
23
+ * 7. `aria-hidden-focus` — Tab traversal
24
+ * 8. `focus-indicator` — screenshot diff
25
+ * 9. `content-on-hover` — hover + dismiss tests
26
+ * 10. `frame-tested` — cross-origin iframe injection
27
+ *
28
+ * @param cdp - CDP session connected to the page being tested.
29
+ * @param axeResults - Raw results from axe-core's `analyze()`.
30
+ * @param options - Per-resolver options and `skipRules` filter.
31
+ * @returns Modified axe results with all resolvable findings classified.
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * import { resolveAllIncomplete } from '@a11y-oracle/axe-bridge';
36
+ *
37
+ * const raw = await axe.run(document);
38
+ * const resolved = await resolveAllIncomplete(cdp, raw, {
39
+ * wcagLevel: 'wcag22aa',
40
+ * skipRules: ['frame-tested'], // skip iframe injection
41
+ * });
42
+ * ```
43
+ */
44
+ export declare function resolveAllIncomplete(cdp: CDPSessionLike, axeResults: AxeResults, options?: IncompleteResolutionOptions): Promise<AxeResults>;
45
+ //# sourceMappingURL=resolve-all.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-all.d.ts","sourceRoot":"","sources":["../../src/lib/resolve-all.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,KAAK,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AAyF1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,OAAO,CAAC,UAAU,CAAC,CAiBrB"}
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @module resolve-all
3
+ *
4
+ * Orchestrator that pipes axe-core results through all available
5
+ * incomplete rule resolvers in sequence.
6
+ *
7
+ * Each resolver receives the output of the previous one, so resolved
8
+ * findings accumulate through the pipeline.
9
+ */
10
+ import { resolveIncompleteContrast } from './axe-bridge.js';
11
+ import { resolveIdenticalLinksSamePurpose } from './resolvers/identical-links-same-purpose.js';
12
+ import { resolveLinkInTextBlock } from './resolvers/link-in-text-block.js';
13
+ import { resolveTargetSize } from './resolvers/target-size.js';
14
+ import { resolveScrollableRegionFocusable } from './resolvers/scrollable-region-focusable.js';
15
+ import { resolveSkipLink } from './resolvers/skip-link.js';
16
+ import { resolveAriaHiddenFocus } from './resolvers/aria-hidden-focus.js';
17
+ import { resolveFocusIndicator } from './resolvers/focus-indicator.js';
18
+ import { resolveContentOnHover } from './resolvers/content-on-hover.js';
19
+ import { resolveFrameTested } from './resolvers/frame-tested.js';
20
+ /**
21
+ * Ordered pipeline of resolvers. Each entry maps a rule ID to
22
+ * its resolver function. The order is chosen to run simpler
23
+ * resolvers first (pure DOM queries) and more complex ones
24
+ * (screenshots, keyboard traversal) later.
25
+ */
26
+ const RESOLVER_PIPELINE = [
27
+ {
28
+ ruleId: 'color-contrast',
29
+ invoke: (cdp, results, opts) => resolveIncompleteContrast(cdp, results, {
30
+ wcagLevel: opts.wcagLevel,
31
+ ...opts.contrast,
32
+ }),
33
+ },
34
+ {
35
+ ruleId: 'identical-links-same-purpose',
36
+ invoke: (cdp, results) => resolveIdenticalLinksSamePurpose(cdp, results),
37
+ },
38
+ {
39
+ ruleId: 'link-in-text-block',
40
+ invoke: (cdp, results, opts) => resolveLinkInTextBlock(cdp, results, opts.linkInTextBlock),
41
+ },
42
+ {
43
+ ruleId: 'target-size',
44
+ invoke: (cdp, results, opts) => resolveTargetSize(cdp, results, opts.targetSize),
45
+ },
46
+ {
47
+ ruleId: 'scrollable-region-focusable',
48
+ invoke: (cdp, results, opts) => resolveScrollableRegionFocusable(cdp, results, opts.scrollableRegion),
49
+ },
50
+ {
51
+ ruleId: 'skip-link',
52
+ invoke: (cdp, results, opts) => resolveSkipLink(cdp, results, opts.skipLink),
53
+ },
54
+ {
55
+ ruleId: 'aria-hidden-focus',
56
+ invoke: (cdp, results, opts) => resolveAriaHiddenFocus(cdp, results, opts.ariaHiddenFocus),
57
+ },
58
+ {
59
+ ruleId: 'focus-indicator',
60
+ invoke: (cdp, results, opts) => resolveFocusIndicator(cdp, results, opts.focusIndicator),
61
+ },
62
+ {
63
+ ruleId: 'content-on-hover',
64
+ invoke: (cdp, results, opts) => resolveContentOnHover(cdp, results, opts.contentOnHover),
65
+ },
66
+ {
67
+ ruleId: 'frame-tested',
68
+ invoke: (cdp, results, opts) => resolveFrameTested(cdp, results, opts.frameTested),
69
+ },
70
+ ];
71
+ /**
72
+ * Resolve all incomplete axe-core results by running each resolver
73
+ * in sequence.
74
+ *
75
+ * The pipeline processes rules in order:
76
+ * 1. `color-contrast` — visual pixel analysis
77
+ * 2. `identical-links-same-purpose` — URL normalization
78
+ * 3. `link-in-text-block` — default-state style checks
79
+ * 4. `target-size` — bounding box measurements
80
+ * 5. `scrollable-region-focusable` — scroll + focus tests
81
+ * 6. `skip-link` — focus visibility check
82
+ * 7. `aria-hidden-focus` — Tab traversal
83
+ * 8. `focus-indicator` — screenshot diff
84
+ * 9. `content-on-hover` — hover + dismiss tests
85
+ * 10. `frame-tested` — cross-origin iframe injection
86
+ *
87
+ * @param cdp - CDP session connected to the page being tested.
88
+ * @param axeResults - Raw results from axe-core's `analyze()`.
89
+ * @param options - Per-resolver options and `skipRules` filter.
90
+ * @returns Modified axe results with all resolvable findings classified.
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * import { resolveAllIncomplete } from '@a11y-oracle/axe-bridge';
95
+ *
96
+ * const raw = await axe.run(document);
97
+ * const resolved = await resolveAllIncomplete(cdp, raw, {
98
+ * wcagLevel: 'wcag22aa',
99
+ * skipRules: ['frame-tested'], // skip iframe injection
100
+ * });
101
+ * ```
102
+ */
103
+ export async function resolveAllIncomplete(cdp, axeResults, options = {}) {
104
+ const skipSet = new Set(options.skipRules ?? []);
105
+ let current = axeResults;
106
+ for (const entry of RESOLVER_PIPELINE) {
107
+ // Skip if rule is in skipRules
108
+ if (skipSet.has(entry.ruleId))
109
+ continue;
110
+ // Skip if no incomplete entries exist for this rule
111
+ const hasRule = current.incomplete.some((r) => r.id === entry.ruleId);
112
+ if (!hasRule)
113
+ continue;
114
+ current = await entry.invoke(cdp, current, options);
115
+ }
116
+ return current;
117
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @module resolver-pipeline
3
+ *
4
+ * Shared utilities for axe-core incomplete rule resolvers.
5
+ * Provides the common clone → find-rule → analyze → promote → return
6
+ * pipeline that every resolver follows.
7
+ */
8
+ import type { AxeResults, AxeRule, AxeNode } from './types.js';
9
+ /**
10
+ * Extract the innermost CSS selector from an axe node's target.
11
+ *
12
+ * axe-core represents selectors as arrays where shadow DOM targets
13
+ * have multiple entries. We use the last (innermost) selector.
14
+ */
15
+ export declare function getSelector(node: AxeNode): string;
16
+ /**
17
+ * Deep-clone an AxeResults object to avoid mutating the original.
18
+ */
19
+ export declare function cloneResults(results: AxeResults): AxeResults;
20
+ /**
21
+ * Create a rule shell (all metadata, no nodes) from an existing rule.
22
+ */
23
+ export declare function ruleShell(rule: AxeRule): AxeRule;
24
+ /** Categorized node results from a resolver's analysis phase. */
25
+ export interface PromotionResult {
26
+ passNodes: AxeNode[];
27
+ violationNodes: AxeNode[];
28
+ incompleteNodes: AxeNode[];
29
+ }
30
+ /**
31
+ * Apply node promotions to a cloned AxeResults object.
32
+ *
33
+ * Moves nodes from the incomplete rule entry into the passes and
34
+ * violations arrays, then updates or removes the incomplete entry.
35
+ *
36
+ * @param clone - The cloned AxeResults to mutate.
37
+ * @param ruleIndex - Index of the rule in `clone.incomplete`.
38
+ * @param rule - The incomplete rule being resolved.
39
+ * @param result - Categorized nodes from the resolver's analysis.
40
+ */
41
+ export declare function applyPromotions(clone: AxeResults, ruleIndex: number, rule: AxeRule, result: PromotionResult): void;
42
+ /**
43
+ * Find a rule by ID in the incomplete array.
44
+ *
45
+ * @returns The rule index and rule object, or null if not found.
46
+ */
47
+ export declare function findIncompleteRule(clone: AxeResults, ruleId: string): {
48
+ index: number;
49
+ rule: AxeRule;
50
+ } | null;
51
+ //# sourceMappingURL=resolver-pipeline.d.ts.map