Files
Imhotep/packages/imhotep-fixtures/src/e2e-property-react.test.ts
T

159 lines
5.5 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { imhotepComponent, react, getDefaultRendererRegistry } from 'imhotep-playwright'
import type { Page } from 'playwright'
import { generatedDomain } from 'imhotep-core/property-contracts'
import fc from 'fast-check'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
function fixtureUrl(name: string): string {
return 'file://' + resolve(__dirname, 'pages', `${name}.html`)
}
/** Custom React 18 adapter that properly unmounts roots between property runs. */
function createReact18Adapter() {
return {
id: 'react',
async mount(page: Page, _target: unknown, input: unknown) {
await page.evaluate((props) => {
const container = document.getElementById('__imhotep-mount')
if (!container) throw new Error('Mount container #__imhotep-mount not found')
// Unmount any previous React root to avoid duplicate React roots.
const prevRoot = (window as any).__imhotepReactRoot
if (prevRoot) {
prevRoot.unmount()
}
container.innerHTML = ''
const Component = (window as any).__imhotepComponents?.Button
if (!Component) throw new Error('Button component not found on window.__imhotepComponents')
const createElement = (window as any).__imhotepCreateElement
const reactDom = (window as any).__imhotepReactDom
const element = createElement(Component, props)
const root = reactDom.createRoot(container)
;(window as any).__imhotepReactRoot = root
root.render(element)
}, input)
},
async unmount(page: Page) {
await page.evaluate(() => {
const root = (window as any).__imhotepReactRoot
if (root) {
root.unmount()
;(window as any).__imhotepReactRoot = null
}
const container = document.getElementById('__imhotep-mount')
if (container) container.innerHTML = ''
})
}
}
}
test.describe('E2E: React component sampled props', () => {
test('forAllProps mounts component with generated props and asserts layout', async ({ page }) => {
// Navigate to the component fixture page first
await page.goto(fixtureUrl('property-component-react'))
await page.waitForSelector('#__imhotep-mount', { state: 'attached' })
// Register the React renderer adapter
const registry = getDefaultRendererRegistry()
registry.register('react', createReact18Adapter())
const handle = imhotepComponent('Button', {
renderer: react({ reactDom: {}, createElement: () => null }),
fc: fc as any,
})
const domain = generatedDomain(
fc.record({
size: fc.constantFrom('sm', 'md', 'lg'),
label: fc.string({ minLength: 1, maxLength: 15 }),
disabled: fc.boolean(),
}),
{ seed: 42, numRuns: 15 }
)
let runCount = 0
const result = await handle.forAllProps(
page,
domain,
async (scene, _ctx) => {
runCount++
const data = await scene.extract('[data-testid="component-button"]')
expect(Array.isArray(data)).toBe(true)
expect((data as any[]).length).toBe(1)
const box = (data as any[])[0].rect as { x: number; y: number; width: number; height: number }
// Assert button is inside viewport
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.y).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(1280)
expect(box.y + box.height).toBeLessThanOrEqual(720)
// Assert button has minimum width
expect(box.width).toBeGreaterThanOrEqual(44)
expect(box.height).toBeGreaterThanOrEqual(32)
}
)
expect(result.passed).toBe(true)
expect(result.mode).toBe('sampled')
expect(result.seed).toBe(42)
expect(runCount).toBe(15)
})
test('component receives actual props not just metadata', async ({ page }) => {
await page.goto(fixtureUrl('property-component-react'))
await page.waitForSelector('#__imhotep-mount', { state: 'attached' })
const registry = getDefaultRendererRegistry()
registry.register('react', createReact18Adapter())
const handle = imhotepComponent('Button', {
renderer: react({ reactDom: {}, createElement: () => null }),
fc: fc as any,
})
const domain = generatedDomain(
fc.record({
size: fc.constantFrom('sm', 'md', 'lg'),
label: fc.string({ minLength: 1, maxLength: 10 }),
}),
{ seed: 77, numRuns: 10 }
)
const observedProps: Array<{ size: string; label: string }> = []
const result = await handle.forAllProps(
page,
domain,
async (scene, ctx) => {
const input = ctx.input as { size: string; label: string }
observedProps.push(input)
// Verify the DOM reflects the actual props
const btn = page.locator('[data-testid="component-button"]')
const text = await btn.textContent()
expect(text).toBe(input.label)
// Verify size class is applied
const classAttr = await btn.getAttribute('class')
expect(classAttr).toContain('btn-' + input.size)
const data = await scene.extract('[data-testid="component-button"]')
expect((data as any[]).length).toBe(1)
}
)
expect(result.passed).toBe(true)
expect(observedProps.length).toBeGreaterThan(0)
// Verify we saw different sizes (probabilistic but very likely with 10 runs)
const sizes = new Set(observedProps.map(p => p.size))
expect(sizes.size).toBeGreaterThanOrEqual(1)
})
})