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.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user