v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 = ''
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user