@afixt/test-utils 2.1.1 → 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.
@@ -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.1.1",
3
+ "version": "2.3.0",
4
4
  "description": "Various utilities for accessibility testing",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/constants.js CHANGED
@@ -404,6 +404,7 @@ const NON_VISIBLE_SELECTORS = [
404
404
  'param',
405
405
  'noframes',
406
406
  'ruby > rp',
407
+ 'template',
407
408
  ];
408
409
 
409
410
  /**
package/src/domUtils.js CHANGED
@@ -6,6 +6,18 @@ const {
6
6
  SEMANTIC_CONTAINER_ROLES,
7
7
  INTERACTIVE_HANDLER_ATTRIBUTES,
8
8
  } = require('./constants.js');
9
+ const { deepQuerySelector, deepQuerySelectorAll } = require('./shadowDomUtils.js');
10
+
11
+ /**
12
+ * Escapes a string for use inside a CSS selector.
13
+ * Uses the native CSS.escape when available (all modern browsers),
14
+ * falls back to identity for environments that lack it (e.g. JSDOM).
15
+ *
16
+ * @param {string} value
17
+ * @returns {string}
18
+ */
19
+ const cssEscape =
20
+ typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape : value => value;
9
21
 
10
22
  const domUtils = {
11
23
  /**
@@ -98,7 +110,7 @@ const domUtils = {
98
110
  * @returns {Element[]} An array of elements that have duplicate IDs.
99
111
  */
100
112
  getElementsWithDuplicateIds() {
101
- const nodes = document.querySelectorAll('[id]');
113
+ const nodes = deepQuerySelectorAll(document, '[id]');
102
114
  const ids = {};
103
115
  const duplicates = [];
104
116
  nodes.forEach(node => {
@@ -108,7 +120,7 @@ const domUtils = {
108
120
  duplicates.push(id);
109
121
  }
110
122
  });
111
- return duplicates.map(id => document.querySelector(`#${id}`));
123
+ return duplicates.map(id => deepQuerySelector(document, `#${cssEscape(id)}`));
112
124
  },
113
125
 
114
126
  /**
@@ -162,10 +174,10 @@ const domUtils = {
162
174
  }
163
175
 
164
176
  // CSS-escape the ID for use in attribute selectors
165
- const escaped = CSS.escape(id);
177
+ const escaped = cssEscape(id);
166
178
 
167
179
  // Direct attribute references
168
- if (document.querySelector('[for="' + escaped + '"]')) {
180
+ if (deepQuerySelector(document, '[for="' + escaped + '"]')) {
169
181
  return true;
170
182
  }
171
183
 
@@ -178,7 +190,7 @@ const domUtils = {
178
190
  'aria-flowto',
179
191
  ];
180
192
  for (const attr of ariaTokenAttrs) {
181
- const elements = document.querySelectorAll('[' + attr + ']');
193
+ const elements = deepQuerySelectorAll(document, '[' + attr + ']');
182
194
  for (const el of elements) {
183
195
  const ids = el.getAttribute(attr).trim().split(/\s+/);
184
196
  if (ids.includes(id)) {
@@ -190,18 +202,18 @@ const domUtils = {
190
202
  // Single-ID ARIA references
191
203
  const ariaSingleAttrs = ['aria-activedescendant', 'aria-errormessage'];
192
204
  for (const attr of ariaSingleAttrs) {
193
- if (document.querySelector('[' + attr + '="' + escaped + '"]')) {
205
+ if (deepQuerySelector(document, '[' + attr + '="' + escaped + '"]')) {
194
206
  return true;
195
207
  }
196
208
  }
197
209
 
198
210
  // Fragment references in href
199
- if (document.querySelector('a[href="#' + escaped + '"]')) {
211
+ if (deepQuerySelector(document, 'a[href="#' + escaped + '"]')) {
200
212
  return true;
201
213
  }
202
214
 
203
215
  // Table headers attribute (space-separated list of IDs)
204
- const headerElements = document.querySelectorAll('[headers]');
216
+ const headerElements = deepQuerySelectorAll(document, '[headers]');
205
217
  for (const el of headerElements) {
206
218
  const ids = el.getAttribute('headers').trim().split(/\s+/);
207
219
  if (ids.includes(id)) {
@@ -210,7 +222,7 @@ const domUtils = {
210
222
  }
211
223
 
212
224
  // list attribute on input elements
213
- if (document.querySelector('input[list="' + escaped + '"]')) {
225
+ if (deepQuerySelector(document, 'input[list="' + escaped + '"]')) {
214
226
  return true;
215
227
  }
216
228
 
@@ -226,10 +238,31 @@ const domUtils = {
226
238
  if (!element) {
227
239
  return false;
228
240
  }
229
- return (
241
+
242
+ // Check aria-hidden (self or ancestor)
243
+ if (
230
244
  element.getAttribute('aria-hidden') === 'true' ||
231
245
  (element.closest && !!element.closest('[aria-hidden="true"]'))
232
- );
246
+ ) {
247
+ return true;
248
+ }
249
+
250
+ // Check inert (self or ancestor) — inert elements are removed from the a11y tree
251
+ let el = element;
252
+ while (el) {
253
+ if (el.hasAttribute && el.hasAttribute('inert')) {
254
+ return true;
255
+ }
256
+ el = el.parentElement;
257
+ }
258
+
259
+ // Check content-visibility: hidden — genuinely hides content from AT
260
+ const style = window.getComputedStyle(element);
261
+ if (style.contentVisibility === 'hidden') {
262
+ return true;
263
+ }
264
+
265
+ return false;
233
266
  },
234
267
 
235
268
  /**
@@ -397,3 +430,4 @@ const domUtils = {
397
430
  };
398
431
 
399
432
  module.exports = domUtils;
433
+ module.exports.cssEscape = cssEscape;
package/src/formUtils.js CHANGED
@@ -3,6 +3,9 @@
3
3
  * @module formUtils
4
4
  */
5
5
 
6
+ const { isHiddenFromAT } = require('./domUtils.js');
7
+ const { deepGetElementById } = require('./shadowDomUtils.js');
8
+
6
9
  const formUtils = {
7
10
  /**
8
11
  * Check if an element is a labellable form control per HTML spec.
@@ -49,8 +52,8 @@ const formUtils = {
49
52
  if (element.hasAttribute('aria-labelledby')) {
50
53
  const ids = element.getAttribute('aria-labelledby').trim().split(/\s+/);
51
54
  for (let i = 0; i < ids.length; i++) {
52
- const labelEl = document.getElementById(ids[i]);
53
- if (labelEl && labelEl.textContent.trim()) {
55
+ const labelEl = deepGetElementById(document, ids[i]);
56
+ if (labelEl && !isHiddenFromAT(labelEl) && labelEl.textContent.trim()) {
54
57
  return true;
55
58
  }
56
59
  }
@@ -1,4 +1,4 @@
1
- const { isEmpty } = require('./stringUtils.js');
1
+ const { isEmptyOrWhitespace } = require('./stringUtils.js');
2
2
  const { getAccessibleText } = require('./getAccessibleText.js');
3
3
  const {
4
4
  TEXT_ROLES,
@@ -6,6 +6,9 @@ const {
6
6
  LANDMARK_ROLES,
7
7
  LANDMARK_ELEMENT_MAP,
8
8
  } = require('./constants.js');
9
+ const { cssEscape } = require('./domUtils.js');
10
+ const { isA11yVisible } = require('./isA11yVisible.js');
11
+ const { deepGetElementById, deepQuerySelector } = require('./shadowDomUtils.js');
9
12
 
10
13
  /**
11
14
  * Gets the accessible name of an element according to the accessible name calculation algorithm
@@ -26,9 +29,21 @@ function getAccessibleName(element) {
26
29
  // the title won't be used in any meaningful way by Accessibility APIs
27
30
  const unlabellable = 'head *, hr, param, caption, colgroup, col, tbody, tfoot, thead, tr';
28
31
 
29
- // STEP 0 - verify item is visible and can be labelled
30
- // if it isn't visible or can't be labelled then just bail
31
- if (isNotVisible(element) || matchesSelector(element, unlabellable)) {
32
+ if (matchesSelector(element, unlabellable)) {
33
+ return false;
34
+ }
35
+
36
+ // Skip inherently non-visible semantic elements (script, style, template, etc.)
37
+ // but exclude 'area' since area elements have accessible names via alt attribute
38
+ const nonVisibleSelectorsWithoutArea = NON_VISIBLE_SELECTORS.filter(s => s !== 'area');
39
+ if (nonVisibleSelectorsWithoutArea.some(selector => matchesSelector(element, selector))) {
40
+ return false;
41
+ }
42
+
43
+ // STEP 0 - Use isA11yVisible (strict mode) for visibility check.
44
+ // This respects aria-labelledby/describedby references, preventing false
45
+ // negatives for display:none elements that provide names to other elements.
46
+ if (!isA11yVisible(element, true)) {
32
47
  return false;
33
48
  }
34
49
 
@@ -44,7 +59,7 @@ function getAccessibleName(element) {
44
59
 
45
60
  const text = [];
46
61
  for (const id of ids) {
47
- const labelElement = document.getElementById(id);
62
+ const labelElement = deepGetElementById(document, id);
48
63
  if (!labelElement || !getAccessibleText(labelElement)) {
49
64
  return false;
50
65
  }
@@ -58,7 +73,7 @@ function getAccessibleName(element) {
58
73
  // STEP 2.1 - if aria-label exists, return the text in it
59
74
  if (element.hasAttribute('aria-label')) {
60
75
  const ariaLabel = element.getAttribute('aria-label');
61
- if (ariaLabel) {
76
+ if (!isEmptyOrWhitespace(ariaLabel)) {
62
77
  return ariaLabel;
63
78
  }
64
79
  // there is no 'else' here because an empty aria-label is/ should be ignored and calculation continued
@@ -75,7 +90,7 @@ function getAccessibleName(element) {
75
90
  // Use getAccessibleText which handles both text nodes and
76
91
  // image alt text in the subtree (not just textContent)
77
92
  const text = getAccessibleText(element);
78
- if (!isEmpty(text)) {
93
+ if (!isEmptyOrWhitespace(text)) {
79
94
  return text;
80
95
  }
81
96
  }
@@ -95,12 +110,15 @@ function getAccessibleName(element) {
95
110
  )
96
111
  ) {
97
112
  // first we choose the explicit relationship over all others.
98
- if (element.id && document.querySelector('label[for="' + element.id + '"]')) {
113
+ if (
114
+ element.id &&
115
+ deepQuerySelector(document, 'label[for="' + cssEscape(element.id) + '"]')
116
+ ) {
99
117
  id = element.id;
100
118
  // Use only the *first* label that matches this ID.
101
119
  // Sometimes JS libraries screw this up by hiding one of the
102
120
  // labels or misnaming one
103
- label = document.querySelector('label[for="' + id + '"]');
121
+ label = deepQuerySelector(document, 'label[for="' + cssEscape(id) + '"]');
104
122
  if (label) {
105
123
  return getAccessibleText(label);
106
124
  }
@@ -210,11 +228,14 @@ function getAccessibleName(element) {
210
228
  )
211
229
  ) {
212
230
  // first we choose the explicit relationship over all others.
213
- if (element.id && document.querySelector('label[for="' + element.id + '"]')) {
231
+ if (
232
+ element.id &&
233
+ deepQuerySelector(document, 'label[for="' + cssEscape(element.id) + '"]')
234
+ ) {
214
235
  id = element.id;
215
236
 
216
237
  //Use only the *first* label that matches this ID. Sometimes ppl screw this up
217
- label = document.querySelector('label[for="' + id + '"]');
238
+ label = deepQuerySelector(document, 'label[for="' + cssEscape(id) + '"]');
218
239
  if (label) {
219
240
  return getAccessibleText(label);
220
241
  }
@@ -359,7 +380,10 @@ function getAccessibleName(element) {
359
380
  if (element.tagName.toLowerCase() === 'meter') {
360
381
  // Check for label with for attribute
361
382
  if (element.id) {
362
- const label = document.querySelector('label[for="' + element.id + '"]');
383
+ const label = deepQuerySelector(
384
+ document,
385
+ 'label[for="' + cssEscape(element.id) + '"]'
386
+ );
363
387
  if (label && strlen(getAccessibleText(label)) > 0) {
364
388
  return getAccessibleText(label);
365
389
  }
@@ -474,44 +498,6 @@ function getAccessibleName(element) {
474
498
  }
475
499
  }
476
500
 
477
- /**
478
- * Helper function to check if element is NOT visible
479
- * @param {Element} element - The element to check
480
- * @returns {boolean} True if element is not visible, false otherwise
481
- */
482
- function isNotVisible(element) {
483
- // Importing isVisible would be better, but for this standalone function we'll check it this way
484
- if (!element) {
485
- return true;
486
- }
487
-
488
- // Note: 'area' is filtered out because area elements DO have accessible names
489
- // via the alt attribute and should participate in accessible name calculation
490
- const nonVisibleSelectorsWithoutArea = NON_VISIBLE_SELECTORS.filter(s => s !== 'area');
491
-
492
- if (nonVisibleSelectorsWithoutArea.some(selector => matchesSelector(element, selector))) {
493
- return true; // Not visible in accessibility tree
494
- }
495
-
496
- // Check if display is none
497
- const isElemDisplayed = el => window.getComputedStyle(el).display === 'none';
498
-
499
- if (isElemDisplayed(element)) {
500
- return true;
501
- }
502
-
503
- // Check parent elements
504
- let parent = element.parentElement;
505
- while (parent) {
506
- if (isElemDisplayed(parent)) {
507
- return true;
508
- }
509
- parent = parent.parentElement;
510
- }
511
-
512
- return element.getAttribute('aria-hidden') === 'true';
513
- }
514
-
515
501
  /**
516
502
  * Helper function to check if an element matches a selector
517
503
  * @param {Element} element - Element to check
@@ -541,7 +527,7 @@ function matchesSelector(element, selector) {
541
527
  * @returns {number} The string length or 0
542
528
  */
543
529
  function strlen(str) {
544
- return typeof str === 'string' && !isEmpty(str.trim()) ? str.trim().length : 0;
530
+ return typeof str === 'string' && !isEmptyOrWhitespace(str) ? str.trim().length : 0;
545
531
  }
546
532
 
547
533
  // Export the function for CommonJS module usage
@@ -1,4 +1,4 @@
1
- const { isEmpty } = require('./stringUtils.js');
1
+ const { isEmptyOrWhitespace } = require('./stringUtils.js');
2
2
 
3
3
  /**
4
4
  * Get all accessible text for an element, including aria-labels and content from children.
@@ -27,7 +27,7 @@ function getAccessibleText(el, options) {
27
27
  // Check for aria-label first (highest priority)
28
28
  if (el.hasAttribute('aria-label')) {
29
29
  const ariaLabel = el.getAttribute('aria-label').trim();
30
- if (ariaLabel) {
30
+ if (!isEmptyOrWhitespace(ariaLabel)) {
31
31
  return ariaLabel;
32
32
  }
33
33
  }
@@ -65,7 +65,7 @@ function collectSubtreeText(node, visibleOnly) {
65
65
  for (let child = node.firstChild; child; child = child.nextSibling) {
66
66
  if (child.nodeType === Node.TEXT_NODE) {
67
67
  const text = child.nodeValue.trim();
68
- if (!isEmpty(text)) {
68
+ if (!isEmptyOrWhitespace(text)) {
69
69
  parts.push(text);
70
70
  }
71
71
  } else if (child.nodeType === Node.ELEMENT_NODE) {