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:
2026-08-14 03:19:29 +02:00
parent d71e3a98da
commit e49b3ec112
41 changed files with 35947 additions and 50 deletions
+137
View File
@@ -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);
});
}