@afixt/test-utils 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +1 -2
- package/BROWSER_TESTING.md +12 -12
- package/CHANGELOG.md +21 -0
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/src/domUtils.js +9 -8
- package/src/formUtils.js +2 -1
- package/src/getAccessibleName.js +16 -6
- package/src/getImageText.js +5 -1
- package/src/index.js +4 -0
- package/src/isA11yVisible.js +9 -7
- package/src/shadowDomUtils.js +118 -0
- package/test/domUtils.test.js +32 -0
- package/test/formUtils.test.js +21 -0
- package/test/getAccessibleName.test.js +39 -0
- package/test/isA11yVisible.test.js +23 -0
- package/test/shadowDomUtils.test.js +248 -0
- package/todo.md +3 -2
package/BROWSER_TESTING.md
CHANGED
|
@@ -6,13 +6,13 @@ This project uses **JSDOM** for the main test suite with **Playwright** for CSS
|
|
|
6
6
|
|
|
7
7
|
## Current Status
|
|
8
8
|
|
|
9
|
-
- **
|
|
9
|
+
- **1101 tests passing** in JSDOM environment (8 conditionally skipped)
|
|
10
10
|
- **20 tests passing** in Playwright for CSS pseudo-element support
|
|
11
|
-
- **Total:
|
|
11
|
+
- **Total: 1121 tests passing** across both environments
|
|
12
12
|
|
|
13
13
|
### Implementation Complete
|
|
14
14
|
|
|
15
|
-
✅ JSDOM tests for all standard functionality (
|
|
15
|
+
✅ JSDOM tests for all standard functionality (1101 tests)
|
|
16
16
|
✅ Standalone Playwright tests for CSS pseudo-elements (20 tests)
|
|
17
17
|
✅ 8 CSS pseudo-element tests conditionally skipped in JSDOM, covered by Playwright
|
|
18
18
|
|
|
@@ -30,12 +30,12 @@ The 20 CSS pseudo-element tests run with standalone Playwright tests that inject
|
|
|
30
30
|
### JSDOM Tests (Main Test Suite)
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
npm test # Run all
|
|
33
|
+
npm test # Run all 1101 JSDOM tests
|
|
34
34
|
npm run test:coverage # Run with coverage
|
|
35
35
|
npm run test:watch # Watch mode
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
**Status:**
|
|
38
|
+
**Status:** 1101 tests passing (8 conditionally skipped)
|
|
39
39
|
|
|
40
40
|
**Pros:**
|
|
41
41
|
|
|
@@ -113,17 +113,17 @@ The Playwright tests inject browser-compatible versions of `getGeneratedContent`
|
|
|
113
113
|
|
|
114
114
|
```json
|
|
115
115
|
{
|
|
116
|
-
"test": "vitest run", // JSDOM tests (
|
|
116
|
+
"test": "vitest run", // JSDOM tests (1101 tests)
|
|
117
117
|
"test:coverage": "vitest run --coverage", // JSDOM with coverage
|
|
118
118
|
"test:playwright:css": "playwright test ...", // Playwright CSS tests (20 tests)
|
|
119
|
-
"test:all": "npm run test && npm run test:playwright:css" // All tests (
|
|
119
|
+
"test:all": "npm run test && npm run test:playwright:css" // All tests (1121 tests)
|
|
120
120
|
}
|
|
121
121
|
```
|
|
122
122
|
|
|
123
123
|
## Test Coverage
|
|
124
124
|
|
|
125
|
-
- **
|
|
126
|
-
- **
|
|
127
|
-
- **95.
|
|
128
|
-
- **
|
|
129
|
-
- **
|
|
125
|
+
- **88.98% statement coverage**
|
|
126
|
+
- **84.37% branch coverage**
|
|
127
|
+
- **95.21% function coverage**
|
|
128
|
+
- **89.04% line coverage**
|
|
129
|
+
- **1121 total tests passing** (1101 JSDOM + 20 Playwright)
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.2.0] - 2026-03-03
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
- **domUtils**: Add `inert` attribute and `content-visibility: hidden` detection to `isHiddenFromAT` (#62)
|
|
10
|
+
- **testContrast**: Return `null` (cannot-determine) instead of `true` for elements with background images (#63)
|
|
11
|
+
- **testContrast**: Return `null` for elements with CSS `filter` or `mix-blend-mode` that alter perceived color (#64)
|
|
12
|
+
- **getAccessibleName**: Replace internal `isNotVisible` with `isA11yVisible` for proper visibility checking, including aria-labelledby reference exceptions (#57)
|
|
13
|
+
- **getAccessibleName**: Handle zero-width spaces and non-breaking spaces in aria-label with `isEmptyOrWhitespace` (#70)
|
|
14
|
+
- **getComputedRole**: Add context-dependent implicit roles per HTML-AAM (td/gridcell in grid, header/footer as generic inside sectioning elements) (#68)
|
|
15
|
+
- **isHidden**: Distinguish `hidden="until-found"` from plain `hidden` attribute (#69)
|
|
16
|
+
- **formUtils**: Skip `aria-hidden="true"` labels when resolving `aria-labelledby` in `hasExplicitAccessibleName` (#67)
|
|
17
|
+
- **constants**: Add `template` to `NON_VISIBLE_SELECTORS` (#71)
|
|
18
|
+
- **domUtils, getAccessibleName**: Escape all ID interpolations in CSS selectors to handle special characters (#72)
|
|
19
|
+
|
|
20
|
+
## [2.1.1] - 2026-03-01
|
|
21
|
+
|
|
22
|
+
### Fixes
|
|
23
|
+
|
|
24
|
+
- **isA11yVisible**: Escape element IDs in CSS selectors to handle special characters like colons
|
|
25
|
+
|
|
5
26
|
## [2.1.0] - 2026-02-25
|
|
6
27
|
|
|
7
28
|
### Features
|
package/CLAUDE.md
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
### Test Status
|
|
46
46
|
|
|
47
|
-
- **
|
|
47
|
+
- **1129 tests passing** in JSDOM environment
|
|
48
48
|
- **8 tests skipped** - Conditionally skipped in JSDOM; require real browser CSS pseudo-element support
|
|
49
49
|
- **20 Playwright tests** cover CSS pseudo-element functionality in a real browser
|
|
50
50
|
- See `BROWSER_TESTING.md` for details on the hybrid testing approach
|
package/package.json
CHANGED
package/src/domUtils.js
CHANGED
|
@@ -6,6 +6,7 @@ const {
|
|
|
6
6
|
SEMANTIC_CONTAINER_ROLES,
|
|
7
7
|
INTERACTIVE_HANDLER_ATTRIBUTES,
|
|
8
8
|
} = require('./constants.js');
|
|
9
|
+
const { deepQuerySelector, deepQuerySelectorAll } = require('./shadowDomUtils.js');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Escapes a string for use inside a CSS selector.
|
|
@@ -109,7 +110,7 @@ const domUtils = {
|
|
|
109
110
|
* @returns {Element[]} An array of elements that have duplicate IDs.
|
|
110
111
|
*/
|
|
111
112
|
getElementsWithDuplicateIds() {
|
|
112
|
-
const nodes = document
|
|
113
|
+
const nodes = deepQuerySelectorAll(document, '[id]');
|
|
113
114
|
const ids = {};
|
|
114
115
|
const duplicates = [];
|
|
115
116
|
nodes.forEach(node => {
|
|
@@ -119,7 +120,7 @@ const domUtils = {
|
|
|
119
120
|
duplicates.push(id);
|
|
120
121
|
}
|
|
121
122
|
});
|
|
122
|
-
return duplicates.map(id => document
|
|
123
|
+
return duplicates.map(id => deepQuerySelector(document, `#${cssEscape(id)}`));
|
|
123
124
|
},
|
|
124
125
|
|
|
125
126
|
/**
|
|
@@ -176,7 +177,7 @@ const domUtils = {
|
|
|
176
177
|
const escaped = cssEscape(id);
|
|
177
178
|
|
|
178
179
|
// Direct attribute references
|
|
179
|
-
if (document
|
|
180
|
+
if (deepQuerySelector(document, '[for="' + escaped + '"]')) {
|
|
180
181
|
return true;
|
|
181
182
|
}
|
|
182
183
|
|
|
@@ -189,7 +190,7 @@ const domUtils = {
|
|
|
189
190
|
'aria-flowto',
|
|
190
191
|
];
|
|
191
192
|
for (const attr of ariaTokenAttrs) {
|
|
192
|
-
const elements = document
|
|
193
|
+
const elements = deepQuerySelectorAll(document, '[' + attr + ']');
|
|
193
194
|
for (const el of elements) {
|
|
194
195
|
const ids = el.getAttribute(attr).trim().split(/\s+/);
|
|
195
196
|
if (ids.includes(id)) {
|
|
@@ -201,18 +202,18 @@ const domUtils = {
|
|
|
201
202
|
// Single-ID ARIA references
|
|
202
203
|
const ariaSingleAttrs = ['aria-activedescendant', 'aria-errormessage'];
|
|
203
204
|
for (const attr of ariaSingleAttrs) {
|
|
204
|
-
if (document
|
|
205
|
+
if (deepQuerySelector(document, '[' + attr + '="' + escaped + '"]')) {
|
|
205
206
|
return true;
|
|
206
207
|
}
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
// Fragment references in href
|
|
210
|
-
if (document
|
|
211
|
+
if (deepQuerySelector(document, 'a[href="#' + escaped + '"]')) {
|
|
211
212
|
return true;
|
|
212
213
|
}
|
|
213
214
|
|
|
214
215
|
// Table headers attribute (space-separated list of IDs)
|
|
215
|
-
const headerElements = document
|
|
216
|
+
const headerElements = deepQuerySelectorAll(document, '[headers]');
|
|
216
217
|
for (const el of headerElements) {
|
|
217
218
|
const ids = el.getAttribute('headers').trim().split(/\s+/);
|
|
218
219
|
if (ids.includes(id)) {
|
|
@@ -221,7 +222,7 @@ const domUtils = {
|
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
// list attribute on input elements
|
|
224
|
-
if (document
|
|
225
|
+
if (deepQuerySelector(document, 'input[list="' + escaped + '"]')) {
|
|
225
226
|
return true;
|
|
226
227
|
}
|
|
227
228
|
|
package/src/formUtils.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const { isHiddenFromAT } = require('./domUtils.js');
|
|
7
|
+
const { deepGetElementById } = require('./shadowDomUtils.js');
|
|
7
8
|
|
|
8
9
|
const formUtils = {
|
|
9
10
|
/**
|
|
@@ -51,7 +52,7 @@ const formUtils = {
|
|
|
51
52
|
if (element.hasAttribute('aria-labelledby')) {
|
|
52
53
|
const ids = element.getAttribute('aria-labelledby').trim().split(/\s+/);
|
|
53
54
|
for (let i = 0; i < ids.length; i++) {
|
|
54
|
-
const labelEl = document
|
|
55
|
+
const labelEl = deepGetElementById(document, ids[i]);
|
|
55
56
|
if (labelEl && !isHiddenFromAT(labelEl) && labelEl.textContent.trim()) {
|
|
56
57
|
return true;
|
|
57
58
|
}
|
package/src/getAccessibleName.js
CHANGED
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
} = require('./constants.js');
|
|
9
9
|
const { cssEscape } = require('./domUtils.js');
|
|
10
10
|
const { isA11yVisible } = require('./isA11yVisible.js');
|
|
11
|
+
const { deepGetElementById, deepQuerySelector } = require('./shadowDomUtils.js');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Gets the accessible name of an element according to the accessible name calculation algorithm
|
|
@@ -58,7 +59,7 @@ function getAccessibleName(element) {
|
|
|
58
59
|
|
|
59
60
|
const text = [];
|
|
60
61
|
for (const id of ids) {
|
|
61
|
-
const labelElement = document
|
|
62
|
+
const labelElement = deepGetElementById(document, id);
|
|
62
63
|
if (!labelElement || !getAccessibleText(labelElement)) {
|
|
63
64
|
return false;
|
|
64
65
|
}
|
|
@@ -109,12 +110,15 @@ function getAccessibleName(element) {
|
|
|
109
110
|
)
|
|
110
111
|
) {
|
|
111
112
|
// first we choose the explicit relationship over all others.
|
|
112
|
-
if (
|
|
113
|
+
if (
|
|
114
|
+
element.id &&
|
|
115
|
+
deepQuerySelector(document, 'label[for="' + cssEscape(element.id) + '"]')
|
|
116
|
+
) {
|
|
113
117
|
id = element.id;
|
|
114
118
|
// Use only the *first* label that matches this ID.
|
|
115
119
|
// Sometimes JS libraries screw this up by hiding one of the
|
|
116
120
|
// labels or misnaming one
|
|
117
|
-
label = document
|
|
121
|
+
label = deepQuerySelector(document, 'label[for="' + cssEscape(id) + '"]');
|
|
118
122
|
if (label) {
|
|
119
123
|
return getAccessibleText(label);
|
|
120
124
|
}
|
|
@@ -224,11 +228,14 @@ function getAccessibleName(element) {
|
|
|
224
228
|
)
|
|
225
229
|
) {
|
|
226
230
|
// first we choose the explicit relationship over all others.
|
|
227
|
-
if (
|
|
231
|
+
if (
|
|
232
|
+
element.id &&
|
|
233
|
+
deepQuerySelector(document, 'label[for="' + cssEscape(element.id) + '"]')
|
|
234
|
+
) {
|
|
228
235
|
id = element.id;
|
|
229
236
|
|
|
230
237
|
//Use only the *first* label that matches this ID. Sometimes ppl screw this up
|
|
231
|
-
label = document
|
|
238
|
+
label = deepQuerySelector(document, 'label[for="' + cssEscape(id) + '"]');
|
|
232
239
|
if (label) {
|
|
233
240
|
return getAccessibleText(label);
|
|
234
241
|
}
|
|
@@ -373,7 +380,10 @@ function getAccessibleName(element) {
|
|
|
373
380
|
if (element.tagName.toLowerCase() === 'meter') {
|
|
374
381
|
// Check for label with for attribute
|
|
375
382
|
if (element.id) {
|
|
376
|
-
const label =
|
|
383
|
+
const label = deepQuerySelector(
|
|
384
|
+
document,
|
|
385
|
+
'label[for="' + cssEscape(element.id) + '"]'
|
|
386
|
+
);
|
|
377
387
|
if (label && strlen(getAccessibleText(label)) > 0) {
|
|
378
388
|
return getAccessibleText(label);
|
|
379
389
|
}
|
package/src/getImageText.js
CHANGED
|
@@ -29,7 +29,11 @@ async function getImageText(imagePath, options = {}) {
|
|
|
29
29
|
// errorHandler silences the bare `throw` in tesseract.js createWorker.js when a
|
|
30
30
|
// job is rejected — without it the throw escapes try/catch and becomes an uncaught
|
|
31
31
|
// exception in the host process. The promise rejection is still caught below.
|
|
32
|
-
} = await _internal.recognize(imagePath, lang, {
|
|
32
|
+
} = await _internal.recognize(imagePath, lang, {
|
|
33
|
+
logger,
|
|
34
|
+
errorHandler: () => {},
|
|
35
|
+
...tesseractOptions,
|
|
36
|
+
});
|
|
33
37
|
|
|
34
38
|
const extractedText = text.trim();
|
|
35
39
|
return extractedText.length > 0 ? extractedText : false;
|
package/src/index.js
CHANGED
|
@@ -82,6 +82,9 @@ const tableUtils = require('./tableUtils.js');
|
|
|
82
82
|
// Query cache utilities
|
|
83
83
|
const queryCache = require('./queryCache.js');
|
|
84
84
|
|
|
85
|
+
// Shadow DOM utilities
|
|
86
|
+
const shadowDomUtils = require('./shadowDomUtils.js');
|
|
87
|
+
|
|
85
88
|
// List event listeners
|
|
86
89
|
const listEventListeners = require('./listEventListeners.js');
|
|
87
90
|
|
|
@@ -126,4 +129,5 @@ module.exports = {
|
|
|
126
129
|
...tableUtils,
|
|
127
130
|
...queryCache,
|
|
128
131
|
...listEventListeners,
|
|
132
|
+
...shadowDomUtils,
|
|
129
133
|
};
|
package/src/isA11yVisible.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { NON_VISIBLE_SELECTORS } = require('./constants.js');
|
|
2
2
|
const isHidden = require('./isHidden.js');
|
|
3
3
|
const { cssEscape } = require('./domUtils.js');
|
|
4
|
+
const { deepQuerySelectorAll } = require('./shadowDomUtils.js');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Checks if an element is visible to assistive technologies (AT).
|
|
@@ -71,13 +72,14 @@ function isA11yVisible(element, strict = false) {
|
|
|
71
72
|
// Use CSS.escape(id) to handle IDs containing special characters like colons
|
|
72
73
|
// (e.g. Radix UI generates IDs like "radix-:rj:-tab-account")
|
|
73
74
|
const escapedId = id ? cssEscape(id) : '';
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
75
|
+
deepQuerySelectorAll(
|
|
76
|
+
document,
|
|
77
|
+
`*[aria-labelledby~="${escapedId}"], *[aria-describedby~="${escapedId}"]`
|
|
78
|
+
).forEach(referencingElement => {
|
|
79
|
+
if (window.getComputedStyle(referencingElement).display !== 'none') {
|
|
80
|
+
visible = true;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
81
83
|
|
|
82
84
|
// Check if any parent has aria-hidden="true" when strict mode is on
|
|
83
85
|
if (visible && strict) {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Shadow-DOM-aware element lookup utilities
|
|
3
|
+
* @module shadowDomUtils
|
|
4
|
+
* @description Provides deep traversal functions that cross open shadow DOM
|
|
5
|
+
* boundaries when looking up elements by ID or CSS selector.
|
|
6
|
+
*
|
|
7
|
+
* **Limitation:** Closed shadow roots (created with `{ mode: 'closed' }`) are
|
|
8
|
+
* inaccessible by spec — `.shadowRoot` returns `null` for them — so these
|
|
9
|
+
* utilities can only traverse *open* shadow roots.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Finds an element by ID, searching through open shadow roots when the
|
|
14
|
+
* element is not found in the light DOM.
|
|
15
|
+
*
|
|
16
|
+
* @param {Document|ShadowRoot|Element} root - The root to start searching from.
|
|
17
|
+
* Typically `document`, but can be any subtree root or ShadowRoot.
|
|
18
|
+
* @param {string} id - The ID to search for.
|
|
19
|
+
* @returns {Element|null} The first matching element, or `null`.
|
|
20
|
+
*/
|
|
21
|
+
function deepGetElementById(root, id) {
|
|
22
|
+
if (!root || !id) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Fast path: root supports getElementById (Document and ShadowRoot do)
|
|
27
|
+
if (typeof root.getElementById === 'function') {
|
|
28
|
+
const el = root.getElementById(id);
|
|
29
|
+
if (el) {
|
|
30
|
+
return el;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Recurse into open shadow roots
|
|
35
|
+
const allElements = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
|
36
|
+
for (const el of allElements) {
|
|
37
|
+
if (el.shadowRoot) {
|
|
38
|
+
const found = deepGetElementById(el.shadowRoot, id);
|
|
39
|
+
if (found) {
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Runs `querySelector` against the given root and, if no match is found,
|
|
50
|
+
* recursively searches open shadow roots.
|
|
51
|
+
*
|
|
52
|
+
* @param {Document|ShadowRoot|Element} root - The root to start searching from.
|
|
53
|
+
* @param {string} selector - A CSS selector string.
|
|
54
|
+
* @returns {Element|null} The first matching element, or `null`.
|
|
55
|
+
*/
|
|
56
|
+
function deepQuerySelector(root, selector) {
|
|
57
|
+
if (!root || !selector) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fast path
|
|
62
|
+
if (typeof root.querySelector === 'function') {
|
|
63
|
+
const el = root.querySelector(selector);
|
|
64
|
+
if (el) {
|
|
65
|
+
return el;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Recurse into open shadow roots
|
|
70
|
+
const allElements = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
|
71
|
+
for (const el of allElements) {
|
|
72
|
+
if (el.shadowRoot) {
|
|
73
|
+
const found = deepQuerySelector(el.shadowRoot, selector);
|
|
74
|
+
if (found) {
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Runs `querySelectorAll` against the given root and then recursively
|
|
85
|
+
* collects matches from all open shadow roots.
|
|
86
|
+
*
|
|
87
|
+
* @param {Document|ShadowRoot|Element} root - The root to start searching from.
|
|
88
|
+
* @param {string} selector - A CSS selector string.
|
|
89
|
+
* @returns {Element[]} An array of all matching elements (may be empty).
|
|
90
|
+
*/
|
|
91
|
+
function deepQuerySelectorAll(root, selector) {
|
|
92
|
+
if (!root || !selector) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const results = [];
|
|
97
|
+
|
|
98
|
+
// Collect from this root
|
|
99
|
+
if (typeof root.querySelectorAll === 'function') {
|
|
100
|
+
results.push(...root.querySelectorAll(selector));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Recurse into open shadow roots
|
|
104
|
+
const allElements = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
|
105
|
+
for (const el of allElements) {
|
|
106
|
+
if (el.shadowRoot) {
|
|
107
|
+
results.push(...deepQuerySelectorAll(el.shadowRoot, selector));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
deepGetElementById,
|
|
116
|
+
deepQuerySelector,
|
|
117
|
+
deepQuerySelectorAll,
|
|
118
|
+
};
|
package/test/domUtils.test.js
CHANGED
|
@@ -1079,4 +1079,36 @@ describe('domUtils', () => {
|
|
|
1079
1079
|
expect(domUtils.getHeadingLevel(el)).toBeNull();
|
|
1080
1080
|
});
|
|
1081
1081
|
});
|
|
1082
|
+
|
|
1083
|
+
describe('shadow DOM integration', () => {
|
|
1084
|
+
it('isIdReferenced should find label[for] inside a shadow root', () => {
|
|
1085
|
+
const host = document.createElement('div');
|
|
1086
|
+
document.body.appendChild(host);
|
|
1087
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
1088
|
+
const label = document.createElement('label');
|
|
1089
|
+
label.setAttribute('for', 'shadow-ref-target');
|
|
1090
|
+
shadow.appendChild(label);
|
|
1091
|
+
|
|
1092
|
+
expect(domUtils.isIdReferenced('shadow-ref-target')).toBe(true);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it('getElementsWithDuplicateIds should detect duplicate IDs across shadow roots', () => {
|
|
1096
|
+
// Light DOM element with id="dup"
|
|
1097
|
+
const div1 = document.createElement('div');
|
|
1098
|
+
div1.id = 'dup';
|
|
1099
|
+
document.body.appendChild(div1);
|
|
1100
|
+
|
|
1101
|
+
// Shadow DOM element with same id="dup"
|
|
1102
|
+
const host = document.createElement('div');
|
|
1103
|
+
document.body.appendChild(host);
|
|
1104
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
1105
|
+
const div2 = document.createElement('div');
|
|
1106
|
+
div2.id = 'dup';
|
|
1107
|
+
shadow.appendChild(div2);
|
|
1108
|
+
|
|
1109
|
+
const duplicates = domUtils.getElementsWithDuplicateIds();
|
|
1110
|
+
expect(duplicates.length).toBe(1);
|
|
1111
|
+
expect(duplicates[0].id).toBe('dup');
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1082
1114
|
});
|
package/test/formUtils.test.js
CHANGED
|
@@ -357,4 +357,25 @@ describe('formUtils', () => {
|
|
|
357
357
|
expect(text.trim()).toBe('');
|
|
358
358
|
});
|
|
359
359
|
});
|
|
360
|
+
|
|
361
|
+
describe('shadow DOM integration', () => {
|
|
362
|
+
it('hasExplicitAccessibleName should find aria-labelledby target inside shadow root', () => {
|
|
363
|
+
// Create shadow host with label element inside
|
|
364
|
+
const host = document.createElement('div');
|
|
365
|
+
document.body.appendChild(host);
|
|
366
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
367
|
+
const labelEl = document.createElement('span');
|
|
368
|
+
labelEl.id = 'shadow-group-label';
|
|
369
|
+
labelEl.textContent = 'Group Name';
|
|
370
|
+
shadow.appendChild(labelEl);
|
|
371
|
+
|
|
372
|
+
// Create an element in light DOM referencing the shadow label
|
|
373
|
+
const group = document.createElement('div');
|
|
374
|
+
group.setAttribute('role', 'radiogroup');
|
|
375
|
+
group.setAttribute('aria-labelledby', 'shadow-group-label');
|
|
376
|
+
document.body.appendChild(group);
|
|
377
|
+
|
|
378
|
+
expect(formUtils.hasExplicitAccessibleName(group)).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
360
381
|
});
|
|
@@ -511,4 +511,43 @@ describe('getAccessibleName', () => {
|
|
|
511
511
|
expect(getAccessibleName(button)).toBe(false);
|
|
512
512
|
});
|
|
513
513
|
});
|
|
514
|
+
|
|
515
|
+
describe('shadow DOM integration', () => {
|
|
516
|
+
it('should resolve aria-labelledby referencing an element inside a shadow root', () => {
|
|
517
|
+
// Create a shadow host with the label inside
|
|
518
|
+
const host = document.createElement('div');
|
|
519
|
+
document.body.appendChild(host);
|
|
520
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
521
|
+
const labelSpan = document.createElement('span');
|
|
522
|
+
labelSpan.id = 'shadow-label';
|
|
523
|
+
labelSpan.textContent = 'Shadow Label';
|
|
524
|
+
shadow.appendChild(labelSpan);
|
|
525
|
+
|
|
526
|
+
// Create the button in light DOM that references the shadow label
|
|
527
|
+
const button = document.createElement('button');
|
|
528
|
+
button.setAttribute('aria-labelledby', 'shadow-label');
|
|
529
|
+
document.body.appendChild(button);
|
|
530
|
+
|
|
531
|
+
expect(getAccessibleName(button)).toBe('Shadow Label');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('should resolve label[for] inside a shadow root for text inputs', () => {
|
|
535
|
+
// Create a shadow host with the label inside
|
|
536
|
+
const host = document.createElement('div');
|
|
537
|
+
document.body.appendChild(host);
|
|
538
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
539
|
+
const label = document.createElement('label');
|
|
540
|
+
label.setAttribute('for', 'shadow-input');
|
|
541
|
+
label.textContent = 'Shadow Input Label';
|
|
542
|
+
shadow.appendChild(label);
|
|
543
|
+
|
|
544
|
+
// Create the input in light DOM
|
|
545
|
+
const input = document.createElement('input');
|
|
546
|
+
input.type = 'text';
|
|
547
|
+
input.id = 'shadow-input';
|
|
548
|
+
document.body.appendChild(input);
|
|
549
|
+
|
|
550
|
+
expect(getAccessibleName(input)).toBe('Shadow Input Label');
|
|
551
|
+
});
|
|
552
|
+
});
|
|
514
553
|
});
|
|
@@ -269,4 +269,27 @@ describe('isA11yVisible', () => {
|
|
|
269
269
|
const element = document.getElementById('test');
|
|
270
270
|
expect(isA11yVisible(element)).toBe(true);
|
|
271
271
|
});
|
|
272
|
+
|
|
273
|
+
describe('shadow DOM integration', () => {
|
|
274
|
+
it('should detect aria-labelledby reference from inside a shadow root', () => {
|
|
275
|
+
// Create a hidden element that provides an accessible name
|
|
276
|
+
const hidden = document.createElement('span');
|
|
277
|
+
hidden.id = 'shadow-ref-source';
|
|
278
|
+
hidden.style.display = 'none';
|
|
279
|
+
hidden.textContent = 'Label text';
|
|
280
|
+
document.body.appendChild(hidden);
|
|
281
|
+
|
|
282
|
+
// Create shadow host with an element that references the hidden element
|
|
283
|
+
const host = document.createElement('div');
|
|
284
|
+
document.body.appendChild(host);
|
|
285
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
286
|
+
const button = document.createElement('button');
|
|
287
|
+
button.setAttribute('aria-labelledby', 'shadow-ref-source');
|
|
288
|
+
shadow.appendChild(button);
|
|
289
|
+
|
|
290
|
+
// The hidden element should still be considered AT-visible because
|
|
291
|
+
// it's referenced by an aria-labelledby from the shadow DOM
|
|
292
|
+
expect(isA11yVisible(hidden)).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
272
295
|
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
deepGetElementById,
|
|
4
|
+
deepQuerySelector,
|
|
5
|
+
deepQuerySelectorAll,
|
|
6
|
+
} from '../src/shadowDomUtils.js';
|
|
7
|
+
|
|
8
|
+
describe('shadowDomUtils', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
document.body.innerHTML = '';
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// =========================================================================
|
|
14
|
+
// Batch 1: deepGetElementById — basic cases
|
|
15
|
+
// =========================================================================
|
|
16
|
+
describe('deepGetElementById', () => {
|
|
17
|
+
it('should return null when root is null', () => {
|
|
18
|
+
expect(deepGetElementById(null, 'foo')).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should return null when id is null or empty', () => {
|
|
22
|
+
expect(deepGetElementById(document, null)).toBeNull();
|
|
23
|
+
expect(deepGetElementById(document, '')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should find an element in the light DOM', () => {
|
|
27
|
+
document.body.innerHTML = '<div id="target">Hello</div>';
|
|
28
|
+
const result = deepGetElementById(document, 'target');
|
|
29
|
+
expect(result).not.toBeNull();
|
|
30
|
+
expect(result.textContent).toBe('Hello');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should find an element inside an open shadow root', () => {
|
|
34
|
+
const host = document.createElement('div');
|
|
35
|
+
document.body.appendChild(host);
|
|
36
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
37
|
+
const inner = document.createElement('span');
|
|
38
|
+
inner.id = 'shadow-target';
|
|
39
|
+
inner.textContent = 'Inside shadow';
|
|
40
|
+
shadow.appendChild(inner);
|
|
41
|
+
|
|
42
|
+
const result = deepGetElementById(document, 'shadow-target');
|
|
43
|
+
expect(result).not.toBeNull();
|
|
44
|
+
expect(result.textContent).toBe('Inside shadow');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should find an element in a nested shadow root', () => {
|
|
48
|
+
const host1 = document.createElement('div');
|
|
49
|
+
document.body.appendChild(host1);
|
|
50
|
+
const shadow1 = host1.attachShadow({ mode: 'open' });
|
|
51
|
+
|
|
52
|
+
const host2 = document.createElement('div');
|
|
53
|
+
shadow1.appendChild(host2);
|
|
54
|
+
const shadow2 = host2.attachShadow({ mode: 'open' });
|
|
55
|
+
|
|
56
|
+
const inner = document.createElement('span');
|
|
57
|
+
inner.id = 'nested-target';
|
|
58
|
+
inner.textContent = 'Nested';
|
|
59
|
+
shadow2.appendChild(inner);
|
|
60
|
+
|
|
61
|
+
const result = deepGetElementById(document, 'nested-target');
|
|
62
|
+
expect(result).not.toBeNull();
|
|
63
|
+
expect(result.textContent).toBe('Nested');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return null when element is not found anywhere', () => {
|
|
67
|
+
document.body.innerHTML = '<div id="other">Other</div>';
|
|
68
|
+
expect(deepGetElementById(document, 'nonexistent')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should prefer light DOM match (fast path)', () => {
|
|
72
|
+
// Light DOM element
|
|
73
|
+
document.body.innerHTML = '<div id="dup">Light</div>';
|
|
74
|
+
|
|
75
|
+
// Also put one in shadow DOM
|
|
76
|
+
const host = document.createElement('div');
|
|
77
|
+
document.body.appendChild(host);
|
|
78
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
79
|
+
const inner = document.createElement('span');
|
|
80
|
+
inner.id = 'dup';
|
|
81
|
+
inner.textContent = 'Shadow';
|
|
82
|
+
shadow.appendChild(inner);
|
|
83
|
+
|
|
84
|
+
const result = deepGetElementById(document, 'dup');
|
|
85
|
+
expect(result).not.toBeNull();
|
|
86
|
+
expect(result.textContent).toBe('Light');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should work when root is a ShadowRoot', () => {
|
|
90
|
+
const host = document.createElement('div');
|
|
91
|
+
document.body.appendChild(host);
|
|
92
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
93
|
+
const inner = document.createElement('span');
|
|
94
|
+
inner.id = 'sr-target';
|
|
95
|
+
inner.textContent = 'Direct';
|
|
96
|
+
shadow.appendChild(inner);
|
|
97
|
+
|
|
98
|
+
const result = deepGetElementById(shadow, 'sr-target');
|
|
99
|
+
expect(result).not.toBeNull();
|
|
100
|
+
expect(result.textContent).toBe('Direct');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// =========================================================================
|
|
105
|
+
// Batch 3-4: deepQuerySelector
|
|
106
|
+
// =========================================================================
|
|
107
|
+
describe('deepQuerySelector', () => {
|
|
108
|
+
it('should return null when root is null', () => {
|
|
109
|
+
expect(deepQuerySelector(null, 'div')).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should return null when selector is null or empty', () => {
|
|
113
|
+
expect(deepQuerySelector(document, null)).toBeNull();
|
|
114
|
+
expect(deepQuerySelector(document, '')).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should find an element in the light DOM', () => {
|
|
118
|
+
document.body.innerHTML = '<label for="name">Name</label>';
|
|
119
|
+
const result = deepQuerySelector(document, 'label[for="name"]');
|
|
120
|
+
expect(result).not.toBeNull();
|
|
121
|
+
expect(result.textContent).toBe('Name');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should find a label[for] inside a shadow root', () => {
|
|
125
|
+
const host = document.createElement('div');
|
|
126
|
+
document.body.appendChild(host);
|
|
127
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
128
|
+
const label = document.createElement('label');
|
|
129
|
+
label.setAttribute('for', 'email');
|
|
130
|
+
label.textContent = 'Email';
|
|
131
|
+
shadow.appendChild(label);
|
|
132
|
+
|
|
133
|
+
const result = deepQuerySelector(document, 'label[for="email"]');
|
|
134
|
+
expect(result).not.toBeNull();
|
|
135
|
+
expect(result.textContent).toBe('Email');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should find an element in a nested shadow root', () => {
|
|
139
|
+
const host1 = document.createElement('div');
|
|
140
|
+
document.body.appendChild(host1);
|
|
141
|
+
const shadow1 = host1.attachShadow({ mode: 'open' });
|
|
142
|
+
|
|
143
|
+
const host2 = document.createElement('div');
|
|
144
|
+
shadow1.appendChild(host2);
|
|
145
|
+
const shadow2 = host2.attachShadow({ mode: 'open' });
|
|
146
|
+
|
|
147
|
+
const el = document.createElement('span');
|
|
148
|
+
el.className = 'deep-nested';
|
|
149
|
+
el.textContent = 'Found';
|
|
150
|
+
shadow2.appendChild(el);
|
|
151
|
+
|
|
152
|
+
const result = deepQuerySelector(document, '.deep-nested');
|
|
153
|
+
expect(result).not.toBeNull();
|
|
154
|
+
expect(result.textContent).toBe('Found');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should return null when no match is found', () => {
|
|
158
|
+
document.body.innerHTML = '<div>Nothing matching</div>';
|
|
159
|
+
expect(deepQuerySelector(document, '.nonexistent')).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should prefer light DOM match (fast path)', () => {
|
|
163
|
+
document.body.innerHTML = '<span class="item">Light</span>';
|
|
164
|
+
|
|
165
|
+
const host = document.createElement('div');
|
|
166
|
+
document.body.appendChild(host);
|
|
167
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
168
|
+
const inner = document.createElement('span');
|
|
169
|
+
inner.className = 'item';
|
|
170
|
+
inner.textContent = 'Shadow';
|
|
171
|
+
shadow.appendChild(inner);
|
|
172
|
+
|
|
173
|
+
const result = deepQuerySelector(document, '.item');
|
|
174
|
+
expect(result.textContent).toBe('Light');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// =========================================================================
|
|
179
|
+
// Batch 5-6: deepQuerySelectorAll
|
|
180
|
+
// =========================================================================
|
|
181
|
+
describe('deepQuerySelectorAll', () => {
|
|
182
|
+
it('should return empty array when root is null', () => {
|
|
183
|
+
expect(deepQuerySelectorAll(null, 'div')).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should return empty array when selector is null or empty', () => {
|
|
187
|
+
expect(deepQuerySelectorAll(document, null)).toEqual([]);
|
|
188
|
+
expect(deepQuerySelectorAll(document, '')).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should find elements in the light DOM', () => {
|
|
192
|
+
document.body.innerHTML = '<div class="x">A</div><div class="x">B</div>';
|
|
193
|
+
const results = deepQuerySelectorAll(document, '.x');
|
|
194
|
+
expect(results).toHaveLength(2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should collect elements from multiple shadow roots', () => {
|
|
198
|
+
document.body.innerHTML = '<span class="item">Light</span>';
|
|
199
|
+
|
|
200
|
+
const host1 = document.createElement('div');
|
|
201
|
+
document.body.appendChild(host1);
|
|
202
|
+
const shadow1 = host1.attachShadow({ mode: 'open' });
|
|
203
|
+
const inner1 = document.createElement('span');
|
|
204
|
+
inner1.className = 'item';
|
|
205
|
+
shadow1.appendChild(inner1);
|
|
206
|
+
|
|
207
|
+
const host2 = document.createElement('div');
|
|
208
|
+
document.body.appendChild(host2);
|
|
209
|
+
const shadow2 = host2.attachShadow({ mode: 'open' });
|
|
210
|
+
const inner2 = document.createElement('span');
|
|
211
|
+
inner2.className = 'item';
|
|
212
|
+
shadow2.appendChild(inner2);
|
|
213
|
+
|
|
214
|
+
const results = deepQuerySelectorAll(document, '.item');
|
|
215
|
+
expect(results).toHaveLength(3);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should collect from nested shadow roots', () => {
|
|
219
|
+
const host1 = document.createElement('div');
|
|
220
|
+
document.body.appendChild(host1);
|
|
221
|
+
const shadow1 = host1.attachShadow({ mode: 'open' });
|
|
222
|
+
|
|
223
|
+
const host2 = document.createElement('div');
|
|
224
|
+
shadow1.appendChild(host2);
|
|
225
|
+
const shadow2 = host2.attachShadow({ mode: 'open' });
|
|
226
|
+
|
|
227
|
+
const inner = document.createElement('span');
|
|
228
|
+
inner.className = 'nested-item';
|
|
229
|
+
shadow2.appendChild(inner);
|
|
230
|
+
|
|
231
|
+
const results = deepQuerySelectorAll(document, '.nested-item');
|
|
232
|
+
expect(results).toHaveLength(1);
|
|
233
|
+
expect(results[0].className).toBe('nested-item');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should return an array (not NodeList)', () => {
|
|
237
|
+
document.body.innerHTML = '<div class="y">A</div>';
|
|
238
|
+
const results = deepQuerySelectorAll(document, '.y');
|
|
239
|
+
expect(Array.isArray(results)).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should return empty array when nothing matches', () => {
|
|
243
|
+
document.body.innerHTML = '<div>Nothing</div>';
|
|
244
|
+
const results = deepQuerySelectorAll(document, '.missing');
|
|
245
|
+
expect(results).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
package/todo.md
CHANGED
|
@@ -6,6 +6,7 @@ _No pending tasks_
|
|
|
6
6
|
|
|
7
7
|
## Completed
|
|
8
8
|
|
|
9
|
-
- ✅ Test coverage improvements (
|
|
9
|
+
- ✅ Test coverage improvements (89.04% line coverage, 84.37% branch coverage)
|
|
10
10
|
- ✅ Playwright integration for CSS pseudo-element tests
|
|
11
|
-
- ✅
|
|
11
|
+
- ✅ 1121 total tests passing (1101 JSDOM + 20 Playwright)
|
|
12
|
+
- ✅ v2.2.0 release (issues #57, #62, #63, #64, #67, #68, #69, #70, #71, #72)
|