feat(ct): react component as props (#28382)

closes: https://github.com/microsoft/playwright/issues/28367#issuecomment-1830298864
This commit is contained in:
Sander 2023-12-23 05:51:59 +01:00 committed by GitHub
parent afb2582eaa
commit 4d62784eeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 65 additions and 6 deletions

View file

@ -140,7 +140,7 @@ async function innerMount(page: Page, jsxOrType: JsxComponent | string, options:
function createComponent(jsxOrType: JsxComponent | string, options: Omit<MountOptions, 'hooksConfig'> = {}): Component { function createComponent(jsxOrType: JsxComponent | string, options: Omit<MountOptions, 'hooksConfig'> = {}): Component {
if (typeof jsxOrType !== 'string') return jsxOrType; if (typeof jsxOrType !== 'string') return jsxOrType;
return { kind: 'object', type: jsxOrType, options }; return { __pw_component_marker: true, kind: 'object', type: jsxOrType, options };
} }
function wrapFunctions(object: any, page: Page, callbacks: Function[]) { function wrapFunctions(object: any, page: Page, callbacks: Function[]) {

View file

@ -121,7 +121,8 @@ export default declare((api: BabelAPI) => {
children.push(t.spreadElement(child.expression)); children.push(t.spreadElement(child.expression));
} }
const component = [ const component: T.ObjectProperty[] = [
t.objectProperty(t.identifier('__pw_component_marker'), t.booleanLiteral(true)),
t.objectProperty(t.identifier('kind'), t.stringLiteral('jsx')), t.objectProperty(t.identifier('kind'), t.stringLiteral('jsx')),
t.objectProperty(t.identifier('type'), t.stringLiteral(componentName)), t.objectProperty(t.identifier('type'), t.stringLiteral(componentName)),
t.objectProperty(t.identifier('props'), t.objectExpression(props)), t.objectProperty(t.identifier('props'), t.objectExpression(props)),

View file

@ -22,6 +22,7 @@ export type JsonObject = { [Key in string]?: JsonValue };
// JsxComponentChild can be anything, consider cases like: <>{1}</>, <>{null}</> // JsxComponentChild can be anything, consider cases like: <>{1}</>, <>{null}</>
export type JsxComponentChild = JsxComponent | string | number | boolean | null; export type JsxComponentChild = JsxComponent | string | number | boolean | null;
export type JsxComponent = { export type JsxComponent = {
__pw_component_marker: true,
kind: 'jsx', kind: 'jsx',
type: string, type: string,
props: Record<string, any>, props: Record<string, any>,
@ -36,6 +37,7 @@ export type MountOptions = {
}; };
export type ObjectComponent = { export type ObjectComponent = {
__pw_component_marker: true,
kind: 'object', kind: 'object',
type: string, type: string,
options?: MountOptions options?: MountOptions

View file

@ -32,7 +32,7 @@ const __pwRegistry = new Map();
const __pwRootRegistry = new Map(); const __pwRootRegistry = new Map();
/** /**
* @param {{[key: string]: () => Promise<FrameworkComponent>}} components * @param {Record<string, () => Promise<FrameworkComponent>>} components
*/ */
export function pwRegister(components) { export function pwRegister(components) {
for (const [name, value] of Object.entries(components)) for (const [name, value] of Object.entries(components))
@ -44,7 +44,7 @@ export function pwRegister(components) {
* @returns {component is JsxComponent} * @returns {component is JsxComponent}
*/ */
function isComponent(component) { function isComponent(component) {
return !(typeof component !== 'object' || Array.isArray(component)); return component.__pw_component_marker === true && component.kind === 'jsx';
} }
/** /**
@ -73,6 +73,23 @@ async function __pwResolveComponent(component) {
if (component.children?.length) if (component.children?.length)
await Promise.all(component.children.map(child => __pwResolveComponent(child))); await Promise.all(component.children.map(child => __pwResolveComponent(child)));
if (component.props)
await __resolveProps(component.props);
}
/**
* @param {Record<string, any>} props
*/
async function __resolveProps(props) {
for (const prop of Object.values(props)) {
if (Array.isArray(prop))
await Promise.all(prop.map(child => __pwResolveComponent(child)));
else if (isComponent(prop))
await __pwResolveComponent(prop);
else if (typeof prop === 'object' && prop !== null)
await __resolveProps(prop);
}
} }
/** /**
@ -86,18 +103,37 @@ function __renderChild(child) {
return child; return child;
} }
/**
* @param {Record<string, any>} props
*/
function __renderProps(props) {
const newProps = {};
for (const [key, prop] of Object.entries(props)) {
if (Array.isArray(prop))
newProps[key] = prop.map(child => __renderChild(child));
else if (isComponent(prop))
newProps[key] = __renderChild(prop);
else if (typeof prop === 'object' && prop !== null)
newProps[key] = __renderProps(prop);
else
newProps[key] = prop;
}
return newProps;
}
/** /**
* @param {JsxComponent} component * @param {JsxComponent} component
*/ */
function __pwRender(component) { function __pwRender(component) {
const componentFunc = __pwRegistry.get(component.type); const componentFunc = __pwRegistry.get(component.type);
const props = __renderProps(component.props || {});
const children = component.children?.map(child => __renderChild(child)).filter(child => { const children = component.children?.map(child => __renderChild(child)).filter(child => {
if (typeof child === 'string') if (typeof child === 'string')
return !!child.trim(); return !!child.trim();
return true; return true;
}); });
const reactChildren = Array.isArray(children) && children.length === 1 ? children[0] : children; const reactChildren = Array.isArray(children) && children.length === 1 ? children[0] : children;
return __pwReact.createElement(componentFunc || component.type, component.props, reactChildren); return __pwReact.createElement(componentFunc || component.type, props, reactChildren);
} }
window.playwrightMount = async (component, rootElement, hooksConfig) => { window.playwrightMount = async (component, rootElement, hooksConfig) => {
@ -117,7 +153,6 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => {
'Attempting to mount a component into an container that already has a React root' 'Attempting to mount a component into an container that already has a React root'
); );
} }
const root = __pwCreateRoot(rootElement); const root = __pwCreateRoot(rootElement);
__pwRootRegistry.set(rootElement, root); __pwRootRegistry.set(rootElement, root);
root.render(App()); root.render(App());

View file

@ -0,0 +1,9 @@
import { ReactNode } from "react";
type ComponentAsProp = {
component: ReactNode[] | ReactNode;
};
export function ComponentAsProp({ component }: ComponentAsProp) {
return <div>{component}</div>
}

View file

@ -1,12 +1,24 @@
import { test, expect } from '@playwright/experimental-ct-react'; import { test, expect } from '@playwright/experimental-ct-react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import EmptyFragment from '@/components/EmptyFragment'; import EmptyFragment from '@/components/EmptyFragment';
import { ComponentAsProp } from '@/components/ComponentAsProp';
test('render props', async ({ mount }) => { test('render props', async ({ mount }) => {
const component = await mount(<Button title="Submit" />); const component = await mount(<Button title="Submit" />);
await expect(component).toContainText('Submit'); await expect(component).toContainText('Submit');
}); });
test('render component as props', async ({ mount }) => {
const component = await mount(<ComponentAsProp component={<Button title="Submit" />} />);
await expect(component.getByRole('button', { name: 'submit' })).toBeVisible();
});
test('render jsx array as props', async ({ mount }) => {
const component = await mount(<ComponentAsProp component={[<h4>{[4]}</h4>,[[<p>[2,3]</p>]]]} />);
await expect(component.getByRole('heading', { level: 4 })).toHaveText('4');
await expect(component.getByRole('paragraph')).toHaveText('[2,3]');
});
test('render attributes', async ({ mount }) => { test('render attributes', async ({ mount }) => {
const component = await mount(<Button className="primary" title="Submit" />); const component = await mount(<Button className="primary" title="Submit" />);
await expect(component).toHaveClass('primary'); await expect(component).toHaveClass('primary');