v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)

This commit is contained in:
John Dvorak
2025-08-15 10:00:00 -07:00
commit 92deb689cd
321 changed files with 79170 additions and 0 deletions
@@ -0,0 +1,243 @@
/**
* Unit tests for the ContextPool implementation.
*
* All tests use mocked Browser / BrowserContext / Page so that they
* run without a real Playwright installation. Real browser coverage
* is provided by the runtime integration suite.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import type { Browser, BrowserContext, Page } from 'playwright'
import { ContextPool, ContextEntry, ContextPoolOptions } from './context-pool.js'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
interface MockPage extends Page {
_evaluateResult: unknown
_evaluateError?: Error
_evaluateCalled: number
}
interface MockContext extends BrowserContext {
_pages: MockPage[]
_closed: boolean
_closeCalls: number
_newPageCalls: number
}
interface MockBrowser extends Browser {
_contexts: MockContext[]
_newContextCalls: number
}
function createMockPage(evaluateResult: unknown = 1, evaluateError?: Error): MockPage {
const page: MockPage = {
_evaluateResult: evaluateResult,
_evaluateError: evaluateError,
_evaluateCalled: 0,
evaluate: async () => {
page._evaluateCalled++
if (page._evaluateError) {
throw page._evaluateError
}
return page._evaluateResult
},
} as unknown as MockPage
return page
}
function createMockContext(pages: MockPage[] = []): MockContext {
const ctx: MockContext = {
_pages: pages,
_closed: false,
_closeCalls: 0,
_newPageCalls: 0,
pages: () => ctx._pages,
newPage: async () => {
ctx._newPageCalls++
const page = createMockPage()
ctx._pages.push(page)
return page
},
close: async () => {
ctx._closeCalls++
ctx._closed = true
},
} as unknown as MockContext
return ctx
}
function createMockBrowser(): MockBrowser {
const browser: MockBrowser = {
_contexts: [],
_newContextCalls: 0,
newContext: async () => {
browser._newContextCalls++
const ctx = createMockContext([createMockPage()])
browser._contexts.push(ctx)
return ctx
},
} as unknown as MockBrowser
return browser
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ContextPool', () => {
it('acquire creates new context when pool empty', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser)
const ctx = await pool.acquire('env1')
assert.strictEqual(browser._newContextCalls, 1)
assert.ok(ctx)
assert.strictEqual(pool.pools.get('env1')?.length, 1)
})
it('acquire returns pooled context for matching envKey', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser)
const ctx1 = await pool.acquire('env1')
pool.release(ctx1)
const ctx2 = await pool.acquire('env1')
assert.strictEqual(ctx1, ctx2)
assert.strictEqual(browser._newContextCalls, 1)
})
it('acquire creates new context when envKey mismatches', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser)
const ctx1 = await pool.acquire('env1')
pool.release(ctx1)
const ctx2 = await pool.acquire('env2')
assert.notStrictEqual(ctx1, ctx2)
assert.strictEqual(browser._newContextCalls, 2)
})
it('acquire evicts LRU when at maxSize', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser, { maxSize: 1 })
const ctx1 = await pool.acquire('env1') as MockContext
pool.release(ctx1)
// Force ctx1 to be LRU.
const entry1 = pool.pools.get('env1')![0]
entry1.lastUsed = Date.now() - 1000
const ctx2 = await pool.acquire('env2')
assert.strictEqual(ctx1._closeCalls, 1)
assert.notStrictEqual(ctx2, ctx1)
assert.strictEqual(pool.pools.has('env1'), false)
assert.strictEqual(pool.pools.has('env2'), true)
})
it('release returns context to pool without closing', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser)
const ctx = await pool.acquire('env1') as MockContext
assert.strictEqual(ctx._closeCalls, 0)
pool.release(ctx)
assert.strictEqual(ctx._closeCalls, 0)
assert.strictEqual(pool.pools.get('env1')?.length, 1)
})
it('healthCheck marks dead context and removes it', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser, { maxSize: 2 })
// Seed a dead context directly into the pool.
const deadPage = createMockPage(1, new Error('evaluate failed'))
const deadCtx = createMockContext([deadPage])
const entry: ContextEntry = {
context: deadCtx,
envKey: 'env1',
createdAt: Date.now(),
lastUsed: Date.now(),
healthStatus: 'healthy',
}
pool.pools.set('env1', [entry])
const healthy = await pool.healthCheck(entry)
assert.strictEqual(healthy, false)
assert.strictEqual(entry.healthStatus, 'dead')
// Acquire should evict the dead entry and create a new context.
const ctx = await pool.acquire('env1')
assert.notStrictEqual(ctx, deadCtx)
assert.strictEqual((deadCtx as MockContext)._closeCalls, 1)
})
it('reap closes idle contexts', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser, { maxIdleMs: 0 })
const ctx = await pool.acquire('env1') as MockContext
pool.release(ctx)
await new Promise((r) => setTimeout(r, 10))
await pool.reap()
assert.strictEqual(ctx._closeCalls, 1)
assert.strictEqual(pool.pools.has('env1'), false)
})
it('dispose closes all contexts', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser)
const ctx1 = await pool.acquire('env1') as MockContext
const ctx2 = await pool.acquire('env2') as MockContext
await pool.dispose()
assert.strictEqual(ctx1._closeCalls, 1)
assert.strictEqual(ctx2._closeCalls, 1)
assert.strictEqual(pool.pools.size, 0)
})
it('idempotent dispose', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser)
const ctx = await pool.acquire('env1') as MockContext
await pool.dispose()
await pool.dispose() // should not throw
assert.strictEqual(ctx._closeCalls, 1)
assert.strictEqual(pool.pools.size, 0)
})
it('concurrent acquire does not double-allocate', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser, { maxSize: 3 })
const promises = [
pool.acquire('env1'),
pool.acquire('env1'),
pool.acquire('env1'),
]
const contexts = await Promise.all(promises)
assert.strictEqual(browser._newContextCalls, 1)
assert.strictEqual(contexts[0], contexts[1])
assert.strictEqual(contexts[1], contexts[2])
})
})
@@ -0,0 +1,296 @@
/**
* Map-indexed, health-checked, bounded browser context pool for Imhotep.
*
* Phase 1 of the Parallel Pooling Rewrite. Replaces the old array-based
* context pooling with a Map-indexed pool that performs health checks,
* evicts LRU entries, and respects a max size bound.
*/
import { Browser, BrowserContext } from 'playwright'
export interface ContextEntry {
context: BrowserContext
envKey: string
createdAt: number
lastUsed: number
healthStatus: 'healthy' | 'sick' | 'dead'
}
export interface ContextPoolOptions {
maxSize?: number
maxIdleMs?: number
healthCheckTimeoutMs?: number
}
export class ContextPool {
private browser: Browser
private options: Required<ContextPoolOptions>
private disposed: boolean
private acquireLock: Promise<void>
// Invariant: always a Map<string, ContextEntry[]>
pools: Map<string, ContextEntry[]>
constructor(browser: Browser, options?: ContextPoolOptions) {
this.browser = browser
this.options = {
maxSize: options?.maxSize ?? 10,
maxIdleMs: options?.maxIdleMs ?? 30000,
healthCheckTimeoutMs: options?.healthCheckTimeoutMs ?? 5000,
}
this.pools = new Map()
this.disposed = false
this.acquireLock = Promise.resolve()
}
/**
* Acquire a healthy browser context for the given envKey.
*
* Returns a pooled context if a healthy match exists, otherwise creates
* a new one. Evicts the LRU entry when at maxSize. Dead contexts are
* closed and skipped.
*/
async acquire(envKey: string): Promise<BrowserContext> {
if (this.disposed) {
throw new Error('ContextPool has been disposed')
}
const release = await this._acquireLock()
try {
const pool = this.pools.get(envKey)
if (pool) {
for (let i = 0; i < pool.length; i++) {
const entry = pool[i]
if (entry.healthStatus === 'dead') {
await this._closeEntry(entry)
pool.splice(i, 1)
i--
continue
}
const healthy = await this.healthCheck(entry)
if (healthy) {
entry.lastUsed = Date.now()
pool.splice(i, 1)
pool.push(entry)
return entry.context
}
// healthCheck may have marked the entry dead on error.
if ((entry as ContextEntry).healthStatus === 'dead') {
await this._closeEntry(entry)
pool.splice(i, 1)
i--
}
}
}
const total = this._totalCount()
if (total >= this.options.maxSize) {
if (total === 0) {
throw new Error('Pool at maxSize but no contexts available to evict')
}
await this._evictLRU()
}
const context = await this.browser.newContext()
const now = Date.now()
const entry: ContextEntry = {
context,
envKey,
createdAt: now,
lastUsed: now,
healthStatus: 'healthy',
}
const targetPool = this.pools.get(envKey) ?? []
targetPool.push(entry)
this.pools.set(envKey, targetPool)
return context
} finally {
release()
}
}
/**
* Release a context back to its pool.
*
* Idempotent: releasing the same context multiple times is safe.
* Never throws.
*/
release(context: BrowserContext): void {
if (this.disposed) {
return
}
for (const [, pool] of this.pools) {
const idx = pool.findIndex((e) => e.context === context)
if (idx !== -1) {
const entry = pool[idx]
entry.lastUsed = Date.now()
pool.splice(idx, 1)
pool.push(entry)
return
}
}
}
/**
* Evaluate `() => 1` in the context to verify it is still responsive.
*
* Times out after `healthCheckTimeoutMs`. Marks the entry:
* - 'healthy' on success
* - 'sick' on timeout
* - 'dead' on any error
*/
async healthCheck(entry: ContextEntry): Promise<boolean> {
try {
let page = entry.context.pages()[0]
if (!page) {
page = await entry.context.newPage()
}
const timeout = this.options.healthCheckTimeoutMs
const result = await Promise.race([
page.evaluate(() => 1),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Health check timeout')),
timeout
)
),
])
if (result === 1) {
entry.healthStatus = 'healthy'
return true
}
entry.healthStatus = 'sick'
return false
} catch {
entry.healthStatus = 'dead'
return false
}
}
/**
* Close contexts that have been idle longer than `maxIdleMs`, and close
* all dead contexts. Safe to call multiple times.
*/
async reap(): Promise<void> {
if (this.disposed) {
return
}
const now = Date.now()
const toRemove: Array<{ envKey: string; index: number; entry: ContextEntry }> = []
for (const [envKey, pool] of this.pools) {
for (let i = 0; i < pool.length; i++) {
const entry = pool[i]
const idle = now - entry.lastUsed
if (idle > this.options.maxIdleMs || entry.healthStatus === 'dead') {
toRemove.push({ envKey, index: i, entry })
}
}
}
// Remove from end first so indices stay valid.
toRemove.sort((a, b) => b.index - a.index)
for (const { envKey, index, entry } of toRemove) {
await this._closeEntry(entry)
const pool = this.pools.get(envKey)
if (pool) {
pool.splice(index, 1)
if (pool.length === 0) {
this.pools.delete(envKey)
}
}
}
}
/**
* Close all contexts and clear the pool.
*
* Idempotent: calling twice is safe.
*/
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
const entries: ContextEntry[] = []
for (const pool of this.pools.values()) {
entries.push(...pool)
}
for (const entry of entries) {
await this._closeEntry(entry)
}
this.pools.clear()
}
private async _closeEntry(entry: ContextEntry): Promise<void> {
try {
await entry.context.close()
} catch {
// Ignore close errors on already-closed contexts.
}
}
private _totalCount(): number {
let count = 0
for (const pool of this.pools.values()) {
count += pool.length
}
return count
}
private async _evictLRU(): Promise<void> {
let lruEntry: ContextEntry | null = null
let lruEnvKey: string | null = null
let lruIndex = -1
for (const [envKey, pool] of this.pools) {
for (let i = 0; i < pool.length; i++) {
const entry = pool[i]
if (!lruEntry || entry.lastUsed < lruEntry.lastUsed) {
lruEntry = entry
lruEnvKey = envKey
lruIndex = i
}
}
}
if (lruEntry && lruEnvKey !== null && lruIndex >= 0) {
await this._closeEntry(lruEntry)
const pool = this.pools.get(lruEnvKey)
if (pool) {
pool.splice(lruIndex, 1)
if (pool.length === 0) {
this.pools.delete(lruEnvKey)
}
}
}
}
private async _acquireLock(): Promise<() => void> {
const prevLock = this.acquireLock
let release: () => void
this.acquireLock = new Promise<void>((resolve) => {
release = resolve
})
await prevLock
return release!
}
}
@@ -0,0 +1,43 @@
/**
* Custom renderer adapter for Imhotep Playwright.
*
* Allows users to provide their own mount function for arbitrary
* render targets. This is the escape hatch for frameworks not
* explicitly supported by built-in adapters.
*/
import { Page } from 'playwright'
import type { SceneTarget } from 'imhotep-core/scene-target'
import { RendererAdapter } from './renderers.js'
export interface CustomAdapterOptions {
/** Unique identifier for this adapter instance. */
id?: string
/** User-provided mount function. */
mount: (page: Page, target: SceneTarget, input: unknown) => Promise<void>
/** Optional user-provided unmount function. */
unmount?: (page: Page) => Promise<void>
}
export function createCustomAdapter(options: CustomAdapterOptions): RendererAdapter {
return {
id: options.id ?? 'custom',
async mount(page: Page, target: SceneTarget, input: unknown): Promise<void> {
if (target.kind !== 'custom-renderer' && target.kind !== 'react-component' && target.kind !== 'vue-component') {
throw new Error(
`Custom adapter received unsupported target kind: ${target.kind}. ` +
`Expected 'custom-renderer', 'react-component', or 'vue-component'. ` +
`If you registered this adapter for a specific renderer, ensure the target matches.`
)
}
await options.mount(page, target, input)
},
async unmount(page: Page): Promise<void> {
if (options.unmount) {
await options.unmount(page)
}
},
}
}
@@ -0,0 +1,85 @@
/**
* Playwright determinism proof tests.
*
* Verifies that the public API honours seed and deterministic options,
* and that execution context propagates correctly through the UI handle.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { createDeterministicContext, createVolatileContext } from 'imhotep-core'
import type { ExecutionContext } from 'imhotep-core'
// We cannot import `imhotep` directly here because it requires a real
// Playwright page. Instead we test the context factory logic in isolation.
// Full integration tests belong in the fixtures e2e suite.
describe('Playwright determinism', () => {
it('createDeterministicContext with same seed produces identical rng', () => {
const ctx1 = createDeterministicContext(99)
const ctx2 = createDeterministicContext(99)
assert.deepStrictEqual(
[ctx1.rng(), ctx1.rng(), ctx1.rng()],
[ctx2.rng(), ctx2.rng(), ctx2.rng()],
)
})
it('createDeterministicContext produces stable ids', () => {
const ctx = createDeterministicContext(7)
assert.strictEqual(ctx.idGenerator(), 'id-1')
assert.strictEqual(ctx.idGenerator(), 'id-2')
assert.strictEqual(ctx.idGenerator(), 'id-3')
})
it('createDeterministicContext clock is stable', () => {
const ctx = createDeterministicContext(7)
assert.strictEqual(ctx.clock(), 0)
assert.strictEqual(ctx.clock(), 0)
})
it('createVolatileContext clock advances', () => {
const ctx = createVolatileContext()
const t1 = ctx.clock()
const t2 = ctx.clock()
assert.ok(t2 >= t1)
})
it('parallel contexts with same seed produce identical sequences', () => {
const ctxA = createDeterministicContext(1)
const ctxB = createDeterministicContext(1)
assert.deepStrictEqual(
[ctxA.rng(), ctxA.rng()],
[ctxB.rng(), ctxB.rng()],
'same seed must yield same rng sequence',
)
})
it('parallel contexts with different seeds do not interfere', () => {
const ctxA = createDeterministicContext(1)
const ctxB = createDeterministicContext(2)
const idsA = [ctxA.idGenerator(), ctxA.idGenerator()]
const idsB = [ctxB.idGenerator(), ctxB.idGenerator()]
assert.deepStrictEqual(idsA, ['id-1', 'id-2'])
assert.deepStrictEqual(idsB, ['id-1', 'id-2'])
assert.notDeepStrictEqual(
[ctxA.rng(), ctxA.rng()],
[ctxB.rng(), ctxB.rng()],
'different seeds must yield different rng sequences',
)
})
it('property runner seeds are stored in replay metadata', () => {
// This is a contract test: replay metadata must carry the seed.
const meta = {
version: 1 as const,
seed: 42,
numRuns: 100,
sceneTarget: { kind: 'fixture' as const, fixtureId: 'test.html' },
inputDomain: { mode: 'generated' as const, arbitrary: 'record' },
timestamp: new Date().toISOString(),
}
assert.strictEqual(meta.seed, 42)
assert.strictEqual(meta.numRuns, 100)
})
})
@@ -0,0 +1,254 @@
/**
* Environment matrix application for Imhotep Playwright runtime.
*
* Defines the axes of the environment matrix and provides a pure function
* to apply a concrete environment case to a Playwright page.
*
* V1.0 environment support:
* - viewport: native (page.setViewportSize)
* - colorScheme: native (page.emulateMedia)
* - reducedMotion: approximate (page.emulateMedia when available, else CSS injection)
* - pointer: approximate (viewport.hasTouch or CSS custom property)
*
* Unsupported environment axes fail closed with a clear error.
*/
import { Page } from 'playwright'
import {
getEnvSupportInfo,
isSupportedEnvironment,
} from 'imhotep-state'
/** Supported color schemes. */
export type ColorScheme = 'light' | 'dark' | 'no-preference'
/** Supported pointer types. */
export type PointerType = 'mouse' | 'touch' | 'fine' | 'coarse'
/** Supported reduced-motion preferences. */
export type ReducedMotion = 'reduce' | 'no-preference'
/** A single concrete environment case derived from the matrix. */
export interface EnvironmentCase {
viewport: { width: number; height: number }
colorScheme?: ColorScheme
pointerType?: PointerType
reducedMotion?: ReducedMotion
deviceScaleFactor?: number
locale?: string
}
/** Diagnostic entry for environment application. */
export interface EnvironmentDiagnostic {
env: string
status: 'native' | 'approximate' | 'unsupported' | 'ignored'
note?: string
}
/**
* Applies an environment case to the given Playwright page.
*
* This mutates the browser context through Playwright APIs so that
* subsequent extractions reflect the requested environment.
*
* @param page - The Playwright page to configure.
* @param env - The concrete environment case to apply.
* @returns Diagnostics describing how each axis was applied.
* @throws Error when an unsupported environment axis is requested.
*/
export async function applyEnvironment(
page: Page,
env: EnvironmentCase
): Promise<EnvironmentDiagnostic[]> {
const diagnostics: EnvironmentDiagnostic[] = []
// Validate all keys before applying anything (fail-closed).
// Map EnvironmentCase property names to support-matrix canonical names.
const envKeyMap: Record<string, string> = {
pointerType: 'pointer',
}
const envKeys = Object.keys(env).filter((k) => k !== 'viewport')
for (const key of envKeys) {
const canonicalKey = envKeyMap[key] ?? key
if (!isSupportedEnvironment(canonicalKey)) {
const info = getEnvSupportInfo(canonicalKey)
throw new Error(info.note ?? `Environment '${key}' is not supported in V1.0`)
}
}
// Set viewport dimensions and device scale factor if provided.
await page.setViewportSize({
width: env.viewport.width,
height: env.viewport.height,
})
diagnostics.push({
env: 'viewport',
status: 'native',
note: 'Playwright page.setViewportSize()',
})
// Apply color scheme emulation via Playwright's context-level API.
if (env.colorScheme) {
await page.emulateMedia({ colorScheme: env.colorScheme })
diagnostics.push({
env: 'colorScheme',
status: 'native',
note: 'Playwright page.emulateMedia({ colorScheme })',
})
}
// Apply reduced-motion preference.
// Prefer Playwright's native emulateMedia when available (Chromium 90+).
if (env.reducedMotion) {
try {
await page.emulateMedia({ reducedMotion: env.reducedMotion })
diagnostics.push({
env: 'reducedMotion',
status: 'native',
note: 'Playwright page.emulateMedia({ reducedMotion })',
})
} catch {
// Fallback to CSS injection when Playwright does not support it.
await page.evaluate((pref: string) => {
const style = document.createElement('style')
style.setAttribute('id', '__imhotep-reduced-motion__')
style.textContent = `
@media (prefers-reduced-motion: ${pref}) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
`
const existing = document.getElementById('__imhotep-reduced-motion__')
if (existing) existing.remove()
document.head.appendChild(style)
}, env.reducedMotion)
diagnostics.push({
env: 'reducedMotion',
status: 'approximate',
note:
'reducedMotion state is approximated: CSS injection fallback. ' +
'True media-query matching requires browser-level emulation.',
})
}
}
// Apply pointer-type preference.
// Prefer viewport.hasTouch when available; fallback to CSS custom property.
if (env.pointerType) {
try {
const viewport = page.viewportSize()
const hasTouch = !!(viewport && (viewport.width <= 768 || env.pointerType === 'touch'))
await page.evaluate(({ ptr, touch }: { ptr: string; touch: boolean }) => {
document.documentElement.style.setProperty('--imhotep-pointer-type', ptr)
if (touch) {
document.documentElement.classList.add('imhotep-pointer-coarse')
} else {
document.documentElement.classList.remove('imhotep-pointer-coarse')
}
}, { ptr: env.pointerType, touch: hasTouch })
diagnostics.push({
env: 'pointer',
status: 'approximate',
note:
'pointer state is approximated: CSS custom property + viewport heuristic. ' +
'True pointer-type media queries require browser-level emulation not exposed by Playwright.',
})
} catch {
diagnostics.push({
env: 'pointer',
status: 'approximate',
note: 'pointer state is approximated: failed to apply.',
})
}
}
// Locale can be set via the context if available; page-level evaluation
// can be used as a fallback. Locale is not part of the V1.0 supported matrix.
if (env.locale) {
await page.evaluate((loc: string) => {
document.documentElement.lang = loc
}, env.locale)
diagnostics.push({
env: 'locale',
status: 'ignored',
note: 'Locale is not part of the V1.0 environment matrix.',
})
}
return diagnostics
}
/**
* Expands a set of environment axes into concrete environment cases.
*
* Each axis is an array of values; the Cartesian product yields all cases.
* Callers should deduplicate equivalent cases before execution.
*
* @param axes - The environment axes to expand.
* @returns An array of concrete environment cases.
*/
export interface EnvironmentAxes {
viewports: Array<{ width: number; height: number }>
colorSchemes?: ColorScheme[]
pointerTypes?: PointerType[]
reducedMotions?: ReducedMotion[]
deviceScaleFactors?: number[]
locales?: string[]
}
export function expandEnvironmentMatrix(axes: EnvironmentAxes): EnvironmentCase[] {
const cases: EnvironmentCase[] = []
const viewports = axes.viewports
const colorSchemes = axes.colorSchemes ?? [undefined as unknown as ColorScheme]
const pointerTypes = axes.pointerTypes ?? [undefined as unknown as PointerType]
const reducedMotions = axes.reducedMotions ?? [undefined as unknown as ReducedMotion]
const deviceScaleFactors = axes.deviceScaleFactors ?? [undefined as unknown as number]
const locales = axes.locales ?? [undefined as unknown as string]
for (const viewport of viewports) {
for (const colorScheme of colorSchemes) {
for (const pointerType of pointerTypes) {
for (const reducedMotion of reducedMotions) {
for (const deviceScaleFactor of deviceScaleFactors) {
for (const locale of locales) {
const caseItem: EnvironmentCase = { viewport }
if (colorScheme !== undefined) caseItem.colorScheme = colorScheme
if (pointerType !== undefined) caseItem.pointerType = pointerType
if (reducedMotion !== undefined) caseItem.reducedMotion = reducedMotion
if (deviceScaleFactor !== undefined) caseItem.deviceScaleFactor = deviceScaleFactor
if (locale !== undefined) caseItem.locale = locale
cases.push(caseItem)
}
}
}
}
}
}
return cases
}
/**
* Deduplicates environment cases that are structurally equivalent.
*
* @param cases - The array of environment cases to deduplicate.
* @returns A new array with duplicates removed.
*/
export function deduplicateEnvironmentCases(cases: EnvironmentCase[]): EnvironmentCase[] {
const seen = new Set<string>()
const result: EnvironmentCase[] = []
for (const c of cases) {
const key = JSON.stringify(c)
if (!seen.has(key)) {
seen.add(key)
result.push(c)
}
}
return result
}
@@ -0,0 +1,160 @@
/**
* Fast-check adapter for Imhotep property runs.
*
* Bridges the InputDomain contract (from imhotep-core) to fast-check
* arbitraries. Responsible for:
* - converting generated domains to fc.Arbitrary values
* - seeding for reproducibility
* - replay metadata extraction
*
* Dependency injected: the adapter receives fast-check as a parameter
* so tests can pass a stub or mock.
*/
import type { InputDomain } from 'imhotep-core/property-contracts'
export interface FastCheckAdapter {
assert: (prop: unknown, params?: Record<string, unknown>) => Promise<unknown> | unknown
record: (recordModel: Record<string, unknown>) => unknown
constantFrom: (...values: unknown[]) => unknown
sample: (arb: unknown, params?: { seed?: number; numRuns?: number }) => unknown[]
property: (arb: unknown, predicate: (value: unknown) => boolean | Promise<boolean>) => unknown
/** Optional async variant for predicates that return Promises. Required for real fast-check v4. */
asyncProperty?: (arb: unknown, predicate: (value: unknown) => boolean | Promise<boolean>) => unknown
}
export interface FastCheckRunConfig {
seed: number
numRuns: number
}
export interface FastCheckRunOutcome {
passed: boolean
counterexample?: unknown
numRuns: number
seed: number
diagnostics: unknown[]
}
/**
* Build a fast-check arbitrary from an InputDomain.
*
* For 'generated' mode, the arbitrary is passed through directly.
* For 'enumerated' mode, we build a constantFrom arbitrary over the values.
*/
export function buildArbitrary(
domain: InputDomain,
fc: FastCheckAdapter,
): unknown {
if (domain.mode === 'enumerated') {
if (domain.values.length === 0) {
throw new Error('Enumerated domain has no values')
}
return fc.constantFrom(...domain.values)
}
if (domain.mode === 'generated') {
return domain.arbitrary
}
throw new Error(`Unknown InputDomain mode`)
}
/**
* Run a property using fast-check with the given domain and predicate.
*
* Returns an outcome with pass/fail, counterexample, and replay metadata.
* The caller is responsible for converting the counterexample to a scene
* and running the full Imhotep evaluation.
*/
export async function runFastCheckProperty(
domain: InputDomain,
predicate: (value: unknown) => boolean | Promise<boolean>,
fc: FastCheckAdapter,
config: FastCheckRunConfig,
): Promise<FastCheckRunOutcome> {
const arb = buildArbitrary(domain, fc)
// Use asyncProperty when available (real fast-check v4) so async predicates are awaited.
const prop = fc.asyncProperty
? fc.asyncProperty(arb, predicate)
: fc.property(arb, predicate)
try {
await fc.assert(prop, {
seed: config.seed,
numRuns: config.numRuns,
})
return {
passed: true,
numRuns: config.numRuns,
seed: config.seed,
diagnostics: [],
}
} catch (err: unknown) {
const counterexample = extractCounterexample(err)
return {
passed: false,
counterexample,
numRuns: config.numRuns,
seed: config.seed,
diagnostics: [
{
code: 'IMH_PROPERTY_RUN_FAILED',
message: `Property failed after ${config.numRuns} runs`,
counterexample,
error: err instanceof Error ? err.message : String(err),
},
],
}
}
}
/**
* Sample values from a domain without running a full property.
* Useful for enumerated-mode preflight or debug logging.
*/
export function sampleDomain(
domain: InputDomain,
fc: FastCheckAdapter,
config: { seed?: number; numRuns?: number },
): unknown[] {
const arb = buildArbitrary(domain, fc)
const samples = fc.sample(arb, { seed: config.seed, numRuns: config.numRuns ?? 10 })
return Array.isArray(samples) ? samples : []
}
/**
* Extract counterexample from a fast-check error.
* fast-check v4.7.0 error shape includes counterexample in various forms.
*/
function extractCounterexample(err: unknown): unknown {
if (err && typeof err === 'object') {
const e = err as Record<string, unknown>
// fast-check v4 error object
if (e.counterexample !== undefined) {
return e.counterexample
}
// Some versions nest it under counterExample
if (e.counterExample !== undefined) {
return e.counterExample
}
// Error message may contain serialized counterexample
if (typeof e.message === 'string') {
const match = e.message.match(/Counterexample: (.+)/)
if (match) {
try {
return JSON.parse(match[1] as string)
} catch {
return match[1]
}
}
}
}
return undefined
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Playwright test fixtures for Imhotep.
*
* Provides a native Playwright fixture that injects an Imhotep page
* wrapper into every test, enabling fluent relational assertions
* directly within Playwright test suites.
*/
import { test as baseTest } from '@playwright/test'
import type { TestFixture, WorkerFixture, Fixtures, PlaywrightTestArgs, PlaywrightWorkerArgs } from '@playwright/test'
import { ImhotepPage, ImhotepPageOptions } from './page.js'
import { ImhotepRuntime, createRuntime } from './runtime.js'
import { EnvironmentCase } from './environment.js'
/**
* Extended Playwright test type with Imhotep fixtures.
*
* Usage:
*
* ```js
* import { test } from 'imhotep-playwright'
*
* test('product page layout', async ({ page, imhotepPage }) => {
* await page.goto('/products/123')
* // Use imhotepPage for assertions...
* })
* ```
*/
export interface ImhotepFixtures {
/**
* The Imhotep runtime for the current worker process.
*
* One runtime is created per worker and manages pooled browser
* contexts and pages.
*/
imhotepRuntime: ImhotepRuntime
/**
* The Imhotep page wrapper borrowed for the current test.
*
* This is automatically borrowed from the worker runtime and
* returned after the test completes.
*/
imhotepPage: ImhotepPage
/**
* Optional environment case applied before the test begins.
*
* If provided, the fixture will configure the page viewport,
* color scheme, pointer type, and reduced-motion preference.
*/
imhotepEnvironment: EnvironmentCase | undefined
/**
* Options passed to the Imhotep runtime and page wrappers.
*/
imhotepOptions: ImhotepPageOptions
}
/** Internal test-scoped fixtures for type safety. */
interface ImhotepTestFixtures {
imhotepPage: ImhotepPage
imhotepEnvironment: EnvironmentCase | undefined
}
/** Internal worker-scoped fixtures for type safety. */
interface ImhotepWorkerFixtures {
imhotepRuntime: ImhotepRuntime
imhotepOptions: ImhotepPageOptions
}
/**
* Default environment case used when none is explicitly provided.
*/
export const defaultEnvironment: EnvironmentCase = {
viewport: { width: 1280, height: 720 },
colorScheme: 'no-preference',
reducedMotion: 'no-preference',
}
// ---------------------------------------------------------------------------
// Individual fixture definitions with explicit types.
// ---------------------------------------------------------------------------
const imhotepOptionsFixture: WorkerFixture<ImhotepPageOptions, ImhotepWorkerFixtures & PlaywrightWorkerArgs> = async (
{},
use
) => {
await use({ injectRuntime: true })
}
const imhotepEnvironmentFixture: TestFixture<EnvironmentCase | undefined, ImhotepTestFixtures & ImhotepWorkerFixtures & PlaywrightTestArgs & PlaywrightWorkerArgs> = async (
{},
use
) => {
await use(defaultEnvironment)
}
const imhotepRuntimeFixture: WorkerFixture<ImhotepRuntime, ImhotepWorkerFixtures & PlaywrightWorkerArgs> = async (
{ browser, imhotepOptions },
use
) => {
const runtime = await createRuntime(browser, { pageOptions: imhotepOptions })
await use(runtime)
await runtime.shutdown()
}
const imhotepPageFixture: TestFixture<ImhotepPage, ImhotepTestFixtures & ImhotepWorkerFixtures & PlaywrightTestArgs & PlaywrightWorkerArgs> = async (
{ imhotepRuntime, imhotepEnvironment },
use
) => {
const page = await imhotepRuntime.createPage()
if (imhotepEnvironment) {
await page.applyEnvironment(imhotepEnvironment)
}
await use(page)
await imhotepRuntime.releasePage(page)
}
/**
* The base Imhotep fixtures object.
*
* Extend this with `test.extend()` if you need to add custom fixtures
* or override defaults for a specific test file.
*/
export const imhotepFixtures: Fixtures<ImhotepTestFixtures, ImhotepWorkerFixtures, PlaywrightTestArgs, PlaywrightWorkerArgs> = {
imhotepOptions: [imhotepOptionsFixture, { scope: 'worker', option: true }],
imhotepEnvironment: [imhotepEnvironmentFixture, { option: true }],
imhotepRuntime: [imhotepRuntimeFixture, { scope: 'worker' }],
imhotepPage: [imhotepPageFixture, { scope: 'test' }],
}
/**
* Pre-extended Playwright test object with Imhotep fixtures.
*
* Import this instead of `@playwright/test` to get Imhotep support
* out of the box.
*/
export const test = baseTest.extend<ImhotepTestFixtures, ImhotepWorkerFixtures>(imhotepFixtures)
/**
* Re-export `expect` from Playwright so that test files only need
* one import.
*/
export { expect } from '@playwright/test'
@@ -0,0 +1,293 @@
/**
* Tests for the FOL compiler's cross-package compatibility.
*
* Verifies that duck-type checks replace instanceof so that assertions
* compile correctly when packages are symlinked or duplicated.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { compileAssertionsToFOL } from './fol-compiler.js'
describe('fol-compiler duck-type compilation', () => {
it('compiles a plain object resembling FluentRelation', () => {
const plainRelation = {
relation: 'leftOf',
assertion: {
getSubject: () => '.a',
getQuantifier: () => 'all',
},
referenceSelector: '.b',
options: { minGap: 8 },
}
const formula = compileAssertionsToFOL([plainRelation as any])
assert.ok(formula !== null, 'Plain object FluentRelation should compile to a formula')
assert.strictEqual(formula?.kind, 'forall')
})
it('compiles a plain object resembling FluentQuantifier', () => {
const plainQuantifier = {
bindings: [{ selector: '.item' }],
toFormula: () => ({
type: 'FormulaNode',
kind: 'forall',
bindings: [],
body: { type: 'FormulaNode', kind: 'predicate', predicate: 'true', args: [] },
}),
}
const formula = compileAssertionsToFOL([plainQuantifier as any])
assert.ok(formula !== null, 'Plain object FluentQuantifier should compile to a formula')
})
it('ignores objects that do not duck-type as FluentRelation or FluentQuantifier', () => {
const plainObj = { foo: 'bar' }
const formula = compileAssertionsToFOL([plainObj as any])
assert.strictEqual(formula, null)
})
it('rejects null and primitive values', () => {
assert.strictEqual(compileAssertionsToFOL([null as any]), null)
assert.strictEqual(compileAssertionsToFOL([42 as any]), null)
assert.strictEqual(compileAssertionsToFOL(['string' as any]), null)
})
it('adapts grammar.ts ForAll shape to logic-ast.ts shape', () => {
const grammarForAll = {
bindings: [{ selector: '.item' }],
toFormula: () => ({
type: 'ForAll',
variable: { type: 'VariableRef', name: '$x' },
domain: { type: 'DomainRef', kind: 'elements', selector: '.item' },
body: {
type: 'PredicateCall',
name: 'leftOf',
args: [{ type: 'VariableRef', name: '$x' }],
},
}),
}
const formula = compileAssertionsToFOL([grammarForAll as any])
assert.ok(formula !== null)
assert.strictEqual(formula!.kind, 'forall')
assert.strictEqual((formula as any).bindings[0].variables[0], '$x')
assert.strictEqual((formula as any).bindings[0].domain.domain, 'elements')
assert.strictEqual((formula as any).body.kind, 'predicate')
assert.strictEqual((formula as any).body.predicate, 'leftOf')
})
it('adapts grammar.ts Exists shape to logic-ast.ts shape', () => {
const grammarExists = {
bindings: [{ selector: '.item' }],
toFormula: () => ({
type: 'Exists',
variable: { type: 'VariableRef', name: '$y' },
domain: { type: 'DomainRef', kind: 'elements', selector: '.item' },
body: {
type: 'PredicateCall',
name: 'above',
args: [{ type: 'VariableRef', name: '$y' }],
},
}),
}
const formula = compileAssertionsToFOL([grammarExists as any])
assert.ok(formula !== null)
assert.strictEqual(formula!.kind, 'exists')
})
it('adapts grammar.ts And shape to logic-ast.ts shape', () => {
const grammarAnd = {
bindings: [{ selector: '.item' }],
toFormula: () => ({
type: 'And',
left: {
type: 'PredicateCall',
name: 'leftOf',
args: [{ type: 'VariableRef', name: '$x' }],
},
right: {
type: 'PredicateCall',
name: 'above',
args: [{ type: 'VariableRef', name: '$x' }],
},
}),
}
const formula = compileAssertionsToFOL([grammarAnd as any])
assert.ok(formula !== null)
assert.strictEqual(formula!.kind, 'and')
assert.strictEqual((formula as any).left.kind, 'predicate')
assert.strictEqual((formula as any).right.kind, 'predicate')
})
it('adapts grammar.ts Not shape to logic-ast.ts shape', () => {
const grammarNot = {
bindings: [{ selector: '.item' }],
toFormula: () => ({
type: 'Not',
operand: {
type: 'PredicateCall',
name: 'overlaps',
args: [{ type: 'VariableRef', name: '$x' }],
},
}),
}
const formula = compileAssertionsToFOL([grammarNot as any])
assert.ok(formula !== null)
assert.strictEqual(formula!.kind, 'not')
assert.strictEqual((formula as any).operand.kind, 'predicate')
})
it('adapts grammar.ts Implies shape to logic-ast.ts shape', () => {
const grammarImplies = {
bindings: [{ selector: '.item' }],
toFormula: () => ({
type: 'Implies',
left: {
type: 'PredicateCall',
name: 'inside',
args: [{ type: 'VariableRef', name: '$x' }],
},
right: {
type: 'PredicateCall',
name: 'alignedWith',
args: [{ type: 'VariableRef', name: '$x' }],
},
}),
}
const formula = compileAssertionsToFOL([grammarImplies as any])
assert.ok(formula !== null)
assert.strictEqual(formula!.kind, 'implies')
assert.strictEqual((formula as any).antecedent.kind, 'predicate')
assert.strictEqual((formula as any).consequent.kind, 'predicate')
})
it('adapts deeply nested grammar.ts shape', () => {
const grammarNested = {
bindings: [{ selector: '.item' }],
toFormula: () => ({
type: 'ForAll',
variable: { type: 'VariableRef', name: '$x' },
domain: { type: 'DomainRef', kind: 'elements', selector: '.item' },
body: {
type: 'And',
left: {
type: 'Not',
operand: {
type: 'PredicateCall',
name: 'overlaps',
args: [{ type: 'VariableRef', name: '$x' }],
},
},
right: {
type: 'Exists',
variable: { type: 'VariableRef', name: '$y' },
domain: { type: 'DomainRef', kind: 'elements', selector: '.ref' },
body: {
type: 'PredicateCall',
name: 'leftOf',
args: [
{ type: 'VariableRef', name: '$x' },
{ type: 'VariableRef', name: '$y' },
],
},
},
},
}),
}
const formula = compileAssertionsToFOL([grammarNested as any])
assert.ok(formula !== null)
assert.strictEqual(formula!.kind, 'forall')
const body = (formula as any).body
assert.strictEqual(body.kind, 'and')
assert.strictEqual(body.left.kind, 'not')
assert.strictEqual(body.right.kind, 'exists')
assert.strictEqual(body.right.body.kind, 'predicate')
assert.strictEqual(body.right.body.args.length, 2)
})
it('passes through already-correct logic-ast.ts shape', () => {
const logicAstForAll = {
bindings: [{ selector: '.item' }],
toFormula: () => ({
type: 'FormulaNode',
kind: 'forall',
bindings: [
{
type: 'TupleBinding',
variables: ['$x'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.item' },
},
],
body: { type: 'FormulaNode', kind: 'predicate', predicate: 'true', args: [] },
}),
}
const formula = compileAssertionsToFOL([logicAstForAll as any])
assert.ok(formula !== null)
assert.strictEqual(formula!.kind, 'forall')
})
it('compiles mixed arrays of plain objects', () => {
const plainRelation = {
relation: 'above',
assertion: {
getSubject: () => '.c',
getQuantifier: () => 'all',
},
referenceSelector: '.d',
options: { minGap: 4 },
}
const plainQuantifier = {
bindings: [{ selector: '.x' }],
toFormula: () => ({
type: 'FormulaNode',
kind: 'exists',
bindings: [],
body: { type: 'FormulaNode', kind: 'predicate', predicate: 'true', args: [] },
}),
}
const formula = compileAssertionsToFOL([plainRelation as any, plainQuantifier as any])
assert.ok(formula !== null)
assert.strictEqual(formula?.kind, 'and')
})
it('propagates assertion layout space to simple relations', () => {
const relation = {
relation: 'leftOf',
assertion: {
getSubject: () => '.a',
getQuantifier: () => 'all',
getSpace: () => 'layout',
},
referenceSelector: '.b',
options: {},
}
const formula = compileAssertionsToFOL([relation as any]) as any
const predicate = formula.body
assert.strictEqual(predicate.kind, 'predicate')
assert.strictEqual(predicate.options.space, 'layout')
})
it('propagates assertion visual space to compound relations', () => {
const relation = {
relation: 'above',
assertion: {
getSubject: () => '.a',
getQuantifier: () => 'all',
getSpace: () => 'visual',
},
referenceSelector: '.c',
options: {},
_compoundOperator: 'and',
_compoundParts: [
{ relation: 'leftOf', referenceSelector: '.b', options: {} },
{ relation: 'above', referenceSelector: '.c', options: {} },
],
}
const formula = compileAssertionsToFOL([relation as any]) as any
assert.strictEqual(formula.kind, 'forall')
const andBody = formula.body
assert.strictEqual(andBody.kind, 'and')
assert.strictEqual(andBody.left.body.options.space, 'visual')
assert.strictEqual(andBody.right.body.options.space, 'visual')
})
})
@@ -0,0 +1,541 @@
/**
* FOL compiler for Playwright public API assertions.
*
* Converts accumulated FluentAssertion / FluentRelation / FluentQuantifier
* objects into FormulaNode trees suitable for evaluateLogic().
*
* Every assertion compiles to a quantified formula over element domains,
* even singleton assertions, so that the logic engine can evaluate them
* uniformly.
*/
import type {
FormulaNode,
DomainRef,
VariableRef,
PredicateCall,
TermNode,
} from 'imhotep-core'
import type {
FluentAssertion,
FluentRelation,
FluentQuantifier,
} from 'imhotep-dsl'
import { FluentQuantifier as FQ, FluentRelation as FR } from 'imhotep-dsl'
// ---------------------------------------------------------------------------
// AST Adapter: grammar.ts shape → logic-ast.ts shape
// ---------------------------------------------------------------------------
/**
* Adapts a formula from grammar.ts AST shape to logic-ast.ts AST shape.
* Supports both shapes for backward compatibility:
* - If the node has a `kind` property, it's already in logic-ast shape.
* - If the node has a `type` property, it's in grammar.ts shape and needs conversion.
*/
function adaptGrammarFormulaToLogicAst(node: any): FormulaNode {
// Already in logic-ast.ts shape — pass through
if (node && typeof node === 'object' && 'kind' in node) {
return node as FormulaNode
}
// Not an object or null — cannot adapt
if (!node || typeof node !== 'object') {
throw new TypeError('Cannot adapt non-object formula node')
}
const type = node.type
switch (type) {
case 'ForAll': {
return {
type: 'FormulaNode',
kind: 'forall',
bindings: [
{
type: 'TupleBinding',
variables: [node.variable?.name ?? 'x'],
domain: adaptDomainRef(node.domain),
},
],
body: adaptGrammarFormulaToLogicAst(node.body),
} as FormulaNode
}
case 'Exists': {
return {
type: 'FormulaNode',
kind: 'exists',
bindings: [
{
type: 'TupleBinding',
variables: [node.variable?.name ?? 'x'],
domain: adaptDomainRef(node.domain),
},
],
body: adaptGrammarFormulaToLogicAst(node.body),
} as FormulaNode
}
case 'And': {
return {
type: 'FormulaNode',
kind: 'and',
left: adaptGrammarFormulaToLogicAst(node.left),
right: adaptGrammarFormulaToLogicAst(node.right),
} as FormulaNode
}
case 'Or': {
return {
type: 'FormulaNode',
kind: 'or',
left: adaptGrammarFormulaToLogicAst(node.left),
right: adaptGrammarFormulaToLogicAst(node.right),
} as FormulaNode
}
case 'Not': {
return {
type: 'FormulaNode',
kind: 'not',
operand: adaptGrammarFormulaToLogicAst(node.operand),
} as FormulaNode
}
case 'Implies': {
return {
type: 'FormulaNode',
kind: 'implies',
antecedent: adaptGrammarFormulaToLogicAst(node.left),
consequent: adaptGrammarFormulaToLogicAst(node.right),
} as FormulaNode
}
case 'PredicateCall': {
return {
type: 'FormulaNode',
kind: 'predicate',
predicate: node.name ?? 'unknown',
args: (node.args ?? []).map((arg: any) => adaptTermNode(arg)),
} as FormulaNode
}
case 'VariableRef': {
return node as FormulaNode
}
default: {
throw new TypeError(`Unknown grammar AST node type: ${type}`)
}
}
}
/**
* Adapts a DomainRef from grammar.ts shape to logic-ast.ts shape.
* grammar.ts uses `kind`, logic-ast.ts uses `domain`.
*/
function adaptDomainRef(domain: any): DomainRef {
if (!domain || typeof domain !== 'object') {
return { type: 'DomainRef', domain: 'elements' } as DomainRef
}
const domainName = domain.domain || domain.kind || 'elements'
return {
type: 'DomainRef',
domain: domainName,
selector: domain.selector,
parentVar: domain.parentVar,
} as DomainRef
}
/**
* Adapts a term node (VariableRef, DomainRef, or literal) to logic-ast.ts shape.
* VariableRef is already compatible. DomainRef needs kind→domain mapping.
* String/number literals pass through (engine has fallback handler).
*/
function adaptTermNode(arg: any): any {
if (!arg || typeof arg !== 'object') {
return arg
}
if (arg.type === 'VariableRef') {
return arg
}
if (arg.type === 'DomainRef') {
return adaptDomainRef(arg)
}
return arg
}
// ---------------------------------------------------------------------------
// Domain helpers
// ---------------------------------------------------------------------------
function makeDomain(selector: string): DomainRef {
return {
type: 'DomainRef',
domain: 'elements',
selector,
}
}
function makeVar(name: string): VariableRef {
return {
type: 'VariableRef',
name,
}
}
// ---------------------------------------------------------------------------
// Predicate compilation
// ---------------------------------------------------------------------------
function makePredicate(
name: string,
args: TermNode[],
options?: Record<string, unknown>,
): PredicateCall {
const call: PredicateCall = {
type: 'FormulaNode',
kind: 'predicate',
predicate: name,
args,
}
if (options && Object.keys(options).length > 0) {
;(call as any).options = options
}
return call
}
// ---------------------------------------------------------------------------
// Relation → Formula
// ---------------------------------------------------------------------------
function compileRelation(
relation: FluentRelation,
subjectVar: VariableRef,
refVar: VariableRef,
): FormulaNode {
const getAssertionSpace = (): 'layout' | 'visual' | undefined => {
const maybeAssertion = (relation as any).assertion
if (maybeAssertion && typeof maybeAssertion.getSpace === 'function') {
return maybeAssertion.getSpace()
}
return undefined
}
// Handle compound relations (.and / .or chaining)
const compoundParts = (relation as any)._compoundParts as Array<{ relation: string; referenceSelector: string; options: Record<string, unknown> }> | undefined
const compoundOperator = (relation as any)._compoundOperator as 'and' | 'or' | undefined
if (compoundParts && compoundParts.length > 1 && compoundOperator) {
const assertionSpace = getAssertionSpace()
// Each compound part may have a different reference selector.
// Create a separate reference variable + forall binding per part.
let result: FormulaNode | null = null
for (let i = 0; i < compoundParts.length; i++) {
const part = compoundParts[i]
const partOptions = {
...part.options,
...(part.options?.space === undefined && assertionSpace !== undefined ? { space: assertionSpace } : {}),
}
const partRefVar = makeVar(`$ref_${i}`)
const partBody = compileSingleRelation(part.relation, part.referenceSelector, partOptions, subjectVar, partRefVar)
// Wrap in forall over the part's reference domain
const partFormula: FormulaNode = {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: [partRefVar.name],
domain: makeDomain(part.referenceSelector),
}],
body: partBody,
}
if (result === null) {
result = partFormula
} else {
result = {
type: 'FormulaNode',
kind: compoundOperator,
left: result,
right: partFormula,
}
}
}
return result!
}
const assertionSpace = getAssertionSpace()
const relationOptions: Record<string, unknown> = {
...(relation.options as Record<string, unknown>),
...(((relation.options as Record<string, unknown>)?.space === undefined && assertionSpace !== undefined)
? { space: assertionSpace }
: {}),
}
return compileSingleRelation(relation.relation, relation.referenceSelector, relationOptions, subjectVar, refVar)
}
function compileSingleRelation(
name: string,
_referenceSelector: string,
relOptions: Record<string, unknown>,
subjectVar: VariableRef,
refVar: VariableRef,
): FormulaNode {
// Build options map for the predicate evaluator.
const ropts = relOptions as {
minGap?: number | string | { value: number; unit: string }
maxGap?: number | string | { value: number; unit: string }
tolerance?: number | string
axis?: string
min?: number | string | { value: number; unit: string }
max?: number | string | { value: number; unit: string }
dimension?: string
value?: number | string | { value: number; unit: string }
mode?: 'atLeast' | 'atMost' | 'exact' | 'between'
space?: 'layout' | 'visual'
inStackingContext?: boolean
}
const predicateOptions: Record<string, unknown> = {}
if (ropts.minGap !== undefined) predicateOptions.minGap = parseNumeric(ropts.minGap)
if (ropts.maxGap !== undefined) predicateOptions.maxGap = parseNumeric(ropts.maxGap)
if (ropts.tolerance !== undefined) predicateOptions.tolerance = parseNumeric(ropts.tolerance)
if (ropts.axis !== undefined) predicateOptions.axis = ropts.axis
if (ropts.space !== undefined) predicateOptions.space = ropts.space
if (ropts.min !== undefined) predicateOptions.min = parseNumeric(ropts.min)
if (ropts.max !== undefined) predicateOptions.max = parseNumeric(ropts.max)
if (ropts.dimension !== undefined) predicateOptions.dimension = ropts.dimension
if (ropts.value !== undefined) {
const val = parseNumeric(ropts.value)
// Size proxies store the bound in 'value' keyed by mode.
if (ropts.mode === 'atMost') predicateOptions.max = val
else if (ropts.mode === 'between') {
// Between stores min/max directly on options already.
}
else predicateOptions.min = val
}
if (ropts.mode !== undefined) predicateOptions.mode = ropts.mode
if (name.startsWith('size.')) {
// Size assertions are unary — only subject.
return makePredicate(name.replace('size.', ''), [subjectVar], predicateOptions)
}
const base = makePredicate(name, [subjectVar, refVar], predicateOptions)
if (ropts.inStackingContext === true) {
return {
type: 'FormulaNode',
kind: 'and',
left: base,
right: makePredicate('inStackingContext', [subjectVar, refVar]),
}
}
return base
}
function parseNumeric(v: string | number | { value: number; unit?: string } | undefined): number | { value: number; unit: string } | undefined {
if (v === undefined) return undefined
if (typeof v === 'number') return v
if (typeof v === 'object' && typeof v.value === 'number') {
const unit = typeof v.unit === 'string' ? v.unit.toLowerCase() : 'px'
return unit === 'px' ? v.value : { value: v.value, unit }
}
if (typeof v !== 'string') return undefined
const trimmed = v.trim()
const m = trimmed.match(/^(-?\d*\.?\d+)\s*([a-z%]+)?$/i)
if (!m) return undefined
const n = parseFloat(m[1] ?? '')
if (isNaN(n)) return undefined
const unit = (m[2] ?? 'px').toLowerCase()
if (unit !== 'px') {
return { value: n, unit }
}
return n
}
// ---------------------------------------------------------------------------
// Assertion → Formula (with quantifier support)
// ---------------------------------------------------------------------------
interface CompiledAssertion {
formula: FormulaNode
subjectSelector: string
referenceSelector: string | null
}
function compileSingleAssertion(
assertion: FluentRelation | FluentAssertion | FluentQuantifier,
): CompiledAssertion | null {
// FluentQuantifier (forAll / exists from static methods)
// Duck-type check for cross-package compatibility (avoids instanceof
// breakage when packages are symlinked or duplicated).
if (
assertion &&
typeof assertion === 'object' &&
'bindings' in assertion &&
Array.isArray((assertion as any).bindings) &&
'toFormula' in assertion &&
typeof (assertion as any).toFormula === 'function'
) {
const q = assertion as FluentQuantifier
const rawFormula = q.toFormula()
return {
formula: adaptGrammarFormulaToLogicAst(rawFormula),
subjectSelector: q.bindings[0]?.selector ?? '',
referenceSelector: null,
}
}
// FluentCardinality (exactlyOne, atLeastN, atMostN)
// These are evaluated outside the FOL engine using selector match counts.
if (
assertion &&
typeof assertion === 'object' &&
'kind' in assertion &&
['exactlyOne', 'atLeastN', 'atMostN'].includes((assertion as any).kind)
) {
return null
}
// FluentRelation (terminal like .leftOf('.b'))
// Duck-type check for cross-package compatibility.
if (
assertion &&
typeof assertion === 'object' &&
'relation' in assertion &&
typeof (assertion as any).relation === 'string'
) {
const rel = assertion as FluentRelation
const subjectSel = rel.assertion.getSubject()
const refSel = rel.referenceSelector
const quantifier = rel.assertion.getQuantifier()
const isCompound = !!(rel as any)._compoundParts && (rel as any)._compoundParts.length > 1
const subjectVar = makeVar('$subject')
const refVar = makeVar('$reference')
const body = compileRelation(rel, subjectVar, refVar)
const subjectBinding = {
type: 'TupleBinding' as const,
variables: ['$subject'],
domain: makeDomain(subjectSel),
}
// For compound relations, each part already has its own reference forall.
// Skip the outer refBinding to avoid an unused quantifier.
const refBinding = refSel && !isCompound
? {
type: 'TupleBinding' as const,
variables: ['$reference'],
domain: makeDomain(refSel),
}
: null
let formula: FormulaNode
switch (quantifier) {
case 'any': {
const anyBody = refBinding
? ({
type: 'FormulaNode' as const,
kind: 'forall',
bindings: [refBinding],
body,
} as FormulaNode)
: body
formula = {
type: 'FormulaNode',
kind: 'exists',
bindings: [subjectBinding],
body: anyBody,
}
break
}
case 'none': {
const noneBody = refBinding
? ({
type: 'FormulaNode' as const,
kind: 'exists',
bindings: [refBinding],
body,
} as FormulaNode)
: body
formula = {
type: 'FormulaNode',
kind: 'forall',
bindings: [subjectBinding],
body: {
type: 'FormulaNode',
kind: 'not',
operand: noneBody,
},
}
break
}
case 'all':
default: {
// Default and 'all' mean "every subject satisfies against every reference".
const bindings: any[] = [subjectBinding]
if (refBinding) bindings.push(refBinding)
formula = {
type: 'FormulaNode',
kind: 'forall',
bindings,
body,
}
break
}
}
return {
formula,
subjectSelector: subjectSel,
referenceSelector: refSel,
}
}
// FluentAssertion without relation (incomplete — should not happen in practice)
return null
}
// ---------------------------------------------------------------------------
// Public: compile a list of assertions to a single FormulaNode
// ---------------------------------------------------------------------------
export function compileAssertionsToFOL(
assertions: (FluentRelation | FluentAssertion | FluentQuantifier)[],
): FormulaNode | null {
const compiled: FormulaNode[] = []
for (const assertion of assertions) {
const c = compileSingleAssertion(assertion)
if (c) {
compiled.push(c.formula)
}
}
if (compiled.length === 0) {
return null
}
if (compiled.length === 1) {
return compiled[0]
}
// Combine with And
let result = compiled[0]
for (let i = 1; i < compiled.length; i++) {
result = {
type: 'FormulaNode',
kind: 'and',
left: result,
right: compiled[i],
}
}
return result
}
// Re-export for public.ts
export type { CompiledAssertion }
+101
View File
@@ -0,0 +1,101 @@
/**
* Package exports for imhotep-playwright.
*
* This package provides the Playwright integration layer for Imhotep:
* runtime orchestration, test fixtures, environment matrix application,
* and the page wrapper with extraction batching and state materialization.
*/
// Primary public API: attach Imhotep to a Playwright page.
export { createRuntime, ImhotepRuntime } from './runtime.js'
export type { RuntimeOptions } from './runtime.js'
// Pool classes for parallel execution.
export { ContextPool, ContextPoolOptions } from './context-pool.js'
export { PagePool, PagePoolOptions } from './page-pool.js'
export { Semaphore } from './semaphore.js'
export { DEFAULT_CONCURRENCY_CAP } from './property-runner.js'
export type { PropertyRunnerOptions } from './property-runner.js'
// Page wrapper and state materialization.
export { ImhotepPage, wrapPage } from './page.js'
export type {
ImhotepPageOptions,
StateSnapshot,
} from './page.js'
// Environment matrix expansion and application.
export {
applyEnvironment,
expandEnvironmentMatrix,
deduplicateEnvironmentCases,
} from './environment.js'
export type {
EnvironmentCase,
EnvironmentAxes,
ColorScheme,
PointerType,
ReducedMotion,
} from './environment.js'
// Playwright test fixtures.
export {
test,
expect,
imhotepFixtures,
defaultEnvironment,
} from './fixture.js'
export type { ImhotepFixtures } from './fixture.js'
// High-level public API: fluent UI handle with assertion accumulation.
export { imhotep, imhotepComponent, imhotepStory, imhotepFixture } from './public.js'
export type { ImhotepUi, ComponentOptions, StoryOptions, FixtureOptions, PropertyRunHandle } from './public.js'
// Semantic subject reference types (Stream 7).
export type { SubjectReference } from 'imhotep-core'
export { subjectReferenceToString } from 'imhotep-core'
// Scene target resolution and renderer adapters.
export { resolveTarget, ensureMountContainer, clearMountContainer } from './targets.js'
export type { ResolvedTarget, TargetResolverOptions } from './targets.js'
export {
createRendererRegistry,
getDefaultRendererRegistry,
registerRenderer,
react,
vue,
storybook,
custom,
} from './renderers.js'
export type {
RendererAdapter,
RendererRegistry,
RendererDescriptor,
ReactRendererOptions,
VueRendererOptions,
StorybookRendererOptions,
CustomRendererOptions,
} from './renderers.js'
export { createReactAdapter } from './react-adapter.js'
export type { ReactAdapterOptions } from './react-adapter.js'
export { createVueAdapter } from './vue-adapter.js'
export type { VueAdapterOptions } from './vue-adapter.js'
export { createStorybookAdapter } from './storybook-adapter.js'
export type { StorybookAdapterOptions } from './storybook-adapter.js'
export { createCustomAdapter } from './custom-renderer-adapter.js'
export type { CustomAdapterOptions } from './custom-renderer-adapter.js'
// Reusable assertion presets.
export {
touchTarget,
toolbarAlignment,
cardSpacing,
formLabelAlignment,
modalContainment,
} from './presets.js'
export type { PresetResult } from './presets.js'
@@ -0,0 +1,196 @@
/**
* Tests for the PagePool module.
*
* Uses mock BrowserContext and Page objects to verify pooling,
* borrowing, returning, resetting, and disposal behavior.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import type { Page, BrowserContext } from 'playwright'
import { PagePool, PageEntry, ContextPool } from './page-pool.js'
function createMockContext(): BrowserContext & Record<string, any> {
const context: any = {
newPageCalls: 0,
clearCookiesCalls: 0,
closeCalls: 0,
createdPages: [],
newPage: async () => {
context.newPageCalls++
const page = createMockPage(context)
context.createdPages.push(page)
return page
},
clearCookies: async () => {
context.clearCookiesCalls++
},
close: async () => {
context.closeCalls++
},
}
return context as BrowserContext & Record<string, any>
}
function createMockPage(context?: any): Page & Record<string, any> {
const page: any = {
gotoCalls: [],
evaluateCalls: 0,
setViewportSizeCalls: [],
closeCalls: 0,
goto: async (url: string) => {
page.gotoCalls.push(url)
},
context: () => context,
evaluate: async () => {
page.evaluateCalls++
},
setViewportSize: async (size: any) => {
page.setViewportSizeCalls.push(size)
},
close: async () => {
page.closeCalls++
},
addInitScript: async () => {},
}
return page as Page & Record<string, any>
}
describe('PagePool', () => {
it('warm creates specified number of pages', async () => {
const context = createMockContext()
const pool = new PagePool({} as ContextPool)
await pool.warm(context, 3)
assert.strictEqual(context.newPageCalls, 3)
const entries: PageEntry[] = (pool as any).pools.get(context) ?? []
assert.strictEqual(entries.length, 3)
assert.strictEqual(entries.every((e) => !e.inUse), true)
})
it('borrow returns warmed page', async () => {
const context = createMockContext()
const pool = new PagePool({} as ContextPool)
await pool.warm(context, 1)
const entriesBefore: PageEntry[] = (pool as any).pools.get(context) ?? []
const warmedPage = entriesBefore[0].imhotepPage
const borrowed = await pool.borrow(context)
assert.strictEqual(borrowed, warmedPage)
const entriesAfter: PageEntry[] = (pool as any).pools.get(context) ?? []
assert.strictEqual(entriesAfter[0].inUse, true)
})
it('borrow creates new page when none available', async () => {
const context = createMockContext()
const pool = new PagePool({} as ContextPool)
const borrowed = await pool.borrow(context)
assert.strictEqual(context.newPageCalls, 1)
assert.ok(borrowed)
const entries: PageEntry[] = (pool as any).pools.get(context) ?? []
assert.strictEqual(entries.length, 1)
assert.strictEqual(entries[0].inUse, true)
})
it('borrow throws when at maxPagesPerContext and all in use', async () => {
const context = createMockContext()
const pool = new PagePool({} as ContextPool, { maxPagesPerContext: 2 })
await pool.borrow(context)
await pool.borrow(context)
await assert.rejects(
async () => pool.borrow(context),
/Max pages per context \(2\) reached and all pages are in use/
)
})
it('return resets page and marks available', async () => {
const context = createMockContext()
const pool = new PagePool({} as ContextPool)
const borrowed = await pool.borrow(context)
const mockPage = context.createdPages[0]
// Reset was called once during borrow
assert.strictEqual(mockPage.evaluateCalls, 1)
await pool.return(borrowed)
// Return called reset again
assert.strictEqual(mockPage.evaluateCalls, 2)
const entries: PageEntry[] = (pool as any).pools.get(context) ?? []
assert.strictEqual(entries[0].inUse, false)
})
it('reset clears cookies and localStorage', async () => {
const context = createMockContext()
const pool = new PagePool({} as ContextPool)
await pool.borrow(context)
const mockPage = context.createdPages[0]
assert.strictEqual(mockPage.gotoCalls.includes('about:blank'), true)
assert.strictEqual(context.clearCookiesCalls, 1)
assert.strictEqual(mockPage.evaluateCalls, 1)
assert.deepStrictEqual(mockPage.setViewportSizeCalls, [{ width: 1280, height: 720 }])
})
it('dispose closes all pages', async () => {
const context = createMockContext()
const pool = new PagePool({} as ContextPool)
await pool.warm(context, 2)
await pool.dispose()
assert.strictEqual(context.createdPages[0].closeCalls, 1)
assert.strictEqual(context.createdPages[1].closeCalls, 1)
})
it('idempotent dispose', async () => {
const context = createMockContext()
const pool = new PagePool({} as ContextPool)
await pool.warm(context, 1)
await pool.dispose()
await pool.dispose()
assert.strictEqual(context.createdPages[0].closeCalls, 1)
})
it('borrowed page is inUse', async () => {
const context = createMockContext()
const pool = new PagePool({} as ContextPool, { maxPagesPerContext: 1 })
await pool.warm(context, 1)
await pool.borrow(context)
await assert.rejects(
async () => pool.borrow(context),
/Max pages per context \(1\) reached and all pages are in use/
)
})
it('returned page is not inUse', async () => {
const context = createMockContext()
const pool = new PagePool({} as ContextPool, { maxPagesPerContext: 1 })
await pool.warm(context, 1)
const first = await pool.borrow(context)
await pool.return(first)
const second = await pool.borrow(context)
assert.strictEqual(context.newPageCalls, 1)
assert.strictEqual(second, first)
})
})
@@ -0,0 +1,145 @@
/**
* Page pool for maintaining warm, resettable pages per context.
*
* Eliminates newPage() latency in tight loops by keeping a pool of
* pre-warmed pages that are reset to a clean state between uses.
*/
import { Page, BrowserContext } from 'playwright'
import { ImhotepPage, wrapPage } from './page.js'
export interface PageEntry {
page: Page
imhotepPage: ImhotepPage
context: BrowserContext
inUse: boolean
lastReset: number
}
export interface PagePoolOptions {
maxPagesPerContext?: number
warmPages?: number
}
// Minimal ContextPool interface for Phase 2.
// Phase 1 will provide the full implementation.
export interface ContextPool {
// Intentionally empty — PagePool only stores a reference.
}
export class PagePool {
private contextPool: ContextPool
private maxPagesPerContext: number
private warmPages: number
private pools: Map<BrowserContext, PageEntry[]> = new Map()
private disposed = false
constructor(contextPool: ContextPool, options?: PagePoolOptions) {
this.contextPool = contextPool
this.maxPagesPerContext = options?.maxPagesPerContext ?? 8
this.warmPages = options?.warmPages ?? 2
}
async warm(context: BrowserContext, count?: number): Promise<void> {
if (this.disposed) return
const targetCount = count ?? this.warmPages
const existing = this.pools.get(context) ?? []
const availableSlots = this.maxPagesPerContext - existing.length
const toCreate = Math.min(targetCount, availableSlots)
for (let i = 0; i < toCreate; i++) {
const page = await context.newPage()
const imhotepPage = await wrapPage(page)
const entry: PageEntry = {
page,
imhotepPage,
context,
inUse: false,
lastReset: Date.now(),
}
existing.push(entry)
}
this.pools.set(context, existing)
}
async borrow(context: BrowserContext): Promise<ImhotepPage> {
if (this.disposed) {
throw new Error('PagePool has been disposed')
}
const entries = this.pools.get(context) ?? []
const available = entries.find((e) => !e.inUse)
if (available) {
// Mark in-use immediately before the yielding reset() to prevent
// concurrent borrow() calls from grabbing the same page.
available.inUse = true
await this.reset(available.page)
available.lastReset = Date.now()
return available.imhotepPage
}
if (entries.length < this.maxPagesPerContext) {
const page = await context.newPage()
await this.reset(page)
const imhotepPage = await wrapPage(page)
const entry: PageEntry = {
page,
imhotepPage,
context,
inUse: true,
lastReset: Date.now(),
}
entries.push(entry)
this.pools.set(context, entries)
return imhotepPage
}
throw new Error(
`Max pages per context (${this.maxPagesPerContext}) reached and all pages are in use`
)
}
async return(imhotepPage: ImhotepPage): Promise<void> {
if (this.disposed) return
for (const entries of this.pools.values()) {
const entry = entries.find((e) => e.imhotepPage === imhotepPage)
if (entry) {
await this.reset(entry.page)
entry.inUse = false
return
}
}
}
private async reset(page: Page): Promise<void> {
try {
await page.goto('about:blank')
const context = page.context()
await context.clearCookies()
await page.evaluate(() => localStorage.clear())
await page.setViewportSize({ width: 1280, height: 720 })
} catch {
// Page may already be closed; ignore.
}
}
async dispose(): Promise<void> {
if (this.disposed) return
this.disposed = true
for (const entries of this.pools.values()) {
for (const entry of entries) {
try {
await entry.page.close()
} catch {
// Already closed or other error; ignore.
}
}
}
this.pools.clear()
}
}
+688
View File
@@ -0,0 +1,688 @@
/**
* Page wrapper with Imhotep integration.
*
* Wraps a Playwright page to provide:
* - CDP session lifecycle management
* - Extraction request batching
* - State materialization hooks (default, hover, focus-visible)
* - A fluent entry point for Imhotep assertions
*/
import { Page, BrowserContext, Locator } from 'playwright'
import { EnvironmentCase, applyEnvironment } from './environment.js'
import type { SceneTarget } from 'imhotep-core/scene-target'
import type { SubjectReference } from 'imhotep-core'
import { subjectReferenceToString } from 'imhotep-core'
import { RendererRegistry, getDefaultRendererRegistry, RendererAdapter } from './renderers.js'
import { resolveTarget, ResolvedTarget } from './targets.js'
/** Supported state snapshot types for materialization. */
export type StateSnapshot =
| 'default'
| 'hover'
| 'focus'
| 'focusVisible'
| 'active'
| 'focus-visible'
| 'disabled'
| 'checked'
| 'expanded'
| 'collapsed'
| 'selected'
| 'pressed'
| 'visited'
/** Approximation note attached to state materialization results. */
export interface StateMaterializationNote {
state: StateSnapshot
status: 'native' | 'approximate'
note?: string
}
export interface MaterializeStateOptions {
force?: boolean
timeoutMs?: number
}
/** Options for creating an Imhotep page wrapper. */
export interface ImhotepPageOptions {
/** Inject Imhotep runtime scripts into the page on attach. */
injectRuntime?: boolean
/** Explicit seed for deterministic property runs and id generation. */
seed?: number
/** Force deterministic mode (default true in test environments). */
deterministic?: boolean
/** Directory to cache extracted geometry snapshots. Set to null to disable caching. */
cacheDir?: string | null
}
/** Internal batch queue for extraction requests. */
interface ExtractionRequest {
subject: string | SubjectReference
resolve: (value: unknown) => void
reject: (reason: unknown) => void
}
/**
* Wraps a Playwright page with Imhotep-specific capabilities.
*
* This class is the primary integration surface between Playwright's
* page model and Imhotep's geometry extraction and assertion engine.
*/
export class ImhotepPage {
private page: Page
private context: BrowserContext
private options: ImhotepPageOptions
private batchQueue: ExtractionRequest[] = []
private batchTimer: ReturnType<typeof setTimeout> | null = null
private attached = false
constructor(page: Page, options: ImhotepPageOptions = {}) {
this.page = page
this.context = page.context()
this.options = options
}
/** Returns the underlying Playwright page. */
get playwrightPage(): Page {
return this.page
}
/** Returns the browser context that owns this page. */
get browserContext(): BrowserContext {
return this.context
}
/**
* Attaches Imhotep to the page.
*
* This initializes CDP sessions, injects runtime helpers if requested,
* and prepares the page for extraction and assertion.
*/
async attach(): Promise<void> {
if (this.attached) return
if (this.options.injectRuntime) {
// Inject a lightweight helper that Imhotep extraction scripts can
// call to resolve selectors and report element metadata.
await this.page.addInitScript(() => {
// @ts-expect-error global helper
window.__imhotep = window.__imhotep || {
resolveSelector(selector: string) {
return Array.from(document.querySelectorAll(selector)).map((el) => {
const rect = el.getBoundingClientRect()
return {
tagName: el.tagName,
id: (el as HTMLElement).id,
className: (el as HTMLElement).className,
rect: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
},
}
})
},
}
})
}
this.attached = true
}
/**
* Detaches Imhotep from the page, cleaning up timers and state.
*/
async detach(): Promise<void> {
if (this.batchTimer) {
clearTimeout(this.batchTimer)
this.batchTimer = null
}
// Clean up any injected ARIA/pseudo-class stylesheets.
await this.clearInjectedStyles()
this.attached = false
}
/**
* Removes any temporary stylesheets injected by state materialization.
*
* This is useful for reverting approximate states (e.g. :visited)
* back to their default appearance between assertions.
*/
async clearInjectedStyles(): Promise<void> {
await this.page.evaluate(() => {
const styleId = '__imhotep-visited-styles__'
const existing = document.getElementById(styleId)
if (existing) existing.remove()
// Remove data attributes used for visited simulation.
document.querySelectorAll('[data-imhotep-visited]').forEach((el) => {
el.removeAttribute('data-imhotep-visited')
})
})
}
/**
* Applies an environment case to the underlying page.
*
* @param env - The environment case to apply.
*/
async applyEnvironment(env: EnvironmentCase): Promise<void> {
await applyEnvironment(this.page, env)
}
/**
* Materializes a state snapshot on the page.
*
* Uses native Playwright APIs where possible. Approximate states
* return a note that is visible in diagnostics.
*
* @param selector - The element selector to materialize state on.
* @param state - The state snapshot to materialize.
* @returns Materialization note with native/approximate status.
*/
async materializeState(
selector: string,
state: StateSnapshot,
options: MaterializeStateOptions = {},
): Promise<StateMaterializationNote> {
const isStrictModeViolation = (err: unknown): boolean => {
const message = err instanceof Error ? err.message : String(err)
return message.includes('strict mode violation') || message.includes('resolved to')
}
const actionTimeout = options.timeoutMs
// Normalize kebab-case to camelCase before validation.
const normalized = (state as string).replace(/-([a-z])/g, (_, char) => char.toUpperCase()) as StateSnapshot
// Fail closed: validate state against V1.0 supported matrix.
const validStates: StateSnapshot[] = [
'default',
'hover',
'focus',
'focusVisible',
'active',
'disabled',
'checked',
'expanded',
'collapsed',
'selected',
'pressed',
'visited',
]
if (!validStates.includes(normalized)) {
throw new Error(`State '${state}' is not supported in V1.0`)
}
const locator = this.page.locator(selector)
switch (normalized) {
case 'default': {
// Native: blur active element and move mouse away.
await this.page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
if (active && active.blur) active.blur()
})
await this.page.mouse.move(0, 0)
// Revert any approximate states by clearing injected styles.
await this.clearInjectedStyles()
return {
state,
status: 'native',
note: 'Reset to baseline: blur + mouse.move(0,0)',
}
}
case 'hover': {
const useForce = options.force ?? true
try {
if (actionTimeout !== undefined) {
await locator.hover({ force: useForce, timeout: actionTimeout })
} else {
await locator.hover({ force: useForce })
}
} catch (err) {
if (isStrictModeViolation(err)) {
if (actionTimeout !== undefined) {
await locator.first().hover({ force: useForce, timeout: actionTimeout })
} else {
await locator.first().hover({ force: useForce })
}
} else if (options.force === true) {
await this.page.evaluate((sel) => {
const el = document.querySelector(sel)
if (!el) {
throw new Error(`Selector '${sel}' did not resolve for hover materialization`)
}
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
}, selector)
return {
state,
status: 'approximate',
note: 'locator.hover() timed out; fallback dispatched mouseover/mouseenter events via page.evaluate().',
}
} else {
throw err
}
}
return {
state,
status: 'native',
note: 'Playwright locator.hover()',
}
}
case 'focus': {
try {
if (actionTimeout !== undefined) {
await locator.focus({ timeout: actionTimeout })
} else {
await locator.focus()
}
} catch (err) {
if (isStrictModeViolation(err)) {
if (actionTimeout !== undefined) {
await locator.first().focus({ timeout: actionTimeout })
} else {
await locator.first().focus()
}
} else if (options.force === true) {
await this.page.evaluate((sel) => {
const el = document.querySelector(sel) as HTMLElement | null
if (!el) {
throw new Error(`Selector '${sel}' did not resolve for focus materialization`)
}
el.focus()
}, selector)
return {
state,
status: 'approximate',
note: 'locator.focus() timed out; fallback used element.focus() via page.evaluate().',
}
} else {
throw err
}
}
return {
state,
status: 'native',
note: 'Playwright locator.focus()',
}
}
case 'focusVisible': {
try {
if (actionTimeout !== undefined) {
await locator.focus({ timeout: actionTimeout })
} else {
await locator.focus()
}
} catch (err) {
if (isStrictModeViolation(err)) {
if (actionTimeout !== undefined) {
await locator.first().focus({ timeout: actionTimeout })
} else {
await locator.first().focus()
}
} else if (options.force === true) {
await this.page.evaluate((sel) => {
const el = document.querySelector(sel) as HTMLElement | null
if (!el) {
throw new Error(`Selector '${sel}' did not resolve for focusVisible materialization`)
}
el.focus()
}, selector)
} else {
throw err
}
}
await this.page.keyboard.press('Shift')
return {
state,
status: 'approximate',
note:
'focus-visible state is approximated: locator.focus() + keyboard.press("Shift"). ' +
'True :focus-visible matching requires browser heuristics we cannot fully control.',
}
}
case 'active': {
// Native: mousedown without mouseup via Playwright mouse API.
const box = await locator.boundingBox()
if (box) {
await this.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await this.page.mouse.down()
}
return {
state,
status: 'native',
note: 'Playwright mouse.down() at element center',
}
}
case 'disabled': {
// Approximate: set disabled attribute/property and add CSS class.
// True :disabled matching requires native browser attribute semantics.
await this.page.evaluate((sel) => {
const fallbackSelector = sel.replace(/:disabled\b/g, '')
const el = (document.querySelector(sel) || document.querySelector(fallbackSelector)) as HTMLInputElement | null
if (!el) throw new Error(`Selector '${sel}' not found`)
el.disabled = true
el.setAttribute('disabled', '')
el.classList.add('disabled')
}, selector)
return {
state,
status: 'approximate',
note:
'disabled state is approximated: sets disabled attribute and adds :disabled CSS class. ' +
'True :disabled pseudo-class matching requires native browser attribute semantics.',
}
}
case 'checked': {
// Approximate: set checked property and dispatch change event.
// True :checked matching depends on native form control state.
await this.page.evaluate((sel) => {
const fallbackSelector = sel.replace(/:checked\b/g, '')
const el = (document.querySelector(sel) || document.querySelector(fallbackSelector)) as HTMLInputElement | null
if (!el) throw new Error(`Selector '${sel}' not found`)
if (el.type !== 'checkbox' && el.type !== 'radio') {
throw new Error(`Element must be checkbox or radio, got ${el.type}`)
}
el.checked = true
el.dispatchEvent(new Event('change', { bubbles: true }))
}, selector)
return {
state,
status: 'approximate',
note:
'checked state is approximated: sets element.checked = true and dispatches change event. ' +
'True :checked matching depends on native form control state.',
}
}
case 'expanded': {
// Approximate: set aria-expanded="true".
// Associated display toggling is left to the caller.
await this.page.evaluate((sel) => {
const el = document.querySelector(sel) as HTMLElement | null
if (!el) throw new Error(`Selector '${sel}' not found`)
el.setAttribute('aria-expanded', 'true')
}, selector)
return {
state,
status: 'approximate',
note:
'expanded state is approximated: sets aria-expanded="true". ' +
'Associated display toggling is left to the caller.',
}
}
case 'collapsed': {
// Approximate: set aria-expanded="false".
// Associated display toggling is left to the caller.
await this.page.evaluate((sel) => {
const el = document.querySelector(sel) as HTMLElement | null
if (!el) throw new Error(`Selector '${sel}' not found`)
el.setAttribute('aria-expanded', 'false')
}, selector)
return {
state,
status: 'approximate',
note:
'collapsed state is approximated: sets aria-expanded="false". ' +
'Associated display toggling is left to the caller.',
}
}
case 'selected': {
// Approximate: set aria-selected="true" and add "selected" class.
await this.page.evaluate((sel) => {
const el = document.querySelector(sel) as HTMLElement | null
if (!el) throw new Error(`Selector '${sel}' not found`)
el.setAttribute('aria-selected', 'true')
el.classList.add('selected')
}, selector)
return {
state,
status: 'approximate',
note:
'selected state is approximated: sets aria-selected="true" and adds "selected" CSS class.',
}
}
case 'pressed': {
// Approximate: set aria-pressed="true" and add "pressed" class.
await this.page.evaluate((sel) => {
const el = document.querySelector(sel) as HTMLElement | null
if (!el) throw new Error(`Selector '${sel}' not found`)
el.setAttribute('aria-pressed', 'true')
el.classList.add('pressed')
}, selector)
return {
state,
status: 'approximate',
note:
'pressed state is approximated: sets aria-pressed="true" and adds "pressed" CSS class.',
}
}
case 'visited': {
// Approximate: inject temporary stylesheet with a:visited rules.
// True :visited is privacy-protected and cannot be read or set via JS.
const styleId = '__imhotep-visited-styles__'
await this.page.evaluate(
({ sel, id }) => {
// Remove any existing injected visited styles.
const existing = document.getElementById(id)
if (existing) existing.remove()
const style = document.createElement('style')
style.id = id
style.textContent = `
${sel}:visited,
${sel}[data-imhotep-visited] {
color: #551a8b !important;
text-decoration: underline !important;
}
`
document.head.appendChild(style)
// Add data attribute to target elements for broader selector coverage.
const elements = document.querySelectorAll(sel)
elements.forEach((el) => el.setAttribute('data-imhotep-visited', 'true'))
},
{ sel: selector, id: styleId }
)
return {
state,
status: 'approximate',
note:
'visited state is approximated: injects temporary <style> tag with a:visited rules. ' +
'True :visited pseudo-class is privacy-protected and cannot be read or set via JavaScript.',
}
}
default: {
// Exhaustive switch; TypeScript narrows this away in practice.
throw new Error(`Unsupported state snapshot: ${state}`)
}
}
}
/**
* Mounts a renderer target into the page.
*
* Used for component, story, and custom renderer targets that need
* to be mounted into a page rather than navigated to directly.
*
* @param target - The scene target to mount.
* @param input - Optional render input (props, args, etc.).
* @param registry - Optional renderer registry (defaults to global).
* @returns The resolved target with adapter info.
*/
async mountRenderer(
target: SceneTarget,
input?: unknown,
registry?: RendererRegistry
): Promise<ResolvedTarget> {
return resolveTarget(this.page, target, { registry: registry ?? getDefaultRendererRegistry() })
}
/**
* Resolves a SubjectReference to a Playwright Locator.
*
* Uses Playwright's built-in semantic locators for role, text,
* label, and testId references, falling back to CSS locator for
* explicit selectors. This reduces brittleness compared to raw
* CSS selectors.
*/
resolveSubject(ref: SubjectReference): Locator {
switch (ref.kind) {
case 'css':
return this.page.locator(ref.selector)
case 'role':
return ref.name
? this.page.getByRole(ref.role as any, { name: ref.name })
: this.page.getByRole(ref.role as any)
case 'text':
return this.page.getByText(ref.text)
case 'labelText':
return this.page.getByLabel(ref.label)
case 'testId':
return this.page.getByTestId(ref.testId)
default:
throw new Error(`Unsupported SubjectReference kind: ${(ref as any).kind}`)
}
}
/**
* Extract geometry data for a semantic subject reference.
*
* Resolves the subject to a Playwright locator, then evaluates
* bounding-client-rect data for all matched elements.
*/
async extractSubject(ref: SubjectReference): Promise<unknown> {
const locator = this.resolveSubject(ref)
const elements = await locator.all()
return Promise.all(
elements.map(async (el) => {
const rect = await el.boundingBox()
const tagName = await el.evaluate((e) => (e as HTMLElement).tagName)
return {
tagName,
rect: rect
? {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
}
: null,
origin: subjectReferenceToString(ref),
}
})
)
}
/**
* Queues an extraction request for batching.
*
* Accepts either a raw CSS selector string or a semantic
* SubjectReference. Requests are collected and flushed together
* to minimize round trips.
*
* @param subject - The selector or subject reference to extract geometry for.
* @returns A promise resolving with the extracted data.
*/
async extract(subject: string | SubjectReference): Promise<unknown> {
// If a SubjectReference is passed, bypass the string-based batch queue
// and resolve directly via Playwright's semantic locators.
if (typeof subject !== 'string') {
return this.extractSubject(subject)
}
return new Promise((resolve, reject) => {
this.batchQueue.push({ subject, resolve, reject })
this.scheduleBatchFlush()
})
}
/** Schedules a flush of the extraction batch queue. */
private scheduleBatchFlush(): void {
if (this.batchTimer) return
this.batchTimer = setTimeout(() => {
this.flushBatch()
}, 0)
}
/** Flushes all queued extraction requests in a single pass. */
private async flushBatch(): Promise<void> {
this.batchTimer = null
const queue = this.batchQueue.splice(0)
if (queue.length === 0) return
try {
// Gather all unique selectors from the batch (string subjects only).
const selectors = Array.from(new Set(queue.map((q) => q.subject as string)))
// Perform a single bulk extraction via page evaluation.
const results = await this.page.evaluate((selList: string[]) => {
const map: Record<string, unknown> = {}
for (const sel of selList) {
// Use the injected helper if available, otherwise fall back
// to a basic bounding-client-rect capture.
// @ts-expect-error global helper
if (window.__imhotep && window.__imhotep.resolveSelector) {
// @ts-expect-error global helper
map[sel] = window.__imhotep.resolveSelector(sel)
} else {
map[sel] = Array.from(document.querySelectorAll(sel)).map((el) => {
const rect = el.getBoundingClientRect()
return {
tagName: el.tagName,
rect: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
},
}
})
}
}
return map
}, selectors)
// Resolve each queued request with its corresponding result.
for (const request of queue) {
const subjectKey = request.subject as string
request.resolve(results[subjectKey])
}
} catch (err) {
// Reject all queued requests on any failure.
for (const request of queue) {
request.reject(err)
}
}
}
}
/**
* Creates an Imhotep page wrapper for the given Playwright page.
*
* @param page - The Playwright page to wrap.
* @param options - Optional configuration for the wrapper.
* @returns The Imhotep page wrapper.
*/
export async function wrapPage(
page: Page,
options?: ImhotepPageOptions
): Promise<ImhotepPage> {
const wrapper = new ImhotepPage(page, options)
await wrapper.attach()
return wrapper
}
@@ -0,0 +1,481 @@
/**
* Phase 9 Integration Tests — Full pooled stack end-to-end validation.
*
* These tests exercise the complete ContextPool + PagePool + Runtime +
* PropertyRunner stack. Mock-based tests run without Playwright;
* real-browser tests are gated by `hasPlaywright`.
*/
import { describe, it, before, after } from 'node:test'
import assert from 'node:assert'
import type { Browser, BrowserContext, Page } from 'playwright'
import { createRuntime, ImhotepRuntime } from './runtime.js'
import { ContextPool, ContextEntry } from './context-pool.js'
import { PagePool, PageEntry } from './page-pool.js'
import { Semaphore } from './semaphore.js'
import { runEnumeratedProperty, RendererAdapter, MountedScene } from './property-runner.js'
import type { SceneTarget, RenderCase } from 'imhotep-core/scene-target'
import type { InputDomain } from 'imhotep-core/property-contracts'
import type { GeometryWorld } from 'imhotep-core/world'
import type { FastCheckAdapter } from './fast-check-adapter.ts'
import { ImhotepPage } from './page.js'
// ---------------------------------------------------------------------------
// Mock builders
// ---------------------------------------------------------------------------
interface MockPage extends Page {
_gotoCalls: string[]
_evaluateCalls: number
_evaluateError?: Error
_closed: boolean
_closeCalls: number
_setViewportSizeCalls: Array<{ width: number; height: number }>
_context: MockContext
}
interface MockContext extends BrowserContext {
_pages: MockPage[]
_closed: boolean
_closeCalls: number
_newPageCalls: number
_clearCookiesCalls: number
}
interface MockBrowser extends Browser {
_contexts: MockContext[]
_newContextCalls: number
_closeCalls: number
}
function createMockPage(context: MockContext, evaluateError?: Error): MockPage {
const page: MockPage = {
_gotoCalls: [],
_evaluateCalls: 0,
_evaluateError: evaluateError,
_closed: false,
_closeCalls: 0,
_setViewportSizeCalls: [],
_context: context,
goto: async (url: string) => {
page._gotoCalls.push(url)
},
evaluate: async <R, Arg>(script: string | ((arg: Arg) => R), _arg?: Arg): Promise<R> => {
page._evaluateCalls++
if (page._evaluateError) {
throw page._evaluateError
}
// Return 1 for health checks
if (typeof script === 'function' && script.toString().trim() === '() => 1') {
return 1 as unknown as R
}
return undefined as unknown as R
},
setViewportSize: async (size: { width: number; height: number }) => {
page._setViewportSizeCalls.push(size)
},
close: async () => {
page._closeCalls++
page._closed = true
},
context: () => context,
addInitScript: async () => {},
mouse: {
move: async () => {},
},
keyboard: {
press: async () => {},
},
locator: () => ({
hover: async () => {},
focus: async () => {},
boundingBox: async () => ({ x: 0, y: 0, width: 100, height: 100 }),
all: async () => [],
}) as any,
} as unknown as MockPage
return page
}
function createMockContext(pages: MockPage[] = []): MockContext {
const ctx: MockContext = {
_pages: pages,
_closed: false,
_closeCalls: 0,
_newPageCalls: 0,
_clearCookiesCalls: 0,
pages: () => ctx._pages,
newPage: async () => {
ctx._newPageCalls++
const page = createMockPage(ctx)
ctx._pages.push(page)
return page
},
close: async () => {
ctx._closeCalls++
ctx._closed = true
},
clearCookies: async () => {
ctx._clearCookiesCalls++
},
} as unknown as MockContext
return ctx
}
function createMockBrowser(): MockBrowser {
const browser: MockBrowser = {
_contexts: [],
_newContextCalls: 0,
_closeCalls: 0,
newContext: async () => {
browser._newContextCalls++
const ctx = createMockContext([createMockPage({} as MockContext)])
browser._contexts.push(ctx)
return ctx
},
close: async () => {
browser._closeCalls++
},
} as unknown as MockBrowser
return browser
}
function createMockFastCheckAdapter(): FastCheckAdapter {
return {
assert: async () => {},
record: (model) => model,
constantFrom: (...values) => values,
sample: (arb, params) => {
const numRuns = params?.numRuns ?? 10
const out: unknown[] = []
for (let i = 0; i < numRuns; i++) {
if (Array.isArray(arb)) {
out.push(arb[i % arb.length])
} else {
out.push(arb)
}
}
return out
},
property: (arb, predicate) => ({ arbitrary: arb, predicate }),
}
}
function createMockWorld(): GeometryWorld {
return {
sceneId: 'test',
snapshotId: 'snap-1',
env: {
viewportWidth: 1280,
viewportHeight: 720,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: 'no-preference',
locale: 'en',
writingMode: 'horizontal-tb',
},
source: {
url: 'http://localhost/test',
browserName: 'chromium',
browserVersion: '120',
engine: 'chromium-cdp',
},
strings: [],
subjects: [],
dom: { nodes: [], nodeCount: 0 },
frames: [],
matrices: [],
rects: [],
boxes: [],
fragments: [],
transforms: [],
styles: [],
text: [],
topology: { ancestors: [], descendants: [], stacks: [] },
scroll: [],
clipping: [],
paint: [],
visibility: [],
provenance: [],
confidence: [],
} as unknown as GeometryWorld
}
// ---------------------------------------------------------------------------
// hasPlaywright gate
// ---------------------------------------------------------------------------
let hasPlaywright = false
try {
await import('playwright')
hasPlaywright = true
} catch {
hasPlaywright = false
}
const realIt = hasPlaywright ? it : it.skip
// ---------------------------------------------------------------------------
// Test 1: Full stack flow
// ---------------------------------------------------------------------------
describe('pooling integration', () => {
it('full stack: create runtime -> create page -> run assertion -> release -> shutdown', async () => {
const browser = createMockBrowser()
const runtime = await createRuntime(browser)
// Create page (borrows from pool)
const page = await runtime.createPage()
assert.ok(page instanceof ImhotepPage)
// Verify page is usable (can call methods)
assert.ok(page.playwrightPage)
assert.strictEqual(typeof page.playwrightPage.goto, 'function')
// Return page
await runtime.releasePage(page)
// Shutdown
await runtime.shutdown()
assert.strictEqual(browser._closeCalls, 1)
})
// ---------------------------------------------------------------------------
// Test 2: Pool reuse
// ---------------------------------------------------------------------------
it('pool reuse: create 5 pages, release 4, 5th reuses existing', async () => {
const browser = createMockBrowser()
const runtime = await createRuntime(browser, {
poolOptions: { maxPagesPerContext: 4 },
})
const context = await runtime.createContext()
// Borrow 4 pages
const pages: ImhotepPage[] = []
for (let i = 0; i < 4; i++) {
pages.push(await runtime.createPage(context))
}
// Get the underlying mock context to check page creation count
const mockCtx = context as unknown as MockContext
assert.strictEqual(mockCtx._newPageCalls, 4)
// Return all 4
for (const page of pages) {
await runtime.releasePage(page)
}
// Borrow 5th — should reuse one of the first 4
const page5 = await runtime.createPage(context)
// Total page count should still be 4 (not 5)
assert.strictEqual(mockCtx._newPageCalls, 4)
// Verify it's one of the original pages
const page5Underlying = page5.playwrightPage
const originalUnderlyings = pages.map((p) => p.playwrightPage)
assert.ok(originalUnderlyings.includes(page5Underlying))
await runtime.releasePage(page5)
await runtime.shutdown()
})
// ---------------------------------------------------------------------------
// Test 3: Parallel enumerated concurrency
// ---------------------------------------------------------------------------
it('parallel enumerated: 20 cases, concurrency 4, verify 4 active at peak', async () => {
let currentConcurrent = 0
let maxConcurrent = 0
const adapter: RendererAdapter = {
async mount(_sceneTarget: SceneTarget, renderCase: RenderCase): Promise<MountedScene> {
currentConcurrent++
maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
// Simulate work
await new Promise((r) => setTimeout(r, 30))
return {
caseId: renderCase.caseId,
world: createMockWorld(),
}
},
async unmount(): Promise<void> {
currentConcurrent--
},
}
const deps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m: MountedScene) => m.world },
fc: createMockFastCheckAdapter(),
}
const values = Array.from({ length: 20 }, (_, i) => i)
const domain: InputDomain = { mode: 'enumerated', values }
const result = await runEnumeratedProperty(
{ kind: 'fixture', fixtureId: 'test' },
domain,
async () => true,
deps,
{ concurrency: 4 }
)
assert.strictEqual(result.passed, true)
assert.strictEqual(result.totalCases, 20)
assert.ok(maxConcurrent <= 4, `Expected max concurrent <= 4, got ${maxConcurrent}`)
// Should have actually used concurrency (not sequential)
assert.ok(maxConcurrent > 1, `Expected some concurrency, got ${maxConcurrent}`)
})
// ---------------------------------------------------------------------------
// Test 4: Context health check
// ---------------------------------------------------------------------------
it('context health check: simulate dead context, verify replacement', async () => {
const browser = createMockBrowser()
const pool = new ContextPool(browser, { maxSize: 2 })
// Acquire a context
const ctx1 = await pool.acquire('env1') as MockContext
assert.ok(ctx1)
assert.strictEqual(browser._newContextCalls, 1)
// Manually close it to simulate crash
await ctx1.close()
ctx1._closed = true
// Force the page.evaluate to fail on the dead context
if (ctx1._pages[0]) {
ctx1._pages[0]._evaluateError = new Error('Context is dead')
}
// Release back to pool (it will be marked as available)
pool.release(ctx1)
// Acquire again with same envKey — health check should detect dead one
const ctx2 = await pool.acquire('env1') as MockContext
// A NEW context should be created (old one was closed/evicted)
assert.notStrictEqual(ctx1, ctx2)
assert.strictEqual(browser._newContextCalls, 2)
await pool.dispose()
})
// ---------------------------------------------------------------------------
// Test 5: Page reset (real browser)
// ---------------------------------------------------------------------------
describe('real browser integration', () => {
let browser: Browser | null = null
let runtime: ImhotepRuntime | null = null
before(async () => {
if (!hasPlaywright) return
const { chromium } = await import('playwright')
browser = await chromium.launch()
runtime = await createRuntime(browser)
})
after(async () => {
if (runtime) {
await runtime.shutdown()
}
})
realIt('page reset: navigate, set cookie, return, borrow, verify clean', async () => {
if (!runtime) throw new Error('Runtime not initialized')
const page = await runtime.createPage()
const pwPage = page.playwrightPage
// Navigate to a URL and set a cookie
await pwPage.goto('data:text/html,<html><body><h1>Test</h1></body></html>')
await pwPage.context().addCookies([{
name: 'test-cookie',
value: 'test-value',
domain: 'localhost',
path: '/',
}])
// Verify cookie exists
const cookiesBefore = await pwPage.context().cookies()
assert.ok(cookiesBefore.some((c) => c.name === 'test-cookie'))
// Return page to pool (triggers reset)
await runtime.releasePage(page)
// Borrow same page again
const page2 = await runtime.createPage(pwPage.context())
const pwPage2 = page2.playwrightPage
// Verify URL is about:blank
const url = pwPage2.url()
assert.ok(url === 'about:blank' || url === 'about:blank/', `Expected about:blank, got ${url}`)
// Verify cookies are cleared
const cookiesAfter = await pwPage2.context().cookies()
assert.strictEqual(cookiesAfter.length, 0)
await runtime.releasePage(page2)
})
})
// ---------------------------------------------------------------------------
// Test 6: Stress test
// ---------------------------------------------------------------------------
it('stress: 100 sequential borrows/returns, no leaks', async () => {
const browser = createMockBrowser()
const runtime = await createRuntime(browser, {
poolOptions: { maxPagesPerContext: 4 },
})
const context = await runtime.createContext()
const mockCtx = context as unknown as MockContext
// Borrow and return 100 times
for (let i = 0; i < 100; i++) {
const page = await runtime.createPage(context)
await runtime.releasePage(page)
}
// Total pages created should never exceed maxPagesPerContext
assert.ok(
mockCtx._newPageCalls <= 4,
`Expected <= 4 pages created, got ${mockCtx._newPageCalls}`
)
// No pages should be left inUse
const pagePool = (runtime as any).pagePool as PagePool
const entries: PageEntry[] = (pagePool as any).pools.get(context) ?? []
const inUseCount = entries.filter((e) => e.inUse).length
assert.strictEqual(inUseCount, 0, `Expected 0 pages inUse, got ${inUseCount}`)
await runtime.shutdown()
})
})
@@ -0,0 +1,51 @@
import assert from 'node:assert'
import { describe, it } from 'node:test'
import { expect as dslExpect } from 'imhotep-dsl'
import type { ImhotepUi } from './public.js'
import {
touchTarget,
toolbarAlignment,
cardSpacing,
formLabelAlignment,
modalContainment,
} from './presets.js'
function createPresetUi(): ImhotepUi {
return {
expect: (subject: string | any) => dslExpect(String(subject)),
} as unknown as ImhotepUi
}
describe('preset contracts', () => {
it('touchTarget emits width and height clauses', () => {
const result = touchTarget(createPresetUi(), '.button')
assert.strictEqual(result.assertions.length, 2)
assert.ok(result.clauses.some((c) => c.relation === 'size.atLeast' && c.options?.dimension === 'width'))
assert.ok(result.clauses.some((c) => c.relation === 'size.atLeast' && c.options?.dimension === 'height'))
})
it('toolbarAlignment emits pairwise spacing and alignment clauses', () => {
const result = toolbarAlignment(createPresetUi(), ['.a', '.b', '.c'])
assert.strictEqual(result.assertions.length, 4)
assert.ok(result.clauses.some((c) => c.relation === 'leftOf'))
assert.ok(result.clauses.some((c) => c.relation === 'alignedWith'))
})
it('cardSpacing emits horizontal gap contract', () => {
const result = cardSpacing(createPresetUi(), '.card-a', '.card-b', { minGap: 20 })
assert.strictEqual(result.assertions.length, 2)
const leftOf = result.clauses.find((c) => c.relation === 'leftOf')
assert.strictEqual(leftOf?.bounds?.minGap, 20)
})
it('formLabelAlignment supports above mode', () => {
const result = formLabelAlignment(createPresetUi(), '.label', '.input', { labelAbove: true })
assert.ok(result.clauses.some((c) => c.relation === 'above'))
})
it('modalContainment emits containment clauses', () => {
const result = modalContainment(createPresetUi(), '.modal')
assert.ok(result.clauses.some((c) => c.relation === 'inside'))
assert.ok(result.clauses.some((c) => c.relation === 'centeredWithin'))
})
})
@@ -0,0 +1,88 @@
import { lowerToCanonical, type CanonicalClauseDescriptor } from 'imhotep-dsl'
import type { FluentRelation } from 'imhotep-dsl'
import type { ImhotepUi } from './public.js'
export interface PresetResult {
assertions: FluentRelation[]
clauses: CanonicalClauseDescriptor[]
}
function toPresetResult(assertions: FluentRelation[]): PresetResult {
const clauses: CanonicalClauseDescriptor[] = []
for (const assertion of assertions) {
clauses.push(...lowerToCanonical(assertion.toAst()))
}
return { assertions, clauses }
}
export function touchTarget(
ui: ImhotepUi,
selector: string,
options: { minSize?: number } = {},
): PresetResult {
const minSize = options.minSize ?? 44
const assertions = [
ui.expect(selector).to.be.atLeast(minSize, 'width'),
ui.expect(selector).to.be.atLeast(minSize, 'height'),
]
return toPresetResult(assertions)
}
export function toolbarAlignment(
ui: ImhotepUi,
selectors: string[],
options: { minGap?: number; axis?: 'centerY' | 'centerX' } = {},
): PresetResult {
const minGap = options.minGap ?? 8
const axis = options.axis ?? 'centerY'
const assertions: FluentRelation[] = []
for (let i = 0; i < selectors.length - 1; i++) {
assertions.push(ui.expect(selectors[i]).to.be.leftOf(selectors[i + 1], { minGap }))
assertions.push(ui.expect(selectors[i]).to.be.alignedWith(selectors[i + 1], { axis }))
}
return toPresetResult(assertions)
}
export function cardSpacing(
ui: ImhotepUi,
cardSelector: string,
nextCardSelector: string,
options: { minGap?: number } = {},
): PresetResult {
const minGap = options.minGap ?? 16
const assertions = [
ui.expect(cardSelector).to.be.leftOf(nextCardSelector, { minGap }),
ui.expect(cardSelector).to.be.alignedWith(nextCardSelector, { axis: 'top' }),
]
return toPresetResult(assertions)
}
export function formLabelAlignment(
ui: ImhotepUi,
labelSelector: string,
inputSelector: string,
options: { labelAbove?: boolean; maxGap?: number } = {},
): PresetResult {
const labelAbove = options.labelAbove ?? true
const maxGap = options.maxGap ?? 16
const assertions: FluentRelation[] = []
if (labelAbove) {
assertions.push(ui.expect(labelSelector).to.be.above(inputSelector, { maxGap }))
assertions.push(ui.expect(labelSelector).to.be.alignedWith(inputSelector, { axis: 'left' }))
} else {
assertions.push(ui.expect(labelSelector).to.be.leftOf(inputSelector, { maxGap }))
assertions.push(ui.expect(labelSelector).to.be.alignedWith(inputSelector, { axis: 'centerY' }))
}
return toPresetResult(assertions)
}
export function modalContainment(
ui: ImhotepUi,
modalSelector: string,
): PresetResult {
const assertions = [
ui.expect(modalSelector).to.be.inside('body'),
ui.expect(modalSelector).to.be.centeredWithin('body'),
]
return toPresetResult(assertions)
}
@@ -0,0 +1,886 @@
/**
* Integration tests for the property runner.
*
* Mocks the renderer adapter and scene target so these tests exercise
* the runner orchestration without needing a real browser.
*
* Pattern: Arrange-Act-Assert with Node.js built-in test runner.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import type { SceneTarget, RenderCase } from 'imhotep-core/scene-target'
import type { InputDomain } from 'imhotep-core/property-contracts'
import type { GeometryWorld } from 'imhotep-core/world'
import type { MountedScene, RendererAdapter, PropertyPredicate, PropertyRunnerDeps } from './property-runner.js'
import { runProperty, runEnumeratedProperty, runSampledProperty } from './property-runner.js'
import type { FastCheckAdapter } from './fast-check-adapter.js'
// ---------------------------------------------------------------------------
// Mock Builder
// ---------------------------------------------------------------------------
function createMockRendererAdapter(
opts: { failOnCaseId?: string } = {},
): RendererAdapter {
const mountedScenes: MountedScene[] = []
return {
async mount(_sceneTarget: SceneTarget, renderCase: RenderCase): Promise<MountedScene> {
if (opts.failOnCaseId && renderCase.caseId === opts.failOnCaseId) {
throw new Error(`Simulated mount failure for ${renderCase.caseId}`)
}
const scene: MountedScene = {
caseId: renderCase.caseId,
world: createMockWorld(renderCase.caseId),
}
mountedScenes.push(scene)
return scene
},
async unmount(mounted: MountedScene): Promise<void> {
const idx = mountedScenes.findIndex((s) => s.caseId === mounted.caseId)
if (idx >= 0) mountedScenes.splice(idx, 1)
},
}
}
function createMockWorld(sceneId: string): GeometryWorld {
return {
sceneId,
snapshotId: 'snap-1',
env: {
viewportWidth: 1280,
viewportHeight: 720,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: 'no-preference',
locale: 'en',
writingMode: 'horizontal-tb',
},
source: {
url: 'http://localhost/test',
browserName: 'chromium',
browserVersion: '120',
engine: 'chromium-cdp',
},
strings: [],
subjects: [],
dom: { nodes: [], nodeCount: 0 },
frames: [],
matrices: [],
rects: [],
boxes: [],
fragments: [],
transforms: [],
styles: [],
text: [],
topology: { ancestors: [], descendants: [], stacks: [] },
scroll: [],
clipping: [],
paint: [],
visibility: [],
provenance: [],
confidence: [],
} as unknown as GeometryWorld
}
function createMockFastCheckAdapter(): FastCheckAdapter {
return {
assert: async (prop: unknown, params?: Record<string, unknown>) => {
const p = prop as { predicate: (value: unknown) => boolean | Promise<boolean>; arbitrary: unknown }
const numRuns = (params?.numRuns as number) ?? 100
const seed = (params?.seed as number) ?? 42
// Deterministic pseudo-random from seed for replayability
let rngState = seed
const next = () => {
rngState = (rngState * 16807 + 0) % 2147483647
return rngState / 2147483647
}
for (let i = 0; i < numRuns; i++) {
const value = generateValue(p.arbitrary, next)
const passed = await p.predicate(value)
if (!passed) {
const err = new Error(`Property failed at run ${i}`)
;(err as unknown as Record<string, unknown>).counterexample = value
throw err
}
}
},
record: (recordModel: Record<string, unknown>) => recordModel,
constantFrom: (...values: unknown[]) => values,
sample: (arb: unknown, params?: { seed?: number; numRuns?: number }) => {
const numRuns = params?.numRuns ?? 10
const seed = params?.seed ?? 42
let rngState = seed
const next = () => {
rngState = (rngState * 16807 + 0) % 2147483647
return rngState / 2147483647
}
const out: unknown[] = []
for (let i = 0; i < numRuns; i++) {
out.push(generateValue(arb, next))
}
return out
},
property: (arb: unknown, predicate: (value: unknown) => boolean | Promise<boolean>) => {
return { arbitrary: arb, predicate }
},
}
}
function generateValue(arb: unknown, next: () => number): unknown {
if (Array.isArray(arb)) {
const idx = Math.floor(next() * arb.length)
return arb[idx]
}
if (arb && typeof arb === 'object') {
const obj: Record<string, unknown> = {}
for (const [key, val] of Object.entries(arb)) {
obj[key] = generateValue(val, next)
}
return obj
}
return arb
}
function createConcurrencyTrackingAdapter(delayMs = 50) {
let currentConcurrent = 0
let maxConcurrent = 0
const adapter: RendererAdapter = {
async mount(_sceneTarget: SceneTarget, renderCase: RenderCase): Promise<MountedScene> {
currentConcurrent++
maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
await new Promise((r) => setTimeout(r, delayMs))
return {
caseId: renderCase.caseId,
world: createMockWorld(renderCase.caseId),
}
},
async unmount(): Promise<void> {
currentConcurrent--
},
}
return {
adapter,
getMaxConcurrent: () => maxConcurrent,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const sceneTarget: SceneTarget = { kind: 'fixture', fixtureId: 'button-fixture' }
describe('property runner — enumerated mode', () => {
it('evaluates every enumerated case deterministically', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const values = ['sm', 'md', 'lg']
const domain: InputDomain = { mode: 'enumerated', values }
const evaluated: string[] = []
const predicate: PropertyPredicate = async (scene) => {
evaluated.push((scene as MountedScene).caseId)
return true
}
const result = await runEnumeratedProperty(sceneTarget, domain, predicate, deps)
assert.strictEqual(result.mode, 'enumerated-determinate')
assert.strictEqual(result.totalCases, 3)
assert.strictEqual(result.passed, true)
assert.strictEqual(evaluated.length, 3)
assert.ok(evaluated.includes('enum-0'))
assert.ok(evaluated.includes('enum-1'))
assert.ok(evaluated.includes('enum-2'))
})
it('stops at first failing case and reports it', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const values = ['sm', 'md', 'lg']
const domain: InputDomain = { mode: 'enumerated', values }
const predicate: PropertyPredicate = async (scene) => {
return (scene as MountedScene).caseId !== 'enum-1'
}
const result = await runEnumeratedProperty(sceneTarget, domain, predicate, deps)
assert.strictEqual(result.mode, 'enumerated-determinate')
assert.strictEqual(result.passed, false)
assert.strictEqual(result.failingCase, 'md')
assert.strictEqual(result.caseIndex, 1)
})
it('enumerated runs all cases concurrently', async () => {
const { adapter } = createConcurrencyTrackingAdapter(50)
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const values = [1, 2, 3, 4, 5]
const domain: InputDomain = { mode: 'enumerated', values }
const start = performance.now()
const result = await runEnumeratedProperty(sceneTarget, domain, async () => true, deps, {
concurrency: 5,
})
const elapsed = performance.now() - start
assert.strictEqual(result.passed, true)
assert.strictEqual(result.totalCases, 5)
assert.ok(elapsed < 200, `Expected concurrent run to be fast, but took ${elapsed}ms`)
})
it('enumerated stops on first failure with failFast', async () => {
const { adapter } = createConcurrencyTrackingAdapter(10)
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const values = [1, 2, 3, 4, 5]
const domain: InputDomain = { mode: 'enumerated', values }
const evaluated: number[] = []
const predicate: PropertyPredicate = async (scene) => {
const idx = parseInt((scene as MountedScene).caseId.replace('enum-', ''))
evaluated.push(idx)
return idx !== 2
}
const result = await runEnumeratedProperty(sceneTarget, domain, predicate, deps, {
concurrency: 1,
failFast: true,
})
assert.strictEqual(result.passed, false)
assert.strictEqual(result.caseIndex, 2)
assert.ok(evaluated.includes(0))
assert.ok(evaluated.includes(1))
assert.ok(evaluated.includes(2))
assert.ok(!evaluated.includes(3))
assert.ok(!evaluated.includes(4))
})
it('enumerated runs all cases despite failure without failFast', async () => {
const { adapter } = createConcurrencyTrackingAdapter(10)
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const values = [1, 2, 3, 4, 5]
const domain: InputDomain = { mode: 'enumerated', values }
const evaluated: number[] = []
const predicate: PropertyPredicate = async (scene) => {
const idx = parseInt((scene as MountedScene).caseId.replace('enum-', ''))
evaluated.push(idx)
return idx !== 2
}
const result = await runEnumeratedProperty(sceneTarget, domain, predicate, deps, {
concurrency: 1,
failFast: false,
})
assert.strictEqual(result.passed, false)
assert.strictEqual(result.caseIndex, 2)
assert.strictEqual(evaluated.length, 5)
assert.ok(evaluated.includes(0))
assert.ok(evaluated.includes(1))
assert.ok(evaluated.includes(2))
assert.ok(evaluated.includes(3))
assert.ok(evaluated.includes(4))
})
it('enumerated respects concurrency limit', async () => {
const { adapter, getMaxConcurrent } = createConcurrencyTrackingAdapter(50)
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const values = [1, 2, 3, 4, 5]
const domain: InputDomain = { mode: 'enumerated', values }
const result = await runEnumeratedProperty(sceneTarget, domain, async () => true, deps, {
concurrency: 2,
})
assert.strictEqual(result.passed, true)
assert.ok(getMaxConcurrent() <= 2, `Expected max concurrent <= 2, but got ${getMaxConcurrent()}`)
assert.ok(getMaxConcurrent() > 1, `Expected some concurrency, but got ${getMaxConcurrent()}`)
})
})
describe('property runner — sampled mode', () => {
it('runs sampled cases with fast-check and reports seed', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = {
mode: 'generated',
arbitrary: fc.constantFrom('sm', 'md', 'lg'),
seed: 42,
numRuns: 20,
}
let runCount = 0
const predicate: PropertyPredicate = async () => {
runCount++
return true
}
const result = await runSampledProperty(sceneTarget, domain, predicate, deps, { seed: 42, numRuns: 20 })
assert.strictEqual(result.mode, 'sampled')
assert.strictEqual(result.seed, 42)
assert.strictEqual(result.numRuns, 20)
assert.strictEqual(result.passed, true)
assert.strictEqual(runCount, 20)
})
it('captures counterexample on failure', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = {
mode: 'generated',
arbitrary: fc.constantFrom('sm', 'md', 'lg'),
seed: 42,
numRuns: 20,
}
let callCount = 0
const predicate: PropertyPredicate = async () => {
callCount++
return callCount < 5
}
const result = await runSampledProperty(sceneTarget, domain, predicate, deps, { seed: 42, numRuns: 20 })
assert.strictEqual(result.mode, 'sampled')
assert.strictEqual(result.passed, false)
assert.ok(result.counterexample !== undefined)
assert.ok(result.diagnostics.length > 0)
})
it('reproduces identical run with same seed', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = {
mode: 'generated',
arbitrary: fc.constantFrom('sm', 'md', 'lg'),
seed: 123,
numRuns: 10,
}
const run1Inputs: unknown[] = []
const run2Inputs: unknown[] = []
const predicate1: PropertyPredicate = async (_scene, ctx) => {
run1Inputs.push(ctx.renderCase.input)
return true
}
const predicate2: PropertyPredicate = async (_scene, ctx) => {
run2Inputs.push(ctx.renderCase.input)
return true
}
await runSampledProperty(sceneTarget, domain, predicate1, deps, { seed: 123, numRuns: 10 })
await runSampledProperty(sceneTarget, domain, predicate2, deps, { seed: 123, numRuns: 10 })
assert.deepStrictEqual(run1Inputs, run2Inputs)
})
it('sampled runs iterations concurrently', async () => {
const { adapter } = createConcurrencyTrackingAdapter(50)
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = {
mode: 'generated',
arbitrary: fc.constantFrom('a', 'b', 'c'),
seed: 42,
numRuns: 10,
}
const start = performance.now()
const result = await runSampledProperty(sceneTarget, domain, async () => true, deps, {
seed: 42,
numRuns: 10,
concurrency: 10,
})
const elapsed = performance.now() - start
assert.strictEqual(result.passed, true)
assert.strictEqual(result.numRuns, 10)
assert.ok(elapsed < 300, `Expected concurrent sampled run to be fast, but took ${elapsed}ms`)
})
it('sampled respects concurrency limit', async () => {
const { adapter, getMaxConcurrent } = createConcurrencyTrackingAdapter(50)
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = {
mode: 'generated',
arbitrary: fc.constantFrom('a', 'b', 'c'),
seed: 42,
numRuns: 10,
}
const result = await runSampledProperty(sceneTarget, domain, async () => true, deps, {
seed: 42,
numRuns: 10,
concurrency: 2,
})
assert.strictEqual(result.passed, true)
assert.ok(getMaxConcurrent() <= 2, `Expected max concurrent <= 2, but got ${getMaxConcurrent()}`)
assert.ok(getMaxConcurrent() > 1, `Expected some concurrency, but got ${getMaxConcurrent()}`)
})
it('sampled preserves seed and shrinking', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = {
mode: 'generated',
arbitrary: fc.constantFrom('sm', 'md', 'lg'),
seed: 42,
numRuns: 20,
}
let callCount = 0
const predicate: PropertyPredicate = async () => {
callCount++
return callCount < 5
}
const result1 = await runSampledProperty(sceneTarget, domain, predicate, deps, {
seed: 42,
numRuns: 20,
})
callCount = 0
const result2 = await runSampledProperty(sceneTarget, domain, predicate, deps, {
seed: 42,
numRuns: 20,
})
assert.strictEqual(result1.passed, false)
assert.strictEqual(result2.passed, false)
assert.deepStrictEqual(result1.counterexample, result2.counterexample)
assert.deepStrictEqual(result1.minimalFailingCase, result2.minimalFailingCase)
})
})
describe('property runner — error handling', () => {
it('error in one case does not crash runner', async () => {
const evaluated: number[] = []
const adapter: RendererAdapter = {
async mount(_sceneTarget, renderCase) {
if (renderCase.caseId === 'enum-2') {
throw new Error('Mount failed')
}
return {
caseId: renderCase.caseId,
world: createMockWorld(renderCase.caseId),
}
},
async unmount() {},
}
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const values = [1, 2, 3, 4, 5]
const domain: InputDomain = { mode: 'enumerated', values }
const predicate: PropertyPredicate = async (scene) => {
const idx = parseInt((scene as MountedScene).caseId.replace('enum-', ''))
evaluated.push(idx)
return true
}
const result = await runEnumeratedProperty(sceneTarget, domain, predicate, deps, {
failFast: false,
})
assert.strictEqual(result.passed, false)
assert.strictEqual(result.caseIndex, 2)
assert.ok(evaluated.includes(0))
assert.ok(evaluated.includes(1))
assert.ok(evaluated.includes(3))
assert.ok(evaluated.includes(4))
})
})
describe('property runner — mount/unmount', () => {
it('mount/unmount called for every case', async () => {
let mountCount = 0
let unmountCount = 0
const adapter: RendererAdapter = {
async mount(_sceneTarget, renderCase) {
mountCount++
return {
caseId: renderCase.caseId,
world: createMockWorld(renderCase.caseId),
}
},
async unmount() {
unmountCount++
},
}
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const values = ['a', 'b', 'c', 'd', 'e']
const domain: InputDomain = { mode: 'enumerated', values }
const result = await runEnumeratedProperty(sceneTarget, domain, async () => true, deps)
assert.strictEqual(result.passed, true)
assert.strictEqual(mountCount, 5)
assert.strictEqual(unmountCount, 5)
})
})
describe('property runner — dispatch', () => {
it('dispatches to enumerated runner for enumerated domain', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = { mode: 'enumerated', values: [1, 2, 3] }
const result = await runProperty(sceneTarget, domain, async () => true, deps)
assert.strictEqual(result.mode, 'enumerated-determinate')
})
it('dispatches to sampled runner for generated domain', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = {
mode: 'generated',
arbitrary: fc.constantFrom('a', 'b'),
seed: 1,
numRuns: 5,
}
const result = await runProperty(sceneTarget, domain, async () => true, deps, { seed: 1, numRuns: 5 })
assert.strictEqual(result.mode, 'sampled')
})
it('throws for unsupported domain mode', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain = { mode: 'unknown' } as unknown as InputDomain
await assert.rejects(
async () => runProperty(sceneTarget, domain, async () => true, deps),
/Unsupported InputDomain mode/,
)
})
})
describe('property runner — report mode', () => {
it('compact enumerated result strips verbose fields', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const values = ['sm', 'md', 'lg']
const domain: InputDomain = { mode: 'enumerated', values }
const predicate: PropertyPredicate = async (scene) => {
return (scene as MountedScene).caseId !== 'enum-1'
}
const result = await runEnumeratedProperty(sceneTarget, domain, predicate, deps, {
reportMode: 'compact',
})
assert.strictEqual(result.mode, 'enumerated-determinate')
assert.strictEqual(result.passed, false)
assert.strictEqual(result.caseIndex, 1)
assert.strictEqual(result.minimalFailingCase, 'md')
assert.ok(result.durationMs !== undefined)
assert.ok(result.replayPayload !== undefined)
assert.deepStrictEqual(result.replayPayload!, {
props: 'md',
seed: -1,
caseIndex: 1,
})
assert.strictEqual(result.diagnostics.length, 0)
assert.strictEqual((result as any).failingScene, undefined)
})
it('verbose enumerated result includes full payload', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const values = ['sm', 'md', 'lg']
const domain: InputDomain = { mode: 'enumerated', values }
const predicate: PropertyPredicate = async (scene) => {
return (scene as MountedScene).caseId !== 'enum-1'
}
const result = await runEnumeratedProperty(sceneTarget, domain, predicate, deps, {
reportMode: 'verbose',
})
assert.strictEqual(result.mode, 'enumerated-determinate')
assert.strictEqual(result.passed, false)
assert.strictEqual(result.caseIndex, 1)
assert.strictEqual(result.minimalFailingCase, 'md')
assert.ok(result.durationMs !== undefined)
assert.ok(Array.isArray(result.diagnostics))
assert.ok(result.replayPayload !== undefined)
assert.deepStrictEqual(result.replayPayload!, {
props: 'md',
seed: -1,
caseIndex: 1,
})
})
it('compact sampled result strips verbose fields', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = {
mode: 'generated',
arbitrary: fc.constantFrom('sm', 'md', 'lg'),
seed: 42,
numRuns: 20,
}
let callCount = 0
const predicate: PropertyPredicate = async () => {
callCount++
return callCount < 5
}
const result = await runSampledProperty(sceneTarget, domain, predicate, deps, {
seed: 42,
numRuns: 20,
reportMode: 'compact',
})
assert.strictEqual(result.mode, 'sampled')
assert.strictEqual(result.passed, false)
assert.strictEqual(result.caseIndex, 4)
assert.ok(result.minimalFailingCase !== undefined)
assert.ok(result.durationMs !== undefined)
assert.ok(result.replayPayload !== undefined)
assert.strictEqual(result.replayPayload!.seed, 42)
assert.strictEqual(result.replayPayload!.caseIndex, 4)
assert.ok(result.replayPayload!.props !== undefined)
assert.strictEqual(result.diagnostics.length, 0)
assert.strictEqual((result as any).failingScene, undefined)
})
it('verbose sampled result includes full payload', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = {
mode: 'generated',
arbitrary: fc.constantFrom('sm', 'md', 'lg'),
seed: 42,
numRuns: 20,
}
let callCount = 0
const predicate: PropertyPredicate = async () => {
callCount++
return callCount < 5
}
const result = await runSampledProperty(sceneTarget, domain, predicate, deps, {
seed: 42,
numRuns: 20,
reportMode: 'verbose',
})
assert.strictEqual(result.mode, 'sampled')
assert.strictEqual(result.passed, false)
assert.strictEqual(result.caseIndex, 4)
assert.ok(result.minimalFailingCase !== undefined)
assert.ok(result.durationMs !== undefined)
assert.ok(Array.isArray(result.diagnostics))
assert.ok(result.replayPayload !== undefined)
assert.strictEqual(result.replayPayload!.seed, 42)
assert.strictEqual(result.replayPayload!.caseIndex, 4)
assert.ok(result.replayPayload!.props !== undefined)
})
it('reproduces identical run from sampled replay payload', async () => {
const adapter = createMockRendererAdapter()
const fc = createMockFastCheckAdapter()
const deps: PropertyRunnerDeps = {
rendererAdapter: adapter,
worldExtractor: { extract: async (m) => m.world },
fc,
}
const domain: InputDomain = {
mode: 'generated',
arbitrary: fc.constantFrom('sm', 'md', 'lg'),
seed: 123,
numRuns: 10,
}
let callCount = 0
const collectedInputs: unknown[] = []
const predicate: PropertyPredicate = async (_scene, ctx) => {
collectedInputs.push(ctx.renderCase.input)
callCount++
return callCount < 7
}
const result = await runSampledProperty(sceneTarget, domain, predicate, deps, {
seed: 123,
numRuns: 10,
reportMode: 'verbose',
})
assert.strictEqual(result.passed, false)
assert.ok(result.replayPayload !== undefined)
const replayInputs: unknown[] = []
let replayCount = 0
const replayPayload = result.replayPayload!
const replayPredicate: PropertyPredicate = async (_scene, ctx) => {
replayInputs.push(ctx.renderCase.input)
replayCount++
return replayCount < replayPayload.caseIndex + 1
}
await runSampledProperty(sceneTarget, domain, replayPredicate, deps, {
seed: replayPayload.seed,
numRuns: replayPayload.caseIndex + 1,
reportMode: 'verbose',
})
assert.deepStrictEqual(
collectedInputs[replayPayload.caseIndex],
replayInputs[replayPayload.caseIndex],
)
})
})
@@ -0,0 +1,387 @@
/**
* Property runner orchestrator for Imhotep.
*
* Coordinates sampled and enumerated property runs over render cases.
* For each generated or enumerated input:
* 1. Mount the render case via the renderer adapter.
* 2. Extract the geometry world.
* 3. Run the user callback (which may use deterministic FOL).
* 4. Record pass/fail.
*
* The runner preserves Invariant 6: every result clearly states its mode
* (enumerated-determinate or sampled) and carries full replay metadata.
*
* All dependencies are injected: renderer adapter, fast-check adapter,
* and world extractor so tests can substitute mocks.
*/
import type { SceneTarget, RenderCase } from 'imhotep-core/scene-target'
import type { InputDomain } from 'imhotep-core/property-contracts'
import type { SampledRunResult, EnumeratedRunResult } from 'imhotep-core/property-results'
import type { GeometryWorld } from 'imhotep-core/world'
import type { FastCheckAdapter, FastCheckRunConfig } from './fast-check-adapter.js'
import { buildArbitrary, runFastCheckProperty, sampleDomain } from './fast-check-adapter.js'
import { Semaphore } from './semaphore.js'
import { generateSeed } from 'imhotep-core'
import os from 'node:os'
/**
* Default concurrency cap for property runs.
*
* Based on contention analysis: browser I/O saturates around 4-6 concurrent
* pages. This is a heuristic — tune for your machine and workload.
*
* @see CONTENTION_ANALYSIS.md for benchmark methodology
*/
export const DEFAULT_CONCURRENCY_CAP = 4
// ---------------------------------------------------------------------------
// Renderer Adapter Contract (minimal — Stream 4 owns the full definition)
// ---------------------------------------------------------------------------
export interface RendererAdapter {
mount(sceneTarget: SceneTarget, renderCase: RenderCase): Promise<MountedScene>
unmount(mounted: MountedScene): Promise<void>
}
export interface MountedScene {
caseId: string
world: GeometryWorld
cleanup?: () => Promise<void>
}
// ---------------------------------------------------------------------------
// World Extractor Contract
// ---------------------------------------------------------------------------
export interface WorldExtractor {
extract(mounted: MountedScene): Promise<GeometryWorld>
}
// ---------------------------------------------------------------------------
// User Callback
// ---------------------------------------------------------------------------
export interface PropertyRunContext {
runIndex: number
seed: number
renderCase: RenderCase
durationMs?: number
}
export type PropertyPredicate = (
scene: MountedScene,
ctx: PropertyRunContext,
) => boolean | Promise<boolean>
// ---------------------------------------------------------------------------
// Property Runner
// ---------------------------------------------------------------------------
export interface PropertyRunnerDeps {
rendererAdapter: RendererAdapter
worldExtractor: WorldExtractor
fc: FastCheckAdapter
idGen?: () => string
}
export interface PropertyRunnerOptions {
seed?: number
numRuns?: number
verbose?: boolean
reportMode?: 'compact' | 'verbose'
/**
* Max concurrent cases to run in parallel.
*
* Default: Math.min(os.cpus().length, DEFAULT_CONCURRENCY_CAP)
*
* The optimal value depends on your machine and test workload:
* - Browser I/O saturates around 4-6 concurrent pages
* - CPU-bound predicates may benefit from more workers
* - Start with default, measure, then tune up or down
*/
concurrency?: number
failFast?: boolean
}
/**
* Run a property over an enumerated input domain.
*
* Every case is evaluated concurrently up to the concurrency limit.
* If any case fails and failFast is true (default), no new cases are
* launched but in-flight cases are allowed to complete.
*/
export async function runEnumeratedProperty(
sceneTarget: SceneTarget,
domain: InputDomain,
predicate: PropertyPredicate,
deps: PropertyRunnerDeps,
options?: PropertyRunnerOptions,
): Promise<EnumeratedRunResult> {
if (domain.mode !== 'enumerated') {
throw new Error(`runEnumeratedProperty requires enumerated domain, got ${domain.mode}`)
}
const values = domain.values
const limit = options?.concurrency ?? 1
const failFast = options?.failFast ?? true
const reportMode = options?.reportMode ?? 'verbose'
const diagnostics: unknown[] = []
const startTotal = performance.now()
const semaphore = new Semaphore(limit)
let firstFailureIndex = -1
let firstFailureValue: unknown = undefined
let firstFailureDuration = 0
const results: Array<{ index: number; passed: boolean }> = []
await Promise.all(
values.map((value, i) =>
semaphore.run(async () => {
if (firstFailureIndex >= 0 && failFast) return
const renderCase: RenderCase = {
caseId: `enum-${i}`,
input: value,
}
let mounted: MountedScene | null = null
const caseStart = performance.now()
try {
mounted = await deps.rendererAdapter.mount(sceneTarget, renderCase)
const ctx: PropertyRunContext = {
runIndex: i,
seed: -1,
renderCase,
}
const passed = await predicate(mounted, ctx)
const caseDuration = performance.now() - caseStart
results[i] = { index: i, passed }
if (!passed && firstFailureIndex < 0) {
firstFailureIndex = i
firstFailureValue = value
firstFailureDuration = caseDuration
}
} catch (err: unknown) {
const caseDuration = performance.now() - caseStart
if (reportMode === 'verbose') {
diagnostics.push({
code: 'IMH_ENUMERATED_RUN_ERROR',
message: err instanceof Error ? err.message : String(err),
runIndex: i,
input: value,
})
}
if (firstFailureIndex < 0) {
firstFailureIndex = i
firstFailureValue = value
firstFailureDuration = caseDuration
}
} finally {
if (mounted) {
await deps.rendererAdapter.unmount(mounted)
}
}
})
)
)
if (firstFailureIndex >= 0) {
return {
mode: 'enumerated-determinate',
totalCases: values.length,
passed: false,
failingCase: firstFailureValue,
caseIndex: firstFailureIndex,
seed: -1,
minimalFailingCase: firstFailureValue,
durationMs: Math.round(firstFailureDuration),
replayPayload: {
props: firstFailureValue,
seed: -1,
caseIndex: firstFailureIndex,
},
diagnostics: reportMode === 'compact' ? [] : diagnostics,
}
}
const totalDuration = performance.now() - startTotal
return {
mode: 'enumerated-determinate',
totalCases: values.length,
passed: true,
durationMs: Math.round(totalDuration),
diagnostics: reportMode === 'compact' ? [] : diagnostics,
}
}
/**
* Run a property over a generated (sampled) input domain using fast-check.
*
* Values are sampled upfront with the given seed, then executed concurrently
* up to the concurrency limit. If a failure is found, fast-check is run
* sequentially on the failing case to obtain shrinking.
*/
export async function runSampledProperty(
sceneTarget: SceneTarget,
domain: InputDomain,
predicate: PropertyPredicate,
deps: PropertyRunnerDeps,
options?: PropertyRunnerOptions,
): Promise<SampledRunResult> {
if (domain.mode !== 'generated') {
throw new Error(`runSampledProperty requires generated domain, got ${domain.mode}`)
}
const seed = options?.seed ?? generateSeed()
const numRuns = options?.numRuns ?? 100
const limit = options?.concurrency ?? 1
const failFast = options?.failFast ?? true
const reportMode = options?.reportMode ?? 'verbose'
const diagnostics: unknown[] = []
const startTotal = performance.now()
// Phase 1: Sample all values with fast-check
const values = sampleDomain(domain, deps.fc, { seed, numRuns })
// Phase 2: Run all values sequentially through semaphore (concurrency=1
// by default ensures deterministic execution and avoids page-pool races)
const semaphore = new Semaphore(limit)
let firstFailureIndex = -1
let firstFailureInput: unknown = undefined
let firstFailureScene: unknown = undefined
let firstFailureDuration = 0
await Promise.all(
values.map((value, i) =>
semaphore.run(async () => {
if (firstFailureIndex >= 0 && failFast) return
const renderCase: RenderCase = {
caseId: `sampled-${i}`,
input: value,
}
let mounted: MountedScene | null = null
const caseStart = performance.now()
try {
mounted = await deps.rendererAdapter.mount(sceneTarget, renderCase)
const ctx: PropertyRunContext = {
runIndex: i,
seed,
renderCase,
}
const passed = await predicate(mounted, ctx)
const caseDuration = performance.now() - caseStart
if (!passed && firstFailureIndex < 0) {
firstFailureIndex = i
firstFailureInput = value
firstFailureScene = mounted
firstFailureDuration = caseDuration
}
} catch (err: unknown) {
const caseDuration = performance.now() - caseStart
if (reportMode === 'verbose') {
diagnostics.push({
code: 'IMH_SAMPLED_RUN_ERROR',
message: err instanceof Error ? err.message : String(err),
runIndex: i,
input: value,
})
}
if (firstFailureIndex < 0) {
firstFailureIndex = i
firstFailureInput = value
firstFailureDuration = caseDuration
}
} finally {
if (mounted) {
await deps.rendererAdapter.unmount(mounted)
}
}
})
)
)
if (firstFailureIndex < 0) {
const totalDuration = performance.now() - startTotal
return {
mode: 'sampled',
seed,
numRuns: values.length,
passed: true,
diagnostics: reportMode === 'compact' ? [] : diagnostics,
durationMs: Math.round(totalDuration),
}
}
// Phase 3: Run fast-check to shrink the failing case
const shrinkConfig: FastCheckRunConfig = { seed, numRuns: firstFailureIndex + 1 }
const shrinkOutcome = await runFastCheckProperty(
domain,
async (input: unknown) => {
const renderCase: RenderCase = { caseId: 'shrink', input }
let mounted: MountedScene | null = null
try {
mounted = await deps.rendererAdapter.mount(sceneTarget, renderCase)
const ctx: PropertyRunContext = { runIndex: 0, seed, renderCase }
return await predicate(mounted, ctx)
} finally {
if (mounted) {
await deps.rendererAdapter.unmount(mounted)
}
}
},
deps.fc,
shrinkConfig
)
const minimalFailingCase = shrinkOutcome.counterexample ?? firstFailureInput
return {
mode: 'sampled',
seed,
numRuns: values.length,
passed: false,
counterexample: firstFailureInput,
shrunkCounterexample: reportMode === 'compact' ? undefined : minimalFailingCase,
failingScene: reportMode === 'compact' ? undefined : firstFailureScene,
diagnostics: reportMode === 'compact' ? [] : [...diagnostics, ...shrinkOutcome.diagnostics],
minimalFailingCase,
caseIndex: firstFailureIndex,
durationMs: Math.round(firstFailureDuration),
replayPayload: {
props: minimalFailingCase,
seed,
caseIndex: firstFailureIndex,
},
}
}
/**
* Dispatch to the appropriate runner based on InputDomain mode.
*/
export async function runProperty(
sceneTarget: SceneTarget,
domain: InputDomain,
predicate: PropertyPredicate,
deps: PropertyRunnerDeps,
options?: PropertyRunnerOptions,
): Promise<SampledRunResult | EnumeratedRunResult> {
if (domain.mode === 'enumerated') {
return runEnumeratedProperty(sceneTarget, domain, predicate, deps, options)
}
if (domain.mode === 'generated') {
return runSampledProperty(sceneTarget, domain, predicate, deps, options)
}
throw new Error(`Unsupported InputDomain mode: ${(domain as InputDomain).mode}`)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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)
},
}
}
@@ -0,0 +1,164 @@
/**
* Renderer registry and adapter contract for Imhotep Playwright.
*
* React, Vue, Storybook, and custom renderers are accidental; the scene
* is essential. This module defines the adapter boundary that keeps
* renderer concerns out of the core.
*/
import type { Page } from 'playwright'
import type { SceneTarget, RenderCase } from 'imhotep-core/scene-target'
// ---------------------------------------------------------------------------
// Renderer Adapter Contract
// ---------------------------------------------------------------------------
export interface RendererAdapter {
/** Unique identifier for this adapter instance. */
id: string
/** Mount the target into the given page with the provided input. */
mount(page: Page, target: SceneTarget, input: unknown): Promise<void>
/** Unmount or clean up the renderer state from the page. */
unmount?(page: Page): Promise<void>
}
// ---------------------------------------------------------------------------
// Renderer Registry
// ---------------------------------------------------------------------------
export interface RendererRegistry {
/** Register a renderer adapter under a rendererId. */
register(rendererId: string, adapter: RendererAdapter): void
/** Look up a renderer adapter by its id. */
resolve(rendererId: string): RendererAdapter | undefined
/** List all registered renderer ids. */
list(): string[]
}
export function createRendererRegistry(): RendererRegistry {
const adapters = new Map<string, RendererAdapter>()
return {
register(rendererId: string, adapter: RendererAdapter): void {
adapters.set(rendererId, adapter)
},
resolve(rendererId: string): RendererAdapter | undefined {
return adapters.get(rendererId)
},
list(): string[] {
return Array.from(adapters.keys())
},
}
}
// ---------------------------------------------------------------------------
// Global default registry
// ---------------------------------------------------------------------------
const defaultRegistry = createRendererRegistry()
export function getDefaultRendererRegistry(): RendererRegistry {
return defaultRegistry
}
// ---------------------------------------------------------------------------
// Renderer Factory Functions
// ---------------------------------------------------------------------------
export interface ReactRendererOptions {
/** Id for this renderer instance (defaults to 'react'). */
id?: string
/** ReactDOM entry point; injected to avoid direct dependency. */
reactDom: {
render?(element: unknown, container: Element): void
createRoot?(container: Element): { render(element: unknown): void }
}
/** React.createElement entry point; injected to avoid direct dependency. */
createElement: (type: unknown, props?: Record<string, unknown>, ...children: unknown[]) => unknown
}
export interface VueRendererOptions {
/** Id for this renderer instance (defaults to 'vue'). */
id?: string
/** Vue createApp entry point; injected to avoid direct dependency. */
createApp: (component: unknown, props?: Record<string, unknown>) => {
mount(selector: string): void
unmount(): void
}
}
export interface StorybookRendererOptions {
/** Id for this renderer instance (defaults to 'storybook'). */
id?: string
/** Base URL of the Storybook instance. */
storybookUrl: string
}
export interface CustomRendererOptions {
/** Id for this renderer instance. */
id: string
/** User-provided mount function. */
mount: (page: Page, target: SceneTarget, input: unknown) => Promise<void>
/** Optional user-provided unmount function. */
unmount?: (page: Page) => Promise<void>
}
// Factory results are lightweight descriptors that the public API expands
// into registered adapters at runtime.
export interface RendererDescriptor {
rendererId: string
kind: 'react' | 'vue' | 'storybook' | 'custom'
options: unknown
}
export function react(options: ReactRendererOptions): RendererDescriptor {
return {
rendererId: options.id ?? 'react',
kind: 'react',
options,
}
}
export function vue(options: VueRendererOptions): RendererDescriptor {
return {
rendererId: options.id ?? 'vue',
kind: 'vue',
options,
}
}
export function storybook(options: StorybookRendererOptions): RendererDescriptor {
return {
rendererId: options.id ?? 'storybook',
kind: 'storybook',
options,
}
}
export function custom(options: CustomRendererOptions): RendererDescriptor {
return {
rendererId: options.id,
kind: 'custom',
options,
}
}
// ---------------------------------------------------------------------------
// Adapter Registration Helpers
// ---------------------------------------------------------------------------
export function registerRenderer(
registry: RendererRegistry,
descriptor: RendererDescriptor,
adapterFactory: (descriptor: RendererDescriptor) => RendererAdapter
): void {
const adapter = adapterFactory(descriptor)
registry.register(descriptor.rendererId, adapter)
}
@@ -0,0 +1,635 @@
/**
* Integration tests for the Imhotep Playwright runtime.
*
* Tests cover page wrapper creation, environment application, and
* state materialization. Tests that require a real browser are
* skipped when Playwright is not available.
*/
import { describe, it, before, after } from 'node:test'
import assert from 'node:assert'
import type { Page, BrowserContext, Browser, Locator } from 'playwright'
// We import the modules under test via relative paths so that these
// tests remain runnable before the package is fully built.
import {
applyEnvironment,
expandEnvironmentMatrix,
deduplicateEnvironmentCases,
EnvironmentCase,
} from './environment.js'
import { ImhotepPage, wrapPage } from './page.js'
import { ImhotepRuntime, createRuntime } from './runtime.js'
// ---------------------------------------------------------------------------
// Minimal Playwright mock for unit-style integration tests.
// ---------------------------------------------------------------------------
function createMockLocator(): Locator {
return {
hover: async () => {},
focus: async () => {},
} as unknown as Locator
}
function createMockPage(context?: BrowserContext): Page {
const evalResults: Array<{ script: string | Function; arg: unknown }> = []
const page = {
locator: (_selector: string) => createMockLocator(),
setViewportSize: async (_size: { width: number; height: number }) => {
// No-op for mock.
},
emulateMedia: async (_options?: unknown) => {
// No-op for mock.
},
evaluate: async <R, Arg>(script: string | ((arg: Arg) => R), arg?: Arg): Promise<R> => {
evalResults.push({ script: script as string | Function, arg })
// Return 1 for health checks; undefined otherwise.
if (typeof script === 'function' && script.toString().trim() === '() => 1') {
return 1 as unknown as R
}
return undefined as unknown as R
},
addInitScript: async (_script: unknown, _arg?: unknown) => {
// No-op for mock.
},
goto: async (_url: string, _options?: unknown) => {
// No-op for mock.
},
mouse: {
move: async (_x: number, _y: number) => {},
},
keyboard: {
press: async (_key: string) => {},
},
context: () => {
return context ?? ({} as BrowserContext)
},
close: async () => {},
}
// Attach the eval log so tests can inspect captured calls.
;(page as unknown as Record<string, unknown>).__evalResults = evalResults
return page as unknown as Page
}
function createMockContext(): BrowserContext {
const pages: Page[] = []
const context = {
newPage: async () => {
const page = createMockPage(context as unknown as BrowserContext)
pages.push(page)
return page
},
pages: () => pages,
close: async () => {},
clearCookies: async () => {},
}
return context as unknown as BrowserContext
}
function createMockBrowser(): Browser {
return {
newContext: async (_options?: unknown) => {
return createMockContext()
},
close: async () => {},
} as unknown as Browser
}
// ---------------------------------------------------------------------------
// Environment application tests.
// ---------------------------------------------------------------------------
describe('applyEnvironment', () => {
it('should set viewport size', async () => {
const page = createMockPage()
const env: EnvironmentCase = {
viewport: { width: 1024, height: 768 },
}
let viewportCalled = false
let capturedSize: { width: number; height: number } | null = null
page.setViewportSize = async (size) => {
viewportCalled = true
capturedSize = size
}
await applyEnvironment(page, env)
assert.strictEqual(viewportCalled, true)
assert.deepStrictEqual(capturedSize, { width: 1024, height: 768 })
})
it('should emulate color scheme', async () => {
const page = createMockPage()
const env: EnvironmentCase = {
viewport: { width: 1280, height: 720 },
colorScheme: 'dark',
}
let mediaCalled = false
let capturedColorScheme: string | null = null
page.emulateMedia = async (options) => {
mediaCalled = true
capturedColorScheme = options?.colorScheme ?? null
}
await applyEnvironment(page, env)
assert.strictEqual(mediaCalled, true)
assert.strictEqual(capturedColorScheme, 'dark')
})
it('should apply reduced-motion via native emulateMedia', async () => {
const page = createMockPage()
const env: EnvironmentCase = {
viewport: { width: 1280, height: 720 },
reducedMotion: 'reduce',
}
let emulateMediaCalled = false
let capturedReducedMotion: string | null = null
page.emulateMedia = async (options) => {
emulateMediaCalled = true
capturedReducedMotion = (options as any)?.reducedMotion ?? null
}
await applyEnvironment(page, env)
assert.strictEqual(emulateMediaCalled, true)
assert.strictEqual(capturedReducedMotion, 'reduce')
})
})
// ---------------------------------------------------------------------------
// Environment matrix expansion tests.
// ---------------------------------------------------------------------------
describe('expandEnvironmentMatrix', () => {
it('should expand a single-axis matrix', () => {
const cases = expandEnvironmentMatrix({
viewports: [
{ width: 1280, height: 720 },
{ width: 375, height: 667 },
],
})
assert.strictEqual(cases.length, 2)
assert.deepStrictEqual(cases[0].viewport, { width: 1280, height: 720 })
assert.deepStrictEqual(cases[1].viewport, { width: 375, height: 667 })
})
it('should expand a multi-axis matrix', () => {
const cases = expandEnvironmentMatrix({
viewports: [{ width: 1280, height: 720 }],
colorSchemes: ['light', 'dark'],
pointerTypes: ['mouse', 'touch'],
})
assert.strictEqual(cases.length, 4)
const hasLightMouse = cases.some(
(c) => c.colorScheme === 'light' && c.pointerType === 'mouse'
)
const hasDarkTouch = cases.some(
(c) => c.colorScheme === 'dark' && c.pointerType === 'touch'
)
assert.strictEqual(hasLightMouse, true)
assert.strictEqual(hasDarkTouch, true)
})
})
describe('deduplicateEnvironmentCases', () => {
it('should remove duplicate cases', () => {
const cases: EnvironmentCase[] = [
{ viewport: { width: 1280, height: 720 } },
{ viewport: { width: 1280, height: 720 } },
{ viewport: { width: 375, height: 667 } },
]
const deduped = deduplicateEnvironmentCases(cases)
assert.strictEqual(deduped.length, 2)
})
})
// ---------------------------------------------------------------------------
// Page wrapper creation tests.
// ---------------------------------------------------------------------------
describe('ImhotepPage', () => {
it('should create a wrapper from a Playwright page', async () => {
const page = createMockPage()
const wrapper = await wrapPage(page)
assert.ok(wrapper instanceof ImhotepPage)
assert.strictEqual(wrapper.playwrightPage, page)
})
it('should apply environment through the wrapper', async () => {
const page = createMockPage()
const wrapper = await wrapPage(page)
const env: EnvironmentCase = {
viewport: { width: 1920, height: 1080 },
colorScheme: 'dark',
}
let viewportCalled = false
page.setViewportSize = async () => {
viewportCalled = true
}
await wrapper.applyEnvironment(env)
assert.strictEqual(viewportCalled, true)
})
})
// ---------------------------------------------------------------------------
// State materialization tests.
// ---------------------------------------------------------------------------
describe('state materialization', () => {
it('should materialize default state', async () => {
const page = createMockPage()
const wrapper = await wrapPage(page)
let blurCalled = false
page.evaluate = async <R, Arg>(
script: string | ((arg: Arg) => R),
_arg?: Arg
): Promise<R> => {
if (typeof script === 'function') {
// Simulate the default-state script that calls blur.
blurCalled = true
}
return undefined as unknown as R
}
await wrapper.materializeState('.button', 'default')
assert.strictEqual(blurCalled, true)
})
it('should materialize hover state', async () => {
const page = createMockPage()
const wrapper = await wrapPage(page)
let hoverCalled = false
const originalLocator = page.locator('.button')
page.locator = (_selector: string) => {
return {
...originalLocator,
hover: async (_opts?: unknown) => {
hoverCalled = true
},
} as unknown as Locator
}
await wrapper.materializeState('.button', 'hover')
assert.strictEqual(hoverCalled, true)
})
it('should materialize focus-visible state', async () => {
const page = createMockPage()
const wrapper = await wrapPage(page)
let focusCalled = false
let keyCalled = false
const originalLocator = page.locator('.button')
page.locator = (_selector: string) => {
return {
...originalLocator,
focus: async () => {
focusCalled = true
},
} as unknown as Locator
}
page.keyboard.press = async (_key: string) => {
keyCalled = true
}
await wrapper.materializeState('.button', 'focusVisible')
assert.strictEqual(focusCalled, true)
assert.strictEqual(keyCalled, true)
})
it('should accept kebab-case focus-visible state', async () => {
const page = createMockPage()
const wrapper = await wrapPage(page)
let focusCalled = false
let keyCalled = false
const originalLocator = page.locator('.button')
page.locator = (_selector: string) => {
return {
...originalLocator,
focus: async () => {
focusCalled = true
},
} as unknown as Locator
}
page.keyboard.press = async (_key: string) => {
keyCalled = true
}
await wrapper.materializeState('.button', 'focus-visible')
assert.strictEqual(focusCalled, true)
assert.strictEqual(keyCalled, true)
})
it('focus materialization falls back to first match when selector is non-unique', async () => {
const page = createMockPage()
const wrapper = await wrapPage(page)
let firstFocused = false
page.locator = (_selector: string) => {
const firstLocator = {
focus: async () => {
firstFocused = true
},
}
return {
focus: async () => {
throw new Error('strict mode violation: locator resolved to 2 elements')
},
first: () => firstLocator,
} as unknown as Locator
}
await wrapper.materializeState('button', 'focus')
assert.strictEqual(firstFocused, true)
})
it('focus materialization falls back to DOM focus when locator calls time out', async () => {
const page = createMockPage()
const wrapper = await wrapPage(page)
page.locator = (_selector: string) => {
const firstLocator = {
focus: async () => {
throw new Error('Timeout 1500ms exceeded while waiting for locator.first().focus()')
},
}
return {
focus: async () => {
throw new Error('Timeout 1500ms exceeded while waiting for locator.focus()')
},
first: () => firstLocator,
} as unknown as Locator
}
const note = await wrapper.materializeState('button', 'focus', { force: true, timeoutMs: 1 })
assert.strictEqual(note.status, 'approximate')
const evalResults = (page as unknown as { __evalResults: Array<{ arg: unknown }> }).__evalResults
assert.ok(evalResults.some((entry) => entry.arg === 'button'))
})
it('focus materialization throws timeout error when force fallback is disabled', async () => {
const page = createMockPage()
const wrapper = await wrapPage(page)
page.locator = (_selector: string) => {
return {
focus: async () => {
throw new Error('Timeout 1ms exceeded while waiting for locator.focus()')
},
} as unknown as Locator
}
await assert.rejects(
() => wrapper.materializeState('button', 'focus', { timeoutMs: 1 }),
/Timeout 1ms exceeded/,
)
})
})
// ---------------------------------------------------------------------------
// Runtime orchestrator tests.
// ---------------------------------------------------------------------------
describe('ImhotepRuntime', () => {
it('createContext returns new context', async () => {
const browser = createMockBrowser()
const runtime = await createRuntime(browser)
const ctx = await runtime.createContext()
assert.ok(ctx)
})
it('createContext returns pooled context for same env', async () => {
const browser = createMockBrowser()
const runtime = await createRuntime(browser)
const env: EnvironmentCase = {
viewport: { width: 1280, height: 720 },
}
const ctx1 = await runtime.createContext(env)
const ctx2 = await runtime.createContext(env)
assert.strictEqual(ctx1, ctx2)
})
it('createPage borrows from pool', async () => {
const browser = createMockBrowser()
const runtime = await createRuntime(browser)
const page = await runtime.createPage()
assert.ok(page instanceof ImhotepPage)
})
it('releasePage returns to pool', async () => {
const browser = createMockBrowser()
const runtime = await createRuntime(browser)
const page1 = await runtime.createPage()
await runtime.releasePage(page1)
const page2 = await runtime.createPage()
assert.strictEqual(page1.playwrightPage, page2.playwrightPage)
})
it('shutdown disposes pools and closes browser', async () => {
let browserClosed = false
const browser = createMockBrowser()
browser.close = async () => {
browserClosed = true
}
const runtime = await createRuntime(browser)
await runtime.shutdown()
assert.strictEqual(browserClosed, true)
})
it('idempotent shutdown', async () => {
const browser = createMockBrowser()
const runtime = await createRuntime(browser)
await runtime.shutdown()
await runtime.shutdown()
assert.ok(true)
})
it('executeRenderCase works with borrowed page', async () => {
const browser = createMockBrowser()
const runtime = await createRuntime(browser)
const wrapper = await runtime.createPage()
const resolved = await runtime.executeRenderCase(
wrapper.playwrightPage,
{ kind: 'page', url: 'about:blank' },
{ caseId: 'test-case', input: {} }
)
assert.ok(resolved)
assert.strictEqual(resolved.page, wrapper.playwrightPage)
assert.strictEqual(resolved.mounted, false)
})
})
// ---------------------------------------------------------------------------
// Extraction batching tests.
// ---------------------------------------------------------------------------
describe('extraction batching', () => {
it('should batch multiple extraction requests', async () => {
const page = createMockPage()
const wrapper = await wrapPage(page)
let evaluateCallCount = 0
page.evaluate = async <R, Arg>(
_script: string | ((arg: Arg) => R),
_arg?: Arg
): Promise<R> => {
evaluateCallCount++
return { '.a': [{ rect: { x: 0 } }], '.b': [{ rect: { x: 10 } }] } as unknown as R
}
// Fire two extraction requests concurrently.
const p1 = wrapper.extract('.a')
const p2 = wrapper.extract('.b')
const [r1, r2] = await Promise.all([p1, p2])
// Despite two requests, evaluate should be called once because
// they are batched into a single bulk extraction.
assert.strictEqual(evaluateCallCount, 1)
assert.ok(Array.isArray(r1))
assert.ok(Array.isArray(r2))
})
})
// ---------------------------------------------------------------------------
// Real browser integration tests (skipped if Playwright unavailable).
// ---------------------------------------------------------------------------
let hasPlaywright = false
try {
// Dynamic import so that missing playwright does not crash the test file.
await import('playwright')
hasPlaywright = true
} catch {
hasPlaywright = false
}
const realIt = hasPlaywright ? it : it.skip
describe('real browser integration', () => {
let browser: Browser | null = null
let runtime: ImhotepRuntime | null = null
before(async () => {
if (!hasPlaywright) return
const { chromium } = await import('playwright')
browser = await chromium.launch()
runtime = await createRuntime(browser)
})
after(async () => {
if (runtime) {
await runtime.shutdown()
}
})
realIt('should create a real page wrapper', async () => {
if (!runtime) throw new Error('Runtime not initialized')
const wrapper = await runtime.createPage()
assert.ok(wrapper instanceof ImhotepPage)
})
realIt('should apply environment to a real page', async () => {
if (!runtime) throw new Error('Runtime not initialized')
const wrapper = await runtime.createPage()
const env: EnvironmentCase = {
viewport: { width: 800, height: 600 },
colorScheme: 'dark',
}
await wrapper.applyEnvironment(env)
const size = await wrapper.playwrightPage.evaluate(() => {
return {
width: window.innerWidth,
height: window.innerHeight,
}
})
assert.strictEqual(size.width, 800)
assert.strictEqual(size.height, 600)
})
realIt('should materialize hover state on a real page', async () => {
if (!runtime) throw new Error('Runtime not initialized')
const wrapper = await runtime.createPage()
await wrapper.playwrightPage.setViewportSize({ width: 800, height: 600 })
await wrapper.playwrightPage.setContent(`
<!DOCTYPE html>
<html>
<head><style>body { margin: 20px; }</style></head>
<body><button id="btn">Click me</button></body>
</html>
`)
await wrapper.playwrightPage.waitForSelector('#btn', { state: 'visible' })
await wrapper.materializeState('#btn', 'hover')
// After hover, the element should match the :hover pseudo-class
// in the browser's computed style.
const isHovered = await wrapper.playwrightPage.evaluate(() => {
const btn = document.getElementById('btn')
if (!btn) return false
return btn.matches(':hover')
})
assert.strictEqual(isHovered, true)
})
})
+227
View File
@@ -0,0 +1,227 @@
/**
* Main runtime orchestrator for Imhotep Playwright.
*
* Manages browser context lifecycle, page creation, and coordinates
* extraction requests across the full test execution flow.
*
* The runtime is the accidental boundary: it handles Playwright
* lifecycle, CDP session wiring, and caching policy while delegating
* all geometry logic to the pure domain packages.
*
* Phase 3: Delegates all resource lifecycle to ContextPool and PagePool.
*/
import { Browser, BrowserContext, Page, LaunchOptions } from 'playwright'
import { ImhotepPage, wrapPage, ImhotepPageOptions } from './page.js'
import { EnvironmentCase, applyEnvironment } from './environment.js'
import type { SceneTarget, RenderCase } from 'imhotep-core/scene-target'
import { resolveTarget, ResolvedTarget } from './targets.js'
import { ContextPool as ContextPoolClass, ContextPoolOptions } from './context-pool.js'
import { PagePool, PagePoolOptions } from './page-pool.js'
/** Configuration for the Imhotep runtime. */
export interface RuntimeOptions {
/** Playwright launch options for the browser. */
launchOptions?: LaunchOptions
/** Default browser context options. */
contextOptions?: Parameters<Browser['newContext']>[0]
/** Default Imhotep page wrapper options. */
pageOptions?: ImhotepPageOptions
/** Pool configuration for context and page pools. */
poolOptions?: ContextPoolOptions & PagePoolOptions
}
/**
* Orchestrates browser lifecycle, page creation, and extraction
* coordination for Imhotep tests.
*
* A single runtime instance should be created per test worker or
* process. It owns the browser instance and delegates context and
* page pooling to ContextPool and PagePool.
*/
export class ImhotepRuntime {
private browser: Browser
private options: RuntimeOptions
private contextPool: ContextPoolClass
private pagePool: PagePool
private disposed: boolean
constructor(browser: Browser, options?: RuntimeOptions) {
this.browser = browser
this.options = options ?? {}
this.contextPool = new ContextPoolClass(browser, this.options.poolOptions)
this.pagePool = new PagePool(this.contextPool, this.options.poolOptions)
this.disposed = false
}
/** Returns the underlying Playwright browser instance. */
getBrowser(): Browser {
return this.browser
}
/**
* Acquires a browser context from the pool.
*
* @param env - Optional environment case for the context.
* @returns A Playwright browser context.
*/
async createContext(env?: EnvironmentCase): Promise<BrowserContext> {
const envKey = env ? JSON.stringify(env) : 'default'
return this.contextPool.acquire(envKey)
}
/**
* Borrows a page from the pool, wrapping it with Imhotep.
*
* If no context is provided, a new one is acquired automatically.
*
* @param context - Optional browser context to borrow the page in.
* @param env - Optional environment case to apply to the page.
* @returns The Imhotep-wrapped page.
*/
async createPage(
context?: BrowserContext,
env?: EnvironmentCase
): Promise<ImhotepPage> {
const ctx = context ?? (await this.createContext(env))
const wrapper = await this.pagePool.borrow(ctx)
if (env) {
await wrapper.applyEnvironment(env)
}
return wrapper
}
/**
* Execute a single render case against a page.
*
* Resolves the scene target, applies the render input, and returns
* the resolved page ready for extraction and assertion.
*
* @param page - The Playwright page to render into.
* @param target - The scene target to resolve.
* @param renderCase - The render case with input and optional env.
* @returns The resolved target with mounted page.
*/
async executeRenderCase(
page: Page,
target: SceneTarget,
renderCase: RenderCase
): Promise<ResolvedTarget> {
const resolved = await resolveTarget(page, target, { input: renderCase.input })
if (renderCase.env) {
await applyEnvironment(page, renderCase.env as EnvironmentCase)
}
return resolved
}
/**
* Returns a borrowed page to the pool.
*
* @param page - The Imhotep page wrapper to release.
*/
async releasePage(page: ImhotepPage): Promise<void> {
await this.pagePool.return(page)
}
/**
* Disposes pools and closes the browser instance.
*
* Idempotent: calling twice is safe.
*/
async shutdown(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
await this.pagePool.dispose()
await this.contextPool.dispose()
await this.browser.close()
}
}
/**
* Minimal runtime interface for property-run adapters.
*/
export interface RuntimeLike {
getBrowser(): Browser
createPage(context?: BrowserContext, env?: EnvironmentCase): Promise<ImhotepPage>
releasePage(page: ImhotepPage): Promise<void>
shutdown?(): Promise<void>
}
/**
* Lightweight runtime that wraps a single Playwright page.
*
* Used when the caller passes a Playwright Page directly instead of an
* ImhotepRuntime. The wrapped page is returned for every createPage()
* call, avoiding the overhead of pool creation and ensuring the test's
* page is actually used by property-run adapters.
*/
export class SinglePageRuntime implements RuntimeLike {
private page: Page
private wrappedPage: ImhotepPage | null = null
constructor(page: Page) {
this.page = page
}
getBrowser(): Browser {
return this.page.context().browser()!
}
async createPage(): Promise<ImhotepPage> {
if (!this.wrappedPage) {
this.wrappedPage = await wrapPage(this.page)
}
return this.wrappedPage
}
async releasePage(): Promise<void> {
// No-op: we do not own the page.
}
async shutdown(): Promise<void> {
// No-op: do not close the caller's page.
}
}
/**
* Creates an Imhotep runtime with a freshly launched browser.
*
* This is a convenience factory for standalone scripts or custom
* test runners that do not use Playwright's built-in fixture system.
*
* @param browser - The Playwright browser instance to use.
* @param options - Runtime configuration options.
* @returns A new Imhotep runtime.
*/
export async function createRuntime(
browser: Browser,
options?: RuntimeOptions
): Promise<ImhotepRuntime> {
return new ImhotepRuntime(browser, options)
}
/**
* Attaches Imhotep to an existing Playwright page.
*
* This is the primary public API for using Imhotep inside existing
* Playwright tests or scripts. It returns an Imhotep page wrapper
* that provides fluent relational assertions.
*
* @param page - The Playwright page to attach Imhotep to.
* @param options - Optional configuration for the wrapper.
* @returns The Imhotep page wrapper.
*/
export async function imhotep(
page: Page,
options?: ImhotepPageOptions
): Promise<ImhotepPage> {
return wrapPage(page, options)
}
@@ -0,0 +1,367 @@
/**
* Tests for renderer adapters, target resolution, and the renderer registry.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { Page } from 'playwright'
import {
createRendererRegistry,
getDefaultRendererRegistry,
registerRenderer,
react,
vue,
storybook,
custom,
RendererDescriptor,
} from './renderers.js'
import { resolveTarget, ensureMountContainer, clearMountContainer } from './targets.js'
import { createReactAdapter } from './react-adapter.js'
import { createVueAdapter } from './vue-adapter.js'
import { createStorybookAdapter } from './storybook-adapter.js'
import { createCustomAdapter } from './custom-renderer-adapter.js'
import {
pageTarget,
reactComponentTarget,
vueComponentTarget,
storybookStoryTarget,
customRendererTarget,
fixtureTarget,
} from 'imhotep-core/scene-target'
// ---------------------------------------------------------------------------
// Mock Page Factory
// ---------------------------------------------------------------------------
function createMockPage(): Page {
const evalLog: Array<{ script: string | Function; arg: unknown }> = []
const gotoLog: string[] = []
const page = {
goto: async (url: string) => {
gotoLog.push(url)
},
evaluate: async <R, Arg>(script: string | ((arg: Arg) => R), arg?: Arg): Promise<R> => {
evalLog.push({ script: script as string | Function, arg })
return undefined as unknown as R
},
locator: (_selector: string) => ({
hover: async () => {},
focus: async () => {},
}),
setViewportSize: async () => {},
emulateMedia: async () => {},
mouse: { move: async () => {} },
keyboard: { press: async () => {} },
context: () => ({}),
close: async () => {},
waitForSelector: async () => ({}),
}
;(page as unknown as Record<string, unknown>).__evalLog = evalLog
;(page as unknown as Record<string, unknown>).__gotoLog = gotoLog
return page as unknown as Page
}
// ---------------------------------------------------------------------------
// Renderer Registry
// ---------------------------------------------------------------------------
describe('RendererRegistry', () => {
it('creates an empty registry', () => {
const registry = createRendererRegistry()
assert.deepStrictEqual(registry.list(), [])
assert.strictEqual(registry.resolve('react'), undefined)
})
it('registers and resolves adapters', () => {
const registry = createRendererRegistry()
const adapter = createReactAdapter({
reactDom: {},
createElement: () => null,
})
registry.register('react', adapter)
assert.strictEqual(registry.resolve('react'), adapter)
assert.deepStrictEqual(registry.list(), ['react'])
})
it('returns default registry singleton', () => {
const r1 = getDefaultRendererRegistry()
const r2 = getDefaultRendererRegistry()
assert.strictEqual(r1, r2)
})
})
// ---------------------------------------------------------------------------
// Renderer Factory Functions
// ---------------------------------------------------------------------------
describe('Renderer factory functions', () => {
it('react() creates a descriptor', () => {
const desc = react({ reactDom: {}, createElement: () => null })
assert.strictEqual(desc.kind, 'react')
assert.strictEqual(desc.rendererId, 'react')
})
it('vue() creates a descriptor', () => {
const desc = vue({ createApp: () => ({ mount: () => {}, unmount: () => {} }) })
assert.strictEqual(desc.kind, 'vue')
assert.strictEqual(desc.rendererId, 'vue')
})
it('storybook() creates a descriptor', () => {
const desc = storybook({ storybookUrl: 'http://localhost:6006' })
assert.strictEqual(desc.kind, 'storybook')
assert.strictEqual(desc.rendererId, 'storybook')
})
it('custom() creates a descriptor', () => {
const desc = custom({ id: 'svelte', mount: async () => {} })
assert.strictEqual(desc.kind, 'custom')
assert.strictEqual(desc.rendererId, 'svelte')
})
})
// ---------------------------------------------------------------------------
// Target Resolution
// ---------------------------------------------------------------------------
describe('resolveTarget', () => {
it('navigates to page URL for page target', async () => {
const page = createMockPage()
const target = pageTarget('https://example.com')
const resolved = await resolveTarget(page, target)
const gotoLog = (page as unknown as Record<string, unknown>).__gotoLog as string[]
assert.strictEqual(gotoLog.length, 1)
assert.strictEqual(gotoLog[0], 'https://example.com')
assert.strictEqual(resolved.mounted, false)
})
it('navigates to fixture for fixture target', async () => {
const page = createMockPage()
const target = fixtureTarget('fixtures/button.html')
const resolved = await resolveTarget(page, target)
const gotoLog = (page as unknown as Record<string, unknown>).__gotoLog as string[]
assert.strictEqual(gotoLog.length, 1)
assert.ok(gotoLog[0].includes('fixtures/button.html'))
assert.strictEqual(resolved.mounted, false)
})
it('mounts React component via registry', async () => {
const page = createMockPage()
const registry = createRendererRegistry()
const adapter = createReactAdapter({
reactDom: {},
createElement: () => null,
})
registry.register('react-dom', adapter)
const target = reactComponentTarget('react-dom', 'Button')
const resolved = await resolveTarget(page, target, { registry })
assert.strictEqual(resolved.mounted, true)
assert.strictEqual(resolved.adapter, adapter)
})
it('mounts Vue component via registry', async () => {
const page = createMockPage()
const registry = createRendererRegistry()
const adapter = createVueAdapter({
createApp: () => ({ mount: () => {}, unmount: () => {} }),
})
registry.register('vue-3', adapter)
const target = vueComponentTarget('vue-3', 'Button')
const resolved = await resolveTarget(page, target, { registry })
assert.strictEqual(resolved.mounted, true)
assert.strictEqual(resolved.adapter, adapter)
})
it('mounts Storybook story via registry', async () => {
const page = createMockPage()
const registry = createRendererRegistry()
const adapter = createStorybookAdapter({ storybookUrl: 'http://localhost:6006' })
registry.register('storybook', adapter)
const target = storybookStoryTarget('button--primary', 'http://localhost:6006')
const resolved = await resolveTarget(page, target, { registry })
assert.strictEqual(resolved.mounted, true)
assert.strictEqual(resolved.adapter, adapter)
})
it('throws for unregistered renderer', async () => {
const page = createMockPage()
const registry = createRendererRegistry()
const target = reactComponentTarget('unknown', 'Button')
await assert.rejects(async () => {
await resolveTarget(page, target, { registry })
}, /No renderer adapter registered/)
})
})
// ---------------------------------------------------------------------------
// Mount Container Helpers
// ---------------------------------------------------------------------------
describe('ensureMountContainer', () => {
it('injects mount container via evaluate', async () => {
const page = createMockPage()
await ensureMountContainer(page, '__test-mount')
const evalLog = (page as unknown as Record<string, unknown>).__evalLog as Array<{
script: string | Function
arg: unknown
}>
assert.strictEqual(evalLog.length, 1)
assert.strictEqual(evalLog[0].arg, '__test-mount')
})
})
describe('clearMountContainer', () => {
it('clears mount container via evaluate', async () => {
const page = createMockPage()
await clearMountContainer(page, '__test-mount')
const evalLog = (page as unknown as Record<string, unknown>).__evalLog as Array<{
script: string | Function
arg: unknown
}>
assert.strictEqual(evalLog.length, 1)
assert.strictEqual(evalLog[0].arg, '__test-mount')
})
})
// ---------------------------------------------------------------------------
// React Adapter
// ---------------------------------------------------------------------------
describe('createReactAdapter', () => {
it('creates an adapter with correct id', () => {
const adapter = createReactAdapter({
reactDom: {},
createElement: () => null,
})
assert.strictEqual(adapter.id, 'react')
assert.strictEqual(typeof adapter.mount, 'function')
})
it('rejects non-React targets', async () => {
const adapter = createReactAdapter({
reactDom: {},
createElement: () => null,
})
const page = createMockPage()
const target = vueComponentTarget('vue-3', 'Button')
await assert.rejects(async () => {
await adapter.mount(page, target, {})
}, /non-React target/)
})
})
// ---------------------------------------------------------------------------
// Vue Adapter
// ---------------------------------------------------------------------------
describe('createVueAdapter', () => {
it('creates an adapter with correct id', () => {
const adapter = createVueAdapter({
createApp: () => ({ mount: () => {}, unmount: () => {} }),
})
assert.strictEqual(adapter.id, 'vue')
assert.strictEqual(typeof adapter.mount, 'function')
})
it('rejects non-Vue targets', async () => {
const adapter = createVueAdapter({
createApp: () => ({ mount: () => {}, unmount: () => {} }),
})
const page = createMockPage()
const target = reactComponentTarget('react-dom', 'Button')
await assert.rejects(async () => {
await adapter.mount(page, target, {})
}, /non-Vue target/)
})
})
// ---------------------------------------------------------------------------
// Storybook Adapter
// ---------------------------------------------------------------------------
describe('createStorybookAdapter', () => {
it('creates an adapter with correct id', () => {
const adapter = createStorybookAdapter({ storybookUrl: 'http://localhost:6006' })
assert.strictEqual(adapter.id, 'storybook')
assert.strictEqual(typeof adapter.mount, 'function')
})
it('rejects non-storybook targets', async () => {
const adapter = createStorybookAdapter({ storybookUrl: 'http://localhost:6006' })
const page = createMockPage()
const target = reactComponentTarget('react-dom', 'Button')
await assert.rejects(async () => {
await adapter.mount(page, target, {})
}, /non-storybook target/)
})
})
// ---------------------------------------------------------------------------
// Custom Adapter
// ---------------------------------------------------------------------------
describe('createCustomAdapter', () => {
it('delegates to user-provided mount function', async () => {
let mountCalled = false
const adapter = createCustomAdapter({
mount: async () => {
mountCalled = true
},
})
const page = createMockPage()
const target = customRendererTarget('svelte', 'Widget')
await adapter.mount(page, target, {})
assert.strictEqual(mountCalled, true)
})
it('delegates to user-provided unmount function', async () => {
let unmountCalled = false
const adapter = createCustomAdapter({
mount: async () => {},
unmount: async () => {
unmountCalled = true
},
})
const page = createMockPage()
await adapter.unmount?.(page)
assert.strictEqual(unmountCalled, true)
})
it('unmount is optional', async () => {
const adapter = createCustomAdapter({
mount: async () => {},
})
const page = createMockPage()
// Should not throw even though unmount is not provided.
await adapter.unmount?.(page)
})
})
@@ -0,0 +1,87 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { Semaphore } from './semaphore.js'
describe('Semaphore', () => {
it('allows up to maxConcurrency parallel executions', async () => {
const semaphore = new Semaphore(2)
let concurrent = 0
let maxConcurrent = 0
const tasks = Array.from({ length: 4 }, () =>
semaphore.run(async () => {
concurrent++
if (concurrent > maxConcurrent) {
maxConcurrent = concurrent
}
await new Promise(resolve => setTimeout(resolve, 10))
concurrent--
})
)
await Promise.all(tasks)
assert.strictEqual(maxConcurrent, 2)
})
it('queues when at maxConcurrency', async () => {
const semaphore = new Semaphore(1)
const order: number[] = []
const tasks = Array.from({ length: 3 }, (_, i) =>
semaphore.run(async () => {
order.push(i)
await new Promise(resolve => setTimeout(resolve, 10))
})
)
await Promise.all(tasks)
assert.deepStrictEqual(order, [0, 1, 2])
})
it('executes queued tasks FIFO', async () => {
const semaphore = new Semaphore(1)
const completionOrder: number[] = []
const tasks = Array.from({ length: 5 }, (_, i) =>
semaphore.run(async () => {
await new Promise(resolve => setTimeout(resolve, 5))
completionOrder.push(i)
})
)
await Promise.all(tasks)
assert.deepStrictEqual(completionOrder, [0, 1, 2, 3, 4])
})
it('propagates errors without leaking slots', async () => {
const semaphore = new Semaphore(1)
let subsequentRan = false
const errorTask = semaphore.run(async () => {
throw new Error('intentional error')
})
const normalTask = semaphore.run(async () => {
subsequentRan = true
})
await assert.rejects(errorTask, /intentional error/)
await normalTask
assert.strictEqual(subsequentRan, true)
})
it('handles many tasks', async () => {
const semaphore = new Semaphore(4)
const results: number[] = []
const tasks = Array.from({ length: 100 }, (_, i) =>
semaphore.run(async () => {
results.push(i)
await new Promise(resolve => setTimeout(resolve, 1))
})
)
await Promise.all(tasks)
assert.strictEqual(results.length, 100)
})
})
@@ -0,0 +1,49 @@
export class Semaphore {
private maxConcurrency: number
private running: number
private queue: Array<{
fn: () => Promise<any>
resolve: (value: any) => void
reject: (reason: any) => void
}>
constructor(maxConcurrency: number) {
this.maxConcurrency = maxConcurrency
this.running = 0
this.queue = []
}
async run<T>(fn: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject })
this._tryNext()
})
}
private _tryNext(): void {
if (this.running >= this.maxConcurrency || this.queue.length === 0) {
return
}
const task = this.queue.shift()!
this.running++
this._execute(task)
}
private async _execute(task: {
fn: () => Promise<any>
resolve: (value: any) => void
reject: (reason: any) => void
}): Promise<void> {
try {
const result = await task.fn()
task.resolve(result)
} catch (error) {
task.reject(error)
} finally {
this.running--
this._tryNext()
}
}
}
@@ -0,0 +1,68 @@
/**
* Storybook renderer adapter for Imhotep Playwright.
*
* Navigates to a Storybook story URL or uses Storybook's internal APIs
* to render a specific story. Accepts story args as input.
*/
import { Page } from 'playwright'
import type { SceneTarget } from 'imhotep-core/scene-target'
import { RendererAdapter } from './renderers.js'
export interface StorybookAdapterOptions {
/** Base URL of the Storybook instance. */
storybookUrl: string
}
export function createStorybookAdapter(options: StorybookAdapterOptions): RendererAdapter {
const { storybookUrl } = options
return {
id: 'storybook',
async mount(page: Page, target: SceneTarget, input: unknown): Promise<void> {
if (target.kind !== 'storybook-story') {
throw new Error(`Storybook adapter received non-storybook target: ${target.kind}`)
}
// Build the Storybook iframe URL.
// Standard Storybook v6+ URL format: ?path=/story/{storyId}
// With args: ?path=/story/{storyId}&args={json}
const storyId = target.storyId
const args = input as Record<string, unknown> | undefined
const url = new URL(storybookUrl)
// Only add Storybook path param for non-file URLs.
// File URLs are used for fixture-based testing where the HTML
// page itself is the "story" and does not parse ?path params.
if (url.protocol !== 'file:') {
url.searchParams.set('path', `/story/${storyId}`)
}
if (args && Object.keys(args).length > 0) {
url.searchParams.set('args', JSON.stringify(args))
}
await page.goto(url.toString())
// Wait for the story to be rendered.
// Storybook renders into #root (v6) or #storybook-root (v7+).
try {
await page.waitForSelector('#root, #storybook-root', { state: 'visible', timeout: 10000 })
} catch {
// If neither selector is found, the story may still be loading.
// Fall through and let downstream extraction handle any issues.
}
},
async unmount(page: Page): Promise<void> {
// Storybook unmount is a no-op; navigation away handles cleanup.
await page.evaluate(() => {
const root = document.getElementById('root') || document.getElementById('storybook-root')
if (root) {
root.innerHTML = ''
}
})
},
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Target resolution logic for Imhotep Playwright.
*
* Given a SceneTarget, produce a Playwright page with the target mounted.
* This module bridges the SceneTarget contract to concrete Playwright
* operations while keeping renderer concerns behind the adapter boundary.
*/
import { Page } from 'playwright'
import { pathToFileURL } from 'url'
import type { SceneTarget } from 'imhotep-core/scene-target'
import {
isPageTarget,
isFixtureTarget,
isRendererTarget,
isStorybookStoryTarget,
isComponentTarget,
} from 'imhotep-core/scene-target'
import { RendererRegistry, getDefaultRendererRegistry, RendererAdapter } from './renderers.js'
// ---------------------------------------------------------------------------
// Target Resolution Result
// ---------------------------------------------------------------------------
export interface ResolvedTarget {
/** The Playwright page that now hosts the mounted target. */
page: Page
/** Whether the target was mounted via a renderer adapter. */
mounted: boolean
/** The renderer adapter used, if any. */
adapter?: RendererAdapter
}
// ---------------------------------------------------------------------------
// Target Resolution
// ---------------------------------------------------------------------------
export interface TargetResolverOptions {
/** Renderer registry to use for component and story targets. */
registry?: RendererRegistry
}
/**
* Resolve a SceneTarget to a Playwright page with the target mounted.
*
* For 'page' targets: navigate to the URL.
* For 'fixture' targets: load fixture HTML.
* For renderer targets: use the registered adapter to mount into the page.
*/
export async function resolveTarget(
page: Page,
target: SceneTarget,
options?: TargetResolverOptions & { input?: unknown }
): Promise<ResolvedTarget> {
const registry = options?.registry ?? getDefaultRendererRegistry()
if (isPageTarget(target)) {
await page.goto(target.url)
return { page, mounted: false }
}
if (isFixtureTarget(target)) {
await loadFixture(page, target.fixtureId)
return { page, mounted: false }
}
if (isRendererTarget(target)) {
return await mountRendererTarget(page, target, registry, options?.input)
}
// Fallback: treat as page navigation if url is present (playwright-page)
if (target.kind === 'playwright-page' && target.url) {
await page.goto(target.url)
return { page, mounted: false }
}
throw new Error(`Cannot resolve SceneTarget of kind "${(target as SceneTarget).kind}"`)
}
// ---------------------------------------------------------------------------
// Fixture Loading
// ---------------------------------------------------------------------------
async function loadFixture(page: Page, fixtureId: string): Promise<void> {
// Fixtures are loaded as file URLs or served content.
// If the fixtureId looks like a path, construct a file URL.
// Otherwise assume it is already a resolvable URL.
let url: string
if (fixtureId.startsWith('http')) {
url = fixtureId
} else if (fixtureId.startsWith('file://')) {
url = fixtureId
} else {
url = pathToFileURL(fixtureId).href
}
await page.goto(url)
}
// ---------------------------------------------------------------------------
// Renderer Target Mounting
// ---------------------------------------------------------------------------
async function mountRendererTarget(
page: Page,
target: SceneTarget,
registry: RendererRegistry,
input?: unknown
): Promise<ResolvedTarget> {
// Extract rendererId from the target.
let rendererId: string
if (isStorybookStoryTarget(target)) {
// Storybook uses a special rendererId derived from its kind.
rendererId = 'storybook'
} else if (isComponentTarget(target)) {
rendererId = target.rendererId
} else {
throw new Error(`Unexpected renderer target kind: ${(target as SceneTarget).kind}`)
}
const adapter = registry.resolve(rendererId)
if (!adapter) {
throw new Error(`No renderer adapter registered for rendererId "${rendererId}"`)
}
// Ensure a mount container exists on the page.
await ensureMountContainer(page)
// Mount via the adapter, passing the actual render input.
await adapter.mount(page, target, input ?? {})
return { page, mounted: true, adapter }
}
/**
* Ensure the page has a container div for renderer mounting.
*/
export async function ensureMountContainer(page: Page, containerId = '__imhotep-mount'): Promise<void> {
await page.evaluate((id: string) => {
if (!document.getElementById(id)) {
const div = document.createElement('div')
div.id = id
document.body.appendChild(div)
}
}, containerId)
}
/**
* Clear the mount container on the page.
*/
export async function clearMountContainer(page: Page, containerId = '__imhotep-mount'): Promise<void> {
await page.evaluate((id: string) => {
const el = document.getElementById(id)
if (el) {
el.innerHTML = ''
}
}, containerId)
}
@@ -0,0 +1,118 @@
/**
* Vue renderer adapter for Imhotep Playwright.
*
* Mounts a Vue component into a container div on the page.
* Uses Vue's createApp API (Vue 3) or constructor (Vue 2) depending
* on what is available in the injected Vue runtime.
*/
import { Page } from 'playwright'
import type { SceneTarget } from 'imhotep-core/scene-target'
import { RendererAdapter } from './renderers.js'
export interface VueAdapterOptions {
/** Id for this renderer instance (defaults to 'vue'). */
id?: string
/**
* Vue createApp entry point; used for capability detection at adapter
* construction time (e.g. determining Vue 3 vs Vue 2). The actual runtime
* must be exposed on the page as `window.__imhotepVueCreateApp`.
*/
createApp: (component: unknown, props?: Record<string, unknown>) => {
mount(selector: string): void
unmount(): void
}
/** Container selector or id to mount into. */
containerSelector?: string
}
export function createVueAdapter(options: VueAdapterOptions): RendererAdapter {
const { createApp, containerSelector = '#__imhotep-mount' } = options
return {
id: options.id ?? 'vue',
async mount(page: Page, target: SceneTarget, input: unknown): Promise<void> {
if (target.kind !== 'vue-component') {
throw new Error(`Vue adapter received non-Vue target: ${target.kind}`)
}
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
}) => {
const container = document.querySelector(args.containerSelector)
if (!container) {
throw new Error(`Mount container not found: ${args.containerSelector}`)
}
// Clear previous content.
container.innerHTML = ''
// Resolve Vue createApp BEFORE looking for the component.
// This gives a clearer error when Vue is missing vs component missing.
const createApp = (window as unknown as Record<string, unknown>).__imhotepVueCreateApp as
| ((component: unknown, props?: Record<string, unknown>) => {
mount(selector: string): void
unmount(): void
})
| undefined
if (!createApp) {
throw new Error(
'Vue createApp not found on window.__imhotepVueCreateApp. ' +
'Expose Vue.createApp as window.__imhotepVueCreateApp before mounting. ' +
'Example: window.__imhotepVueCreateApp = Vue.createApp'
)
}
// Resolve the component from the global scope.
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 app = createApp(Component, parsedProps)
app.mount(args.containerSelector)
},
{
componentRef: component as string,
propsJson: JSON.stringify(props),
containerSelector,
}
)
},
async unmount(page: Page): Promise<void> {
await page.evaluate((selector: string) => {
// Attempt Vue 3 unmount if the element has a __vue_app__ property.
const container = document.querySelector(selector)
if (container) {
const app = (container as unknown as Record<string, unknown>).__vue_app__ as
| { unmount(): void }
| undefined
if (app?.unmount) {
app.unmount()
} else {
container.innerHTML = ''
}
}
}, containerSelector)
},
}
}
@@ -0,0 +1,176 @@
/**
* World builder for Playwright-extracted elements.
*
* Converts raw extracted element data (from page.evaluate bounding boxes)
* into a solver-compatible GeometryWorld. All rect math lives here
* public.ts delegates to this module so that coordinate arithmetic is
* isolated from the FOL compilation path.
*/
import type { GeometryWorld } from 'imhotep-solver'
export interface ExtractedElement {
tagName: string
rect: { x: number; y: number; width: number; height: number }
}
/**
* Build a GeometryWorld from a flat list of extracted elements.
*
* Each element receives a sequential subject ID starting at 1.
* Boxes, rects, and subject tables are populated from the visual rects.
*/
export function buildGeometryWorld(elements: ExtractedElement[]): GeometryWorld {
const ids: number[] = []
const borderLeft: number[] = []
const borderTop: number[] = []
const borderRight: number[] = []
const borderBottom: number[] = []
const paddingLeft: number[] = []
const paddingTop: number[] = []
const paddingRight: number[] = []
const paddingBottom: number[] = []
const contentLeft: number[] = []
const contentTop: number[] = []
const contentRight: number[] = []
const contentBottom: number[] = []
for (let i = 0; i < elements.length; i++) {
const id = i + 1
const el = elements[i]
const r = el.rect
ids.push(id)
borderLeft.push(r.x)
borderTop.push(r.y)
borderRight.push(r.x + r.width)
borderBottom.push(r.y + r.height)
paddingLeft.push(0)
paddingTop.push(0)
paddingRight.push(0)
paddingBottom.push(0)
contentLeft.push(r.x)
contentTop.push(r.y)
contentRight.push(r.x + r.width)
contentBottom.push(r.y + r.height)
}
return {
sceneId: 'scene',
snapshotId: 'snapshot',
env: {
viewportWidth: 800,
viewportHeight: 600,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids,
domNodeId: [...ids],
subjectKind: ids.map(() => 0),
primaryBoxId: [...ids],
firstFragmentId: ids.map(() => 0),
fragmentCount: ids.map(() => 0),
},
dom: {
nodeId: [],
parentNodeId: [],
childCount: [],
tagNameStringId: [],
},
boxes: {
boxId: [...ids],
subjectId: [...ids],
frameId: ids.map(() => 0),
borderLeft,
borderTop,
borderRight,
borderBottom,
paddingLeft,
paddingTop,
paddingRight,
paddingBottom,
contentLeft,
contentTop,
contentRight,
contentBottom,
},
visualBoxes: {
boxId: [...ids],
subjectId: [...ids],
frameId: ids.map(() => 0),
borderLeft: [...borderLeft],
borderTop: [...borderTop],
borderRight: [...borderRight],
borderBottom: [...borderBottom],
paddingLeft: [...paddingLeft],
paddingTop: [...paddingTop],
paddingRight: [...paddingRight],
paddingBottom: [...paddingBottom],
contentLeft: [...contentLeft],
contentTop: [...contentTop],
contentRight: [...contentRight],
contentBottom: [...contentBottom],
},
transforms: {
transformId: [],
subjectId: [],
matrixStart: [],
matrixLength: [],
originX: [],
originY: [],
},
matrices: {
values: [],
},
rects: {
rectId: [...ids],
left: [...borderLeft],
top: [...borderTop],
right: [...borderRight],
bottom: [...borderBottom],
},
topology: {
containingBlockOf: [],
nearestPositionedAncestorOf: [],
scrollContainerOf: [],
stackingContextOf: [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
}
}