From f17d345ac9f800a300c890240f456f1096f2fc37 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 21 Sep 2022 15:12:18 -0700 Subject: [PATCH] fix(ct): support empty fragments (#17475) Currently, we ues `#root` vs `#root > *` selector for component roots depending on the number of root children. This heuristic detects fragments that render multiple elements inside the root. However, this does not work with empty fragments that do not render anything. The fix is to make the `#root >> control=component` selector that would dynamically detect the root. This supports empty fragments and also allows for dynamic updates of the fragments. --- .../src/server/injected/injectedScript.ts | 17 +++++++---------- packages/playwright-test/src/mount.ts | 3 +-- .../src/components/EmptyFragment.tsx | 3 +++ .../components/ct-react-vite/src/tests.spec.tsx | 14 +++++++++++--- .../ct-react/src/components/EmptyFragment.tsx | 3 +++ tests/components/ct-react/src/tests.spec.tsx | 12 ++++++++++-- .../ct-solid/src/components/EmptyFragment.tsx | 3 +++ tests/components/ct-solid/src/tests.spec.tsx | 8 ++++++++ .../ct-svelte-vite/src/components/Empty.svelte | 0 .../components/ct-svelte-vite/src/tests.spec.ts | 10 +++++++++- .../ct-svelte/src/components/Empty.svelte | 0 tests/components/ct-svelte/src/tests.spec.ts | 10 +++++++++- .../ct-vue-cli/src/components/EmptyTemplate.vue | 2 ++ .../ct-vue-cli/src/notation-jsx.spec.tsx | 14 +++++++++++--- .../ct-vue-cli/src/notation-vue.spec.ts | 14 +++++++++++--- .../src/components/EmptyTemplate.vue | 2 ++ .../ct-vue-vite/src/notation-jsx.spec.tsx | 12 ++++++++++-- .../ct-vue-vite/src/notation-vue.spec.js | 16 ++++++++++++---- .../ct-vue-vite/src/notation-vue.spec.ts | 14 +++++++++++--- .../src/components/EmptyTemplate.vue | 2 ++ .../ct-vue2-cli/src/notation-jsx.spec.tsx | 12 ++++++++++-- .../ct-vue2-cli/src/notation-vue.spec.ts | 8 ++++++++ 22 files changed, 143 insertions(+), 36 deletions(-) create mode 100644 tests/components/ct-react-vite/src/components/EmptyFragment.tsx create mode 100644 tests/components/ct-react/src/components/EmptyFragment.tsx create mode 100644 tests/components/ct-solid/src/components/EmptyFragment.tsx create mode 100644 tests/components/ct-svelte-vite/src/components/Empty.svelte create mode 100644 tests/components/ct-svelte/src/components/Empty.svelte create mode 100644 tests/components/ct-vue-cli/src/components/EmptyTemplate.vue create mode 100644 tests/components/ct-vue-vite/src/components/EmptyTemplate.vue create mode 100644 tests/components/ct-vue2-cli/src/components/EmptyTemplate.vue diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 41231c394b..dcab9afe04 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -278,6 +278,13 @@ export class InjectedScript { return []; if (body === 'return-empty') return []; + if (body === 'component') { + if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) + return []; + // Usually, we return the mounted component that is a single child. + // However, when mounting fragments, return the root instead. + return [root.childElementCount === 1 ? root.firstElementChild! : root as Element]; + } throw new Error(`Internal error, unknown control selector ${body}`); } }; @@ -302,16 +309,6 @@ export class InjectedScript { return { queryAll }; } - private _createLayoutEngine(name: LayoutSelectorName): SelectorEngineV2 { - const queryAll = (root: SelectorRoot, body: ParsedSelector) => { - if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) - return []; - const has = !!this.querySelector(body, root, false); - return has ? [root as Element] : []; - }; - return { queryAll }; - } - extend(source: string, params: any): any { const constrFunction = globalThis.eval(` (() => { diff --git a/packages/playwright-test/src/mount.ts b/packages/playwright-test/src/mount.ts index 17ab6c3cde..33711783de 100644 --- a/packages/playwright-test/src/mount.ts +++ b/packages/playwright-test/src/mount.ts @@ -124,8 +124,7 @@ async function innerMount(page: Page, jsxOrType: JsxComponent | string, options: await window.playwrightMount(component, rootElement, hooksConfig); - // When mounting fragments, return selector pointing to the root element. - return rootElement.childNodes.length > 1 ? '#root' : '#root > *'; + return '#root >> control=component'; }, { component, hooksConfig: options.hooksConfig }); return selector; } diff --git a/tests/components/ct-react-vite/src/components/EmptyFragment.tsx b/tests/components/ct-react-vite/src/components/EmptyFragment.tsx new file mode 100644 index 0000000000..64cb1177a3 --- /dev/null +++ b/tests/components/ct-react-vite/src/components/EmptyFragment.tsx @@ -0,0 +1,3 @@ +export default function EmptyFragment() { + return <>{[]}; +} diff --git a/tests/components/ct-react-vite/src/tests.spec.tsx b/tests/components/ct-react-vite/src/tests.spec.tsx index 99b46d5780..7cb5d6bc2e 100644 --- a/tests/components/ct-react-vite/src/tests.spec.tsx +++ b/tests/components/ct-react-vite/src/tests.spec.tsx @@ -4,6 +4,7 @@ import DefaultChildren from './components/DefaultChildren'; import MultipleChildren from './components/MultipleChildren'; import MultiRoot from './components/MultiRoot'; import Counter from './components/Counter'; +import EmptyFragment from './components/EmptyFragment'; test.use({ viewport: { width: 500, height: 500 } }); @@ -27,8 +28,8 @@ test('renderer updates callbacks without remounting', async ({ mount }) => { const component = await mount() const messages: string[] = [] - await component.rerender( { - messages.push(message) + await component.rerender( { + messages.push(message) }} />) await component.click(); expect(messages).toEqual(['hello']) @@ -117,4 +118,11 @@ test('unmount a multi root component', async ({ mount, page }) => { await component.unmount() await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 2') -}) +}); + +test('get textContent of the empty fragment', async ({ mount }) => { + const component = await mount(); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); diff --git a/tests/components/ct-react/src/components/EmptyFragment.tsx b/tests/components/ct-react/src/components/EmptyFragment.tsx new file mode 100644 index 0000000000..64cb1177a3 --- /dev/null +++ b/tests/components/ct-react/src/components/EmptyFragment.tsx @@ -0,0 +1,3 @@ +export default function EmptyFragment() { + return <>{[]}; +} diff --git a/tests/components/ct-react/src/tests.spec.tsx b/tests/components/ct-react/src/tests.spec.tsx index 9b5bc34158..64e2727c52 100644 --- a/tests/components/ct-react/src/tests.spec.tsx +++ b/tests/components/ct-react/src/tests.spec.tsx @@ -7,6 +7,7 @@ import DefaultChildren from './components/DefaultChildren'; import MultipleChildren from './components/MultipleChildren'; import MultiRoot from './components/MultiRoot'; import Counter from './components/Counter'; +import EmptyFragment from './components/EmptyFragment'; test.use({ viewport: { width: 500, height: 500 } }); @@ -30,8 +31,8 @@ test('renderer updates callbacks without remounting', async ({ mount }) => { const component = await mount() const messages: string[] = [] - await component.rerender( { - messages.push(message) + await component.rerender( { + messages.push(message) }} />) await component.click(); expect(messages).toEqual(['hello']) @@ -127,6 +128,13 @@ test('render delayed data', async ({ mount }) => { await expect(component).toHaveText('complete'); }); +test('get textContent of the empty fragment', async ({ mount }) => { + const component = await mount(); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); + const testWithServer = test.extend(serverFixtures); testWithServer('components routing should go through context', async ({ mount, context, server }) => { server.setRoute('/hello', (req, res) => { diff --git a/tests/components/ct-solid/src/components/EmptyFragment.tsx b/tests/components/ct-solid/src/components/EmptyFragment.tsx new file mode 100644 index 0000000000..64cb1177a3 --- /dev/null +++ b/tests/components/ct-solid/src/components/EmptyFragment.tsx @@ -0,0 +1,3 @@ +export default function EmptyFragment() { + return <>{[]}; +} diff --git a/tests/components/ct-solid/src/tests.spec.tsx b/tests/components/ct-solid/src/tests.spec.tsx index e16720aa6c..24dc0f67cc 100644 --- a/tests/components/ct-solid/src/tests.spec.tsx +++ b/tests/components/ct-solid/src/tests.spec.tsx @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/experimental-ct-solid' import Button from './components/Button'; import DefaultChildren from './components/DefaultChildren'; import MultiRoot from './components/MultiRoot'; +import EmptyFragment from './components/EmptyFragment'; test.use({ viewport: { width: 500, height: 500 } }); @@ -52,3 +53,10 @@ test('unmount a multi root component', async ({ mount, page }) => { await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 2') }); + +test('get textContent of the empty fragment', async ({ mount }) => { + const component = await mount(); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); diff --git a/tests/components/ct-svelte-vite/src/components/Empty.svelte b/tests/components/ct-svelte-vite/src/components/Empty.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/components/ct-svelte-vite/src/tests.spec.ts b/tests/components/ct-svelte-vite/src/tests.spec.ts index be24cb8e9c..915c2dc205 100644 --- a/tests/components/ct-svelte-vite/src/tests.spec.ts +++ b/tests/components/ct-svelte-vite/src/tests.spec.ts @@ -18,6 +18,7 @@ import { test, expect } from '@playwright/experimental-ct-svelte'; import Button from './components/Button.svelte'; import DefaultSlot from './components/DefaultSlot.svelte'; import MultiRoot from './components/MultiRoot.svelte'; +import Empty from './components/Empty.svelte'; test.use({ viewport: { width: 500, height: 500 } }); @@ -83,4 +84,11 @@ test('unmount a multi root component', async ({ mount, page }) => { await component.unmount() await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 2') -}) +}); + +test('get textContent of the empty component', async ({ mount }) => { + const component = await mount(Empty); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); diff --git a/tests/components/ct-svelte/src/components/Empty.svelte b/tests/components/ct-svelte/src/components/Empty.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/components/ct-svelte/src/tests.spec.ts b/tests/components/ct-svelte/src/tests.spec.ts index be24cb8e9c..915c2dc205 100644 --- a/tests/components/ct-svelte/src/tests.spec.ts +++ b/tests/components/ct-svelte/src/tests.spec.ts @@ -18,6 +18,7 @@ import { test, expect } from '@playwright/experimental-ct-svelte'; import Button from './components/Button.svelte'; import DefaultSlot from './components/DefaultSlot.svelte'; import MultiRoot from './components/MultiRoot.svelte'; +import Empty from './components/Empty.svelte'; test.use({ viewport: { width: 500, height: 500 } }); @@ -83,4 +84,11 @@ test('unmount a multi root component', async ({ mount, page }) => { await component.unmount() await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 2') -}) +}); + +test('get textContent of the empty component', async ({ mount }) => { + const component = await mount(Empty); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); diff --git a/tests/components/ct-vue-cli/src/components/EmptyTemplate.vue b/tests/components/ct-vue-cli/src/components/EmptyTemplate.vue new file mode 100644 index 0000000000..ba5341392f --- /dev/null +++ b/tests/components/ct-vue-cli/src/components/EmptyTemplate.vue @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/tests/components/ct-vue-cli/src/notation-jsx.spec.tsx b/tests/components/ct-vue-cli/src/notation-jsx.spec.tsx index fda81dad5e..1c275378eb 100644 --- a/tests/components/ct-vue-cli/src/notation-jsx.spec.tsx +++ b/tests/components/ct-vue-cli/src/notation-jsx.spec.tsx @@ -4,6 +4,7 @@ import Counter from './components/Counter.vue' import DefaultSlot from './components/DefaultSlot.vue' import NamedSlots from './components/NamedSlots.vue' import MultiRoot from './components/MultiRoot.vue' +import EmptyTemplate from './components/EmptyTemplate.vue' test.use({ viewport: { width: 500, height: 500 } }) @@ -15,10 +16,10 @@ test('render props', async ({ mount }) => { test('renderer and keep the component instance intact', async ({ mount }) => { const component = await mount(); await expect(component.locator('#rerender-count')).toContainText('9001') - + await component.rerender({ props: { count: 1337 } }) await expect(component.locator('#rerender-count')).toContainText('1337') - + await component.rerender({ props: { count: 42 } }) await expect(component.locator('#rerender-count')).toContainText('42') @@ -92,4 +93,11 @@ test('unmount a multi root component', async ({ mount, page }) => { await component.unmount() await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 2') -}) +}); + +test('get textContent of the empty template', async ({ mount }) => { + const component = await mount(); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); diff --git a/tests/components/ct-vue-cli/src/notation-vue.spec.ts b/tests/components/ct-vue-cli/src/notation-vue.spec.ts index a620165a5a..52b8c31a22 100644 --- a/tests/components/ct-vue-cli/src/notation-vue.spec.ts +++ b/tests/components/ct-vue-cli/src/notation-vue.spec.ts @@ -6,6 +6,7 @@ import DefaultSlot from './components/DefaultSlot.vue' import NamedSlots from './components/NamedSlots.vue' import MultiRoot from './components/MultiRoot.vue' import Component from './components/Component.vue' +import EmptyTemplate from './components/EmptyTemplate.vue' test.use({ viewport: { width: 500, height: 500 } }) @@ -20,15 +21,15 @@ test('render props', async ({ mount }) => { test('renderer and keep the component instance intact', async ({ mount }) => { const component = await mount<{ count: number }>(Counter, { - props: { + props: { count: 9001 } }); await expect(component.locator('#rerender-count')).toContainText('9001') - + await component.rerender({ props: { count: 1337 } }) await expect(component.locator('#rerender-count')).toContainText('1337') - + await component.rerender({ props: { count: 42 } }) await expect(component.locator('#rerender-count')).toContainText('42') @@ -117,3 +118,10 @@ test('unmount a multi root component', async ({ mount, page }) => { await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 2') }) + +test('get textContent of the empty template', async ({ mount }) => { + const component = await mount(EmptyTemplate); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); diff --git a/tests/components/ct-vue-vite/src/components/EmptyTemplate.vue b/tests/components/ct-vue-vite/src/components/EmptyTemplate.vue new file mode 100644 index 0000000000..ba5341392f --- /dev/null +++ b/tests/components/ct-vue-vite/src/components/EmptyTemplate.vue @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/tests/components/ct-vue-vite/src/notation-jsx.spec.tsx b/tests/components/ct-vue-vite/src/notation-jsx.spec.tsx index c0316b4a7c..bfdae6120b 100644 --- a/tests/components/ct-vue-vite/src/notation-jsx.spec.tsx +++ b/tests/components/ct-vue-vite/src/notation-jsx.spec.tsx @@ -4,6 +4,7 @@ import Counter from './components/Counter.vue' import DefaultSlot from './components/DefaultSlot.vue' import NamedSlots from './components/NamedSlots.vue' import MultiRoot from './components/MultiRoot.vue' +import EmptyTemplate from './components/EmptyTemplate.vue' test.use({ viewport: { width: 500, height: 500 } }) @@ -15,10 +16,10 @@ test('render props', async ({ mount }) => { test('renderer and keep the component instance intact', async ({ mount }) => { const component = await mount(); await expect(component.locator('#rerender-count')).toContainText('9001') - + await component.rerender({ props: { count: 1337 } }) await expect(component.locator('#rerender-count')).toContainText('1337') - + await component.rerender({ props: { count: 42 } }) await expect(component.locator('#rerender-count')).toContainText('42') @@ -96,3 +97,10 @@ test('unmount a multi root component', async ({ mount, page }) => { await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 2') }) + +test('get textContent of the empty template', async ({ mount }) => { + const component = await mount(); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); diff --git a/tests/components/ct-vue-vite/src/notation-vue.spec.js b/tests/components/ct-vue-vite/src/notation-vue.spec.js index 8b54ba3769..0128e6b66e 100644 --- a/tests/components/ct-vue-vite/src/notation-vue.spec.js +++ b/tests/components/ct-vue-vite/src/notation-vue.spec.js @@ -5,6 +5,7 @@ import DefaultSlot from './components/DefaultSlot.vue' import MultiRoot from './components/MultiRoot.vue' import NamedSlots from './components/NamedSlots.vue' import Component from './components/Component.vue' +import EmptyTemplate from './components/EmptyTemplate.vue' test.use({ viewport: { width: 500, height: 500 } }) @@ -19,15 +20,15 @@ test('render props', async ({ mount }) => { test('renderer and keep the component instance intact', async ({ mount }) => { const component = await mount(Counter, { - props: { + props: { count: 9001 } }); await expect(component.locator('#rerender-count')).toContainText('9001') - + await component.rerender({ props: { count: 1337 } }) await expect(component.locator('#rerender-count')).toContainText('1337') - + await component.rerender({ props: { count: 42 } }) await expect(component.locator('#rerender-count')).toContainText('42') @@ -108,4 +109,11 @@ test('unmount a multi root component', async ({ mount, page }) => { await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 2') -}) +}); + +test('get textContent of the empty template', async ({ mount }) => { + const component = await mount(EmptyTemplate); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); diff --git a/tests/components/ct-vue-vite/src/notation-vue.spec.ts b/tests/components/ct-vue-vite/src/notation-vue.spec.ts index 5177bdbb09..31631e9811 100644 --- a/tests/components/ct-vue-vite/src/notation-vue.spec.ts +++ b/tests/components/ct-vue-vite/src/notation-vue.spec.ts @@ -6,6 +6,7 @@ import DefaultSlot from './components/DefaultSlot.vue' import NamedSlots from './components/NamedSlots.vue' import MultiRoot from './components/MultiRoot.vue' import Component from './components/Component.vue' +import EmptyTemplate from './components/EmptyTemplate.vue' test.use({ viewport: { width: 500, height: 500 } }) @@ -20,15 +21,15 @@ test('render props', async ({ mount }) => { test('renderer and keep the component instance intact', async ({ mount }) => { const component = await mount<{ count: number }>(Counter, { - props: { + props: { count: 9001 } }); await expect(component.locator('#rerender-count')).toContainText('9001') - + await component.rerender({ props: { count: 1337 } }) await expect(component.locator('#rerender-count')).toContainText('1337') - + await component.rerender({ props: { count: 42 } }) await expect(component.locator('#rerender-count')).toContainText('42') @@ -120,3 +121,10 @@ test('unmount a multi root component', async ({ mount, page }) => { await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 2') }) + +test('get textContent of the empty template', async ({ mount }) => { + const component = await mount(EmptyTemplate); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); diff --git a/tests/components/ct-vue2-cli/src/components/EmptyTemplate.vue b/tests/components/ct-vue2-cli/src/components/EmptyTemplate.vue new file mode 100644 index 0000000000..ba5341392f --- /dev/null +++ b/tests/components/ct-vue2-cli/src/components/EmptyTemplate.vue @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/tests/components/ct-vue2-cli/src/notation-jsx.spec.tsx b/tests/components/ct-vue2-cli/src/notation-jsx.spec.tsx index e37a8232a9..ba3be167cf 100644 --- a/tests/components/ct-vue2-cli/src/notation-jsx.spec.tsx +++ b/tests/components/ct-vue2-cli/src/notation-jsx.spec.tsx @@ -3,6 +3,7 @@ import Button from './components/Button.vue' import Counter from './components/Counter.vue' import DefaultSlot from './components/DefaultSlot.vue' import NamedSlots from './components/NamedSlots.vue' +import EmptyTemplate from './components/EmptyTemplate.vue' test.use({ viewport: { width: 500, height: 500 } }) @@ -14,10 +15,10 @@ test('render props', async ({ mount }) => { test('renderer and keep the component instance intact', async ({ mount }) => { const component = await mount() await expect(component.locator('#rerender-count')).toContainText('9001') - + await component.rerender({ props: { count: 1337 } }) await expect(component.locator('#rerender-count')).toContainText('1337') - + await component.rerender({ props: { count: 42 } }) await expect(component.locator('#rerender-count')).toContainText('42') @@ -83,3 +84,10 @@ test('run hooks', async ({ page, mount }) => { }) expect(messages).toEqual(['Before mount: {\"route\":\"A\"}', 'After mount el: HTMLButtonElement']) }) + +test('get textContent of the empty template', async ({ mount }) => { + const component = await mount(); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +}); diff --git a/tests/components/ct-vue2-cli/src/notation-vue.spec.ts b/tests/components/ct-vue2-cli/src/notation-vue.spec.ts index 255c955db1..a67685f415 100644 --- a/tests/components/ct-vue2-cli/src/notation-vue.spec.ts +++ b/tests/components/ct-vue2-cli/src/notation-vue.spec.ts @@ -4,6 +4,7 @@ import Counter from './components/Counter.vue' import DefaultSlot from './components/DefaultSlot.vue' import NamedSlots from './components/NamedSlots.vue' import Component from './components/Component.vue' +import EmptyTemplate from './components/EmptyTemplate.vue' test.use({ viewport: { width: 500, height: 500 } }) @@ -106,3 +107,10 @@ test('unmount', async ({ page, mount }) => { await component.unmount(); await expect(page.locator('#root')).not.toContainText('Submit'); }); + +test('get textContent of the empty template', async ({ mount }) => { + const component = await mount(EmptyTemplate); + expect(await component.allTextContents()).toEqual(['']); + expect(await component.textContent()).toBe(''); + await expect(component).toHaveText(''); +});