Skip to content

Commit 13d5d41

Browse files
feat(testing): support deep piercing with Puppeteer (#5481)
* feat(testing): support deep piercing with Puppeteer * minor clean ups * add e2e tests * rely on puppeteer implementation * revert stencil config * prettier
1 parent 404d2ba commit 13d5d41

File tree

13 files changed

+217
-86
lines changed

13 files changed

+217
-86
lines changed

src/testing/puppeteer/puppeteer-element.ts

+26-74
Original file line numberDiff line numberDiff line change
@@ -543,12 +543,19 @@ export class E2EElement extends MockHTMLElement implements pd.E2EElementInternal
543543
}
544544

545545
export async function find(page: pd.E2EPageInternal, rootHandle: puppeteer.ElementHandle, selector: pd.FindSelector) {
546-
const { lightSelector, shadowSelector, text, contains } = getSelector(selector);
546+
const { lightSelector, text, contains } = getSelector(selector);
547547

548548
let elmHandle: puppeteer.ElementHandle;
549549

550+
if (typeof selector === 'string' && selector.includes('>>>')) {
551+
const handle = await page.$(selector);
552+
const elm = new E2EElement(page, handle);
553+
await elm.e2eSync();
554+
return elm;
555+
}
556+
550557
if (typeof lightSelector === 'string') {
551-
elmHandle = await findWithCssSelector(page, rootHandle, lightSelector, shadowSelector);
558+
elmHandle = await findWithCssSelector(rootHandle, lightSelector);
552559
} else {
553560
elmHandle = await findWithText(page, rootHandle, text, contains);
554561
}
@@ -562,40 +569,13 @@ export async function find(page: pd.E2EPageInternal, rootHandle: puppeteer.Eleme
562569
return elm;
563570
}
564571

565-
async function findWithCssSelector(
566-
page: pd.E2EPageInternal,
567-
rootHandle: puppeteer.ElementHandle,
568-
lightSelector: string,
569-
shadowSelector: string,
570-
) {
571-
let elmHandle = await rootHandle.$(lightSelector);
572+
async function findWithCssSelector(rootHandle: puppeteer.ElementHandle, lightSelector: string) {
573+
const elmHandle = await rootHandle.$(lightSelector);
572574

573575
if (!elmHandle) {
574576
return null;
575577
}
576578

577-
if (shadowSelector) {
578-
const shadowHandle = await page.evaluateHandle(
579-
(elm: Element, shadowSelector: string) => {
580-
if (!elm.shadowRoot) {
581-
throw new Error(`shadow root does not exist for element: ${elm.tagName.toLowerCase()}`);
582-
}
583-
584-
return elm.shadowRoot.querySelector(shadowSelector);
585-
},
586-
elmHandle,
587-
shadowSelector,
588-
);
589-
590-
await elmHandle.dispose();
591-
592-
if (!shadowHandle) {
593-
return null;
594-
}
595-
596-
elmHandle = shadowHandle.asElement() as puppeteer.ElementHandle<Element>;
597-
}
598-
599579
return elmHandle;
600580
}
601581

@@ -659,50 +639,26 @@ export async function findAll(
659639
) {
660640
const foundElms: E2EElement[] = [];
661641

662-
const { lightSelector, shadowSelector } = getSelector(selector);
642+
if (typeof selector === 'string' && selector.includes('>>>')) {
643+
const handles = await page.$$(selector);
644+
for (let i = 0; i < handles.length; i++) {
645+
const elm = new E2EElement(page, handles[i]);
646+
await elm.e2eSync();
647+
foundElms.push(elm);
648+
}
649+
return foundElms;
650+
}
663651

652+
const { lightSelector } = getSelector(selector);
664653
const lightElmHandles = await rootHandle.$$(lightSelector);
665654
if (lightElmHandles.length === 0) {
666655
return foundElms;
667656
}
668657

669-
if (shadowSelector) {
670-
// light dom selected, then shadow dom selected inside of light dom elements
671-
for (let i = 0; i < lightElmHandles.length; i++) {
672-
const executionContext = getPuppeteerExecution(lightElmHandles[i]);
673-
const shadowJsHandle = await executionContext.evaluateHandle(
674-
(elm: Element, shadowSelector: string) => {
675-
if (!elm.shadowRoot) {
676-
throw new Error(`shadow root does not exist for element: ${elm.tagName.toLowerCase()}`);
677-
}
678-
679-
return elm.shadowRoot.querySelectorAll(shadowSelector);
680-
},
681-
lightElmHandles[i],
682-
shadowSelector,
683-
);
684-
685-
await lightElmHandles[i].dispose();
686-
687-
const shadowJsProperties = await shadowJsHandle.getProperties();
688-
await shadowJsHandle.dispose();
689-
690-
for (const shadowJsProperty of shadowJsProperties.values()) {
691-
const shadowElmHandle = shadowJsProperty.asElement() as puppeteer.ElementHandle;
692-
if (shadowElmHandle) {
693-
const elm = new E2EElement(page, shadowElmHandle);
694-
await elm.e2eSync();
695-
foundElms.push(elm);
696-
}
697-
}
698-
}
699-
} else {
700-
// light dom only
701-
for (let i = 0; i < lightElmHandles.length; i++) {
702-
const elm = new E2EElement(page, lightElmHandles[i]);
703-
await elm.e2eSync();
704-
foundElms.push(elm);
705-
}
658+
for (let i = 0; i < lightElmHandles.length; i++) {
659+
const elm = new E2EElement(page, lightElmHandles[i]);
660+
await elm.e2eSync();
661+
foundElms.push(elm);
706662
}
707663

708664
return foundElms;
@@ -711,16 +667,12 @@ export async function findAll(
711667
function getSelector(selector: pd.FindSelector) {
712668
const rtn = {
713669
lightSelector: null as string,
714-
shadowSelector: null as string,
715670
text: null as string,
716671
contains: null as string,
717672
};
718673

719674
if (typeof selector === 'string') {
720-
const splt = selector.split('>>>');
721-
722-
rtn.lightSelector = splt[0].trim();
723-
rtn.shadowSelector = splt.length > 1 ? splt[1].trim() : null;
675+
rtn.lightSelector = selector.trim();
724676
} else if (typeof selector.text === 'string') {
725677
rtn.text = selector.text.trim();
726678
} else if (typeof selector.contains === 'string') {

test/end-to-end/src/components.d.ts

+39
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export namespace Components {
2222
"cars": CarData[];
2323
"selected": CarData;
2424
}
25+
interface CmpA {
26+
}
27+
interface CmpB {
28+
}
29+
interface CmpC {
30+
}
2531
interface DomApi {
2632
}
2733
interface DomInteraction {
@@ -139,6 +145,24 @@ declare global {
139145
prototype: HTMLCarListElement;
140146
new (): HTMLCarListElement;
141147
};
148+
interface HTMLCmpAElement extends Components.CmpA, HTMLStencilElement {
149+
}
150+
var HTMLCmpAElement: {
151+
prototype: HTMLCmpAElement;
152+
new (): HTMLCmpAElement;
153+
};
154+
interface HTMLCmpBElement extends Components.CmpB, HTMLStencilElement {
155+
}
156+
var HTMLCmpBElement: {
157+
prototype: HTMLCmpBElement;
158+
new (): HTMLCmpBElement;
159+
};
160+
interface HTMLCmpCElement extends Components.CmpC, HTMLStencilElement {
161+
}
162+
var HTMLCmpCElement: {
163+
prototype: HTMLCmpCElement;
164+
new (): HTMLCmpCElement;
165+
};
142166
interface HTMLDomApiElement extends Components.DomApi, HTMLStencilElement {
143167
}
144168
var HTMLDomApiElement: {
@@ -253,6 +277,9 @@ declare global {
253277
"build-data": HTMLBuildDataElement;
254278
"car-detail": HTMLCarDetailElement;
255279
"car-list": HTMLCarListElement;
280+
"cmp-a": HTMLCmpAElement;
281+
"cmp-b": HTMLCmpBElement;
282+
"cmp-c": HTMLCmpCElement;
256283
"dom-api": HTMLDomApiElement;
257284
"dom-interaction": HTMLDomInteractionElement;
258285
"dom-visible": HTMLDomVisibleElement;
@@ -287,6 +314,12 @@ declare namespace LocalJSX {
287314
"onCarSelected"?: (event: CarListCustomEvent<CarData>) => void;
288315
"selected"?: CarData;
289316
}
317+
interface CmpA {
318+
}
319+
interface CmpB {
320+
}
321+
interface CmpC {
322+
}
290323
interface DomApi {
291324
}
292325
interface DomInteraction {
@@ -336,6 +369,9 @@ declare namespace LocalJSX {
336369
"build-data": BuildData;
337370
"car-detail": CarDetail;
338371
"car-list": CarList;
372+
"cmp-a": CmpA;
373+
"cmp-b": CmpB;
374+
"cmp-c": CmpC;
339375
"dom-api": DomApi;
340376
"dom-interaction": DomInteraction;
341377
"dom-visible": DomVisible;
@@ -365,6 +401,9 @@ declare module "@stencil/core" {
365401
* Component that helps display a list of cars
366402
*/
367403
"car-list": LocalJSX.CarList & JSXBase.HTMLAttributes<HTMLCarListElement>;
404+
"cmp-a": LocalJSX.CmpA & JSXBase.HTMLAttributes<HTMLCmpAElement>;
405+
"cmp-b": LocalJSX.CmpB & JSXBase.HTMLAttributes<HTMLCmpBElement>;
406+
"cmp-c": LocalJSX.CmpC & JSXBase.HTMLAttributes<HTMLCmpCElement>;
368407
"dom-api": LocalJSX.DomApi & JSXBase.HTMLAttributes<HTMLDomApiElement>;
369408
"dom-interaction": LocalJSX.DomInteraction & JSXBase.HTMLAttributes<HTMLDomInteractionElement>;
370409
"dom-visible": LocalJSX.DomVisible & JSXBase.HTMLAttributes<HTMLDomVisibleElement>;
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Component, h } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'cmp-a',
5+
shadow: true,
6+
})
7+
export class ComponentA {
8+
render() {
9+
return (
10+
<div>
11+
<section>
12+
<span>I am in component A</span>
13+
</section>
14+
<cmp-b></cmp-b>
15+
</div>
16+
);
17+
}
18+
}
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Component, h } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'cmp-b',
5+
shadow: true,
6+
})
7+
export class ComponentB {
8+
render() {
9+
return (
10+
<div>
11+
<section>
12+
<span>I am in component B</span>
13+
</section>
14+
<cmp-c></cmp-c>
15+
</div>
16+
);
17+
}
18+
}
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Component, h } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'cmp-c',
5+
shadow: true,
6+
})
7+
export class ComponentC {
8+
render() {
9+
return (
10+
<div>
11+
<span>I am in component C</span>
12+
</div>
13+
);
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { newE2EPage } from '@stencil/core/testing';
2+
3+
describe('Shadow DOM piercing', () => {
4+
it('can pierce through shadow DOM via Puppeteer primitives', async () => {
5+
// create a new puppeteer page
6+
const page = await newE2EPage({
7+
html: `
8+
<cmp-a></cmp-a>
9+
`,
10+
});
11+
12+
const spanCmpA = await page.$('cmp-a >>> span');
13+
expect(await spanCmpA.evaluate((el) => el.textContent)).toBe('I am in component A');
14+
const spanCmpB = await page.$('cmp-a >>> cmp-b >>> span');
15+
expect(await spanCmpB.evaluate((el) => el.textContent)).toBe('I am in component B');
16+
const spanCmpC = await page.$('cmp-a >>> cmp-b >>> cmp-c >>> span');
17+
expect(await spanCmpC.evaluate((el) => el.textContent)).toBe('I am in component C');
18+
19+
// we skip through the shadow dom
20+
const spanCmp = await page.$('cmp-a >>> cmp-c >>> span');
21+
expect(await spanCmp.evaluate((el) => el.textContent)).toBe('I am in component C');
22+
});
23+
24+
it('can pierce through shadow DOM via Stencil E2E testing API', async () => {
25+
// create a new puppeteer page
26+
const page = await newE2EPage({
27+
html: `
28+
<cmp-a></cmp-a>
29+
`,
30+
});
31+
32+
const spanCmpA = await page.find('cmp-a >>> span');
33+
expect(spanCmpA.textContent).toBe('I am in component A');
34+
const spanCmpB = await page.find('cmp-a >>> cmp-b >>> span');
35+
expect(spanCmpB.textContent).toBe('I am in component B');
36+
const spanCmpC = await page.find('cmp-a >>> div > cmp-b >>> div cmp-c >>> span');
37+
expect(spanCmpC.textContent).toBe('I am in component C');
38+
39+
// we skip through the shadow dom
40+
const spanCmp = await page.find('cmp-a >>> cmp-c >>> span');
41+
expect(spanCmp.textContent).toBe('I am in component C');
42+
});
43+
44+
it('can pierce through shadow DOM via findAll', async () => {
45+
// create a new puppeteer page
46+
const page = await newE2EPage({
47+
html: `
48+
<cmp-a></cmp-a>
49+
`,
50+
});
51+
52+
const spans = await page.findAll('cmp-a >>> span');
53+
expect(spans).toHaveLength(3);
54+
expect(spans[0].textContent).toBe('I am in component A');
55+
expect(spans[1].textContent).toBe('I am in component B');
56+
expect(spans[2].textContent).toBe('I am in component C');
57+
58+
const spansCmpB = await page.findAll('cmp-a >>> cmp-b >>> span');
59+
expect(spansCmpB).toHaveLength(2);
60+
expect(spansCmpB[0].textContent).toBe('I am in component B');
61+
expect(spansCmpB[1].textContent).toBe('I am in component C');
62+
63+
const spansCmpC = await page.findAll('cmp-a >>> cmp-b >>> cmp-c >>> span');
64+
expect(spansCmpC).toHaveLength(1);
65+
expect(spansCmpC[0].textContent).toBe('I am in component C');
66+
67+
// we skip through the shadow dom
68+
const spansCmp = await page.findAll('cmp-a >>> cmp-c >>> span');
69+
expect(spansCmp).toHaveLength(1);
70+
expect(spansCmp[0].textContent).toBe('I am in component C');
71+
});
72+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# cmp-c
2+
3+
4+
5+
<!-- Auto Generated Below -->
6+
7+
8+
## Dependencies
9+
10+
### Used by
11+
12+
- [cmp-b](.)
13+
14+
### Graph
15+
```mermaid
16+
graph TD;
17+
cmp-b --> cmp-c
18+
style cmp-c fill:#f9f,stroke:#333,stroke-width:4px
19+
```
20+
21+
----------------------------------------------
22+
23+
*Built with [StencilJS](https://stenciljs.com/)*

test/wdio/reflect-nan-attribute-hyphen/reflect-nan-attribute-hyphen.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ export class ReflectNanAttributeHyphen {
99
// for this test, it's necessary that 'reflect' is true, the class member is camel-cased, and is of type 'number'
1010
@Prop({ reflect: true }) valNum: number;
1111

12-
// counter to proxy the number of times a render has occurred, since we don't have access to those dev tools during
13-
// karma tests
12+
// counter to proxy the number of times a render has occurred
1413
renderCount = 0;
1514

1615
render() {

test/wdio/reflect-nan-attribute-with-child/child-reflect-nan-attribute.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ export class ChildReflectNanAttribute {
99
// for this test, it's necessary that 'reflect' is true, the class member is not camel-cased, and is of type 'number'
1010
@Prop({ reflect: true }) val: number;
1111

12-
// counter to proxy the number of times a render has occurred, since we don't have access to those dev tools during
13-
// karma tests
12+
// counter to proxy the number of times a render has occurred
1413
renderCount = 0;
1514

1615
render() {

0 commit comments

Comments
 (0)