import { test, expect } from '../../fixtures/multi-client'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; import { dumpRtcDiagnostics, installAutoResumeAudioContext, installWebRTCTracking, waitForAllPeerAudioFlow, waitForPeerConnected, waitForConnectedPeerCount, waitForAudioStatsPresent } from '../../helpers/webrtc-helpers'; const ICE_STORAGE_KEY = 'metoyou_ice_servers'; interface StoredIceServerEntry { type?: string; urls?: string; } /** * Tests that user-configured ICE servers are persisted and used by peer connections. * * On localhost TURN relay is never needed (direct always succeeds), so this test: * 1. Seeds Bob's browser with an additional TURN entry via localStorage. * 2. Has both users join voice with differing ICE configs. * 3. Verifies both can connect and Bob's TURN entry is still in storage. */ test.describe('STUN/TURN fallback behaviour', () => { test.describe.configure({ timeout: 180_000 }); test('users with different ICE configs can voice chat together', async ({ createClient }) => { const suffix = `turnfb_${Date.now()}`; const serverName = `Fallback ${suffix}`; const alice = await createClient(); const bob = await createClient(); // Install WebRTC tracking before any navigation so we can inspect // peer connections and audio stats. await installWebRTCTracking(alice.page); await installWebRTCTracking(bob.page); // Ensure AudioContexts auto-resume so the input-gain pipeline // (source -> gain -> destination) never stalls in "suspended" state. await installAutoResumeAudioContext(alice.page); await installAutoResumeAudioContext(bob.page); // Set deterministic voice settings so noise reduction and input gating // don't swallow the fake audio tone. const voiceSettings = JSON.stringify({ inputVolume: 100, outputVolume: 100, audioBitrate: 96, latencyProfile: 'balanced', includeSystemAudio: false, noiseReduction: false, screenShareQuality: 'balanced', askScreenShareQuality: false }); await alice.page.addInitScript((settings: string) => { localStorage.setItem('metoyou_voice_settings', settings); }, voiceSettings); await bob.page.addInitScript((settings: string) => { localStorage.setItem('metoyou_voice_settings', settings); }, voiceSettings); // Seed Bob with an extra TURN entry before the app reads localStorage. await bob.context.addInitScript((key: string) => { try { const existing = JSON.parse(localStorage.getItem(key) || '[]'); existing.push({ id: 'e2e-turn', type: 'turn', urls: 'turn:localhost:3478', username: 'e2euser', credential: 'e2epass' }); localStorage.setItem(key, JSON.stringify(existing)); } catch { /* noop */ } }, ICE_STORAGE_KEY); await test.step('Register Alice', async () => { const register = new RegisterPage(alice.page); await register.goto(); await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); }); await test.step('Register Bob', async () => { const register = new RegisterPage(bob.page); await register.goto(); await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); }); await test.step('Alice creates a server', async () => { const search = new ServerSearchPage(alice.page); await search.createServer(serverName); }); await test.step('Bob joins Alice server', async () => { const search = new ServerSearchPage(bob.page); await search.searchInput.fill(serverName); const serverCard = bob.page.locator('button', { hasText: serverName }).first(); await expect(serverCard).toBeVisible({ timeout: 15_000 }); await serverCard.click(); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); }); const aliceRoom = new ChatRoomPage(alice.page); const bobRoom = new ChatRoomPage(bob.page); await test.step('Both join voice', async () => { await aliceRoom.joinVoiceChannel('General'); await bobRoom.joinVoiceChannel('General'); }); await test.step('Both users see each other in voice', async () => { await expect( aliceRoom.channelsSidePanel.getByText('Bob') ).toBeVisible({ timeout: 20_000 }); await expect( bobRoom.channelsSidePanel.getByText('Alice') ).toBeVisible({ timeout: 20_000 }); }); await test.step('Peer connections establish and audio flows bidirectionally', async () => { await waitForPeerConnected(alice.page, 30_000); await waitForPeerConnected(bob.page, 30_000); await waitForConnectedPeerCount(alice.page, 1, 30_000); await waitForConnectedPeerCount(bob.page, 1, 30_000); // Wait for audio RTP stats to appear (tracks negotiated) await waitForAudioStatsPresent(alice.page, 30_000); await waitForAudioStatsPresent(bob.page, 30_000); // Allow mesh to settle - voice routing and renegotiation can // cause a second offer/answer cycle after the initial connection. await alice.page.waitForTimeout(5_000); // Chromium's --use-fake-device-for-media-stream can produce a // silent capture track on the very first getUserMedia call. If // bidirectional audio does not flow within a short window, leave // and rejoin voice to re-acquire the mic (the second getUserMedia // on a warm device always works). let audioFlowing = false; try { await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 15_000), waitForAllPeerAudioFlow(bob.page, 1, 15_000)]); audioFlowing = true; } catch { // Silent sender detected - rejoin voice to work around Chromium bug } if (!audioFlowing) { // Leave voice await aliceRoom.disconnectButton.click(); await bobRoom.disconnectButton.click(); await alice.page.waitForTimeout(2_000); // Rejoin await aliceRoom.joinVoiceChannel('General'); await bobRoom.joinVoiceChannel('General'); await expect( aliceRoom.channelsSidePanel.getByText('Bob') ).toBeVisible({ timeout: 20_000 }); await expect( bobRoom.channelsSidePanel.getByText('Alice') ).toBeVisible({ timeout: 20_000 }); await waitForPeerConnected(alice.page, 30_000); await waitForPeerConnected(bob.page, 30_000); await waitForConnectedPeerCount(alice.page, 1, 30_000); await waitForConnectedPeerCount(bob.page, 1, 30_000); await waitForAudioStatsPresent(alice.page, 30_000); await waitForAudioStatsPresent(bob.page, 30_000); await alice.page.waitForTimeout(3_000); } // Final assertion - must succeed after the (optional) rejoin. try { await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 60_000), waitForAllPeerAudioFlow(bob.page, 1, 60_000)]); } catch (error) { console.log('[Alice RTC Diagnostics]\n' + await dumpRtcDiagnostics(alice.page)); console.log('[Bob RTC Diagnostics]\n' + await dumpRtcDiagnostics(bob.page)); throw error; } }); await test.step('Bob still has TURN entry in localStorage', async () => { const stored: StoredIceServerEntry[] = await bob.page.evaluate( (key) => JSON.parse(localStorage.getItem(key) || '[]') as StoredIceServerEntry[], ICE_STORAGE_KEY ); const hasTurn = stored.some( (entry) => entry.type === 'turn' && entry.urls === 'turn:localhost:3478' ); expect(hasTurn).toBe(true); }); }); });