import { type ChildProcess, execFileSync, spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { Socket, createServer } from 'node:net'; import { join } from 'node:path'; import { sleep } from '../room/utils'; import { createToken } from './signalToken'; /** * vitest globalSetup for the browser signal e2e suite. It provides the tests a * ws:// base URL for the LiveKit mock test-server, via one of two paths: * * - LK_TEST_SERVER_URL — connect to an already-running server (CI runs the * published test-server Docker image as a service; see .github/workflows/e2e.yaml). * - LK_SERVER_DIR — a local livekit-server checkout: build cmd/test-server * and spawn it on an ephemeral port (dev convenience). * * If neither is set (or the toolchain is unavailable) the suite is skipped. */ const HOST = '127.0.0.1'; let child: ChildProcess | undefined; function getFreePort(): Promise { return new Promise((resolve, reject) => { const srv = createServer(); srv.unref(); srv.on('error', reject); srv.listen(0, HOST, () => { const addr = srv.address(); const port = typeof addr === 'object' && addr ? addr.port : 0; srv.close(() => resolve(port)); }); }); } // Poll the validate endpoint (from Node — no CORS here) until it answers 200. async function waitReady(serverUrl: string): Promise { const token = await createToken({ signal: 'happy' }); const httpBase = serverUrl.replace(/^ws/, 'http'); const validateUrl = `${httpBase}/rtc/validate?access_token=${encodeURIComponent(token)}`; const deadline = Date.now() + 20_000; while (Date.now() < deadline) { try { if ((await fetch(validateUrl)).status === 200) { return true; } } catch { // not up yet } await sleep(200); } return false; } export default async function setup(project: { provide: (name: string, value: unknown) => void }) { const provide = (serverUrl: string) => { project.provide('serverUrl', serverUrl); project.provide('e2eUnavailable', ''); }; const skip = (reason: string) => { // eslint-disable-next-line no-console console.warn(`\n[e2e] signal-connection e2e suite SKIPPED: ${reason}\n`); project.provide('serverUrl', ''); project.provide('e2eUnavailable', reason); return () => {}; }; // CI / Docker: connect to an externally-managed server. const externalUrl = process.env.LK_TEST_SERVER_URL; if (externalUrl) { if (!(await waitReady(externalUrl))) { return skip(`test-server at ${externalUrl} did not become ready`); } provide(externalUrl); return () => {}; // container lifecycle is owned by CI } // Local dev: build + spawn cmd/test-server from a livekit-server checkout. const serverDir = process.env.LK_SERVER_DIR; if (!serverDir || !existsSync(serverDir)) { return skip( 'set LK_TEST_SERVER_URL (a running server) or LK_SERVER_DIR (a livekit-server checkout)', ); } const binary = join(serverDir, 'bin', 'test-server'); if (!process.env.LK_E2E_SKIP_BUILD) { try { execFileSync('go', ['build', '-o', 'bin/test-server', './cmd/test-server'], { cwd: serverDir, stdio: 'pipe', }); } catch (e) { return skip(`failed to build test-server: ${(e as Error).message}`); } } if (!existsSync(binary)) { return skip(`test-server binary missing at ${binary}`); } // Fresh ephemeral port each run so a leftover server from a crashed run can // never be mistaken for ours (vitest browser-mode teardown is unreliable, so // a spawned server may outlive the run; the ephemeral port makes that // harmless, at the cost of leaving at most one idle process per run). const port = Number(process.env.LK_TEST_SERVER_PORT) || (await getFreePort()); const serverUrl = `ws://${HOST}:${port}`; child = spawn(binary, ['--ports', String(port), '--bind', HOST], { cwd: serverDir, stdio: ['ignore', 'pipe', 'pipe'], }); let serverLog = ''; child.stdout?.on('data', (d: unknown) => { serverLog += String(d); }); child.stderr?.on('data', (d: unknown) => { serverLog += String(d); }); // With stdio: 'pipe' these are net.Sockets (typed as Readable, which lacks unref); // unref the pipe handles so their 'data' listeners don't keep the parent alive. if (child.stdout instanceof Socket) child.stdout.unref(); if (child.stderr instanceof Socket) child.stderr.unref(); child.unref(); const kill = () => { try { child?.kill('SIGKILL'); } catch { // already gone } }; process.once('exit', kill); process.once('SIGINT', () => { kill(); process.exit(130); }); process.once('SIGTERM', () => { kill(); process.exit(143); }); if (!(await waitReady(serverUrl))) { kill(); return skip( `test-server did not become ready on ${serverUrl}\n--- server output ---\n${serverLog}`, ); } provide(serverUrl); return kill; }