@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.
@@ -18,8 +18,7 @@
18
18
  "Bash(node:*)",
19
19
  "Bash(npx vitest run:*)",
20
20
  "Bash(1)",
21
- "Bash(npm run test:playwright:css:*)",
22
- "Bash(gh issue:*)"
21
+ "Bash(npm run test:playwright:css:*)"
23
22
  ]
24
23
  },
25
24
  "enableAllProjectMcpServers": false
@@ -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
- - **943 tests passing** in JSDOM environment (8 conditionally skipped)
9
+ - **1101 tests passing** in JSDOM environment (8 conditionally skipped)
10
10
  - **20 tests passing** in Playwright for CSS pseudo-element support
11
- - **Total: 963 tests passing** across both environments
11
+ - **Total: 1121 tests passing** across both environments
12
12
 
13
13
  ### Implementation Complete
14
14
 
15
- ✅ JSDOM tests for all standard functionality (943 tests)
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 943 JSDOM tests
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:** 943 tests passing (8 conditionally skipped)
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 (943 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 (963 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
- - **87.75% statement coverage**
126
- - **82.11% branch coverage**
127
- - **95.58% function coverage**
128
- - **87.69% line coverage**
129
- - **963 total tests passing** (943 JSDOM + 20 Playwright)
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
- - **1076 tests passing** in JSDOM environment
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afixt/test-utils",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Various utilities for accessibility testing",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
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.querySelectorAll('[id]');
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.querySelector(`#${cssEscape(id)}`));
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.querySelector('[for="' + escaped + '"]')) {
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.querySelectorAll('[' + attr + ']');
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.querySelector('[' + attr + '="' + escaped + '"]')) {
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.querySelector('a[href="#' + escaped + '"]')) {
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.querySelectorAll('[headers]');
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.querySelector('input[list="' + escaped + '"]')) {
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.getElementById(ids[i]);
55
+ const labelEl = deepGetElementById(document, ids[i]);
55
56
  if (labelEl && !isHiddenFromAT(labelEl) && labelEl.textContent.trim()) {
56
57
  return true;
57
58
  }
@@ -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.getElementById(id);
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 (element.id && document.querySelector('label[for="' + cssEscape(element.id) + '"]')) {
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.querySelector('label[for="' + cssEscape(id) + '"]');
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 (element.id && document.querySelector('label[for="' + cssEscape(element.id) + '"]')) {
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.querySelector('label[for="' + cssEscape(id) + '"]');
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 = document.querySelector('label[for="' + cssEscape(element.id) + '"]');
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
  }
@@ -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, { logger, errorHandler: () => {}, ...tesseractOptions });
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
  };
@@ -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
- document
75
- .querySelectorAll(`*[aria-labelledby~="${escapedId}"], *[aria-describedby~="${escapedId}"]`)
76
- .forEach(referencingElement => {
77
- if (window.getComputedStyle(referencingElement).display !== 'none') {
78
- visible = true;
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
+ };
@@ -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
  });
@@ -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 (87.69% line coverage, 82.11% branch coverage)
9
+ - ✅ Test coverage improvements (89.04% line coverage, 84.37% branch coverage)
10
10
  - ✅ Playwright integration for CSS pseudo-element tests
11
- - ✅ 963 total tests passing (943 JSDOM + 20 Playwright)
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)