Files
Toju/e2e/helpers/test-server.ts
T
myxelium e49b3ec112 chore: dev-stack switches, shared e2e harness, and desktop shell rules
- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine
  suspend; the reload client otherwise destroys the session under test.
- `dev-peer.sh` plus a separate userdata dir runs a second local peer.
- `tools/voice-probe.js` samples peer state and RTP counters from a live
  window, persisting to localStorage so a renderer reload cannot erase it.
- e2e helpers for voice pairs, peer-role election, and a TURN relay.
- Electron single-instance and dev-client-load decisions move into rules
  files with colocated specs.
2026-08-14 03:19:29 +02:00

200 lines
4.8 KiB
TypeScript

import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export interface TestServerHandle {
port: number;
url: string;
restart: () => Promise<void>;
/** Kill the process but keep the port and data dir, so `start()` can bring it back. */
kill: () => Promise<void>;
/** Start the server again on the same port and data dir after `kill()`. */
start: () => Promise<void>;
stop: () => Promise<void>;
}
const E2E_DIR = join(__dirname, '..');
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
export async function startTestServer(retries = 3): Promise<TestServerHandle> {
for (let attempt = 1; attempt <= retries; attempt++) {
const port = await allocatePort();
const dataDir = await mkdtemp(join(tmpdir(), 'metoyou-e2e-handle-'));
let child: ChildProcess | null = null;
let stopped = false;
try {
child = await spawnTestServer(port, dataDir);
} catch (error) {
await rm(dataDir, { recursive: true, force: true });
if (attempt < retries) {
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
continue;
}
throw error;
}
return {
port,
url: `http://localhost:${port}`,
restart: async () => {
if (stopped) {
throw new Error('Cannot restart a stopped test server');
}
if (child) {
await stopServer(child);
}
child = await spawnTestServer(port, dataDir);
},
kill: async () => {
if (stopped) {
throw new Error('Cannot kill a stopped test server');
}
if (child) {
await stopServer(child);
child = null;
}
},
start: async () => {
if (stopped) {
throw new Error('Cannot start a stopped test server');
}
if (child) {
return;
}
child = await spawnTestServer(port, dataDir);
},
stop: async () => {
if (stopped) {
return;
}
stopped = true;
if (child) {
await stopServer(child);
child = null;
}
await rm(dataDir, { recursive: true, force: true });
}
};
}
throw new Error('startTestServer: unreachable');
}
async function spawnTestServer(port: number, dataDir: string): Promise<ChildProcess> {
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
cwd: E2E_DIR,
env: {
...process.env,
TEST_SERVER_DATA_DIR: dataDir,
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);
throw error;
}
return child;
}
async function allocatePort(): Promise<number> {
return await new Promise<number>((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<void> {
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<void> {
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<void> {
return new Promise((resolve) => {
setTimeout(resolve, durationMs);
});
}