- `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.
138 lines
3.7 KiB
TypeScript
138 lines
3.7 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { createServer } from 'node:net';
|
|
import { promisify } from 'node:util';
|
|
|
|
const run = promisify(execFile);
|
|
|
|
export interface TurnServerHandle {
|
|
urls: string;
|
|
username: string;
|
|
credential: string;
|
|
stop: () => Promise<void>;
|
|
}
|
|
|
|
const IMAGE = 'coturn/coturn:latest';
|
|
const CONTAINER_NAME = 'metoyou-e2e-turn';
|
|
const USERNAME = 'e2e';
|
|
const CREDENTIAL = 'e2epass';
|
|
const RELAY_MIN_PORT = 49_160;
|
|
const RELAY_MAX_PORT = 49_200;
|
|
|
|
/** Whether a working Docker daemon is reachable, so a spec can skip instead of failing. */
|
|
export async function isDockerAvailable(): Promise<boolean> {
|
|
try {
|
|
await run('docker', ['info'], { timeout: 15_000 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a throwaway coturn on the loopback interface. Relay-only tests need a real
|
|
* TURN server: `iceTransportPolicy: 'relay'` discards every other candidate, so
|
|
* without one there is no path at all and the test would prove nothing.
|
|
*/
|
|
export async function startTurnServer(): Promise<TurnServerHandle> {
|
|
await removeContainer();
|
|
|
|
const port = await allocatePort();
|
|
|
|
await run('docker', [
|
|
'run',
|
|
'--detach',
|
|
'--name',
|
|
CONTAINER_NAME,
|
|
'--network',
|
|
'host',
|
|
IMAGE,
|
|
'-n',
|
|
`--listening-port=${port}`,
|
|
'--listening-ip=127.0.0.1',
|
|
'--relay-ip=127.0.0.1',
|
|
`--min-port=${RELAY_MIN_PORT}`,
|
|
`--max-port=${RELAY_MAX_PORT}`,
|
|
'--lt-cred-mech',
|
|
`--user=${USERNAME}:${CREDENTIAL}`,
|
|
// Both browsers are on this machine. Without this coturn still hands out a
|
|
// relay candidate but refuses to forward to a 127.x peer, so ICE fails in a
|
|
// way that looks like a broken app rather than a blocked relay.
|
|
'--allow-loopback-peers',
|
|
'--realm=metoyou.test',
|
|
'--fingerprint',
|
|
'--no-tls',
|
|
'--no-dtls',
|
|
// Readiness is read back off `docker logs`: coturn logs to a file inside the
|
|
// container unless pointed at stdout, and the per-listener lines only appear
|
|
// at verbose level.
|
|
'--log-file=stdout',
|
|
'--verbose'
|
|
], { timeout: 120_000 });
|
|
|
|
await waitForTurnPort(port);
|
|
|
|
return {
|
|
credential: CREDENTIAL,
|
|
stop: removeContainer,
|
|
urls: `turn:127.0.0.1:${port}?transport=udp`,
|
|
username: USERNAME
|
|
};
|
|
}
|
|
|
|
async function removeContainer(): Promise<void> {
|
|
try {
|
|
await run('docker', [
|
|
'rm',
|
|
'--force',
|
|
CONTAINER_NAME
|
|
], { timeout: 30_000 });
|
|
} catch {
|
|
// No such container - nothing to clean up.
|
|
}
|
|
}
|
|
|
|
async function waitForTurnPort(port: number, timeoutMs = 20_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
while (Date.now() < deadline) {
|
|
const { stdout, stderr } = await run('docker', ['logs', CONTAINER_NAME], { timeout: 10_000 })
|
|
.catch(() => ({ stderr: '', stdout: '' }));
|
|
|
|
if (`${stdout}${stderr}`.includes(`UDP listener opened on: 127.0.0.1:${port}`)) {
|
|
return;
|
|
}
|
|
|
|
await delay(250);
|
|
}
|
|
|
|
throw new Error(`coturn did not open a UDP listener on 127.0.0.1:${port}`);
|
|
}
|
|
|
|
/** coturn binds this itself, so only probe for a free port and hand it over. */
|
|
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 TURN port'));
|
|
return;
|
|
}
|
|
|
|
const { port } = address;
|
|
|
|
probe.close((error) => (error ? reject(error) : resolve(port)));
|
|
});
|
|
});
|
|
}
|
|
|
|
function delay(durationMs: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, durationMs);
|
|
});
|
|
}
|