import { type Page } from '@playwright/test'; import { test } from '../../fixtures/multi-client'; import { createVoicePairInNewServer, type VoicePairClient } from '../../helpers/voice-session'; import { dumpRtcDiagnostics, getAudioStatsDelta, waitForConnectedPeerCount, waitForOpenDataChannelCount } from '../../helpers/webrtc-helpers'; /** * The signal server pings every 30s and gives up on a socket 45s after the last pong, * so it needs up to 75s to declare a client dead and broadcast `user_left`. */ const DEAD_SOCKET_HOLD_MS = 95_000; /** * Outgoing voice used to be 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 sleeping laptop, a flaky wifi hop, or a dropped * socket wiped that copy - and the observer cut its microphone to a peer that never * left the channel. */ test.describe('Losing a peer from the roster must not silence the call', () => { // The roster wipe is injected directly, because reproducing it through a real outage // depends on whether the observer notices the dead transport before `user_left` // arrives - the reducer keeps the voice state while a live peer transport exists. test('keeps sending to a peer the roster forgot', async ({ createClient }) => { test.setTimeout(300_000); const clients = await createVoicePairInNewServer( createClient, `Roster Wipe Voice ${Date.now()}`, { namePrefix: 'Roster Wipe' } ); const [peer, observer] = clients; for (const client of clients) { await assertTwoWayAudio(client, 'before the roster wipe'); } await test.step('The observer is told the peer left the server', async () => { const wipedUserId = await wipeRemoteVoiceMembersFromRoster(observer.page); test.info().annotations.push({ type: 'wiped user', description: wipedUserId }); await waitForNoRemoteVoiceMembersInRoster(observer.page, 15_000); }); // Nothing about the media plane changed, so the peer must not lose a single second of // audio. Checking only the end state would hide the cut: the peer keeps sending voice // heartbeats, so the roster heals itself moments later. await test.step('The peer never stops receiving the observer microphone', async () => { await assertUninterruptedInboundAudio(peer, 10); }); }); /** * The sleep/wake shape without a suspend: one client loses its signal socket long * enough for the server to declare it dead, and its peer connections die with it. When * everything returns the peer re-identifies with no voice state attached, so asking the * peer over the rebuilt data channel is the only thing that can confirm it is still in * our channel. * * `recovery-preserves-media.spec.ts` cannot reach this: killing the server leaves * nobody to broadcast `user_left`. */ test('restores two-way voice after the server declares one client dead', async ({ createClient }) => { test.setTimeout(600_000); const clients = await createVoicePairInNewServer( createClient, `Roster Loss Voice ${Date.now()}`, { namePrefix: 'Roster Loss' } ); const [droppedClient, observer] = clients; for (const client of clients) { await assertTwoWayAudio(client, 'before the outage'); } await test.step('One client loses its signal socket and its peer connections', async () => { await droppedClient.context.setOffline(true); await closeTrackedPeerConnections(droppedClient.page); await observer.page.waitForTimeout(DEAD_SOCKET_HOLD_MS); }); await test.step('Both clients are two-way again once the socket returns', async () => { await droppedClient.context.setOffline(false); for (const client of clients) { await waitForConnectedPeerCount(client.page, 1, 180_000); await waitForOpenDataChannelCount(client.page, 1, 180_000); } for (const client of clients) { await assertTwoWayAudio(client, 'after the socket returned', 90_000); } }); }); }); /** Fail unless the client both sends and receives voice packets within the timeout. */ async function assertTwoWayAudio( client: VoicePairClient, label: string, timeoutMs = 60_000 ): Promise { const deadline = Date.now() + timeoutMs; let outboundPacketsDelta = 0; let inboundPacketsDelta = 0; while (Date.now() < deadline) { ({ outboundPacketsDelta, inboundPacketsDelta } = await getAudioStatsDelta(client.page, 3_000)); if (outboundPacketsDelta > 0 && inboundPacketsDelta > 0) { return; } } throw new Error( `${client.displayName} is not two-way ${label}: sent ${outboundPacketsDelta}, ` + `received ${inboundPacketsDelta} packets in the last sample.\n` + await dumpRtcDiagnostics(client.page) ); } /** * Fail if the client goes even one second without receiving voice packets. Peers gossip * their voice state every 5s, so a torn-down microphone comes back on its own - only a * continuous sample can tell that the audio never stopped. */ async function assertUninterruptedInboundAudio( client: VoicePairClient, seconds: number ): Promise { for (let sample = 1; sample <= seconds; sample++) { const { inboundPacketsDelta } = await getAudioStatsDelta(client.page, 1_000); if (inboundPacketsDelta === 0) { throw new Error( `${client.displayName} stopped receiving voice ${sample}s after the roster wipe.\n` + await dumpRtcDiagnostics(client.page) ); } } } /** Kill the media plane the way a suspend does, leaving the peer to notice on its own. */ async function closeTrackedPeerConnections(page: Page): Promise { await page.evaluate(() => { const connections = (window as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? []; for (const connection of connections) { connection.close(); } }); } /** * Replay what the signal server does when it declares a socket dead: tell this client the * remote user left the server, with no live transport recorded. Returns the wiped user id. */ async function wipeRemoteVoiceMembersFromRoster(page: Page): Promise { return page.evaluate(() => { interface RosterUser { id?: string; oderId?: string; peerId?: string; voiceState?: { isConnected?: boolean }; } interface StoreLike { dispatch: (action: { type: string } & Record) => void; } interface AngularDebugApi { getComponent: (element: Element) => Record; } const host = document.querySelector('app-rooms-side-panel'); const debugApi = (window as { ng?: AngularDebugApi }).ng; if (!host || !debugApi?.getComponent) { throw new Error('Angular debug API is unavailable, cannot reach the store'); } const component = debugApi.getComponent(host); const store = component['store'] as StoreLike | undefined; const users = (component['onlineUsers'] as (() => RosterUser[]) | undefined)?.() ?? []; const currentUser = (component['currentUser'] as (() => RosterUser | null) | undefined)?.() ?? null; const currentRoom = (component['currentRoom'] as (() => { id?: string } | null) | undefined)?.() ?? null; const remoteVoiceUser = users.find((user) => user.voiceState?.isConnected === true && user.id !== currentUser?.id && user.oderId !== currentUser?.oderId); if (!store || !remoteVoiceUser?.id || !currentRoom?.id) { throw new Error('No remote voice member to wipe from the roster'); } store.dispatch({ type: '[Users] User Left', userId: remoteVoiceUser.id, serverId: currentRoom.id, connectedPeerIds: [] }); return remoteVoiceUser.id; }); } /** Wait until no remote user in the client's roster claims to be in voice. */ async function waitForNoRemoteVoiceMembersInRoster(page: Page, timeout: number): Promise { await page.waitForFunction( () => { interface RosterUser { id?: string; oderId?: string; peerId?: string; voiceState?: { isConnected?: boolean }; } interface AngularDebugApi { getComponent: (element: Element) => Record; } 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 users = (component['onlineUsers'] as (() => RosterUser[]) | undefined)?.() ?? []; const currentUser = (component['currentUser'] as (() => RosterUser | null) | undefined)?.() ?? null; const selfIds = new Set([ currentUser?.id, currentUser?.oderId, currentUser?.peerId ].filter(Boolean)); return users .filter((user) => ![ user.id, user.oderId, user.peerId ].some((id) => !!id && selfIds.has(id))) .every((user) => user.voiceState?.isConnected !== true); }, undefined, { timeout } ); }