Files
2026-03-03 23:49:13 +01:00

321 lines
11 KiB
JavaScript

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(`<!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((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