Outgoing voice was gated on the observer's roster copy of the remote user's voice state, which is signaling gossip. The signal server broadcasts `user_left` for any socket it declares dead, so a suspended laptop or a flaky hop wiped that copy and the observer detached its microphone from a peer that never left the channel - a silent member with no way back through the UI. `decideVoicePathRouting` now closes a path only on positive evidence: we left voice, the peer itself reported another channel or none, or the connection is gone. Missing gossip holds an established path instead. Opening still needs confirmation, so a guess never starts sending; the same rule gates playback, camera video, and the microphone a new connection puts in its first offer. Peers are also asked for their voice state when a connection or data channel comes up, so a rebuilt path re-confirms itself. Alongside it, the microphone can be switched mid-call: capture moves to a device service and rules, the live track is swapped with `replaceTrack` so the session is never renegotiated, and the speaking indicator follows the new stream.
157 lines
5.2 KiB
TypeScript
157 lines
5.2 KiB
TypeScript
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<Client>,
|
|
turn: TurnCredentials,
|
|
serverName: string
|
|
): Promise<Client[]> {
|
|
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;
|
|
}
|