@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 +1 -1
- package/src/getAccessibleText.js +6 -1
- package/src/shadowDomUtils.js +83 -0
- package/src/stringUtils.js +10 -1
- package/test/getAccessibleText.test.js +48 -0
- package/test/shadowDomUtils.test.js +98 -0
package/package.json
CHANGED
package/src/getAccessibleText.js
CHANGED
|
@@ -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
|
}
|
package/src/shadowDomUtils.js
CHANGED
|
@@ -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
|
};
|
package/src/stringUtils.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|