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 [];
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(`
(() => {

View file

@ -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;
}

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 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(<Counter />)
const messages: string[] = []
await component.rerender(<Counter onClick={message => {
messages.push(message)
await component.rerender(<Counter onClick={message => {
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(<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 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(<Counter />)
const messages: string[] = []
await component.rerender(<Counter onClick={message => {
messages.push(message)
await component.rerender(<Counter onClick={message => {
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(<EmptyFragment />);
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) => {

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 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(<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 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('');
});

View file

@ -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('');
});

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 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(<Counter 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')
@ -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(<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 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('');
});

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 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(<Counter 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')
@ -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(<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 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('');
});

View file

@ -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('');
});

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 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(<Counter 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')
@ -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(<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 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('');
});