@afixt/test-utils 2.5.0 → 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afixt/test-utils",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Various utilities for accessibility testing",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -62,6 +62,11 @@ function getAccessibleText(el, options) {
62
62
  function collectSubtreeText(node, visibleOnly) {
63
63
  const parts = [];
64
64
 
65
+ // If the node itself has an open shadow root, traverse shadow children first
66
+ if (node.shadowRoot) {
67
+ parts.push(...collectSubtreeText(node.shadowRoot, visibleOnly));
68
+ }
69
+
65
70
  for (let child = node.firstChild; child; child = child.nextSibling) {
66
71
  if (child.nodeType === Node.TEXT_NODE) {
67
72
  const text = child.nodeValue.trim();
@@ -130,7 +135,7 @@ function collectSubtreeText(node, visibleOnly) {
130
135
  }
131
136
  }
132
137
 
133
- // Recurse into other element children
138
+ // Recurse into other element children (shadow roots handled at top of function)
134
139
  parts.push(...collectSubtreeText(child, visibleOnly));
135
140
  }
136
141
  }
@@ -111,8 +111,91 @@ function deepQuerySelectorAll(root, selector) {
111
111
  return results;
112
112
  }
113
113
 
114
+ /**
115
+ * Recursively extracts text content from an element, crossing open shadow
116
+ * DOM boundaries. For closed shadow roots the host element's light-DOM
117
+ * `textContent` is used (the shadow internals are inaccessible by spec).
118
+ *
119
+ * @param {Element} element - The element to extract text from.
120
+ * @returns {string} The concatenated, whitespace-normalised text.
121
+ */
122
+ function getDeepTextContent(element) {
123
+ if (!element) {
124
+ return '';
125
+ }
126
+
127
+ const parts = [];
128
+
129
+ /**
130
+ * Walk every child node, descending into open shadow roots.
131
+ * @param {Node} node - Current node in the walk.
132
+ */
133
+ function walk(node) {
134
+ // If this element has an open shadow root, walk its shadow children
135
+ // instead of (or in addition to) the light DOM children.
136
+ if (node.nodeType === Node.ELEMENT_NODE && node.shadowRoot) {
137
+ for (let sc = node.shadowRoot.firstChild; sc; sc = sc.nextSibling) {
138
+ walk(sc);
139
+ }
140
+ // Also walk slotted light-DOM children
141
+ for (let lc = node.firstChild; lc; lc = lc.nextSibling) {
142
+ walk(lc);
143
+ }
144
+ return;
145
+ }
146
+
147
+ if (node.nodeType === Node.TEXT_NODE) {
148
+ const text = node.nodeValue;
149
+ if (text && text.trim()) {
150
+ parts.push(text.trim());
151
+ }
152
+ return;
153
+ }
154
+
155
+ if (node.nodeType === Node.ELEMENT_NODE) {
156
+ for (let child = node.firstChild; child; child = child.nextSibling) {
157
+ walk(child);
158
+ }
159
+ }
160
+ }
161
+
162
+ walk(element);
163
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
164
+ }
165
+
166
+ /**
167
+ * Checks whether an element or any of its descendants is a custom element
168
+ * (has a hyphen in its tag name per the HTML spec).
169
+ *
170
+ * Useful for detecting elements that *may* contain shadow DOM content
171
+ * which is inaccessible via `textContent` or `querySelectorAll`.
172
+ *
173
+ * @param {Element} element - The element to inspect.
174
+ * @returns {boolean} True if the element itself or a descendant is a custom element.
175
+ */
176
+ function hasCustomElementDescendant(element) {
177
+ if (!element) {
178
+ return false;
179
+ }
180
+
181
+ if (element.tagName && element.tagName.indexOf('-') !== -1) {
182
+ return true;
183
+ }
184
+
185
+ const children = element.querySelectorAll ? element.querySelectorAll('*') : [];
186
+ for (let i = 0; i < children.length; i++) {
187
+ if (children[i].tagName && children[i].tagName.indexOf('-') !== -1) {
188
+ return true;
189
+ }
190
+ }
191
+
192
+ return false;
193
+ }
194
+
114
195
  module.exports = {
115
196
  deepGetElementById,
116
197
  deepQuerySelector,
117
198
  deepQuerySelectorAll,
199
+ getDeepTextContent,
200
+ hasCustomElementDescendant,
118
201
  };
@@ -167,6 +167,8 @@ const stringUtils = (function () {
167
167
 
168
168
  /**
169
169
  * Get the actual visible text content of an element, ignoring aria-label.
170
+ * Traverses open shadow DOM boundaries so that text rendered inside
171
+ * web components is included.
170
172
  * @param {Element} element - The DOM element
171
173
  * @returns {string} The visible text content
172
174
  */
@@ -174,7 +176,14 @@ const stringUtils = (function () {
174
176
  if (!element) {
175
177
  return '';
176
178
  }
177
- return (element.textContent || '').trim();
179
+ // Fast path: no shadow roots in subtree
180
+ const text = (element.textContent || '').trim();
181
+ if (text) {
182
+ return text;
183
+ }
184
+ // Slow path: element may contain custom elements with shadow DOM
185
+ const { getDeepTextContent } = require('./shadowDomUtils.js');
186
+ return getDeepTextContent(element);
178
187
  }
179
188
 
180
189
  /**
@@ -548,4 +548,52 @@ describe('getAccessibleText', () => {
548
548
  expect(result).toBe('Valid Text');
549
549
  });
550
550
  });
551
+
552
+ describe('shadow DOM traversal', () => {
553
+ it('should extract text from inside an open shadow root', () => {
554
+ const host = document.createElement('div');
555
+ document.body.appendChild(host);
556
+ const shadow = host.attachShadow({ mode: 'open' });
557
+ const inner = document.createElement('span');
558
+ inner.textContent = 'Shadow text';
559
+ shadow.appendChild(inner);
560
+
561
+ expect(getAccessibleText(host)).toBe('Shadow text');
562
+ });
563
+
564
+ it('should extract text from nested shadow roots', () => {
565
+ const host1 = document.createElement('div');
566
+ document.body.appendChild(host1);
567
+ const shadow1 = host1.attachShadow({ mode: 'open' });
568
+
569
+ const host2 = document.createElement('div');
570
+ shadow1.appendChild(host2);
571
+ const shadow2 = host2.attachShadow({ mode: 'open' });
572
+
573
+ const inner = document.createElement('span');
574
+ inner.textContent = 'Deeply nested text';
575
+ shadow2.appendChild(inner);
576
+
577
+ expect(getAccessibleText(host1)).toBe('Deeply nested text');
578
+ });
579
+
580
+ it('should prefer aria-label over shadow DOM text', () => {
581
+ const host = document.createElement('div');
582
+ host.setAttribute('aria-label', 'Accessible Label');
583
+ document.body.appendChild(host);
584
+ const shadow = host.attachShadow({ mode: 'open' });
585
+ const inner = document.createElement('span');
586
+ inner.textContent = 'Shadow text';
587
+ shadow.appendChild(inner);
588
+
589
+ expect(getAccessibleText(host)).toBe('Accessible Label');
590
+ });
591
+
592
+ it('should return empty string for custom element with no content and no shadow root', () => {
593
+ const el = document.createElement('my-widget');
594
+ document.body.appendChild(el);
595
+
596
+ expect(getAccessibleText(el)).toBe('');
597
+ });
598
+ });
551
599
  });
@@ -3,6 +3,8 @@ import {
3
3
  deepGetElementById,
4
4
  deepQuerySelector,
5
5
  deepQuerySelectorAll,
6
+ getDeepTextContent,
7
+ hasCustomElementDescendant,
6
8
  } from '../src/shadowDomUtils.js';
7
9
 
8
10
  describe('shadowDomUtils', () => {
@@ -245,4 +247,100 @@ describe('shadowDomUtils', () => {
245
247
  expect(results).toHaveLength(0);
246
248
  });
247
249
  });
250
+
251
+ // =========================================================================
252
+ // getDeepTextContent
253
+ // =========================================================================
254
+ describe('getDeepTextContent', () => {
255
+ it('should return empty string for null', () => {
256
+ expect(getDeepTextContent(null)).toBe('');
257
+ });
258
+
259
+ it('should return text from light DOM', () => {
260
+ document.body.innerHTML = '<div>Hello world</div>';
261
+ const el = document.querySelector('div');
262
+ expect(getDeepTextContent(el)).toBe('Hello world');
263
+ });
264
+
265
+ it('should return text from inside an open shadow root', () => {
266
+ const host = document.createElement('div');
267
+ document.body.appendChild(host);
268
+ const shadow = host.attachShadow({ mode: 'open' });
269
+ const inner = document.createElement('span');
270
+ inner.textContent = 'Shadow content';
271
+ shadow.appendChild(inner);
272
+
273
+ expect(getDeepTextContent(host)).toBe('Shadow content');
274
+ });
275
+
276
+ it('should return text from nested shadow roots', () => {
277
+ const host1 = document.createElement('div');
278
+ document.body.appendChild(host1);
279
+ const shadow1 = host1.attachShadow({ mode: 'open' });
280
+
281
+ const host2 = document.createElement('div');
282
+ shadow1.appendChild(host2);
283
+ const shadow2 = host2.attachShadow({ mode: 'open' });
284
+
285
+ const inner = document.createElement('span');
286
+ inner.textContent = 'Deeply nested';
287
+ shadow2.appendChild(inner);
288
+
289
+ expect(getDeepTextContent(host1)).toBe('Deeply nested');
290
+ });
291
+
292
+ it('should return empty string for element with no text', () => {
293
+ document.body.innerHTML = '<div><img src="test.png"></div>';
294
+ const el = document.querySelector('div');
295
+ expect(getDeepTextContent(el)).toBe('');
296
+ });
297
+
298
+ it('should combine text from shadow and light DOM', () => {
299
+ const host = document.createElement('div');
300
+ host.textContent = 'Light text';
301
+ document.body.appendChild(host);
302
+ const shadow = host.attachShadow({ mode: 'open' });
303
+ const inner = document.createElement('span');
304
+ inner.textContent = 'Shadow text';
305
+ shadow.appendChild(inner);
306
+
307
+ const result = getDeepTextContent(host);
308
+ expect(result).toContain('Shadow text');
309
+ expect(result).toContain('Light text');
310
+ });
311
+ });
312
+
313
+ // =========================================================================
314
+ // hasCustomElementDescendant
315
+ // =========================================================================
316
+ describe('hasCustomElementDescendant', () => {
317
+ it('should return false for null', () => {
318
+ expect(hasCustomElementDescendant(null)).toBe(false);
319
+ });
320
+
321
+ it('should return false for element with no custom elements', () => {
322
+ document.body.innerHTML = '<div><span>Text</span></div>';
323
+ const el = document.querySelector('div');
324
+ expect(hasCustomElementDescendant(el)).toBe(false);
325
+ });
326
+
327
+ it('should return true when the element itself is a custom element', () => {
328
+ const el = document.createElement('my-component');
329
+ document.body.appendChild(el);
330
+ expect(hasCustomElementDescendant(el)).toBe(true);
331
+ });
332
+
333
+ it('should return true when a descendant is a custom element', () => {
334
+ document.body.innerHTML = '<div><store-selector-addon></store-selector-addon></div>';
335
+ const el = document.querySelector('div');
336
+ expect(hasCustomElementDescendant(el)).toBe(true);
337
+ });
338
+
339
+ it('should return false for elements with hyphens in attributes but not tag names', () => {
340
+ document.body.innerHTML =
341
+ '<div data-my-attr="value"><span class="my-class">Text</span></div>';
342
+ const el = document.querySelector('div');
343
+ expect(hasCustomElementDescendant(el)).toBe(false);
344
+ });
345
+ });
248
346
  });