import { test as base, chromium, type Page, type BrowserContext, type Browser } from '@playwright/test'; import { spawn, type ChildProcess } from 'node:child_process'; import { once } from 'node:events'; import { createServer } from 'node:net'; import { join } from 'node:path'; import { installTestServerEndpoint } from '../helpers/seed-test-endpoint'; export interface Client { page: Page; context: BrowserContext; } interface TestServerHandle { port: number; url: string; stop: () => Promise; } interface MultiClientFixture { createClient: () => Promise; testServer: TestServerHandle; } const FAKE_AUDIO_FILE = join(__dirname, 'test-tone.wav'); const CHROMIUM_FAKE_MEDIA_ARGS = [ '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}` ]; const E2E_DIR = join(__dirname, '..'); const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js'); export const test = base.extend({ testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise) => { const testServer = await startTestServer(); await use(testServer); await testServer.stop(); }, createClient: async ({ testServer }, use) => { const browsers: Browser[] = []; const clients: Client[] = []; const factory = async (): Promise => { // Launch a dedicated browser per client so each gets its own fake // audio device - shared browsers can starve the first context's // audio capture under load. const browser = await chromium.launch({ args: CHROMIUM_FAKE_MEDIA_ARGS }); browsers.push(browser); const context = await browser.newContext({ permissions: ['microphone', 'camera'], baseURL: 'http://localhost:4200' }); await installTestServerEndpoint(context, testServer.port); const page = await context.newPage(); clients.push({ page, context }); return { page, context }; }; await use(factory); for (const client of clients) { await client.context.close(); } for (const browser of browsers) { await browser.close(); } } }); export { expect } from '@playwright/test'; async function startTestServer(retries = 3): Promise { for (let attempt = 1; attempt <= retries; attempt++) { const port = await allocatePort(); const child = spawn(process.execPath, [START_SERVER_SCRIPT], { cwd: E2E_DIR, env: { ...process.env, TEST_SERVER_PORT: String(port) }, stdio: 'pipe' }); child.stdout?.on('data', (chunk: Buffer | string) => { process.stdout.write(chunk.toString()); }); child.stderr?.on('data', (chunk: Buffer | string) => { process.stderr.write(chunk.toString()); }); try { await waitForServerReady(port, child); } catch (error) { await stopServer(child); if (attempt < retries) { console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`); continue; } throw error; } return { port, url: `http://localhost:${port}`, stop: async () => { await stopServer(child); } }; } throw new Error('startTestServer: unreachable'); } async function allocatePort(): Promise { return new Promise((resolve, reject) => { const probe = createServer(); probe.once('error', reject); probe.listen(0, '127.0.0.1', () => { const address = probe.address(); if (!address || typeof address === 'string') { probe.close(); reject(new Error('Failed to resolve an ephemeral test server port')); return; } const { port } = address; probe.close((error) => { if (error) { reject(error); return; } resolve(port); }); }); }); } async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise { const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`); } try { const response = await fetch(readyUrl); if (response.ok) { return; } } catch { // Server still starting. } await wait(250); } throw new Error(`Timed out waiting for test server on port ${port}`); } async function stopServer(child: ChildProcess): Promise { if (child.exitCode !== null) { return; } child.kill('SIGTERM'); const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]); if (!exited && child.exitCode === null) { child.kill('SIGKILL'); await once(child, 'exit'); } } function wait(durationMs: number): Promise { return new Promise((resolve) => { setTimeout(resolve, durationMs); }); }