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,112 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
export interface PeerRoleEdge {
|
||||
/** Remote peer id, in the identity space of the signal server routing that peer. */
|
||||
peerId: string;
|
||||
/** Our own actor id in that same identity space. */
|
||||
localActorId: string | null;
|
||||
isInitiator: boolean;
|
||||
connectionState: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read who elected themselves initiator for every active peer, together with the local
|
||||
* actor id in that peer's identity space. Both halves of a pair must describe the same
|
||||
* two ids, which is what makes the election comparable in the first place.
|
||||
*/
|
||||
export async function readPeerRoleEdges(page: Page): Promise<PeerRoleEdge[]> {
|
||||
return await page.evaluate(() => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
interface PeerDataShape {
|
||||
connection?: { connectionState?: string };
|
||||
isInitiator?: boolean;
|
||||
}
|
||||
interface RealtimeShape {
|
||||
peerManager?: { activePeerConnections?: Map<string, PeerDataShape> };
|
||||
signalingTransportHandler?: {
|
||||
getIdentifyCredentialsForPeer?: (peerId: string) => { oderId?: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const realtime = debugApi.getComponent(host)['realtime'] as RealtimeShape | undefined;
|
||||
const peers = realtime?.peerManager?.activePeerConnections;
|
||||
|
||||
if (!peers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const edges: PeerRoleEdge[] = [];
|
||||
|
||||
peers.forEach((peerData, peerId) => {
|
||||
const credentials = realtime?.signalingTransportHandler?.getIdentifyCredentialsForPeer?.(peerId);
|
||||
|
||||
edges.push({
|
||||
connectionState: peerData.connection?.connectionState ?? 'unknown',
|
||||
isInitiator: peerData.isInitiator === true,
|
||||
localActorId: credentials?.oderId ?? null,
|
||||
peerId
|
||||
});
|
||||
});
|
||||
|
||||
return edges;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How many RTCPeerConnections this page has created since load. A clean session creates
|
||||
* exactly one per remote peer; a rebuilt peer - for example a non-initiator that gave up
|
||||
* waiting for an offer that was never elected to be sent - adds another.
|
||||
*/
|
||||
export async function countCreatedPeerConnections(page: Page): Promise<number> {
|
||||
return await page.evaluate(() =>
|
||||
((window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? []).length
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every connected pair must have exactly one initiator. Comparing ids from two different
|
||||
* identity spaces breaks the antisymmetry of the election, so both peers offer (glare) or
|
||||
* neither does until a takeover timer fires.
|
||||
*/
|
||||
export function expectExactlyOneInitiatorPerPair(edgesByClient: Record<string, PeerRoleEdge[]>): void {
|
||||
const directed = new Map<string, boolean>();
|
||||
const pairs = new Set<string>();
|
||||
|
||||
for (const [clientName, edges] of Object.entries(edgesByClient)) {
|
||||
for (const edge of edges) {
|
||||
expect(
|
||||
edge.localActorId,
|
||||
`${clientName} has no local actor id in the identity space of peer ${edge.peerId}`
|
||||
).toBeTruthy();
|
||||
|
||||
const localActorId = edge.localActorId as string;
|
||||
|
||||
directed.set(`${localActorId}->${edge.peerId}`, edge.isInitiator);
|
||||
pairs.add([localActorId, edge.peerId].sort().join('<->'));
|
||||
}
|
||||
}
|
||||
|
||||
expect(pairs.size, 'expected at least one peer pair').toBeGreaterThan(0);
|
||||
|
||||
for (const pair of pairs) {
|
||||
const [first, second] = pair.split('<->');
|
||||
const forward = directed.get(`${first}->${second}`);
|
||||
const backward = directed.get(`${second}->${first}`);
|
||||
|
||||
expect(forward, `missing peer connection ${first} -> ${second}`).not.toBeUndefined();
|
||||
expect(backward, `missing peer connection ${second} -> ${first}`).not.toBeUndefined();
|
||||
expect(
|
||||
[forward, backward].filter(Boolean),
|
||||
`expected exactly one initiator for pair ${pair}`
|
||||
).toHaveLength(1);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,11 @@ const SERVER_ENTRY = existsSync(SERVER_DIST_ENTRY) ? SERVER_DIST_ENTRY : SERVER_
|
||||
const USE_COMPILED_SERVER = SERVER_ENTRY === SERVER_DIST_ENTRY;
|
||||
|
||||
// ── Create isolated temp data directory ──────────────────────────────
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
||||
// The Playwright helper supplies a durable directory when a test needs to
|
||||
// restart the signaling process on the same port without losing its database.
|
||||
const suppliedTmpDir = process.env.TEST_SERVER_DATA_DIR;
|
||||
const ownsTmpDir = !suppliedTmpDir;
|
||||
const tmpDir = suppliedTmpDir || mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
||||
const dataDir = join(tmpDir, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
@@ -81,6 +85,10 @@ child.on('exit', (code) => {
|
||||
|
||||
// ── Cleanup on signals ───────────────────────────────────────────────
|
||||
function cleanup() {
|
||||
if (!ownsTmpDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
|
||||
|
||||
+85
-18
@@ -1,11 +1,18 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -15,27 +22,15 @@ 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 child = spawn(process.execPath, [START_SERVER_SCRIPT], {
|
||||
cwd: E2E_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
TEST_SERVER_PORT: String(port)
|
||||
},
|
||||
stdio: 'pipe'
|
||||
});
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'metoyou-e2e-handle-'));
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
process.stdout.write(chunk.toString());
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
process.stderr.write(chunk.toString());
|
||||
});
|
||||
let child: ChildProcess | null = null;
|
||||
let stopped = false;
|
||||
|
||||
try {
|
||||
await waitForServerReady(port, child);
|
||||
child = await spawnTestServer(port, dataDir);
|
||||
} catch (error) {
|
||||
await stopServer(child);
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
|
||||
if (attempt < retries) {
|
||||
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
|
||||
@@ -48,8 +43,51 @@ export async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
||||
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 () => {
|
||||
await stopServer(child);
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopped = true;
|
||||
|
||||
if (child) {
|
||||
await stopServer(child);
|
||||
child = null;
|
||||
}
|
||||
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -57,6 +95,35 @@ export async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { type BrowserContext, type Page } from '@playwright/test';
|
||||
import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types';
|
||||
|
||||
/** Same shape `IceServerSettingsService` persists under `metoyou_ice_servers`. */
|
||||
interface StoredIceServerEntry {
|
||||
id: string;
|
||||
type: 'stun' | 'turn';
|
||||
urls: string;
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
export interface TurnCredentials {
|
||||
urls: string;
|
||||
username: string;
|
||||
credential: string;
|
||||
}
|
||||
|
||||
const ICE_SERVERS_STORAGE_KEY = 'metoyou_ice_servers';
|
||||
|
||||
/**
|
||||
* Configure the app with a single TURN server, the way a user would in
|
||||
* Settings -> ICE servers. Nothing test-specific reads this back: the app loads
|
||||
* it through `IceServerSettingsService`, so the call really is configured the
|
||||
* product way.
|
||||
*
|
||||
* Call BEFORE any `goto()`.
|
||||
*/
|
||||
export async function seedTurnOnlyIceServers(
|
||||
target: BrowserContext | Page,
|
||||
turn: TurnCredentials
|
||||
): Promise<void> {
|
||||
const entries: StoredIceServerEntry[] = [
|
||||
{
|
||||
credential: turn.credential,
|
||||
id: 'e2e-turn',
|
||||
type: 'turn',
|
||||
urls: turn.urls,
|
||||
username: turn.username
|
||||
}
|
||||
];
|
||||
|
||||
await target.addInitScript(
|
||||
([key, value]) => {
|
||||
localStorage.setItem(key, value);
|
||||
},
|
||||
[ICE_SERVERS_STORAGE_KEY, JSON.stringify(entries)] as const
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take away the direct path. Every `RTCPeerConnection` is built with
|
||||
* `iceTransportPolicy: 'relay'`, so host and server-reflexive candidates are
|
||||
* discarded and the call can only succeed by relaying through the configured
|
||||
* TURN server - which is what a user behind symmetric NAT is forced to do.
|
||||
*
|
||||
* Install AFTER `installWebRTCTracking` (it wraps whatever constructor is
|
||||
* current) and BEFORE any `goto()`.
|
||||
*/
|
||||
export async function forceRelayOnlyIce(target: BrowserContext | Page): Promise<void> {
|
||||
await target.addInitScript(() => {
|
||||
const harness = window as unknown as WebRtcTestHarnessWindow & {
|
||||
__relayIceConfigs?: RTCConfiguration[];
|
||||
};
|
||||
const Wrapped = harness.RTCPeerConnection;
|
||||
|
||||
harness.__relayIceConfigs = [];
|
||||
|
||||
const RelayOnly = function(this: RTCPeerConnection, config?: RTCConfiguration) {
|
||||
const relayConfig: RTCConfiguration = { ...config, iceTransportPolicy: 'relay' };
|
||||
|
||||
harness.__relayIceConfigs?.push(relayConfig);
|
||||
return new Wrapped(relayConfig);
|
||||
} as unknown as typeof RTCPeerConnection;
|
||||
|
||||
RelayOnly.prototype = Wrapped.prototype;
|
||||
Object.setPrototypeOf(RelayOnly, Wrapped);
|
||||
harness.RTCPeerConnection = RelayOnly;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration each peer connection was actually built with. A relay-only
|
||||
* run that connects nothing usually means the app handed over no TURN server at
|
||||
* all, which looks identical to a broken relay from the outside.
|
||||
*/
|
||||
export async function getRelayIceConfigs(page: Page): Promise<RTCConfiguration[]> {
|
||||
return await page.evaluate(() =>
|
||||
(window as unknown as { __relayIceConfigs?: RTCConfiguration[] }).__relayIceConfigs ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectedCandidatePair {
|
||||
localCandidateType: string;
|
||||
remoteCandidateType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate pair each connection actually settled on. `relay` on the local
|
||||
* side means our packets left through the TURN server rather than going direct.
|
||||
*/
|
||||
export async function getSelectedCandidatePairs(page: Page): Promise<SelectedCandidatePair[]> {
|
||||
return await page.evaluate(async () => {
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
|
||||
const pairs: SelectedCandidatePair[] = [];
|
||||
|
||||
for (const pc of connections) {
|
||||
let stats: RTCStatsReport;
|
||||
|
||||
try {
|
||||
stats = await pc.getStats();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidates = new Map<string, string>();
|
||||
|
||||
let selected: { localCandidateId?: string; remoteCandidateId?: string } | null = null;
|
||||
|
||||
stats.forEach((report) => {
|
||||
if (report.type === 'local-candidate' || report.type === 'remote-candidate') {
|
||||
candidates.set(report.id as string, (report as { candidateType?: string }).candidateType ?? 'unknown');
|
||||
}
|
||||
});
|
||||
|
||||
stats.forEach((report) => {
|
||||
if (report.type !== 'candidate-pair') {
|
||||
return;
|
||||
}
|
||||
|
||||
const pair = report as unknown as {
|
||||
state?: string;
|
||||
nominated?: boolean;
|
||||
selected?: boolean;
|
||||
localCandidateId?: string;
|
||||
remoteCandidateId?: string;
|
||||
};
|
||||
|
||||
if (pair.state === 'succeeded' && (pair.nominated || pair.selected)) {
|
||||
selected = pair;
|
||||
}
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pair = selected as { localCandidateId?: string; remoteCandidateId?: string };
|
||||
|
||||
pairs.push({
|
||||
localCandidateType: candidates.get(pair.localCandidateId ?? '') ?? 'unknown',
|
||||
remoteCandidateType: candidates.get(pair.remoteCandidateId ?? '') ?? 'unknown'
|
||||
});
|
||||
}
|
||||
|
||||
return pairs;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until `expectedPairs` connections report a settled candidate pair whose
|
||||
* local candidate is a TURN relay.
|
||||
*/
|
||||
export async function waitForRelayedCandidatePairs(
|
||||
page: Page,
|
||||
expectedPairs: number,
|
||||
timeoutMs = 60_000
|
||||
): Promise<SelectedCandidatePair[]> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
let latest: SelectedCandidatePair[] = [];
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
latest = await getSelectedCandidatePairs(page);
|
||||
|
||||
const relayed = latest.filter((pair) => pair.localCandidateType === 'relay');
|
||||
|
||||
if (relayed.length >= expectedPairs) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1_000);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for ${expectedPairs} relayed candidate pairs. Last seen: ${JSON.stringify(latest)}`
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import type { Client } from '../fixtures/multi-client';
|
||||
import { ChatRoomPage } from '../pages/chat-room.page';
|
||||
import { RegisterPage } from '../pages/register.page';
|
||||
import { ServerSearchPage } from '../pages/server-search.page';
|
||||
import {
|
||||
installAutoResumeAudioContext,
|
||||
installWebRTCTracking,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForOpenDataChannelCount
|
||||
} from './webrtc-helpers';
|
||||
|
||||
const PAIR_PASSWORD = 'TestPass123!';
|
||||
|
||||
export interface VoicePairClient extends Client {
|
||||
displayName: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register two fresh users, put them in a new server, and connect both to one voice
|
||||
* channel with the WebRTC tracking harness installed. Returns once both sides report a
|
||||
* connected peer, an open data channel, and live audio stats.
|
||||
*/
|
||||
export async function createVoicePairInNewServer(
|
||||
createClient: () => Promise<Client>,
|
||||
serverName: string,
|
||||
options: { channelName?: string; namePrefix?: string } = {}
|
||||
): Promise<VoicePairClient[]> {
|
||||
const channelName = options.channelName ?? 'General';
|
||||
const namePrefix = options.namePrefix ?? 'Voice Pair';
|
||||
const uniqueSuffix = Date.now();
|
||||
const clients: VoicePairClient[] = [];
|
||||
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const client = await createClient();
|
||||
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push({
|
||||
...client,
|
||||
displayName: `${namePrefix} ${index + 1}`,
|
||||
username: `voice_pair_${uniqueSuffix}_${index + 1}`
|
||||
});
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(client.username, client.displayName, PAIR_PASSWORD);
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
await new ServerSearchPage(clients[0].page).createServer(serverName, { description: `${namePrefix} voice session` });
|
||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
|
||||
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(channelName);
|
||||
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.joinVoiceChannel(channelName);
|
||||
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
||||
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
||||
await waitForAudioStatsPresent(client.page, 30_000);
|
||||
}
|
||||
|
||||
return clients;
|
||||
}
|
||||
|
||||
/** Pin voice settings so audio levels and codecs do not vary between runs. */
|
||||
export async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
audioBitrate: 96,
|
||||
latencyProfile: 'balanced',
|
||||
includeSystemAudio: false,
|
||||
noiseReduction: false,
|
||||
screenShareQuality: 'balanced',
|
||||
askScreenShareQuality: false
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
|
||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||
await searchInput.fill(roomName);
|
||||
|
||||
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
|
||||
|
||||
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||||
await roomCard.dblclick();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
await waitForCurrentRoomName(page, roomName);
|
||||
}
|
||||
|
||||
export async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
|
||||
const roomButton = page.locator(`button[title="${roomName}"]`);
|
||||
|
||||
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
||||
await roomButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
await waitForCurrentRoomName(page, roomName);
|
||||
}
|
||||
|
||||
export async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(expectedRoomName) => {
|
||||
interface RoomShape { name?: string }
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
|
||||
return currentRoom?.name === expectedRoomName;
|
||||
},
|
||||
roomName,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
export async function joinVoiceChannelUntilConnected(
|
||||
page: Page,
|
||||
channelName: string,
|
||||
attempts = 3
|
||||
): Promise<void> {
|
||||
const room = new ChatRoomPage(page);
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
await room.joinVoiceChannel(channelName);
|
||||
|
||||
try {
|
||||
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
|
||||
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await page.waitForTimeout(1_000);
|
||||
}
|
||||
}
|
||||
|
||||
const lastErrorMessage = lastError instanceof Error
|
||||
? `Last error: ${lastError.message}`
|
||||
: 'Last error: unavailable';
|
||||
|
||||
throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`);
|
||||
}
|
||||
|
||||
export async function waitForLocalVoiceChannelConnection(
|
||||
page: Page,
|
||||
channelName: string,
|
||||
timeout = 20_000
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(name) => {
|
||||
interface VoiceStateShape { isConnected?: boolean; roomId?: string; serverId?: string }
|
||||
interface UserShape { voiceState?: VoiceStateShape }
|
||||
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
|
||||
interface RoomShape { id: string; channels?: ChannelShape[] }
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
|
||||
const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name);
|
||||
const voiceState = currentUser?.voiceState;
|
||||
|
||||
return !!voiceChannel
|
||||
&& voiceState?.isConnected === true
|
||||
&& voiceState.roomId === voiceChannel.id
|
||||
&& voiceState.serverId === currentRoom.id;
|
||||
},
|
||||
channelName,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user