Skip to content

Commit 43d9b05

Browse files
Added getElementByDataTestIdDeep utility function for tests to find shadow dom elements (#172)
* Add getElementByDataTestIdDeep utility function for enhanced testing capabilities * Update CHANGELOG to reflect the addition of getElementByDataTestIdDeep utility function for tests * Refactor getElementByDataTestIdDeep to utilize walkShadowDom for improved shadow DOM traversal; add tests for walkShadowDom functionality * Refactor shadow DOM traversal tests by removing unnecessary comments and improving clarity; streamline walkShadowDom function for better readability * Refactor tests for getElementByDataTestIdDeep by removing redundant comments and enhancing code clarity; ensure consistent formatting in test cases. --------- Co-authored-by: Tudor Morar <tudor.morar@multiversx.com>
1 parent cb10d08 commit 43d9b05

7 files changed

+316
-1
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
## [[0.0.19](https://github.com/multiversx/mx-sdk-dapp-ui/pull/173)] - 2025-07-23
10+
- [Added getElementByDataTestIdDeep utility function for tests](https://github.com/multiversx/mx-sdk-dapp-ui/pull/172)
11+
12+
## [[0.0.19](https://github.com/multiversx/mx-sdk-dapp-ui/pull/172)] - 2025-07-23
1113

1214
- [Updated data-theme](https://github.com/multiversx/mx-sdk-dapp-ui/pull/171)
1315

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createDataTestIdSelector, walkShadowDom } from './shadowDomTraversal';
2+
3+
/**
4+
* Recursively searches the document and all shadow roots for the first element
5+
* matching the given data-testid using Jest/JSDOM.
6+
*
7+
* @param {string} dataTestId - The value of the data-testid attribute to search for.
8+
* @param {Document|ShadowRoot} root - The root to search from (defaults to document).
9+
* @returns {Element|null} - The found element, or null.
10+
*/
11+
export function getElementByDataTestIdDeep(dataTestId: string, root: Document | ShadowRoot = document): Element | null {
12+
return walkShadowDom(root, createDataTestIdSelector(dataTestId));
13+
}
14+
15+
/**
16+
* Jest/Testing Library compatible version that works with screen queries
17+
* @param {string} dataTestId - The value of the data-testid attribute to search for.
18+
* @returns {Element|null} - The found element, or null.
19+
*/
20+
export function queryByDataTestIdDeep(dataTestId: string): Element | null {
21+
return getElementByDataTestIdDeep(dataTestId);
22+
}
23+
24+
/**
25+
* Jest/Testing Library compatible version that throws if element not found
26+
* @param {string} dataTestId - The value of the data-testid attribute to search for.
27+
* @returns {Element} - The found element.
28+
* @throws {Error} If element is not found.
29+
*/
30+
export function getByDataTestIdDeep(dataTestId: string): Element {
31+
const element = getElementByDataTestIdDeep(dataTestId);
32+
if (!element) {
33+
throw new Error(`Unable to find element with data-testid: ${dataTestId}`);
34+
}
35+
return element;
36+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { createDataTestIdSelector, walkShadowDom } from './shadowDomTraversal';
2+
3+
/**
4+
* Recursively searches the document and all shadow roots for the first element
5+
* matching the given data-testid using Playwright.
6+
*
7+
* @param {import('@playwright/test').Page} page - The Playwright Page instance.
8+
* @param {string} testId - The value of the data-testid attribute to search for.
9+
* @returns {Promise<import('@playwright/test').Locator|null>} - Locator for the found element, or null.
10+
*/
11+
export async function getElementByDataTestIdDeep(page: any, dataTestId: string) {
12+
const element = await page.evaluate(
13+
(testId: string) => walkShadowDom(document, createDataTestIdSelector(testId)),
14+
dataTestId,
15+
);
16+
17+
if (!element) {
18+
return null;
19+
}
20+
21+
return page.locator(createDataTestIdSelector(dataTestId)).first();
22+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createDataTestIdSelector, walkShadowDom } from './shadowDomTraversal';
2+
3+
/**
4+
* Recursively searches the document and all shadow roots for the first element
5+
* matching the given data-testid.
6+
*
7+
* @param {import('puppeteer').Page} page - The Puppeteer Page instance.
8+
* @param {string} dataTestId - The value of the data-testid attribute to search for.
9+
* @returns {Promise<import('puppeteer').ElementHandle|null>} - Handle to the found element, or null.
10+
*/
11+
export async function getElementByDataTestIdDeep(page: any, dataTestId: string) {
12+
const handle = await page.evaluateHandle(
13+
(testId: string) => walkShadowDom(document, createDataTestIdSelector(testId)),
14+
dataTestId,
15+
);
16+
17+
const element = handle.asElement();
18+
19+
if (!element) {
20+
await handle.dispose();
21+
return null;
22+
}
23+
24+
return element;
25+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {
2+
getByDataTestIdDeep,
3+
getElementByDataTestIdDeep,
4+
queryByDataTestIdDeep,
5+
} from './getElementByDataTestIdDeep.jest';
6+
7+
describe('getElementByDataTestIdDeep', () => {
8+
let mockElement: any;
9+
let mockShadowRoot: any;
10+
let mockDocument: any;
11+
12+
beforeEach(() => {
13+
mockElement = {
14+
querySelector: jest.fn(),
15+
querySelectorAll: jest.fn(),
16+
shadowRoot: null,
17+
};
18+
19+
mockShadowRoot = {
20+
querySelector: jest.fn(),
21+
querySelectorAll: jest.fn(),
22+
};
23+
24+
mockDocument = {
25+
querySelector: jest.fn(),
26+
querySelectorAll: jest.fn(),
27+
};
28+
29+
global.document = mockDocument as any;
30+
});
31+
32+
afterEach(() => {
33+
jest.clearAllMocks();
34+
});
35+
36+
it('should find element in document root', () => {
37+
const testElement = { id: 'test-element' };
38+
mockDocument.querySelector.mockReturnValue(testElement);
39+
mockDocument.querySelectorAll.mockReturnValue([]);
40+
41+
const result = getElementByDataTestIdDeep('test-id');
42+
43+
expect(mockDocument.querySelector).toHaveBeenCalledWith('[data-testid="test-id"]');
44+
expect(result).toBe(testElement);
45+
});
46+
47+
it('should find element in shadow root', () => {
48+
const testElement = { id: 'shadow-element' };
49+
const elementWithShadow = { ...mockElement, shadowRoot: mockShadowRoot };
50+
51+
mockDocument.querySelector.mockReturnValue(null);
52+
mockDocument.querySelectorAll.mockReturnValue([elementWithShadow]);
53+
54+
mockShadowRoot.querySelector.mockReturnValue(testElement);
55+
mockShadowRoot.querySelectorAll.mockReturnValue([]);
56+
57+
const result = getElementByDataTestIdDeep('test-id');
58+
59+
expect(result).toBe(testElement);
60+
expect(mockShadowRoot.querySelector).toHaveBeenCalledWith('[data-testid="test-id"]');
61+
});
62+
63+
it('should return null when element not found', () => {
64+
mockDocument.querySelector.mockReturnValue(null);
65+
mockDocument.querySelectorAll.mockReturnValue([]);
66+
67+
const result = getElementByDataTestIdDeep('non-existent');
68+
69+
expect(result).toBeNull();
70+
});
71+
72+
it('queryByDataTestIdDeep should return null when element not found', () => {
73+
mockDocument.querySelector.mockReturnValue(null);
74+
mockDocument.querySelectorAll.mockReturnValue([]);
75+
76+
const result = queryByDataTestIdDeep('non-existent');
77+
78+
expect(result).toBeNull();
79+
});
80+
81+
it('getByDataTestIdDeep should throw when element not found', () => {
82+
mockDocument.querySelector.mockReturnValue(null);
83+
mockDocument.querySelectorAll.mockReturnValue([]);
84+
85+
expect(() => getByDataTestIdDeep('non-existent')).toThrow('Unable to find element with data-testid: non-existent');
86+
});
87+
88+
it('getByDataTestIdDeep should return element when found', () => {
89+
const testElement = { id: 'test-element' };
90+
mockDocument.querySelector.mockReturnValue(testElement);
91+
mockDocument.querySelectorAll.mockReturnValue([]);
92+
93+
const result = getByDataTestIdDeep('test-id');
94+
95+
expect(result).toBe(testElement);
96+
});
97+
});
98+
99+
describe('getElementByDataTestIdDeep usage examples', () => {
100+
it('should demonstrate typical usage patterns', () => {
101+
const mockRoot = {
102+
querySelector: jest.fn().mockReturnValue({ id: 'found-element' }),
103+
querySelectorAll: jest.fn().mockReturnValue([]),
104+
};
105+
106+
const result = getElementByDataTestIdDeep('test-id', mockRoot as any);
107+
108+
expect(result).toEqual({ id: 'found-element' });
109+
expect(mockRoot.querySelector).toHaveBeenCalledWith('[data-testid="test-id"]');
110+
});
111+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { walkShadowDom } from './shadowDomTraversal';
2+
3+
describe('walkShadowDom', () => {
4+
let root: Document;
5+
beforeEach(() => {
6+
document.body.innerHTML = '';
7+
root = document;
8+
});
9+
10+
it('finds an element in the light DOM', () => {
11+
const div = document.createElement('div');
12+
div.setAttribute('data-testid', 'foo');
13+
document.body.appendChild(div);
14+
expect(walkShadowDom(root, '[data-testid="foo"]')).toBe(div);
15+
});
16+
17+
it('finds an element in a simulated shadow DOM', () => {
18+
const host = document.createElement('div');
19+
const shadowRoot = document.createElement('div');
20+
const shadowChild = document.createElement('span');
21+
shadowChild.setAttribute('data-testid', 'bar');
22+
shadowRoot.appendChild(shadowChild);
23+
(host as any).shadowRoot = shadowRoot;
24+
document.body.appendChild(host);
25+
const origQuerySelectorAll = document.querySelectorAll;
26+
document.querySelectorAll = function (sel) {
27+
if (sel === '*') {
28+
return [host] as any;
29+
}
30+
31+
return origQuerySelectorAll.call(this, sel);
32+
};
33+
34+
expect(walkShadowDom(document, '[data-testid="bar"]')).toBe(shadowChild);
35+
document.querySelectorAll = origQuerySelectorAll;
36+
});
37+
38+
it('throws if root is missing', () => {
39+
expect(() => walkShadowDom(undefined as any, '[data-testid="foo"]')).toThrow('walkShadowDom: root is required');
40+
});
41+
42+
it('throws if selector is missing', () => {
43+
expect(() => walkShadowDom(document, undefined as any)).toThrow(
44+
'walkShadowDom: selector must be a non-empty string',
45+
);
46+
});
47+
48+
it('throws if selector is not a string', () => {
49+
expect(() => walkShadowDom(document, 123 as any)).toThrow('walkShadowDom: selector must be a non-empty string');
50+
});
51+
52+
it('throws a clear error for invalid CSS selector', () => {
53+
expect(() => walkShadowDom(document, '!!!invalid')).toThrow(/Invalid CSS selector/);
54+
});
55+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Core shadow DOM traversal logic that can be reused across different testing frameworks.
3+
* Note: Requires browser support for Shadow DOM APIs (customElements, shadowRoot, etc.).
4+
*/
5+
6+
/**
7+
* Walks a root node (Document or ShadowRoot) depth-first to find elements.
8+
* @param {Document | ShadowRoot} root - The root node to start searching from
9+
* @param {string} selector - CSS selector to search for
10+
* @returns {Element | null} - The found element, or null
11+
*/
12+
export function walkShadowDom(root: Document | ShadowRoot, selector: string): Element | null {
13+
if (!root) {
14+
throw new Error('walkShadowDom: root is required');
15+
}
16+
17+
if (!selector || typeof selector !== 'string') {
18+
throw new Error('walkShadowDom: selector must be a non-empty string');
19+
}
20+
21+
let found: Element | null = null;
22+
23+
try {
24+
found = root.querySelector(selector);
25+
} catch (err: any) {
26+
if (
27+
(typeof DOMException !== 'undefined' && err instanceof DOMException && err.name === 'SyntaxError') ||
28+
(err && typeof err.message === 'string' && /syntax error|unrecognized expression/i.test(err.message))
29+
) {
30+
throw new Error(`walkShadowDom: Invalid CSS selector: '${selector}'`);
31+
}
32+
throw err;
33+
}
34+
35+
if (found) {
36+
return found;
37+
}
38+
39+
const all: Element[] = Array.from(root.querySelectorAll('*'));
40+
41+
for (const el of all) {
42+
if (el.shadowRoot) {
43+
const inShadow = walkShadowDom(el.shadowRoot, selector);
44+
if (inShadow) {
45+
return inShadow;
46+
}
47+
}
48+
}
49+
return null;
50+
}
51+
52+
/**
53+
* Helper function to create a data-testid selector
54+
* @param {string} testId - The data-testid value
55+
* @returns {string} - CSS selector for the data-testid
56+
*/
57+
export function createDataTestIdSelector(testId: string): string {
58+
const escape =
59+
window.CSS && typeof window.CSS.escape === 'function'
60+
? window.CSS.escape
61+
: (s: string) => s.replace(/"/g, '"').replace(/'/g, "\\'");
62+
63+
return `[data-testid="${escape(testId)}"]`;
64+
}

0 commit comments

Comments
 (0)