import { expect } from '@playwright/test'; import { test, type Client } from '../../fixtures/multi-client'; import { forceRelayOnlyIce, getRelayIceConfigs, seedTurnOnlyIceServers, waitForRelayedCandidatePairs, type TurnCredentials } from '../../helpers/turn-relay'; import { isDockerAvailable, startTurnServer, type TurnServerHandle } from '../../helpers/turn-server'; import { installDeterministicVoiceSettings } from '../../helpers/voice-session'; import { installAutoResumeAudioContext, installWebRTCTracking, waitForAllPeerAudioFlow, waitForAudioStatsPresent, waitForConnectedPeerCount, waitForOpenDataChannelCount } from '../../helpers/webrtc-helpers'; import { ChatRoomPage } from '../../pages/chat-room.page'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; const USER_PASSWORD = 'TestPass123!'; const VOICE_CHANNEL = 'General'; /** * Symmetric NAT gives a browser no usable direct path, so the whole call has to * ride a TURN relay. `iceTransportPolicy: 'relay'` reproduces that without any * network trickery: host and server-reflexive candidates are thrown away, and * only the TURN server the app was configured with is left. */ test.describe('Relay-only voice', () => { let turnServer: TurnServerHandle | null = null; test.beforeAll(async () => { if (!await isDockerAvailable()) { return; } turnServer = await startTurnServer(); }); test.afterAll(async () => { await turnServer?.stop(); turnServer = null; }); test('two users hear each other with every direct path removed', async ({ createClient }) => { test.skip(!turnServer, 'Relay-only voice needs Docker to run a local coturn.'); test.setTimeout(240_000); const turn = turnServer as TurnServerHandle; const clients = await createRelayOnlyVoicePair(createClient, turn, `Relay Only Voice ${Date.now()}`); await test.step('Both ends settled on a TURN relay, not a direct path', async () => { for (const client of clients) { const pairs = await waitForRelayedCandidatePairs(client.page, 1, 60_000); // A direct pair here would mean the policy leaked and the test proved nothing. for (const pair of pairs) { expect(pair.localCandidateType, 'a peer connection escaped the relay-only policy').toBe('relay'); } } }); await test.step('Audio flows both ways through the relay', async () => { for (const client of clients) { await waitForAllPeerAudioFlow(client.page, 1, 60_000); } }); }); }); async function createRelayOnlyVoicePair( createClient: () => Promise, turn: TurnCredentials, serverName: string ): Promise { const clients: Client[] = []; const credentials: { username: string; displayName: string }[] = []; for (let index = 0; index < 2; index++) { const client = await createClient(); await installDeterministicVoiceSettings(client.page); await installWebRTCTracking(client.page); await forceRelayOnlyIce(client.page); await seedTurnOnlyIceServers(client.page, turn); await installAutoResumeAudioContext(client.page); clients.push(client); credentials.push({ displayName: `Relay Voice ${index + 1}`, username: `relay_voice_${Date.now()}_${index + 1}` }); } await test.step('Register both clients', async () => { for (const [index, client] of clients.entries()) { const registerPage = new RegisterPage(client.page); await registerPage.goto(); await registerPage.register( credentials[index].username, credentials[index].displayName, USER_PASSWORD ); await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 }); } }); await test.step('Create and join the server', async () => { await new ServerSearchPage(clients[0].page).createServer(serverName, { description: 'Relay-only voice test' }); 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 test.step('Join both clients to voice', async () => { await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(VOICE_CHANNEL); for (const client of clients) { const room = new ChatRoomPage(client.page); await room.joinVoiceChannel(VOICE_CHANNEL); await expect(room.voiceControls).toBeVisible({ timeout: 20_000 }); } for (const [index, client] of clients.entries()) { try { await waitForConnectedPeerCount(client.page, 1, 90_000); await waitForOpenDataChannelCount(client.page, 1, 90_000); await waitForAudioStatsPresent(client.page, 30_000); } catch (error) { // No TURN server in the config looks exactly like a failed relay from the // outside, so show what the app actually handed to WebRTC. const configs = await getRelayIceConfigs(client.page); console.log(`[relay client ${index + 1} ice configs] ${JSON.stringify(configs.slice(0, 3))}`); throw error; } } }); return clients; }