v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* React renderer adapter for Imhotep Playwright.
|
||||
*
|
||||
* Mounts a React component into a container div on the page.
|
||||
* Uses ReactDOM.render or createRoot depending on what is available
|
||||
* in the injected React runtime.
|
||||
*/
|
||||
|
||||
import { Page } from 'playwright'
|
||||
import type { SceneTarget } from 'imhotep-core/scene-target'
|
||||
import { RendererAdapter } from './renderers.js'
|
||||
|
||||
export interface ReactAdapterOptions {
|
||||
/**
|
||||
* ReactDOM entry point; used for capability detection at adapter
|
||||
* construction time (e.g. determining React 18+ createRoot support).
|
||||
* The actual runtime must be exposed on the page as
|
||||
* `window.__imhotepReactDom` and `window.__imhotepCreateElement`.
|
||||
*/
|
||||
reactDom: {
|
||||
render?(element: unknown, container: Element): void
|
||||
createRoot?(container: Element): { render(element: unknown): void }
|
||||
}
|
||||
/**
|
||||
* React.createElement entry point; used alongside reactDom for capability
|
||||
* detection. The actual runtime must be exposed on the page as
|
||||
* `window.__imhotepCreateElement`.
|
||||
*/
|
||||
createElement: (type: unknown, props?: Record<string, unknown>, ...children: unknown[]) => unknown
|
||||
/** Container selector or id to mount into. */
|
||||
containerSelector?: string
|
||||
}
|
||||
|
||||
export function createReactAdapter(options: ReactAdapterOptions): RendererAdapter {
|
||||
const { reactDom, createElement, containerSelector = '#__imhotep-mount' } = options
|
||||
|
||||
return {
|
||||
id: 'react',
|
||||
|
||||
async mount(page: Page, target: SceneTarget, input: unknown): Promise<void> {
|
||||
if (target.kind !== 'react-component') {
|
||||
throw new Error(`React adapter received non-React target: ${target.kind}`)
|
||||
}
|
||||
|
||||
// The component is resolved externally; we receive it as the input.
|
||||
// If input is a function/component reference, pass it directly.
|
||||
// If input is props, the component must be bundled into the page
|
||||
// or provided via the target's componentId.
|
||||
const component = (input as Record<string, unknown>)?.component ?? target.componentId
|
||||
const props = (input as Record<string, unknown>)?.props ?? input
|
||||
|
||||
await page.evaluate(
|
||||
(args: {
|
||||
componentRef: string
|
||||
propsJson: string
|
||||
containerSelector: string
|
||||
hasCreateRoot: boolean
|
||||
}) => {
|
||||
const container = document.querySelector(args.containerSelector)
|
||||
if (!container) {
|
||||
throw new Error(`Mount container not found: ${args.containerSelector}`)
|
||||
}
|
||||
|
||||
// Clear previous content.
|
||||
container.innerHTML = ''
|
||||
|
||||
// Check for React runtime BEFORE looking for the component.
|
||||
// This gives clearer errors when React is missing vs component missing.
|
||||
const reactDom = (window as unknown as Record<string, unknown>).__imhotepReactDom as
|
||||
| {
|
||||
render?(element: unknown, container: Element): void
|
||||
createRoot?(container: Element): { render(element: unknown): void }
|
||||
}
|
||||
| undefined
|
||||
|
||||
if (!reactDom) {
|
||||
throw new Error(
|
||||
'ReactDOM not found on window.__imhotepReactDom. ' +
|
||||
'Expose ReactDOM as window.__imhotepReactDom before mounting. ' +
|
||||
'Example: window.__imhotepReactDom = ReactDOM'
|
||||
)
|
||||
}
|
||||
|
||||
const createElement = (window as unknown as Record<string, unknown>).__imhotepCreateElement as
|
||||
| ((type: unknown, props?: Record<string, unknown>) => unknown)
|
||||
| undefined
|
||||
|
||||
if (!createElement) {
|
||||
throw new Error(
|
||||
'createElement not found on window.__imhotepCreateElement. ' +
|
||||
'Expose React.createElement as window.__imhotepCreateElement before mounting. ' +
|
||||
'Example: window.__imhotepCreateElement = React.createElement'
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve the component from the global scope.
|
||||
// In a real setup, the page bundles components into window.__imhotepComponents.
|
||||
const componentMap = (window as unknown as Record<string, unknown>).__imhotepComponents as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const Component = componentMap?.[args.componentRef] ?? (window as unknown as Record<string, unknown>)[args.componentRef]
|
||||
|
||||
if (!Component) {
|
||||
throw new Error(
|
||||
`Component "${args.componentRef}" not found on window. ` +
|
||||
`Expose it as window.__imhotepComponents = { "${args.componentRef}": MyComponent } ` +
|
||||
`or window["${args.componentRef}"] = MyComponent`
|
||||
)
|
||||
}
|
||||
|
||||
const parsedProps = JSON.parse(args.propsJson)
|
||||
|
||||
const element = createElement(Component, parsedProps)
|
||||
|
||||
if (args.hasCreateRoot && reactDom.createRoot) {
|
||||
const root = reactDom.createRoot(container)
|
||||
root.render(element)
|
||||
} else if (reactDom.render) {
|
||||
reactDom.render(element, container)
|
||||
} else {
|
||||
throw new Error('Neither createRoot nor render available on ReactDOM')
|
||||
}
|
||||
},
|
||||
{
|
||||
componentRef: component as string,
|
||||
propsJson: JSON.stringify(props),
|
||||
containerSelector,
|
||||
hasCreateRoot: !!reactDom.createRoot,
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
async unmount(page: Page): Promise<void> {
|
||||
await page.evaluate((selector: string) => {
|
||||
const container = document.querySelector(selector)
|
||||
if (container) {
|
||||
container.innerHTML = ''
|
||||
}
|
||||
}, containerSelector)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user