/** * 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 (script: string | ((arg: Arg) => R), _arg?: Arg): Promise => { 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 { currentConcurrent++ maxConcurrent = Math.max(maxConcurrent, currentConcurrent) // Simulate work await new Promise((r) => setTimeout(r, 30)) return { caseId: renderCase.caseId, world: createMockWorld(), } }, async unmount(): Promise { 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,

Test

') 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() }) })