feat(ct): vue2 rerender complete (#17929)

Partial fix for https://github.com/microsoft/playwright/issues/15919
This commit is contained in:
sand4rt 2022-10-10 21:49:52 +02:00 committed by GitHub
parent 410b4447c8
commit da9276bd03
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 137 additions and 39 deletions

View file

@ -42,10 +42,10 @@ type JsonObject = { [Key in string]?: JsonValue };
type Slot = string | string[]; type Slot = string | string[];
export interface MountOptions<Props = Record<string, unknown>> { export interface MountOptions<Props = Record<string, unknown>> {
props?: Props, props?: Props;
slots?: Record<string, Slot> & { default?: Slot }; slots?: Record<string, Slot> & { default?: Slot };
on?: Record<string, Function>, on?: Record<string, Function>;
hooksConfig?: JsonObject, hooksConfig?: JsonObject;
} }
interface MountResult<Props = Record<string, unknown>> extends Locator { interface MountResult<Props = Record<string, unknown>> extends Locator {
@ -55,7 +55,7 @@ interface MountResult<Props = Record<string, unknown>> extends Locator {
interface MountResultJsx extends Locator { interface MountResultJsx extends Locator {
unmount(): Promise<void>; unmount(): Promise<void>;
rerender(props: JSX.Element): Promise<void> rerender(component: JSX.Element): Promise<void>
} }
export interface ComponentFixtures { export interface ComponentFixtures {

View file

@ -50,11 +50,16 @@ export interface MountOptions<Props = Record<string, unknown>> {
interface MountResult<Props = Record<string, unknown>> extends Locator { interface MountResult<Props = Record<string, unknown>> extends Locator {
unmount(): Promise<void>; unmount(): Promise<void>;
rerender(options: { props: Props }): Promise<void> rerender(options: Omit<MountOptions<Props>, 'hooksConfig'>): Promise<void>
}
interface MountResultJsx extends Locator {
unmount(): Promise<void>;
rerender(component: JSX.Element): Promise<void>
} }
export interface ComponentFixtures { export interface ComponentFixtures {
mount(component: JSX.Element): Promise<MountResult>; mount(component: JSX.Element): Promise<MountResultJsx>;
mount(component: any, options?: MountOptions): Promise<MountResult>; mount(component: any, options?: MountOptions): Promise<MountResult>;
mount<Props>(component: any, options: MountOptions & { props: Props }): Promise<MountResult<Props>>; mount<Props>(component: any, options: MountOptions & { props: Props }): Promise<MountResult<Props>>;
} }

View file

@ -37,18 +37,16 @@ export function register(components) {
/** /**
* @param {Component | string} child * @param {Component | string} child
* @param {import('vue').CreateElement} h * @param {import('vue').CreateElement} h
* @returns {import('vue').VNode | string}
*/ */
function renderChild(child, h) { function createChild(child, h) {
return typeof child === 'string' ? child : render(child, h); return typeof child === 'string' ? child : createWrapper(child, h);
} }
/** /**
* @param {Component} component * @param {Component} component
* @param {import('vue').CreateElement} h * @param {import('vue').CreateElement} h
* @returns {import('vue').VNode}
*/ */
function render(component, h) { function createComponent(component, h) {
/** /**
* @type {import('vue').Component | string | undefined} * @type {import('vue').Component | string | undefined}
*/ */
@ -87,9 +85,9 @@ function render(component, h) {
if (typeof child !== 'string' && child.type === 'template' && child.kind === 'jsx') { if (typeof child !== 'string' && child.type === 'template' && child.kind === 'jsx') {
const slotProperty = Object.keys(child.props).find(k => k.startsWith('v-slot:')); const slotProperty = Object.keys(child.props).find(k => k.startsWith('v-slot:'));
const slot = slotProperty ? slotProperty.substring('v-slot:'.length) : 'default'; const slot = slotProperty ? slotProperty.substring('v-slot:'.length) : 'default';
nodeData.scopedSlots[slot] = () => child.children.map(c => renderChild(c, h)); nodeData.scopedSlots[slot] = () => child.children.map(c => createChild(c, h));
} else { } else {
children.push(renderChild(child, h)); children.push(createChild(child, h));
} }
} }
@ -110,7 +108,7 @@ function render(component, h) {
// Vue test util syntax. // Vue test util syntax.
const options = component.options || {}; const options = component.options || {};
for (const [key, value] of Object.entries(options.slots || {})) { for (const [key, value] of Object.entries(options.slots || {})) {
const list = (Array.isArray(value) ? value : [value]).map(v => renderChild(v, h)); const list = (Array.isArray(value) ? value : [value]).map(v => createChild(v, h));
if (key === 'default') if (key === 'default')
children.push(...list); children.push(...list);
else else
@ -130,18 +128,33 @@ function render(component, h) {
lastArg = children; lastArg = children;
} }
const wrapper = h(componentFunc, nodeData, lastArg); return { Component: componentFunc, nodeData, slots: lastArg };
}
/**
* @param {Component} component
* @param {import('vue').CreateElement} h
* @returns {import('vue').VNode}
*/
function createWrapper(component, h) {
const { Component, nodeData, slots } = createComponent(component, h);
const wrapper = h(Component, nodeData, slots);
return wrapper; return wrapper;
} }
const instanceKey = Symbol('instanceKey'); const instanceKey = Symbol('instanceKey');
const wrapperKey = Symbol('wrapperKey');
window.playwrightMount = async (component, rootElement, hooksConfig) => { window.playwrightMount = async (component, rootElement, hooksConfig) => {
for (const hook of /** @type {any} */(window).__pw_hooks_before_mount || []) for (const hook of /** @type {any} */(window).__pw_hooks_before_mount || [])
await hook({ hooksConfig }); await hook({ hooksConfig });
const instance = new Vue({ const instance = new Vue({
render: h => render(component, h), render: h => {
const wrapper = createWrapper(component, h);
/** @type {any} */ (rootElement)[wrapperKey] = wrapper;
return wrapper;
},
}).$mount(); }).$mount();
rootElement.appendChild(instance.$el); rootElement.appendChild(instance.$el);
/** @type {any} */ (rootElement)[instanceKey] = instance; /** @type {any} */ (rootElement)[instanceKey] = instance;
@ -159,10 +172,30 @@ window.playwrightUnmount = async rootElement => {
}; };
window.playwrightRerender = async (element, options) => { window.playwrightRerender = async (element, options) => {
const component = /** @type {any} */(element)[instanceKey]; const wrapper = /** @type {any} */(element)[wrapperKey];
if (!component) if (!wrapper)
throw new Error('Component was not mounted'); throw new Error('Component was not mounted');
for (const [key, value] of Object.entries(/** @type {any} */(options).props || /** @type {any} */(options).options.props)) const component = wrapper.componentInstance;
component.$children[0][key] = value; const { nodeData, slots } = createComponent(options, component.$createElement);
for (const [name] of Object.entries(component.$listeners || {})) {
component.$off(name);
delete component.$listeners[name];
}
for (const [name, value] of Object.entries(nodeData.on || {})) {
component.$on(name, value);
component.$listeners[name] = value;
}
Object.assign(component.$scopedSlots, nodeData.scopedSlots);
component.$slots.default = slots;
for (const [key, value] of Object.entries(nodeData.props || {}))
component[key] = value;
if (!Object.keys(nodeData.props || {}).length)
component.$forceUpdate();
}; };

View file

@ -1,8 +1,10 @@
<template> <template>
<div> <div @click="$emit('submit', 'hello')">
<span id="remount-count">{{ remountCount }}</span> <div id="props">{{ count }}</div>
<span id="rerender-count">{{ count }}</span> <div id="remount-count">{{ remountCount }}</div>
</div> <slot name="main" />
<slot />
</div>
</template> </template>
<script> <script>

View file

@ -12,15 +12,39 @@ test('render props', async ({ mount }) => {
await expect(component).toContainText('Submit') await expect(component).toContainText('Submit')
}) })
test('renderer and keep the component instance intact', async ({ mount }) => { test('renderer updates props without remounting', async ({ mount }) => {
const component = await mount(<Counter count={9001} />) const component = await mount(<Counter count={9001} />)
await expect(component.locator('#rerender-count')).toContainText('9001') await expect(component.locator('#props')).toContainText('9001')
await component.rerender(<Counter count={1337} />) await component.rerender(<Counter count={1337} />)
await expect(component.locator('#rerender-count')).toContainText('1337') await expect(component).not.toContainText('9001')
await expect(component.locator('#props')).toContainText('1337')
await expect(component.locator('#remount-count')).toContainText('1')
})
test('renderer updates event listeners without remounting', async ({ mount }) => {
const messages = []
const component = await mount(<Counter />)
await component.rerender(<Counter v-on:submit={count => {
messages.push(count)
}} />)
await component.click();
expect(messages).toEqual(['hello'])
await component.rerender(<Counter count={42} />) await expect(component.locator('#remount-count')).toContainText('1')
await expect(component.locator('#rerender-count')).toContainText('42') })
test('renderer updates slots without remounting', async ({ mount }) => {
const component = await mount(<Counter>Default Slot</Counter>)
await expect(component).toContainText('Default Slot')
await component.rerender(<Counter>
<template v-slot:main>Test Slot</template>
</Counter>)
await expect(component).not.toContainText('Default Slot')
await expect(component).toContainText('Test Slot')
await expect(component.locator('#remount-count')).toContainText('1') await expect(component.locator('#remount-count')).toContainText('1')
}) })

View file

@ -17,19 +17,53 @@ test('render props', async ({ mount }) => {
await expect(component).toContainText('Submit') await expect(component).toContainText('Submit')
}) })
test('renderer and keep the component instance intact', async ({ mount }) => { test('renderer updates props without remounting', async ({ mount }) => {
const component = await mount<{ count: number }>(Counter, { const component = await mount(Counter, {
props: { props: { count: 9001 }
count: 9001 })
await expect(component.locator('#props')).toContainText('9001')
await component.rerender({
props: { count: 1337 }
})
await expect(component).not.toContainText('9001')
await expect(component.locator('#props')).toContainText('1337')
await expect(component.locator('#remount-count')).toContainText('1')
})
test('renderer updates event listeners without remounting', async ({ mount }) => {
const component = await mount(Counter)
const messages = []
await component.rerender({
on: {
submit: data => messages.push(data)
} }
}) })
await expect(component.locator('#rerender-count')).toContainText('9001') await component.click();
expect(messages).toEqual(['hello'])
await expect(component.locator('#remount-count')).toContainText('1')
})
await component.rerender({ props: { count: 1337 } }) test('renderer updates slots without remounting', async ({ mount }) => {
await expect(component.locator('#rerender-count')).toContainText('1337') const component = await mount(Counter, {
slots: { default: 'Default Slot' }
})
await expect(component).toContainText('Default Slot')
await component.rerender({
slots: { main: 'Test Slot' }
})
await expect(component).not.toContainText('Default Slot')
await expect(component).toContainText('Test Slot')
await component.rerender({
slots: { default: 'Default Slot' }
})
await expect(component).toContainText('Default Slot')
await component.rerender({ props: { count: 42 } })
await expect(component.locator('#rerender-count')).toContainText('42')
await expect(component.locator('#remount-count')).toContainText('1') await expect(component.locator('#remount-count')).toContainText('1')
}) })