159 lines
5.5 KiB
TypeScript
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)
|
||
|
|
})
|
||
|
|
})
|