482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
|
|
/**
|
||
|
|
* 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()
|
||
|
|
})
|
||
|
|
})
|