feat(ct): react rerender (#16549)

This commit is contained in:
sand4rt 2022-08-15 22:10:38 +02:00 committed by GitHub
parent d133b58a96
commit a62accf8ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 231 additions and 47 deletions

View File

@ -34,12 +34,17 @@ export type PlaywrightTestConfig = Omit<BasePlaywrightTestConfig, 'use'> & {
} }
}; };
export interface MountOptions {
hooksConfig?: any;
}
interface MountResult extends Locator { interface MountResult extends Locator {
unmount(): Promise<void>; unmount(): Promise<void>;
rerender(component: JSX.Element): Promise<void>;
} }
export interface ComponentFixtures { export interface ComponentFixtures {
mount(component: JSX.Element, options?: { hooksConfig?: any }): Promise<MountResult>; mount(component: JSX.Element, options?: MountOptions): Promise<MountResult>;
} }
export const test: TestType< export const test: TestType<

View File

@ -82,3 +82,7 @@ window.playwrightUnmount = async rootElement => {
if (!ReactDOM.unmountComponentAtNode(rootElement)) if (!ReactDOM.unmountComponentAtNode(rootElement))
throw new Error('Component was not mounted'); throw new Error('Component was not mounted');
}; };
window.playwrightRerender = async (rootElement, component) => {
ReactDOM.render(render(/** @type {Component} */(component)), rootElement);
};

View File

@ -21,7 +21,7 @@ let boundCallbacksForMount: Function[] = [];
interface MountResult extends Locator { interface MountResult extends Locator {
unmount(locator: Locator): Promise<void>; unmount(locator: Locator): Promise<void>;
rerender(options: Omit<MountOptions, 'hooksConfig'>): Promise<void>; rerender(options: Omit<MountOptions, 'hooksConfig'> | string | JsxComponent): Promise<void>;
} }
export const fixtures: Fixtures< export const fixtures: Fixtures<
@ -60,11 +60,8 @@ export const fixtures: Fixtures<
await window.playwrightUnmount(rootElement); await window.playwrightUnmount(rootElement);
}); });
}, },
rerender: async (options: Omit<MountOptions, 'hooksConfig'>) => { rerender: async (component: JsxComponent | string, options?: Omit<MountOptions, 'hooksConfig'>) => {
await locator.evaluate(async (element, options) => { await innerRerender(page, component, options);
const rootElement = document.getElementById('root')!;
return await window.playwrightRerender(rootElement, options);
}, options);
} }
}); });
}); });
@ -72,13 +69,32 @@ export const fixtures: Fixtures<
}, },
}; };
async function innerMount(page: Page, jsxOrType: JsxComponent | string, options: MountOptions = {}): Promise<string> { async function innerRerender(page: Page, jsxOrType: JsxComponent | string, options: Omit<MountOptions, 'hooksConfig'> = {}): Promise<void> {
let component: Component; const component = createComponent(jsxOrType, options);
if (typeof jsxOrType === 'string') wrapFunctions(component, page, boundCallbacksForMount);
component = { kind: 'object', type: jsxOrType, options };
else
component = jsxOrType;
await page.evaluate(async ({ component }) => {
const unwrapFunctions = (object: any) => {
for (const [key, value] of Object.entries(object)) {
if (typeof value === 'string' && (value as string).startsWith('__pw_func_')) {
const ordinal = +value.substring('__pw_func_'.length);
object[key] = (...args: any[]) => {
(window as any)['__ct_dispatch'](ordinal, args);
};
} else if (typeof value === 'object' && value) {
unwrapFunctions(value);
}
}
};
unwrapFunctions(component);
const rootElement = document.getElementById('root')!;
return await window.playwrightRerender(rootElement, component);
}, { component });
}
async function innerMount(page: Page, jsxOrType: JsxComponent | string, options: MountOptions = {}): Promise<string> {
const component = createComponent(jsxOrType, options);
wrapFunctions(component, page, boundCallbacksForMount); wrapFunctions(component, page, boundCallbacksForMount);
// WebKit does not wait for deferred scripts. // WebKit does not wait for deferred scripts.
@ -114,6 +130,11 @@ async function innerMount(page: Page, jsxOrType: JsxComponent | string, options:
return selector; return selector;
} }
function createComponent(jsxOrType: JsxComponent | string, options: Omit<MountOptions, 'hooksConfig'> = {}): Component {
if (typeof jsxOrType !== 'string') return jsxOrType;
return { kind: 'object', type: jsxOrType, options };
}
function wrapFunctions(object: any, page: Page, callbacks: Function[]) { function wrapFunctions(object: any, page: Page, callbacks: Function[]) {
for (const [key, value] of Object.entries(object)) { for (const [key, value] of Object.entries(object)) {
const type = typeof value; const type = typeof value;

View File

@ -40,6 +40,6 @@ declare global {
interface Window { interface Window {
playwrightMount(component: Component, rootElement: Element, hooksConfig: any): Promise<void>; playwrightMount(component: Component, rootElement: Element, hooksConfig: any): Promise<void>;
playwrightUnmount(rootElement: Element): Promise<void>; playwrightUnmount(rootElement: Element): Promise<void>;
playwrightRerender(rootElement: Element, options: Omit<MountOptions, 'hooksConfig'>): Promise<void>; playwrightRerender(rootElement: Element, optionsOrComponent: Omit<MountOptions, 'hooksConfig'> | Component): Promise<void>;
} }
} }

View File

@ -1,7 +1,5 @@
//@ts-check //@ts-check
import '../src/assets/index.css';
import '../src/index.css';
import { beforeMount, afterMount } from '@playwright/experimental-ct-react/hooks'; import { beforeMount, afterMount } from '@playwright/experimental-ct-react/hooks';
beforeMount(async ({ hooksConfig }) => { beforeMount(async ({ hooksConfig }) => {

View File

@ -1,27 +0,0 @@
import { test, expect } from '@playwright/experimental-ct-react';
import App from './App';
test.use({ viewport: { width: 500, height: 500 } });
test('should work', async ({ mount }) => {
const component = await mount(<App></App>);
await expect(component).toContainText('Hello Vite + React!');
});
test('should configure app', async ({ page, mount }) => {
const messages: string[] = [];
page.on('console', m => messages.push(m.text()));
await mount(<App></App>, {
hooksConfig: {
route: 'A'
}
});
expect(messages).toEqual(['Before mount: {\"route\":\"A\"}', 'After mount']);
});
test('should unmount', async ({ page, mount }) => {
const component = await mount(<App></App>);
await expect(page.locator('#root')).toContainText('Hello Vite + React!');
await component.unmount();
await expect(page.locator('#root')).not.toContainText('Hello Vite + React!');
});

View File

@ -1,6 +1,5 @@
import React from 'react'
import { useState } from 'react' import { useState } from 'react'
import logo from './logo.svg' import logo from './components/logo.svg'
import './App.css' import './App.css'
function App() { function App() {

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,7 @@
type ButtonProps = {
title: string;
onClick?(props: string): void;
}
export default function Button(props: ButtonProps) {
return <button onClick={() => props.onClick?.('hello')}>{props.title}</button>
}

View File

@ -0,0 +1,18 @@
import { useRef } from "react"
type CounterProps = {
count?: number;
onClick?(props: string): void;
children?: any;
}
let _remountCount = 1;
export default function Counter(props: CounterProps) {
const remountCount = useRef(_remountCount++);
return <div onClick={() => props.onClick?.('hello')}>
<div id="props">{ props.count }</div>
<div id="remount-count">{ remountCount.current }</div>
{ props.children }
</div>
}

View File

@ -0,0 +1,15 @@
type DefaultChildrenProps = {
children?: any;
}
export default function DefaultChildren(props: DefaultChildrenProps) {
return <div>
<h1>Welcome!</h1>
<main>
{props.children}
</main>
<footer>
Thanks for visiting.
</footer>
</div>
}

View File

@ -0,0 +1,6 @@
export default function MultiRoot() {
return <>
<div>root 1</div>
<div>root 2</div>
</>
}

View File

@ -0,0 +1,18 @@
type MultipleChildrenProps = {
children?: [any, any, any];
}
export default function MultipleChildren(props: MultipleChildrenProps) {
return <div>
<header>
{props.children?.at(0)}
</header>
<main>
{props.children?.at(1)}
</main>
<footer>
{props.children?.at(2)}
</footer>
</div>
}

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import './index.css' import './assets/index.css'
import App from './App' import App from './App'
ReactDOM.render( ReactDOM.render(

View File

@ -0,0 +1,120 @@
import { test, expect } from '@playwright/experimental-ct-react';
import Button from './components/Button';
import DefaultChildren from './components/DefaultChildren';
import MultipleChildren from './components/MultipleChildren';
import MultiRoot from './components/MultiRoot';
import Counter from './components/Counter';
test.use({ viewport: { width: 500, height: 500 } });
test('props should work', async ({ mount }) => {
const component = await mount(<Button title="Submit" />);
await expect(component).toContainText('Submit');
});
test('renderer updates props without remounting', async ({ mount }) => {
const component = await mount(<Counter count={9001} />)
await expect(component.locator('#props')).toContainText('9001')
await component.rerender(<Counter 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 callbacks without remounting', async ({ mount }) => {
const component = await mount(<Counter />)
const messages: string[] = []
await component.rerender(<Counter onClick={message => {
messages.push(message)
}} />)
await component.click();
expect(messages).toEqual(['hello'])
await expect(component.locator('#remount-count')).toContainText('1')
})
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>Test Slot</Counter>)
await expect(component).not.toContainText('Default Slot')
await expect(component).toContainText('Test Slot')
await expect(component.locator('#remount-count')).toContainText('1')
})
test('callback should work', async ({ mount }) => {
const messages: string[] = []
const component = await mount(<Button title="Submit" onClick={data => {
messages.push(data)
}}></Button>)
await component.click()
expect(messages).toEqual(['hello'])
})
test('default slot should work', async ({ mount }) => {
const component = await mount(<DefaultChildren>
Main Content
</DefaultChildren>)
await expect(component).toContainText('Main Content')
})
test('multiple children should work', async ({ mount }) => {
const component = await mount(<DefaultChildren>
<div id="one">One</div>
<div id="two">Two</div>
</DefaultChildren>)
await expect(component.locator('#one')).toContainText('One')
await expect(component.locator('#two')).toContainText('Two')
})
test('named children should work', async ({ mount }) => {
const component = await mount(<MultipleChildren>
<div>Header</div>
<div>Main Content</div>
<div>Footer</div>
</MultipleChildren>);
await expect(component).toContainText('Header')
await expect(component).toContainText('Main Content')
await expect(component).toContainText('Footer')
})
test('children should callback', async ({ mount }) => {
let clickFired = false;
const component = await mount(<DefaultChildren>
<span onClick={() => clickFired = true}>Main Content</span>
</DefaultChildren>);
await component.locator('text=Main Content').click();
expect(clickFired).toBeTruthy();
})
test('should run hooks', async ({ page, mount }) => {
const messages: string[] = [];
page.on('console', m => messages.push(m.text()));
await mount(<Button title="Submit" />, {
hooksConfig: {
route: 'A'
}
});
expect(messages).toEqual(['Before mount: {\"route\":\"A\"}', 'After mount']);
});
test('should unmount', async ({ page, mount }) => {
const component = await mount(<Button title="Submit" />)
await expect(page.locator('#root')).toContainText('Submit')
await component.unmount();
await expect(page.locator('#root')).not.toContainText('Submit');
});
test('unmount a multi root component should work', async ({ mount, page }) => {
const component = await mount(<MultiRoot />)
await expect(page.locator('#root')).toContainText('root 1')
await expect(page.locator('#root')).toContainText('root 2')
await component.unmount()
await expect(page.locator('#root')).not.toContainText('root 1')
await expect(page.locator('#root')).not.toContainText('root 2')
})