321 lines
11 KiB
JavaScript
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
|