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.
This commit is contained in:
Dmitry Gozman 2022-09-21 15:12:18 -07:00 committed by GitHub
parent d431958603
commit f17d345ac9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 143 additions and 36 deletions

View file

@ -278,6 +278,13 @@ export class InjectedScript {
return []; return [];
if (body === 'return-empty') if (body === 'return-empty')
return []; 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}`); throw new Error(`Internal error, unknown control selector ${body}`);
} }
}; };
@ -302,16 +309,6 @@ export class InjectedScript {
return { queryAll }; 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 { extend(source: string, params: any): any {
const constrFunction = globalThis.eval(` const constrFunction = globalThis.eval(`
(() => { (() => {

View file

@ -124,8 +124,7 @@ async function innerMount(page: Page, jsxOrType: JsxComponent | string, options:
await window.playwrightMount(component, rootElement, hooksConfig); await window.playwrightMount(component, rootElement, hooksConfig);
// When mounting fragments, return selector pointing to the root element. return '#root >> control=component';
return rootElement.childNodes.length > 1 ? '#root' : '#root > *';
}, { component, hooksConfig: options.hooksConfig }); }, { component, hooksConfig: options.hooksConfig });
return selector; return selector;
} }

View file

@ -0,0 +1,3 @@
export default function EmptyFragment() {
return <>{[]}</>;
}

View file

@ -4,6 +4,7 @@ import DefaultChildren from './components/DefaultChildren';
import MultipleChildren from './components/MultipleChildren'; import MultipleChildren from './components/MultipleChildren';
import MultiRoot from './components/MultiRoot'; import MultiRoot from './components/MultiRoot';
import Counter from './components/Counter'; import Counter from './components/Counter';
import EmptyFragment from './components/EmptyFragment';
test.use({ viewport: { width: 500, height: 500 } }); test.use({ viewport: { width: 500, height: 500 } });
@ -117,4 +118,11 @@ test('unmount a multi root component', async ({ mount, page }) => {
await component.unmount() await component.unmount()
await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 1')
await expect(page.locator('#root')).not.toContainText('root 2') await expect(page.locator('#root')).not.toContainText('root 2')
}) });
test('get textContent of the empty fragment', async ({ mount }) => {
const component = await mount(<EmptyFragment />);
expect(await component.allTextContents()).toEqual(['']);
expect(await component.textContent()).toBe('');
await expect(component).toHaveText('');
});

View file

@ -0,0 +1,3 @@
export default function EmptyFragment() {
return <>{[]}</>;
}

View file

@ -7,6 +7,7 @@ import DefaultChildren from './components/DefaultChildren';
import MultipleChildren from './components/MultipleChildren'; import MultipleChildren from './components/MultipleChildren';
import MultiRoot from './components/MultiRoot'; import MultiRoot from './components/MultiRoot';
import Counter from './components/Counter'; import Counter from './components/Counter';
import EmptyFragment from './components/EmptyFragment';
test.use({ viewport: { width: 500, height: 500 } }); test.use({ viewport: { width: 500, height: 500 } });
@ -127,6 +128,13 @@ test('render delayed data', async ({ mount }) => {
await expect(component).toHaveText('complete'); await expect(component).toHaveText('complete');
}); });
test('get textContent of the empty fragment', async ({ mount }) => {
const component = await mount(<EmptyFragment />);
expect(await component.allTextContents()).toEqual(['']);
expect(await component.textContent()).toBe('');
await expect(component).toHaveText('');
});
const testWithServer = test.extend(serverFixtures); const testWithServer = test.extend(serverFixtures);
testWithServer('components routing should go through context', async ({ mount, context, server }) => { testWithServer('components routing should go through context', async ({ mount, context, server }) => {
server.setRoute('/hello', (req, res) => { server.setRoute('/hello', (req, res) => {

View file

@ -0,0 +1,3 @@
export default function EmptyFragment() {
return <>{[]}</>;
}

View file

@ -2,6 +2,7 @@ import { test, expect } from '@playwright/experimental-ct-solid'
import Button from './components/Button'; import Button from './components/Button';
import DefaultChildren from './components/DefaultChildren'; import DefaultChildren from './components/DefaultChildren';
import MultiRoot from './components/MultiRoot'; import MultiRoot from './components/MultiRoot';
import EmptyFragment from './components/EmptyFragment';
test.use({ viewport: { width: 500, height: 500 } }); 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 1')
await expect(page.locator('#root')).not.toContainText('root 2') await expect(page.locator('#root')).not.toContainText('root 2')
}); });
test('get textContent of the empty fragment', async ({ mount }) => {
const component = await mount(<EmptyFragment />);
expect(await component.allTextContents()).toEqual(['']);
expect(await component.textContent()).toBe('');
await expect(component).toHaveText('');
});

View file

@ -18,6 +18,7 @@ import { test, expect } from '@playwright/experimental-ct-svelte';
import Button from './components/Button.svelte'; import Button from './components/Button.svelte';
import DefaultSlot from './components/DefaultSlot.svelte'; import DefaultSlot from './components/DefaultSlot.svelte';
import MultiRoot from './components/MultiRoot.svelte'; import MultiRoot from './components/MultiRoot.svelte';
import Empty from './components/Empty.svelte';
test.use({ viewport: { width: 500, height: 500 } }); test.use({ viewport: { width: 500, height: 500 } });
@ -83,4 +84,11 @@ test('unmount a multi root component', async ({ mount, page }) => {
await component.unmount() await component.unmount()
await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 1')
await expect(page.locator('#root')).not.toContainText('root 2') 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('');
});

View file

@ -18,6 +18,7 @@ import { test, expect } from '@playwright/experimental-ct-svelte';
import Button from './components/Button.svelte'; import Button from './components/Button.svelte';
import DefaultSlot from './components/DefaultSlot.svelte'; import DefaultSlot from './components/DefaultSlot.svelte';
import MultiRoot from './components/MultiRoot.svelte'; import MultiRoot from './components/MultiRoot.svelte';
import Empty from './components/Empty.svelte';
test.use({ viewport: { width: 500, height: 500 } }); test.use({ viewport: { width: 500, height: 500 } });
@ -83,4 +84,11 @@ test('unmount a multi root component', async ({ mount, page }) => {
await component.unmount() await component.unmount()
await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 1')
await expect(page.locator('#root')).not.toContainText('root 2') 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('');
});

View file

@ -0,0 +1,2 @@
<template>
</template>

View file

@ -4,6 +4,7 @@ import Counter from './components/Counter.vue'
import DefaultSlot from './components/DefaultSlot.vue' import DefaultSlot from './components/DefaultSlot.vue'
import NamedSlots from './components/NamedSlots.vue' import NamedSlots from './components/NamedSlots.vue'
import MultiRoot from './components/MultiRoot.vue' import MultiRoot from './components/MultiRoot.vue'
import EmptyTemplate from './components/EmptyTemplate.vue'
test.use({ viewport: { width: 500, height: 500 } }) test.use({ viewport: { width: 500, height: 500 } })
@ -92,4 +93,11 @@ test('unmount a multi root component', async ({ mount, page }) => {
await component.unmount() await component.unmount()
await expect(page.locator('#root')).not.toContainText('root 1') await expect(page.locator('#root')).not.toContainText('root 1')
await expect(page.locator('#root')).not.toContainText('root 2') 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('');
});

View file

@ -6,6 +6,7 @@ import DefaultSlot from './components/DefaultSlot.vue'
import NamedSlots from './components/NamedSlots.vue' import NamedSlots from './components/NamedSlots.vue'
import MultiRoot from './components/MultiRoot.vue' import MultiRoot from './components/MultiRoot.vue'
import Component from './components/Component.vue' import Component from './components/Component.vue'
import EmptyTemplate from './components/EmptyTemplate.vue'
test.use({ viewport: { width: 500, height: 500 } }) test.use({ viewport: { width: 500, height: 500 } })
@ -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 1')
await expect(page.locator('#root')).not.toContainText('root 2') 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('');
});

View file

@ -0,0 +1,2 @@
<template>
</template>

View file

@ -4,6 +4,7 @@ import Counter from './components/Counter.vue'
import DefaultSlot from './components/DefaultSlot.vue' import DefaultSlot from './components/DefaultSlot.vue'
import NamedSlots from './components/NamedSlots.vue' import NamedSlots from './components/NamedSlots.vue'
import MultiRoot from './components/MultiRoot.vue' import MultiRoot from './components/MultiRoot.vue'
import EmptyTemplate from './components/EmptyTemplate.vue'
test.use({ viewport: { width: 500, height: 500 } }) test.use({ viewport: { width: 500, height: 500 } })
@ -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 1')
await expect(page.locator('#root')).not.toContainText('root 2') 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('');
});

View file

@ -5,6 +5,7 @@ import DefaultSlot from './components/DefaultSlot.vue'
import MultiRoot from './components/MultiRoot.vue' import MultiRoot from './components/MultiRoot.vue'
import NamedSlots from './components/NamedSlots.vue' import NamedSlots from './components/NamedSlots.vue'
import Component from './components/Component.vue' import Component from './components/Component.vue'
import EmptyTemplate from './components/EmptyTemplate.vue'
test.use({ viewport: { width: 500, height: 500 } }) test.use({ viewport: { width: 500, height: 500 } })
@ -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 1')
await expect(page.locator('#root')).not.toContainText('root 2') 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('');
});

View file

@ -6,6 +6,7 @@ import DefaultSlot from './components/DefaultSlot.vue'
import NamedSlots from './components/NamedSlots.vue' import NamedSlots from './components/NamedSlots.vue'
import MultiRoot from './components/MultiRoot.vue' import MultiRoot from './components/MultiRoot.vue'
import Component from './components/Component.vue' import Component from './components/Component.vue'
import EmptyTemplate from './components/EmptyTemplate.vue'
test.use({ viewport: { width: 500, height: 500 } }) test.use({ viewport: { width: 500, height: 500 } })
@ -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 1')
await expect(page.locator('#root')).not.toContainText('root 2') 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('');
});

View file

@ -0,0 +1,2 @@
<template>
</template>

View file

@ -3,6 +3,7 @@ import Button from './components/Button.vue'
import Counter from './components/Counter.vue' import Counter from './components/Counter.vue'
import DefaultSlot from './components/DefaultSlot.vue' import DefaultSlot from './components/DefaultSlot.vue'
import NamedSlots from './components/NamedSlots.vue' import NamedSlots from './components/NamedSlots.vue'
import EmptyTemplate from './components/EmptyTemplate.vue'
test.use({ viewport: { width: 500, height: 500 } }) test.use({ viewport: { width: 500, height: 500 } })
@ -83,3 +84,10 @@ test('run hooks', async ({ page, mount }) => {
}) })
expect(messages).toEqual(['Before mount: {\"route\":\"A\"}', 'After mount el: HTMLButtonElement']) expect(messages).toEqual(['Before mount: {\"route\":\"A\"}', 'After mount el: HTMLButtonElement'])
}) })
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('');
});

View file

@ -4,6 +4,7 @@ import Counter from './components/Counter.vue'
import DefaultSlot from './components/DefaultSlot.vue' import DefaultSlot from './components/DefaultSlot.vue'
import NamedSlots from './components/NamedSlots.vue' import NamedSlots from './components/NamedSlots.vue'
import Component from './components/Component.vue' import Component from './components/Component.vue'
import EmptyTemplate from './components/EmptyTemplate.vue'
test.use({ viewport: { width: 500, height: 500 } }) test.use({ viewport: { width: 500, height: 500 } })
@ -106,3 +107,10 @@ test('unmount', async ({ page, mount }) => {
await component.unmount(); await component.unmount();
await expect(page.locator('#root')).not.toContainText('Submit'); 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('');
});