Skip to content

Commit

Permalink
feat(testing): support deep piercing with Puppeteer (#5481)
Browse files Browse the repository at this point in the history
* feat(testing): support deep piercing with Puppeteer

* minor clean ups

* add e2e tests

* rely on puppeteer implementation

* revert stencil config

* prettier
  • Loading branch information
christian-bromann committed Mar 27, 2024
1 parent 404d2ba commit 13d5d41
Show file tree
Hide file tree
Showing 13 changed files with 217 additions and 86 deletions.
100 changes: 26 additions & 74 deletions src/testing/puppeteer/puppeteer-element.ts
Expand Up @@ -543,12 +543,19 @@ export class E2EElement extends MockHTMLElement implements pd.E2EElementInternal
}

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

let elmHandle: puppeteer.ElementHandle;

if (typeof selector === 'string' && selector.includes('>>>')) {
const handle = await page.$(selector);
const elm = new E2EElement(page, handle);
await elm.e2eSync();
return elm;
}

if (typeof lightSelector === 'string') {
elmHandle = await findWithCssSelector(page, rootHandle, lightSelector, shadowSelector);
elmHandle = await findWithCssSelector(rootHandle, lightSelector);
} else {
elmHandle = await findWithText(page, rootHandle, text, contains);
}
Expand All @@ -562,40 +569,13 @@ export async function find(page: pd.E2EPageInternal, rootHandle: puppeteer.Eleme
return elm;
}

async function findWithCssSelector(
page: pd.E2EPageInternal,
rootHandle: puppeteer.ElementHandle,
lightSelector: string,
shadowSelector: string,
) {
let elmHandle = await rootHandle.$(lightSelector);
async function findWithCssSelector(rootHandle: puppeteer.ElementHandle, lightSelector: string) {
const elmHandle = await rootHandle.$(lightSelector);

if (!elmHandle) {
return null;
}

if (shadowSelector) {
const shadowHandle = await page.evaluateHandle(
(elm: Element, shadowSelector: string) => {
if (!elm.shadowRoot) {
throw new Error(`shadow root does not exist for element: ${elm.tagName.toLowerCase()}`);
}

return elm.shadowRoot.querySelector(shadowSelector);
},
elmHandle,
shadowSelector,
);

await elmHandle.dispose();

if (!shadowHandle) {
return null;
}

elmHandle = shadowHandle.asElement() as puppeteer.ElementHandle<Element>;
}

return elmHandle;
}

Expand Down Expand Up @@ -659,50 +639,26 @@ export async function findAll(
) {
const foundElms: E2EElement[] = [];

const { lightSelector, shadowSelector } = getSelector(selector);
if (typeof selector === 'string' && selector.includes('>>>')) {
const handles = await page.$$(selector);
for (let i = 0; i < handles.length; i++) {
const elm = new E2EElement(page, handles[i]);
await elm.e2eSync();
foundElms.push(elm);
}
return foundElms;
}

const { lightSelector } = getSelector(selector);
const lightElmHandles = await rootHandle.$$(lightSelector);
if (lightElmHandles.length === 0) {
return foundElms;
}

if (shadowSelector) {
// light dom selected, then shadow dom selected inside of light dom elements
for (let i = 0; i < lightElmHandles.length; i++) {
const executionContext = getPuppeteerExecution(lightElmHandles[i]);
const shadowJsHandle = await executionContext.evaluateHandle(
(elm: Element, shadowSelector: string) => {
if (!elm.shadowRoot) {
throw new Error(`shadow root does not exist for element: ${elm.tagName.toLowerCase()}`);
}

return elm.shadowRoot.querySelectorAll(shadowSelector);
},
lightElmHandles[i],
shadowSelector,
);

await lightElmHandles[i].dispose();

const shadowJsProperties = await shadowJsHandle.getProperties();
await shadowJsHandle.dispose();

for (const shadowJsProperty of shadowJsProperties.values()) {
const shadowElmHandle = shadowJsProperty.asElement() as puppeteer.ElementHandle;
if (shadowElmHandle) {
const elm = new E2EElement(page, shadowElmHandle);
await elm.e2eSync();
foundElms.push(elm);
}
}
}
} else {
// light dom only
for (let i = 0; i < lightElmHandles.length; i++) {
const elm = new E2EElement(page, lightElmHandles[i]);
await elm.e2eSync();
foundElms.push(elm);
}
for (let i = 0; i < lightElmHandles.length; i++) {
const elm = new E2EElement(page, lightElmHandles[i]);
await elm.e2eSync();
foundElms.push(elm);
}

return foundElms;
Expand All @@ -711,16 +667,12 @@ export async function findAll(
function getSelector(selector: pd.FindSelector) {
const rtn = {
lightSelector: null as string,
shadowSelector: null as string,
text: null as string,
contains: null as string,
};

if (typeof selector === 'string') {
const splt = selector.split('>>>');

rtn.lightSelector = splt[0].trim();
rtn.shadowSelector = splt.length > 1 ? splt[1].trim() : null;
rtn.lightSelector = selector.trim();
} else if (typeof selector.text === 'string') {
rtn.text = selector.text.trim();
} else if (typeof selector.contains === 'string') {
Expand Down
39 changes: 39 additions & 0 deletions test/end-to-end/src/components.d.ts
Expand Up @@ -22,6 +22,12 @@ export namespace Components {
"cars": CarData[];
"selected": CarData;
}
interface CmpA {
}
interface CmpB {
}
interface CmpC {
}
interface DomApi {
}
interface DomInteraction {
Expand Down Expand Up @@ -139,6 +145,24 @@ declare global {
prototype: HTMLCarListElement;
new (): HTMLCarListElement;
};
interface HTMLCmpAElement extends Components.CmpA, HTMLStencilElement {
}
var HTMLCmpAElement: {
prototype: HTMLCmpAElement;
new (): HTMLCmpAElement;
};
interface HTMLCmpBElement extends Components.CmpB, HTMLStencilElement {
}
var HTMLCmpBElement: {
prototype: HTMLCmpBElement;
new (): HTMLCmpBElement;
};
interface HTMLCmpCElement extends Components.CmpC, HTMLStencilElement {
}
var HTMLCmpCElement: {
prototype: HTMLCmpCElement;
new (): HTMLCmpCElement;
};
interface HTMLDomApiElement extends Components.DomApi, HTMLStencilElement {
}
var HTMLDomApiElement: {
Expand Down Expand Up @@ -253,6 +277,9 @@ declare global {
"build-data": HTMLBuildDataElement;
"car-detail": HTMLCarDetailElement;
"car-list": HTMLCarListElement;
"cmp-a": HTMLCmpAElement;
"cmp-b": HTMLCmpBElement;
"cmp-c": HTMLCmpCElement;
"dom-api": HTMLDomApiElement;
"dom-interaction": HTMLDomInteractionElement;
"dom-visible": HTMLDomVisibleElement;
Expand Down Expand Up @@ -287,6 +314,12 @@ declare namespace LocalJSX {
"onCarSelected"?: (event: CarListCustomEvent<CarData>) => void;
"selected"?: CarData;
}
interface CmpA {
}
interface CmpB {
}
interface CmpC {
}
interface DomApi {
}
interface DomInteraction {
Expand Down Expand Up @@ -336,6 +369,9 @@ declare namespace LocalJSX {
"build-data": BuildData;
"car-detail": CarDetail;
"car-list": CarList;
"cmp-a": CmpA;
"cmp-b": CmpB;
"cmp-c": CmpC;
"dom-api": DomApi;
"dom-interaction": DomInteraction;
"dom-visible": DomVisible;
Expand Down Expand Up @@ -365,6 +401,9 @@ declare module "@stencil/core" {
* Component that helps display a list of cars
*/
"car-list": LocalJSX.CarList & JSXBase.HTMLAttributes<HTMLCarListElement>;
"cmp-a": LocalJSX.CmpA & JSXBase.HTMLAttributes<HTMLCmpAElement>;
"cmp-b": LocalJSX.CmpB & JSXBase.HTMLAttributes<HTMLCmpBElement>;
"cmp-c": LocalJSX.CmpC & JSXBase.HTMLAttributes<HTMLCmpCElement>;
"dom-api": LocalJSX.DomApi & JSXBase.HTMLAttributes<HTMLDomApiElement>;
"dom-interaction": LocalJSX.DomInteraction & JSXBase.HTMLAttributes<HTMLDomInteractionElement>;
"dom-visible": LocalJSX.DomVisible & JSXBase.HTMLAttributes<HTMLDomVisibleElement>;
Expand Down
18 changes: 18 additions & 0 deletions test/end-to-end/src/deep-selector/cmpA.tsx
@@ -0,0 +1,18 @@
import { Component, h } from '@stencil/core';

@Component({
tag: 'cmp-a',
shadow: true,
})
export class ComponentA {
render() {
return (
<div>
<section>
<span>I am in component A</span>
</section>
<cmp-b></cmp-b>
</div>
);
}
}
18 changes: 18 additions & 0 deletions test/end-to-end/src/deep-selector/cmpB.tsx
@@ -0,0 +1,18 @@
import { Component, h } from '@stencil/core';

@Component({
tag: 'cmp-b',
shadow: true,
})
export class ComponentB {
render() {
return (
<div>
<section>
<span>I am in component B</span>
</section>
<cmp-c></cmp-c>
</div>
);
}
}
15 changes: 15 additions & 0 deletions test/end-to-end/src/deep-selector/cmpC.tsx
@@ -0,0 +1,15 @@
import { Component, h } from '@stencil/core';

@Component({
tag: 'cmp-c',
shadow: true,
})
export class ComponentC {
render() {
return (
<div>
<span>I am in component C</span>
</div>
);
}
}
72 changes: 72 additions & 0 deletions test/end-to-end/src/deep-selector/deep-selector.e2e.ts
@@ -0,0 +1,72 @@
import { newE2EPage } from '@stencil/core/testing';

describe('Shadow DOM piercing', () => {
it('can pierce through shadow DOM via Puppeteer primitives', async () => {
// create a new puppeteer page
const page = await newE2EPage({
html: `
<cmp-a></cmp-a>
`,
});

const spanCmpA = await page.$('cmp-a >>> span');
expect(await spanCmpA.evaluate((el) => el.textContent)).toBe('I am in component A');
const spanCmpB = await page.$('cmp-a >>> cmp-b >>> span');
expect(await spanCmpB.evaluate((el) => el.textContent)).toBe('I am in component B');
const spanCmpC = await page.$('cmp-a >>> cmp-b >>> cmp-c >>> span');
expect(await spanCmpC.evaluate((el) => el.textContent)).toBe('I am in component C');

// we skip through the shadow dom
const spanCmp = await page.$('cmp-a >>> cmp-c >>> span');
expect(await spanCmp.evaluate((el) => el.textContent)).toBe('I am in component C');
});

it('can pierce through shadow DOM via Stencil E2E testing API', async () => {
// create a new puppeteer page
const page = await newE2EPage({
html: `
<cmp-a></cmp-a>
`,
});

const spanCmpA = await page.find('cmp-a >>> span');
expect(spanCmpA.textContent).toBe('I am in component A');
const spanCmpB = await page.find('cmp-a >>> cmp-b >>> span');
expect(spanCmpB.textContent).toBe('I am in component B');
const spanCmpC = await page.find('cmp-a >>> div > cmp-b >>> div cmp-c >>> span');
expect(spanCmpC.textContent).toBe('I am in component C');

// we skip through the shadow dom
const spanCmp = await page.find('cmp-a >>> cmp-c >>> span');
expect(spanCmp.textContent).toBe('I am in component C');
});

it('can pierce through shadow DOM via findAll', async () => {
// create a new puppeteer page
const page = await newE2EPage({
html: `
<cmp-a></cmp-a>
`,
});

const spans = await page.findAll('cmp-a >>> span');
expect(spans).toHaveLength(3);
expect(spans[0].textContent).toBe('I am in component A');
expect(spans[1].textContent).toBe('I am in component B');
expect(spans[2].textContent).toBe('I am in component C');

const spansCmpB = await page.findAll('cmp-a >>> cmp-b >>> span');
expect(spansCmpB).toHaveLength(2);
expect(spansCmpB[0].textContent).toBe('I am in component B');
expect(spansCmpB[1].textContent).toBe('I am in component C');

const spansCmpC = await page.findAll('cmp-a >>> cmp-b >>> cmp-c >>> span');
expect(spansCmpC).toHaveLength(1);
expect(spansCmpC[0].textContent).toBe('I am in component C');

// we skip through the shadow dom
const spansCmp = await page.findAll('cmp-a >>> cmp-c >>> span');
expect(spansCmp).toHaveLength(1);
expect(spansCmp[0].textContent).toBe('I am in component C');
});
});
23 changes: 23 additions & 0 deletions test/end-to-end/src/deep-selector/readme.md
@@ -0,0 +1,23 @@
# cmp-c



<!-- Auto Generated Below -->


## Dependencies

### Used by

- [cmp-b](.)

### Graph
```mermaid
graph TD;
cmp-b --> cmp-c
style cmp-c fill:#f9f,stroke:#333,stroke-width:4px
```

----------------------------------------------

*Built with [StencilJS](https://stenciljs.com/)*
Expand Up @@ -9,8 +9,7 @@ export class ReflectNanAttributeHyphen {
// for this test, it's necessary that 'reflect' is true, the class member is camel-cased, and is of type 'number'
@Prop({ reflect: true }) valNum: number;

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

render() {
Expand Down
Expand Up @@ -9,8 +9,7 @@ export class ChildReflectNanAttribute {
// for this test, it's necessary that 'reflect' is true, the class member is not camel-cased, and is of type 'number'
@Prop({ reflect: true }) val: number;

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

render() {
Expand Down

0 comments on commit 13d5d41

Please sign in to comment.