@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.
- package/README.md +254 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/lib/axe-bridge.d.ts +33 -0
- package/dist/lib/axe-bridge.d.ts.map +1 -0
- package/dist/lib/axe-bridge.js +106 -0
- package/dist/lib/resolve-all.d.ts +45 -0
- package/dist/lib/resolve-all.d.ts.map +1 -0
- package/dist/lib/resolve-all.js +117 -0
- package/dist/lib/resolver-pipeline.d.ts +51 -0
- package/dist/lib/resolver-pipeline.d.ts.map +1 -0
- package/dist/lib/resolver-pipeline.js +94 -0
- package/dist/lib/resolvers/aria-hidden-focus.d.ts +31 -0
- package/dist/lib/resolvers/aria-hidden-focus.d.ts.map +1 -0
- package/dist/lib/resolvers/aria-hidden-focus.js +148 -0
- package/dist/lib/resolvers/content-on-hover.d.ts +27 -0
- package/dist/lib/resolvers/content-on-hover.d.ts.map +1 -0
- package/dist/lib/resolvers/content-on-hover.js +230 -0
- package/dist/lib/resolvers/focus-indicator.d.ts +32 -0
- package/dist/lib/resolvers/focus-indicator.d.ts.map +1 -0
- package/dist/lib/resolvers/focus-indicator.js +188 -0
- package/dist/lib/resolvers/frame-tested.d.ts +31 -0
- package/dist/lib/resolvers/frame-tested.d.ts.map +1 -0
- package/dist/lib/resolvers/frame-tested.js +177 -0
- package/dist/lib/resolvers/identical-links-same-purpose.d.ts +35 -0
- package/dist/lib/resolvers/identical-links-same-purpose.d.ts.map +1 -0
- package/dist/lib/resolvers/identical-links-same-purpose.js +117 -0
- package/dist/lib/resolvers/link-in-text-block.d.ts +29 -0
- package/dist/lib/resolvers/link-in-text-block.d.ts.map +1 -0
- package/dist/lib/resolvers/link-in-text-block.js +141 -0
- package/dist/lib/resolvers/scrollable-region-focusable.d.ts +26 -0
- package/dist/lib/resolvers/scrollable-region-focusable.d.ts.map +1 -0
- package/dist/lib/resolvers/scrollable-region-focusable.js +139 -0
- package/dist/lib/resolvers/skip-link.d.ts +26 -0
- package/dist/lib/resolvers/skip-link.d.ts.map +1 -0
- package/dist/lib/resolvers/skip-link.js +140 -0
- package/dist/lib/resolvers/target-size.d.ts +25 -0
- package/dist/lib/resolvers/target-size.d.ts.map +1 -0
- package/dist/lib/resolvers/target-size.js +125 -0
- package/dist/lib/types.d.ts +227 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +8 -0
- package/dist/lib/wcag-thresholds.d.ts +34 -0
- package/dist/lib/wcag-thresholds.d.ts.map +1 -0
- package/dist/lib/wcag-thresholds.js +55 -0
- 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"}
|