Files
Imhotep/packages/imhotep-playwright/src/pooling.integration.test.ts
T

482 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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()
})
})