Files
playwriter/playwriter/src/test-utils.ts
2026-02-05 17:06:08 +01:00

394 lines
11 KiB
TypeScript

import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import http from 'node:http'
import net from 'node:net'
import { chromium, BrowserContext } from '@xmorse/playwright-core'
import path from 'node:path'
import fs from 'node:fs'
import os from 'node:os'
import { startPlayWriterCDPRelayServer, type RelayServer } from './cdp-relay.js'
import { createFileLogger } from './create-logger.js'
import { killPortProcess } from 'kill-port-process'
const execAsync = promisify(exec)
const extensionBuildQueues: Map<string, Promise<void>> = new Map()
async function buildExtension({ port, distDir }: { port: number; distDir: string }): Promise<void> {
const previous = extensionBuildQueues.get(distDir) || Promise.resolve()
const buildPromise = previous
.catch((error) => {
console.error('Previous extension build failed:', error)
})
.then(async () => {
// Build into a per-port dist to avoid parallel test runs overwriting each other.
await execAsync(`TESTING=1 PLAYWRITER_PORT=${port} PLAYWRITER_EXTENSION_DIST=${distDir} pnpm build`, { cwd: '../extension' })
})
extensionBuildQueues.set(distDir, buildPromise.finally(() => {}))
await buildPromise
}
export async function getExtensionServiceWorker(context: BrowserContext) {
let serviceWorkers = context.serviceWorkers().filter((sw) => sw.url().startsWith('chrome-extension://'))
let serviceWorker = serviceWorkers[0]
if (!serviceWorker) {
serviceWorker = await context.waitForEvent('serviceworker', {
predicate: (sw) => sw.url().startsWith('chrome-extension://'),
})
}
for (let i = 0; i < 50; i++) {
const isReady = await serviceWorker.evaluate(() => {
// @ts-ignore
return typeof globalThis.toggleExtensionForActiveTab === 'function'
})
if (isReady) {
break
}
await new Promise((r) => setTimeout(r, 100))
}
return serviceWorker
}
export interface TestContext {
browserContext: BrowserContext
userDataDir: string
relayServer: RelayServer
}
export async function setupTestContext({
port,
tempDirPrefix,
toggleExtension = false,
}: {
port: number
tempDirPrefix: string
/** Create initial page and toggle extension on it */
toggleExtension?: boolean
}): Promise<TestContext> {
await killPortProcess(port).catch(() => {})
// Use a port-scoped dist folder so parallel tests don't replace each other's extension builds.
const distDir = `dist-${port}`
console.log('Building extension...')
await buildExtension({ port, distDir })
console.log('Extension built')
const localLogPath = path.join(process.cwd(), 'relay-server.log')
const logger = createFileLogger({ logFilePath: localLogPath })
const relayServer = await startPlayWriterCDPRelayServer({ port, logger })
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix))
const extensionPath = path.resolve('../extension', distDir)
const browserContext = await chromium.launchPersistentContext(userDataDir, {
channel: 'chromium',
headless: !process.env.HEADFUL,
colorScheme: 'dark',
args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`],
})
const serviceWorker = await getExtensionServiceWorker(browserContext)
if (toggleExtension) {
const page = await browserContext.newPage()
await page.goto('about:blank')
await serviceWorker.evaluate(async () => {
await (globalThis as any).toggleExtensionForActiveTab()
})
}
return { browserContext, userDataDir, relayServer }
}
export async function cleanupTestContext(ctx: TestContext | null, cleanup?: (() => Promise<void>) | null): Promise<void> {
if (ctx?.browserContext) {
await ctx.browserContext.close()
}
if (ctx?.relayServer) {
ctx.relayServer.close()
}
if (ctx?.userDataDir) {
try {
fs.rmSync(ctx.userDataDir, { recursive: true, force: true })
} catch (e) {
console.error('Failed to cleanup user data dir:', e)
}
}
if (cleanup) {
await cleanup()
}
}
export type SseServerState = {
connected: boolean
finished: boolean
writeCount: number
closed: boolean
}
export type SseServer = {
baseUrl: string
getState: () => SseServerState
close: () => Promise<void>
}
export async function createSseServer(): Promise<SseServer> {
let sseResponse: http.ServerResponse | null = null
let sseFinished = false
let sseClosed = false
let sseWriteCount = 0
let sseInterval: NodeJS.Timeout | null = null
const openResponses: Set<http.ServerResponse> = new Set()
const openSockets: Set<net.Socket> = new Set()
const server = http.createServer((req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>SSE Test</title>
</head>
<body>
<script>
window.__sseMessages = [];
window.__sseOpen = false;
window.__sseError = null;
window.startSse = function () {
const source = new EventSource('/sse');
window.__sseSource = source;
source.onopen = function () {
window.__sseOpen = true;
};
source.onmessage = function (event) {
window.__sseMessages.push(event.data);
};
source.onerror = function () {
window.__sseError = 'SSE error';
};
return true;
};
window.stopSse = function () {
if (window.__sseSource) {
window.__sseSource.close();
}
};
</script>
</body>
</html>`)
return
}
if (req.url === '/sse') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
})
res.write('retry: 1000\n\n')
res.write('data: hello\n\n')
sseResponse = res
sseWriteCount += 1
openResponses.add(res)
res.on('finish', () => {
sseFinished = true
})
res.on('close', () => {
sseClosed = true
openResponses.delete(res)
if (sseInterval) {
clearInterval(sseInterval)
sseInterval = null
}
})
sseInterval = setInterval(() => {
res.write('data: ping\n\n')
sseWriteCount += 1
}, 200)
return
}
res.writeHead(404)
res.end('Not found')
})
server.on('connection', (socket) => {
openSockets.add(socket)
socket.on('close', () => {
openSockets.delete(socket)
})
})
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve()
})
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('Failed to bind SSE server')
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
getState: () => ({
connected: sseResponse !== null,
finished: sseFinished,
closed: sseClosed,
writeCount: sseWriteCount,
}),
close: async () => {
for (const response of openResponses) {
response.destroy()
}
for (const socket of openSockets) {
socket.destroy()
}
if (sseInterval) {
clearInterval(sseInterval)
sseInterval = null
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
}
}
export async function withTimeout<T>({ promise, timeoutMs, errorMessage }: { promise: Promise<T>; timeoutMs: number; errorMessage: string }): Promise<T> {
return await new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
promise
.then((value) => {
clearTimeout(timeoutId)
resolve(value)
})
.catch((error) => {
clearTimeout(timeoutId)
reject(error)
})
})
}
/** Tagged template for inline JS code strings used in MCP execute calls */
export function js(strings: TemplateStringsArray, ...values: unknown[]): string {
return strings.reduce(
(result, str, i) => result + str + (values[i] || ''),
'',
)
}
export function tryJsonParse(str: string) {
try {
return JSON.parse(str)
} catch {
return str
}
}
/**
* Safely close a browser connected via connectOverCDP.
*
* Playwright's CRConnection uses async message handling (messageWrap) that can cause
* a race condition where _onClose() runs before all pending _onMessage() handlers complete.
* This results in "Assertion error" from crConnection.js when a CDP response arrives
* after callbacks were cleared by dispose().
*
* This helper waits for the message queue to drain before closing, avoiding the race.
*
* @param browser - Browser instance from chromium.connectOverCDP()
* @param drainDelayMs - Time to wait for pending messages to be processed (default: 50ms)
*/
export async function safeCloseCDPBrowser(
browser: Awaited<ReturnType<typeof import('@xmorse/playwright-core').chromium.connectOverCDP>>,
drainDelayMs = 50
): Promise<void> {
// Wait for any queued message handlers to run
// This gives Playwright's messageWrap time to process pending CDP responses
await new Promise(r => setTimeout(r, drainDelayMs))
await browser.close()
}
export type SimpleServer = {
baseUrl: string
close: () => Promise<void>
}
/** Minimal local HTTP server for tests that need cross-origin iframes or custom routes */
export async function createSimpleServer({ routes }: { routes: Record<string, string> }): Promise<SimpleServer> {
const openSockets: Set<net.Socket> = new Set()
const server = http.createServer((req, res) => {
const url = req.url || '/'
const body = routes[url]
if (!body) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('not found')
return
}
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(body)
})
server.on('connection', (socket) => {
openSockets.add(socket)
socket.on('close', () => {
openSockets.delete(socket)
})
})
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve()
})
})
const address = server.address()
if (!address || typeof address === 'string') {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
throw new Error('Failed to start test server')
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () => {
for (const socket of openSockets) {
socket.destroy()
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
},
}
}