diff --git a/src/testing/puppeteer/puppeteer-element.ts b/src/testing/puppeteer/puppeteer-element.ts index 6c2251bfb0f..29ad2fec3b2 100644 --- a/src/testing/puppeteer/puppeteer-element.ts +++ b/src/testing/puppeteer/puppeteer-element.ts @@ -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); } @@ -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; - } - return elmHandle; } @@ -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; @@ -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') { diff --git a/test/end-to-end/src/components.d.ts b/test/end-to-end/src/components.d.ts index e4a30dba7c0..5eff0dde665 100644 --- a/test/end-to-end/src/components.d.ts +++ b/test/end-to-end/src/components.d.ts @@ -22,6 +22,12 @@ export namespace Components { "cars": CarData[]; "selected": CarData; } + interface CmpA { + } + interface CmpB { + } + interface CmpC { + } interface DomApi { } interface DomInteraction { @@ -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: { @@ -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; @@ -287,6 +314,12 @@ declare namespace LocalJSX { "onCarSelected"?: (event: CarListCustomEvent) => void; "selected"?: CarData; } + interface CmpA { + } + interface CmpB { + } + interface CmpC { + } interface DomApi { } interface DomInteraction { @@ -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; @@ -365,6 +401,9 @@ declare module "@stencil/core" { * Component that helps display a list of cars */ "car-list": LocalJSX.CarList & JSXBase.HTMLAttributes; + "cmp-a": LocalJSX.CmpA & JSXBase.HTMLAttributes; + "cmp-b": LocalJSX.CmpB & JSXBase.HTMLAttributes; + "cmp-c": LocalJSX.CmpC & JSXBase.HTMLAttributes; "dom-api": LocalJSX.DomApi & JSXBase.HTMLAttributes; "dom-interaction": LocalJSX.DomInteraction & JSXBase.HTMLAttributes; "dom-visible": LocalJSX.DomVisible & JSXBase.HTMLAttributes; diff --git a/test/end-to-end/src/deep-selector/cmpA.tsx b/test/end-to-end/src/deep-selector/cmpA.tsx new file mode 100644 index 00000000000..963d675c2a6 --- /dev/null +++ b/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 ( +
+
+ I am in component A +
+ +
+ ); + } +} diff --git a/test/end-to-end/src/deep-selector/cmpB.tsx b/test/end-to-end/src/deep-selector/cmpB.tsx new file mode 100644 index 00000000000..d9c8e93911c --- /dev/null +++ b/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 ( +
+
+ I am in component B +
+ +
+ ); + } +} diff --git a/test/end-to-end/src/deep-selector/cmpC.tsx b/test/end-to-end/src/deep-selector/cmpC.tsx new file mode 100644 index 00000000000..37ce29e3e98 --- /dev/null +++ b/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 ( +
+ I am in component C +
+ ); + } +} diff --git a/test/end-to-end/src/deep-selector/deep-selector.e2e.ts b/test/end-to-end/src/deep-selector/deep-selector.e2e.ts new file mode 100644 index 00000000000..58d458a0f5f --- /dev/null +++ b/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: ` + + `, + }); + + 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: ` + + `, + }); + + 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: ` + + `, + }); + + 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'); + }); +}); diff --git a/test/end-to-end/src/deep-selector/readme.md b/test/end-to-end/src/deep-selector/readme.md new file mode 100644 index 00000000000..208d9ae8e50 --- /dev/null +++ b/test/end-to-end/src/deep-selector/readme.md @@ -0,0 +1,23 @@ +# cmp-c + + + + + + +## 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/)* diff --git a/test/wdio/reflect-nan-attribute-hyphen/reflect-nan-attribute-hyphen.tsx b/test/wdio/reflect-nan-attribute-hyphen/reflect-nan-attribute-hyphen.tsx index 679ce4657fa..ce2f75398f8 100644 --- a/test/wdio/reflect-nan-attribute-hyphen/reflect-nan-attribute-hyphen.tsx +++ b/test/wdio/reflect-nan-attribute-hyphen/reflect-nan-attribute-hyphen.tsx @@ -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() { diff --git a/test/wdio/reflect-nan-attribute-with-child/child-reflect-nan-attribute.tsx b/test/wdio/reflect-nan-attribute-with-child/child-reflect-nan-attribute.tsx index cc521fd55b9..16199085f80 100644 --- a/test/wdio/reflect-nan-attribute-with-child/child-reflect-nan-attribute.tsx +++ b/test/wdio/reflect-nan-attribute-with-child/child-reflect-nan-attribute.tsx @@ -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() { diff --git a/test/wdio/reflect-nan-attribute-with-child/parent-reflect-nan-attribute.tsx b/test/wdio/reflect-nan-attribute-with-child/parent-reflect-nan-attribute.tsx index 339fc641d3c..67be116f5f5 100644 --- a/test/wdio/reflect-nan-attribute-with-child/parent-reflect-nan-attribute.tsx +++ b/test/wdio/reflect-nan-attribute-with-child/parent-reflect-nan-attribute.tsx @@ -6,8 +6,7 @@ import { Component, h } from '@stencil/core'; shadow: true, }) export class ParentReflectNanAttribute { - // 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() { diff --git a/test/wdio/reflect-nan-attribute/reflect-nan-attribute.tsx b/test/wdio/reflect-nan-attribute/reflect-nan-attribute.tsx index ed75550e3d4..aba0a1265c6 100644 --- a/test/wdio/reflect-nan-attribute/reflect-nan-attribute.tsx +++ b/test/wdio/reflect-nan-attribute/reflect-nan-attribute.tsx @@ -9,8 +9,7 @@ export class ReflectNanAttribute { // 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() { diff --git a/test/wdio/reflect-single-render/child-with-reflection.tsx b/test/wdio/reflect-single-render/child-with-reflection.tsx index b3c45f008ed..158ff93b3c6 100644 --- a/test/wdio/reflect-single-render/child-with-reflection.tsx +++ b/test/wdio/reflect-single-render/child-with-reflection.tsx @@ -6,8 +6,7 @@ import { Component, h, Prop } from '@stencil/core'; shadow: true, }) export class ChildWithReflection { - // 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; // to properly replicate the issue: diff --git a/test/wdio/reflect-single-render/parent-with-reflect-child.tsx b/test/wdio/reflect-single-render/parent-with-reflect-child.tsx index 992c5b843ad..98148171d67 100644 --- a/test/wdio/reflect-single-render/parent-with-reflect-child.tsx +++ b/test/wdio/reflect-single-render/parent-with-reflect-child.tsx @@ -6,8 +6,7 @@ import { Component, h } from '@stencil/core'; shadow: true, }) export class MyComponent { - // 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() {