import { expect, type Page } from '@playwright/test'; import { test, type Client } from '../../fixtures/multi-client'; import { closeOpenDataChannels, dispatchDataChannelErrors, dumpRtcDiagnostics, getOpenDataChannelCount, installAutoResumeAudioContext, installWebRTCTracking, waitForAllPeerAudioFlow, waitForAudioStatsPresent, waitForConnectedPeerCount, waitForOpenDataChannelCount } from '../../helpers/webrtc-helpers'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; interface VoiceClient extends Client { displayName: string; username: string; } const USER_PASSWORD = 'TestPass123!'; const VOICE_CHANNEL = 'General'; test.describe('Voice data-channel recovery', () => { test('keeps two users hearing each other after a data-channel error and close', async ({ createClient }) => { test.setTimeout(240_000); const clients = await createVoiceScenario(createClient, 2, `DC Recovery Duo ${Date.now()}`); const [alice, bob] = clients; await assertMeshAudio(clients, 1, 'initial two-user voice'); await test.step('A non-fatal data-channel error does not interrupt audio', async () => { const dispatched = await dispatchDataChannelErrors(alice.page); expect(dispatched).toBeGreaterThan(0); await waitForOpenDataChannelCount(alice.page, 1, 15_000); await waitForOpenDataChannelCount(bob.page, 1, 15_000); await assertMeshAudio(clients, 1, 'after synthetic data-channel error'); }); await test.step('A closed data channel is rebuilt and audio resumes both ways', async () => { const closed = await closeOpenDataChannels(alice.page); expect(closed).toBeGreaterThan(0); await waitForConnectedPeerCount(alice.page, 1, 60_000); await waitForConnectedPeerCount(bob.page, 1, 60_000); await waitForOpenDataChannelCount(alice.page, 1, 60_000); await waitForOpenDataChannelCount(bob.page, 1, 60_000); await assertMeshAudio(clients, 1, 'after data-channel close recovery'); }); }); test('heals a three-user voice mesh when one client loses every data channel', async ({ createClient }) => { test.setTimeout(300_000); const clients = await createVoiceScenario(createClient, 3, `DC Recovery Trio ${Date.now()}`); const bob = clients[1]; await assertMeshAudio(clients, 2, 'initial three-user mesh'); await test.step('Bob loses all control channels and the full mesh recovers', async () => { const closed = await closeOpenDataChannels(bob.page); expect(closed).toBe(2); for (const client of clients) { await waitForConnectedPeerCount(client.page, 2, 90_000); await waitForOpenDataChannelCount(client.page, 2, 90_000); } await assertMeshAudio(clients, 2, 'after full control-channel recovery'); }); }); }); async function createVoiceScenario( createClient: () => Promise, userCount: number, serverName: string ): Promise { const clients: VoiceClient[] = []; for (let index = 0; index < userCount; index++) { const client = await createClient(); const displayName = `DC Voice ${index + 1}`; await installDeterministicVoiceSettings(client.page); await installWebRTCTracking(client.page); await installAutoResumeAudioContext(client.page); clients.push({ ...client, displayName, username: `dc_voice_${Date.now()}_${index + 1}` }); } await test.step('Register clients', async () => { for (const client of clients) { const registerPage = new RegisterPage(client.page); await registerPage.goto(); await registerPage.register(client.username, client.displayName, USER_PASSWORD); await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 }); } }); await test.step('Create and join server', async () => { const hostSearch = new ServerSearchPage(clients[0].page); await hostSearch.createServer(serverName, { description: 'Data-channel recovery voice test' }); await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); for (const client of clients.slice(1)) { const searchPage = new ServerSearchPage(client.page); await searchPage.joinServerFromSearch(serverName); await expect(client.page).toHaveURL(/\/room\//, { timeout: 20_000 }); } }); await test.step('Join everyone to voice', async () => { const hostRoom = new ChatRoomPage(clients[0].page); await hostRoom.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 }); } const expectedRemotePeers = clients.length - 1; for (const client of clients) { await waitForConnectedPeerCount(client.page, expectedRemotePeers, 90_000); await waitForOpenDataChannelCount(client.page, expectedRemotePeers, 90_000); await waitForAudioStatsPresent(client.page, 30_000); } }); return clients; } async function installDeterministicVoiceSettings(page: Page): Promise { 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 })); }); } async function assertMeshAudio( clients: readonly VoiceClient[], expectedRemotePeers: number, label: string ): Promise { for (const client of clients) { try { await waitForAllPeerAudioFlow(client.page, expectedRemotePeers, 60_000); } catch (error) { const dataChannelCount = await getOpenDataChannelCount(client.page); console.log(`[${client.displayName} ${label} data channels] ${dataChannelCount}`); console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`); throw error; } } }