@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
@@ -0,0 +1,188 @@
1
+ /**
2
+ * @module focus-indicator
3
+ *
4
+ * Resolver for axe-core's incomplete focus-indicator-related rules
5
+ * (WCAG 2.4.7 Focus Visible). Takes before/after screenshots of
6
+ * elements in resting vs focused states and pixel-diffs them.
7
+ *
8
+ * If the screenshots are identical → no visible focus indicator → Violation.
9
+ * If they differ → focus indicator is present → Pass.
10
+ */
11
+ import { decodePng } from '@a11y-oracle/visual-engine';
12
+ import { getSelector, cloneResults, findIncompleteRule, applyPromotions, } from '../resolver-pipeline.js';
13
+ const RULE_ID = 'focus-indicator';
14
+ /** Default delay after focus for CSS transitions to settle. */
15
+ const DEFAULT_FOCUS_SETTLE_DELAY = 100;
16
+ /**
17
+ * Minimum percentage of pixels that must differ to count as a
18
+ * visible focus indicator. Accounts for anti-aliasing noise.
19
+ */
20
+ const DEFAULT_DIFF_THRESHOLD = 0.1;
21
+ /**
22
+ * Resolve incomplete focus-indicator results.
23
+ *
24
+ * For each flagged element:
25
+ * 1. Capture a clipped screenshot of the element in resting state
26
+ * 2. Focus the element via `element.focus()`
27
+ * 3. Wait for CSS transitions to settle
28
+ * 4. Capture a second screenshot
29
+ * 5. Pixel-diff the two images
30
+ *
31
+ * Different → **Pass** (focus indicator visible).
32
+ * Identical → **Violation** (no visible focus change).
33
+ *
34
+ * @param cdp - CDP session for screenshots and focus dispatch.
35
+ * @param axeResults - Raw axe-core results.
36
+ * @param options - Optional delay and threshold configuration.
37
+ * @returns Modified results with resolved findings.
38
+ */
39
+ export async function resolveFocusIndicator(cdp, axeResults, options) {
40
+ const clone = cloneResults(axeResults);
41
+ const found = findIncompleteRule(clone, RULE_ID);
42
+ if (!found)
43
+ return clone;
44
+ const { index, rule } = found;
45
+ const settleDelay = options?.focusSettleDelay ?? DEFAULT_FOCUS_SETTLE_DELAY;
46
+ const diffThreshold = options?.diffThreshold ?? DEFAULT_DIFF_THRESHOLD;
47
+ const passNodes = [];
48
+ const violationNodes = [];
49
+ const incompleteNodes = [];
50
+ // Ensure focus indicators render even when the page isn't the active
51
+ // window (headless browsers, Cypress AUT iframe, DevTools open, etc.).
52
+ try {
53
+ await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: true });
54
+ }
55
+ catch {
56
+ // Older Chrome versions may not support this — continue without it.
57
+ }
58
+ for (const node of rule.nodes) {
59
+ const selector = getSelector(node);
60
+ if (!selector) {
61
+ incompleteNodes.push(node);
62
+ continue;
63
+ }
64
+ // Ensure element is not focused (reset state)
65
+ await cdp.send('Runtime.evaluate', {
66
+ expression: 'document.activeElement?.blur(); void 0;',
67
+ returnByValue: true,
68
+ });
69
+ // Wait for any transitions to settle after blur
70
+ await delay(settleDelay);
71
+ // Get element bounding box for clipped screenshot
72
+ const boundsResult = await cdp.send('Runtime.evaluate', {
73
+ expression: `(() => {
74
+ const el = document.querySelector(${JSON.stringify(selector)});
75
+ if (!el) return null;
76
+ const rect = el.getBoundingClientRect();
77
+ // Expand clip region slightly to capture outline/box-shadow
78
+ const pad = 4;
79
+ return {
80
+ x: Math.max(0, Math.round(rect.x - pad)),
81
+ y: Math.max(0, Math.round(rect.y - pad)),
82
+ width: Math.round(rect.width + pad * 2),
83
+ height: Math.round(rect.height + pad * 2),
84
+ };
85
+ })()`,
86
+ returnByValue: true,
87
+ });
88
+ const bounds = boundsResult.result.value;
89
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
90
+ incompleteNodes.push(node);
91
+ continue;
92
+ }
93
+ // Capture resting-state screenshot
94
+ const beforeShot = await cdp.send('Page.captureScreenshot', {
95
+ format: 'png',
96
+ clip: {
97
+ x: bounds.x,
98
+ y: bounds.y,
99
+ width: bounds.width,
100
+ height: bounds.height,
101
+ scale: 1,
102
+ },
103
+ });
104
+ // Focus the element
105
+ await cdp.send('Runtime.evaluate', {
106
+ expression: `(() => {
107
+ const el = document.querySelector(${JSON.stringify(selector)});
108
+ if (el) el.focus();
109
+ })()`,
110
+ returnByValue: true,
111
+ });
112
+ // Wait for CSS transitions to settle
113
+ await delay(settleDelay);
114
+ // Capture focused-state screenshot
115
+ const afterShot = await cdp.send('Page.captureScreenshot', {
116
+ format: 'png',
117
+ clip: {
118
+ x: bounds.x,
119
+ y: bounds.y,
120
+ width: bounds.width,
121
+ height: bounds.height,
122
+ scale: 1,
123
+ },
124
+ });
125
+ // Compare screenshots
126
+ const diffPercent = pixelDiffPercent(beforeShot.data, afterShot.data);
127
+ if (diffPercent > diffThreshold) {
128
+ passNodes.push(node);
129
+ }
130
+ else {
131
+ violationNodes.push(node);
132
+ }
133
+ }
134
+ applyPromotions(clone, index, rule, {
135
+ passNodes,
136
+ violationNodes,
137
+ incompleteNodes,
138
+ });
139
+ return clone;
140
+ }
141
+ /**
142
+ * Calculate the percentage of pixels that differ between two PNG images
143
+ * encoded as base64 strings.
144
+ *
145
+ * @param base64Before - Base64-encoded PNG of the resting state.
146
+ * @param base64After - Base64-encoded PNG of the focused state.
147
+ * @returns Percentage (0-100) of pixels that differ.
148
+ */
149
+ function pixelDiffPercent(base64Before, base64After) {
150
+ const beforeBuf = base64ToUint8Array(base64Before);
151
+ const afterBuf = base64ToUint8Array(base64After);
152
+ const before = decodePng(beforeBuf);
153
+ const after = decodePng(afterBuf);
154
+ // If dimensions don't match, treat as entirely different
155
+ if (before.width !== after.width || before.height !== after.height) {
156
+ return 100;
157
+ }
158
+ const totalPixels = before.width * before.height;
159
+ if (totalPixels === 0)
160
+ return 0;
161
+ let diffCount = 0;
162
+ // Pixel threshold: RGB channels must differ by more than this
163
+ const channelThreshold = 10;
164
+ for (let i = 0; i < totalPixels; i++) {
165
+ const offset = i * 4;
166
+ const dr = Math.abs(before.data[offset] - after.data[offset]);
167
+ const dg = Math.abs(before.data[offset + 1] - after.data[offset + 1]);
168
+ const db = Math.abs(before.data[offset + 2] - after.data[offset + 2]);
169
+ if (dr > channelThreshold || dg > channelThreshold || db > channelThreshold) {
170
+ diffCount++;
171
+ }
172
+ }
173
+ return (diffCount / totalPixels) * 100;
174
+ }
175
+ /**
176
+ * Convert a base64-encoded string to Uint8Array.
177
+ */
178
+ function base64ToUint8Array(base64) {
179
+ const binary = atob(base64);
180
+ const bytes = new Uint8Array(binary.length);
181
+ for (let i = 0; i < binary.length; i++) {
182
+ bytes[i] = binary.charCodeAt(i);
183
+ }
184
+ return bytes;
185
+ }
186
+ function delay(ms) {
187
+ return new Promise((resolve) => setTimeout(resolve, ms));
188
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @module frame-tested
3
+ *
4
+ * Resolver for axe-core's `frame-tested` incomplete rule.
5
+ * Handles cross-origin iframes that axe-core cannot inject into.
6
+ *
7
+ * Uses CDP to discover the iframe's execution context, inject axe-core
8
+ * dynamically, run analysis, and merge results into the main report.
9
+ */
10
+ import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
11
+ import type { AxeResults, FrameTestedOptions } from '../types.js';
12
+ /**
13
+ * Resolve incomplete `frame-tested` results.
14
+ *
15
+ * For each flagged iframe:
16
+ * 1. Use `Page.getFrameTree` to discover the iframe's frame ID
17
+ * 2. Use `Page.createIsolatedWorld` to get an execution context
18
+ * 3. Inject axe-core script into the isolated world
19
+ * 4. Run `axe.run()` inside the iframe
20
+ * 5. Merge iframe findings into the main report
21
+ *
22
+ * Successfully tested → **Pass** (with merged findings).
23
+ * Failed to inject/run → stays **Incomplete**.
24
+ *
25
+ * @param cdp - CDP session for frame management and script injection.
26
+ * @param axeResults - Raw axe-core results.
27
+ * @param options - Optional axe-core source and timeout.
28
+ * @returns Modified results with resolved findings.
29
+ */
30
+ export declare function resolveFrameTested(cdp: CDPSessionLike, axeResults: AxeResults, options?: FrameTestedOptions): Promise<AxeResults>;
31
+ //# sourceMappingURL=frame-tested.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frame-tested.d.ts","sourceRoot":"","sources":["../../../src/lib/resolvers/frame-tested.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,KAAK,EAAE,UAAU,EAAW,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAa3E;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,EACtB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,UAAU,CAAC,CA4HrB"}
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @module frame-tested
3
+ *
4
+ * Resolver for axe-core's `frame-tested` incomplete rule.
5
+ * Handles cross-origin iframes that axe-core cannot inject into.
6
+ *
7
+ * Uses CDP to discover the iframe's execution context, inject axe-core
8
+ * dynamically, run analysis, and merge results into the main report.
9
+ */
10
+ import { getSelector, cloneResults, findIncompleteRule, applyPromotions, } from '../resolver-pipeline.js';
11
+ const RULE_ID = 'frame-tested';
12
+ /** Default timeout for axe-core execution inside iframe (ms). */
13
+ const DEFAULT_IFRAME_TIMEOUT = 30_000;
14
+ /**
15
+ * Resolve incomplete `frame-tested` results.
16
+ *
17
+ * For each flagged iframe:
18
+ * 1. Use `Page.getFrameTree` to discover the iframe's frame ID
19
+ * 2. Use `Page.createIsolatedWorld` to get an execution context
20
+ * 3. Inject axe-core script into the isolated world
21
+ * 4. Run `axe.run()` inside the iframe
22
+ * 5. Merge iframe findings into the main report
23
+ *
24
+ * Successfully tested → **Pass** (with merged findings).
25
+ * Failed to inject/run → stays **Incomplete**.
26
+ *
27
+ * @param cdp - CDP session for frame management and script injection.
28
+ * @param axeResults - Raw axe-core results.
29
+ * @param options - Optional axe-core source and timeout.
30
+ * @returns Modified results with resolved findings.
31
+ */
32
+ export async function resolveFrameTested(cdp, axeResults, options) {
33
+ const clone = cloneResults(axeResults);
34
+ const found = findIncompleteRule(clone, RULE_ID);
35
+ if (!found)
36
+ return clone;
37
+ const { index, rule } = found;
38
+ const timeout = options?.iframeTimeout ?? DEFAULT_IFRAME_TIMEOUT;
39
+ const axeSource = options?.axeSource;
40
+ // If no axe source provided, we can't inject — leave as incomplete
41
+ if (!axeSource) {
42
+ return clone;
43
+ }
44
+ const passNodes = [];
45
+ const violationNodes = [];
46
+ const incompleteNodes = [];
47
+ // Get the frame tree to map URLs to frame IDs
48
+ let frameTree;
49
+ try {
50
+ frameTree = (await cdp.send('Page.getFrameTree', {}));
51
+ }
52
+ catch {
53
+ // Cannot get frame tree — leave all as incomplete
54
+ return clone;
55
+ }
56
+ for (const node of rule.nodes) {
57
+ const selector = getSelector(node);
58
+ if (!selector) {
59
+ incompleteNodes.push(node);
60
+ continue;
61
+ }
62
+ try {
63
+ // Get the iframe's URL from the DOM
64
+ const iframeUrlResult = await cdp.send('Runtime.evaluate', {
65
+ expression: `(() => {
66
+ const el = document.querySelector(${JSON.stringify(selector)});
67
+ if (!el) return null;
68
+ return el.src || el.getAttribute('src');
69
+ })()`,
70
+ returnByValue: true,
71
+ });
72
+ const iframeUrl = iframeUrlResult.result.value;
73
+ if (!iframeUrl) {
74
+ incompleteNodes.push(node);
75
+ continue;
76
+ }
77
+ // Find the frame ID for this URL
78
+ const frameId = findFrameId(frameTree, iframeUrl);
79
+ if (!frameId) {
80
+ incompleteNodes.push(node);
81
+ continue;
82
+ }
83
+ // Create an isolated world in the iframe
84
+ const worldResult = (await cdp.send('Page.createIsolatedWorld', {
85
+ frameId,
86
+ worldName: 'a11y-oracle-axe-injection',
87
+ grantUniveralAccess: true,
88
+ }));
89
+ const contextId = worldResult.executionContextId;
90
+ // Inject axe-core into the isolated world
91
+ await cdp.send('Runtime.evaluate', {
92
+ expression: axeSource,
93
+ contextId,
94
+ returnByValue: true,
95
+ });
96
+ // Run axe analysis inside the iframe
97
+ const analysisResult = await cdp.send('Runtime.evaluate', {
98
+ expression: `new Promise((resolve, reject) => {
99
+ const timer = setTimeout(() => reject(new Error('Timeout')), ${timeout});
100
+ if (typeof axe === 'undefined') {
101
+ clearTimeout(timer);
102
+ reject(new Error('axe-core not loaded'));
103
+ return;
104
+ }
105
+ axe.run(document).then(results => {
106
+ clearTimeout(timer);
107
+ resolve({
108
+ violations: results.violations.length,
109
+ incomplete: results.incomplete.length,
110
+ passes: results.passes.length,
111
+ });
112
+ }).catch(err => {
113
+ clearTimeout(timer);
114
+ reject(err);
115
+ });
116
+ })`,
117
+ contextId,
118
+ returnByValue: true,
119
+ awaitPromise: true,
120
+ });
121
+ // If axe ran successfully inside the iframe, the frame-tested
122
+ // rule itself passes (we successfully tested it).
123
+ // Individual violations found inside the iframe are a separate
124
+ // concern tracked by the iframe's own axe results.
125
+ // For the frame-tested rule: successfully tested → Pass
126
+ void analysisResult.result.value;
127
+ passNodes.push(node);
128
+ // Note: In a full implementation, we would merge the iframe's
129
+ // individual violations/passes/incomplete results into the main
130
+ // report. For now, we just record that the frame was tested.
131
+ }
132
+ catch {
133
+ // Failed to inject or run axe in iframe
134
+ incompleteNodes.push(node);
135
+ }
136
+ }
137
+ applyPromotions(clone, index, rule, {
138
+ passNodes,
139
+ violationNodes,
140
+ incompleteNodes,
141
+ });
142
+ return clone;
143
+ }
144
+ /**
145
+ * Recursively search the frame tree for a frame matching the given URL.
146
+ */
147
+ function findFrameId(tree, targetUrl) {
148
+ function searchChildren(children) {
149
+ if (!children)
150
+ return null;
151
+ for (const child of children) {
152
+ if (urlMatches(child.frame.url, targetUrl)) {
153
+ return child.frame.id;
154
+ }
155
+ const found = searchChildren(child.childFrames);
156
+ if (found)
157
+ return found;
158
+ }
159
+ return null;
160
+ }
161
+ return searchChildren(tree.frameTree.childFrames);
162
+ }
163
+ /**
164
+ * Check if two URLs match, handling relative vs absolute URL differences.
165
+ */
166
+ function urlMatches(frameUrl, targetUrl) {
167
+ if (frameUrl === targetUrl)
168
+ return true;
169
+ try {
170
+ const a = new URL(frameUrl);
171
+ const b = new URL(targetUrl, frameUrl);
172
+ return a.href === b.href;
173
+ }
174
+ catch {
175
+ return frameUrl.endsWith(targetUrl) || targetUrl.endsWith(frameUrl);
176
+ }
177
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @module identical-links-same-purpose
3
+ *
4
+ * Resolver for axe-core's `identical-links-same-purpose` incomplete rule
5
+ * (WCAG 2.4.4 Link Purpose). Normalizes URLs and compares destinations
6
+ * for links that share the same accessible text.
7
+ */
8
+ import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
9
+ import type { AxeResults } from '../types.js';
10
+ /**
11
+ * Normalize a URL by stripping query parameters, hashes, trailing slashes,
12
+ * and resolving relative paths against a base URL.
13
+ *
14
+ * @param href - The raw href to normalize.
15
+ * @param baseUrl - The page's base URL for resolving relative paths.
16
+ * @returns Normalized absolute URL string, or null if unparseable.
17
+ */
18
+ export declare function normalizeUrl(href: string, baseUrl: string): string | null;
19
+ /**
20
+ * Resolve incomplete `identical-links-same-purpose` results.
21
+ *
22
+ * axe-core flags this when multiple links share the same accessible text
23
+ * but have structurally different `href` attributes. This resolver
24
+ * normalizes URLs (strips query params, hashes, resolves relative paths)
25
+ * and compares canonical destinations.
26
+ *
27
+ * - Same canonical destination → **Pass**
28
+ * - Different canonical destination → **Violation**
29
+ *
30
+ * @param cdp - CDP session for querying the page's base URL.
31
+ * @param axeResults - Raw axe-core results.
32
+ * @returns Modified results with resolved findings.
33
+ */
34
+ export declare function resolveIdenticalLinksSamePurpose(cdp: CDPSessionLike, axeResults: AxeResults): Promise<AxeResults>;
35
+ //# sourceMappingURL=identical-links-same-purpose.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identical-links-same-purpose.d.ts","sourceRoot":"","sources":["../../../src/lib/resolvers/identical-links-same-purpose.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,KAAK,EAAE,UAAU,EAAW,MAAM,aAAa,CAAC;AAUvD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAezE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,gCAAgC,CACpD,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,UAAU,CAAC,CA4ErB"}
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @module identical-links-same-purpose
3
+ *
4
+ * Resolver for axe-core's `identical-links-same-purpose` incomplete rule
5
+ * (WCAG 2.4.4 Link Purpose). Normalizes URLs and compares destinations
6
+ * for links that share the same accessible text.
7
+ */
8
+ import { getSelector, cloneResults, findIncompleteRule, applyPromotions, } from '../resolver-pipeline.js';
9
+ const RULE_ID = 'identical-links-same-purpose';
10
+ /**
11
+ * Normalize a URL by stripping query parameters, hashes, trailing slashes,
12
+ * and resolving relative paths against a base URL.
13
+ *
14
+ * @param href - The raw href to normalize.
15
+ * @param baseUrl - The page's base URL for resolving relative paths.
16
+ * @returns Normalized absolute URL string, or null if unparseable.
17
+ */
18
+ export function normalizeUrl(href, baseUrl) {
19
+ try {
20
+ const url = new URL(href, baseUrl);
21
+ // Strip query params and hash
22
+ url.search = '';
23
+ url.hash = '';
24
+ // Normalize trailing slash: remove it unless path is just '/'
25
+ let pathname = url.pathname;
26
+ if (pathname.length > 1 && pathname.endsWith('/')) {
27
+ pathname = pathname.slice(0, -1);
28
+ }
29
+ return `${url.origin}${pathname}`;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /**
36
+ * Resolve incomplete `identical-links-same-purpose` results.
37
+ *
38
+ * axe-core flags this when multiple links share the same accessible text
39
+ * but have structurally different `href` attributes. This resolver
40
+ * normalizes URLs (strips query params, hashes, resolves relative paths)
41
+ * and compares canonical destinations.
42
+ *
43
+ * - Same canonical destination → **Pass**
44
+ * - Different canonical destination → **Violation**
45
+ *
46
+ * @param cdp - CDP session for querying the page's base URL.
47
+ * @param axeResults - Raw axe-core results.
48
+ * @returns Modified results with resolved findings.
49
+ */
50
+ export async function resolveIdenticalLinksSamePurpose(cdp, axeResults) {
51
+ const clone = cloneResults(axeResults);
52
+ const found = findIncompleteRule(clone, RULE_ID);
53
+ if (!found)
54
+ return clone;
55
+ const { index, rule } = found;
56
+ // Get the page's base URL for resolving relative hrefs
57
+ const baseUrlResult = await cdp.send('Runtime.evaluate', {
58
+ expression: 'document.baseURI',
59
+ returnByValue: true,
60
+ });
61
+ const baseUrl = baseUrlResult.result.value ?? 'https://localhost';
62
+ const passNodes = [];
63
+ const violationNodes = [];
64
+ const incompleteNodes = [];
65
+ for (const node of rule.nodes) {
66
+ const selector = getSelector(node);
67
+ if (!selector) {
68
+ incompleteNodes.push(node);
69
+ continue;
70
+ }
71
+ // Get the link's href and related links' hrefs
72
+ const result = await cdp.send('Runtime.evaluate', {
73
+ expression: `(() => {
74
+ const el = document.querySelector(${JSON.stringify(selector)});
75
+ if (!el) return null;
76
+ const href = el.getAttribute('href') || el.href;
77
+ // Get the accessible text of this link
78
+ const text = (el.textContent || '').trim().toLowerCase();
79
+ // Find all links on the page with the same text
80
+ const allLinks = Array.from(document.querySelectorAll('a[href]'));
81
+ const sameTextLinks = allLinks.filter(
82
+ a => (a.textContent || '').trim().toLowerCase() === text
83
+ );
84
+ return {
85
+ href,
86
+ relatedHrefs: sameTextLinks.map(a => a.getAttribute('href') || a.href),
87
+ };
88
+ })()`,
89
+ returnByValue: true,
90
+ });
91
+ const data = result.result.value;
92
+ if (!data) {
93
+ incompleteNodes.push(node);
94
+ continue;
95
+ }
96
+ // Normalize all hrefs in the group
97
+ const normalizedHrefs = data.relatedHrefs
98
+ .map((h) => normalizeUrl(h, baseUrl))
99
+ .filter((h) => h !== null);
100
+ // Check if all normalized hrefs are identical
101
+ const uniqueUrls = new Set(normalizedHrefs);
102
+ if (uniqueUrls.size <= 1) {
103
+ // All links in the group go to the same destination
104
+ passNodes.push(node);
105
+ }
106
+ else {
107
+ // Links go to different destinations despite same text
108
+ violationNodes.push(node);
109
+ }
110
+ }
111
+ applyPromotions(clone, index, rule, {
112
+ passNodes,
113
+ violationNodes,
114
+ incompleteNodes,
115
+ });
116
+ return clone;
117
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @module link-in-text-block
3
+ *
4
+ * Resolver for axe-core's `link-in-text-block` incomplete rule
5
+ * (WCAG 1.4.1 Use of Color). Checks the DEFAULT/resting state of
6
+ * inline links for non-color visual differentiation.
7
+ *
8
+ * IMPORTANT: Hover/focus states are NOT checked. If a link only
9
+ * shows differentiation on hover or focus, that is a **Violation**.
10
+ * The link must be visually distinct in its resting state.
11
+ */
12
+ import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
13
+ import type { AxeResults, LinkInTextBlockOptions } from '../types.js';
14
+ /**
15
+ * Resolve incomplete `link-in-text-block` results.
16
+ *
17
+ * Checks the **default/resting state** of inline links for:
18
+ * 1. Non-color visual indicators (underline, border-bottom, font-weight diff)
19
+ * 2. Sufficient color contrast (≥ 3:1) between link and surrounding text
20
+ *
21
+ * If neither is present → Violation (indistinguishable from surrounding text).
22
+ *
23
+ * @param cdp - CDP session for querying computed styles.
24
+ * @param axeResults - Raw axe-core results.
25
+ * @param options - Optional threshold override.
26
+ * @returns Modified results with resolved findings.
27
+ */
28
+ export declare function resolveLinkInTextBlock(cdp: CDPSessionLike, axeResults: AxeResults, options?: LinkInTextBlockOptions): Promise<AxeResults>;
29
+ //# sourceMappingURL=link-in-text-block.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-in-text-block.d.ts","sourceRoot":"","sources":["../../../src/lib/resolvers/link-in-text-block.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EAAE,UAAU,EAAW,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAa/E;;;;;;;;;;;;;GAaG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,EACtB,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,UAAU,CAAC,CAgFrB"}