import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import http from 'node:http'; import { chromium } from '@xmorse/playwright-core'; import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; import { startPlayWriterCDPRelayServer } from './cdp-relay.js'; import { createFileLogger } from './create-logger.js'; import { killPortProcess } from './kill-port.js'; const execAsync = promisify(exec); const extensionBuildQueues = new Map(); async function buildExtension({ port, distDir }) { 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) { 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 async function setupTestContext({ port, tempDirPrefix, toggleExtension = false, }) { 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.toggleExtensionForActiveTab(); }); } return { browserContext, userDataDir, relayServer }; } export async function cleanupTestContext(ctx, cleanup) { 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 async function createSseServer() { let sseResponse = null; let sseFinished = false; let sseClosed = false; let sseWriteCount = 0; let sseInterval = null; const openResponses = new Set(); const openSockets = new Set(); const server = http.createServer((req, res) => { if (req.url === '/') { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` SSE Test `); 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((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((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); }, }; } export async function withTimeout({ promise, timeoutMs, errorMessage, }) { return await new Promise((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, ...values) { return strings.reduce((result, str, i) => result + str + (values[i] || ''), ''); } export function tryJsonParse(str) { 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, drainDelayMs = 50) { // 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(); } /** Minimal local HTTP server for tests that need cross-origin iframes or custom routes */ export async function createSimpleServer({ routes }) { const openSockets = 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((resolve) => { server.listen(0, '127.0.0.1', () => { resolve(); }); }); const address = server.address(); if (!address || typeof address === 'string') { await new Promise((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((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); }, }; } //# sourceMappingURL=test-utils.js.map