Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(testing): support deep piercing with Puppeteer #5481

Merged
merged 7 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 26 additions & 74 deletions src/testing/puppeteer/puppeteer-element.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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/)*
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading
Loading