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