import { expect, type Page } from '@playwright/test'; import { test, type Client } from '../../fixtures/multi-client'; import { countCreatedPeerConnections } from '../../helpers/peer-role'; import { openSettingsDetailPage } from '../../helpers/settings-modal'; import { installDeterministicVoiceSettings } from '../../helpers/voice-session'; import { dumpRtcDiagnostics, 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'; interface VoiceClient extends Client { displayName: string; username: string; } const USER_PASSWORD = 'TestPass123!'; const VOICE_CHANNEL = 'General'; test.describe('Live audio device change', () => { test('switching the microphone mid-call keeps both directions of audio alive', async ({ createClient }) => { test.setTimeout(240_000); const clients = await createVoicePair(createClient, `Mic Swap ${Date.now()}`); const [alice, bob] = clients; await assertMeshAudio(clients, 'initial two-user voice'); const connectionsBefore = { alice: await countCreatedPeerConnections(alice.page), bob: await countCreatedPeerConnections(bob.page) }; const sentTracksBefore = await readOutboundAudioTrackIds(alice.page); expect(sentTracksBefore, 'Alice should be sending audio before the switch').toHaveLength(1); await test.step('Alice picks a different microphone from voice settings', async () => { await openVoiceSettings(alice.page); const alternateDeviceId = await readAlternateInputDeviceId(alice.page); await startVoiceStateWatch(alice.page); await alice.page.getByTestId('voice-settings-input-device').selectOption(alternateDeviceId); // The swap re-captures the microphone; give it a moment before reading senders. await expect .poll(async () => (await readOutboundAudioTrackIds(alice.page))[0], { timeout: 20_000 }) .not.toBe(sentTracksBefore[0]); }); await test.step('The session was never interrupted', async () => { const drops = await stopVoiceStateWatch(alice.page); expect(drops, 'Alice left and rejoined voice instead of swapping the track').toBe(0); expect( await countCreatedPeerConnections(alice.page), 'Alice rebuilt her peer connection to change microphone' ).toBe(connectionsBefore.alice); expect( await countCreatedPeerConnections(bob.page), 'Bob rebuilt his peer connection because Alice changed microphone' ).toBe(connectionsBefore.bob); }); await test.step('Audio still flows both ways on the new microphone', async () => { await waitForConnectedPeerCount(alice.page, 1, 30_000); await waitForConnectedPeerCount(bob.page, 1, 30_000); await assertMeshAudio(clients, 'after microphone switch'); }); }); test('switching the speaker mid-call keeps remote audio playing', async ({ createClient }) => { test.setTimeout(240_000); const clients = await createVoicePair(createClient, `Speaker Swap ${Date.now()}`); const [alice] = clients; await assertMeshAudio(clients, 'initial two-user voice'); await openVoiceSettings(alice.page); const alternateDeviceId = await readAlternateOutputDeviceId(alice.page); test.skip(alternateDeviceId === null, 'This browser exposes no audio output devices'); await startVoiceStateWatch(alice.page); await alice.page.getByTestId('voice-settings-output-device').selectOption(alternateDeviceId as string); await expect .poll(async () => readPreferredOutputDeviceId(alice.page), { timeout: 20_000 }) .toBe(alternateDeviceId === '' ? 'default' : alternateDeviceId); expect(await stopVoiceStateWatch(alice.page), 'Changing the speaker dropped Alice out of voice').toBe(0); await assertMeshAudio(clients, 'after speaker switch'); }); }); async function openVoiceSettings(page: Page): Promise { await openSettingsDetailPage(page, 'voice'); await expect(page.getByTestId('voice-settings-input-device')).toBeVisible({ timeout: 10_000 }); } /** The picker value to switch to: any real device, else the system-default entry. */ async function readAlternateInputDeviceId(page: Page): Promise { const select = page.getByTestId('voice-settings-input-device'); const currentValue = await select.inputValue(); const values = await select.locator('option').evaluateAll( (options) => options.map((option) => (option as HTMLOptionElement).value) ); const alternate = values.find((value) => value !== currentValue); if (alternate === undefined) { throw new Error(`The microphone picker only offers "${currentValue}", so no switch can be made`); } return alternate; } async function readAlternateOutputDeviceId(page: Page): Promise { const select = page.getByTestId('voice-settings-output-device'); const currentValue = await select.inputValue(); const values = await select.locator('option').evaluateAll( (options) => options.map((option) => (option as HTMLOptionElement).value) ); return values.find((value) => value !== currentValue) ?? null; } /** The audio track ids this page is currently sending, one per peer connection. */ async function readOutboundAudioTrackIds(page: Page): Promise<(string | null)[]> { return await page.evaluate(() => { const connections = (window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? []; return connections .filter((connection) => connection.connectionState === 'connected') .map((connection) => { const audioSender = connection .getSenders() .find((sender) => sender.track?.kind === 'audio'); return audioSender?.track?.id ?? null; }); }); } async function readPreferredOutputDeviceId(page: Page): Promise { return await page.evaluate(() => { interface AngularDebugApi { getComponent: (element: Element) => Record; } interface PlaybackShape { preferredOutputDeviceId?: string } const host = document.querySelector('app-voice-settings'); const debugApi = (window as { ng?: AngularDebugApi }).ng; if (!host || !debugApi?.getComponent) { return null; } const playback = debugApi.getComponent(host)['voicePlayback'] as PlaybackShape | undefined; return playback?.preferredOutputDeviceId ?? null; }); } /** * Start counting moments where this client considered itself out of voice. * A device change that tears the session down and rebuilds it registers here, * even when the end state looks healthy again. */ async function startVoiceStateWatch(page: Page): Promise { await page.evaluate(() => { interface AngularDebugApi { getComponent: (element: Element) => Record; } interface VoiceStateShape { isConnected?: boolean } interface UserShape { voiceState?: VoiceStateShape } const watchWindow = window as unknown as { __voiceDrops?: number; __voiceWatch?: number }; watchWindow.__voiceDrops = 0; watchWindow.__voiceWatch = window.setInterval(() => { const host = document.querySelector('app-rooms-side-panel'); const debugApi = (window as { ng?: AngularDebugApi }).ng; if (!host || !debugApi?.getComponent) { return; } const component = debugApi.getComponent(host); const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null; if (currentUser?.voiceState?.isConnected === false) { watchWindow.__voiceDrops = (watchWindow.__voiceDrops ?? 0) + 1; } }, 100); }); } async function stopVoiceStateWatch(page: Page): Promise { return await page.evaluate(() => { const watchWindow = window as unknown as { __voiceDrops?: number; __voiceWatch?: number }; if (watchWindow.__voiceWatch !== undefined) { window.clearInterval(watchWindow.__voiceWatch); watchWindow.__voiceWatch = undefined; } return watchWindow.__voiceDrops ?? 0; }); } async function createVoicePair( createClient: () => Promise, serverName: string ): Promise { const clients: VoiceClient[] = []; for (let index = 0; index < 2; index++) { const client = await createClient(); await installDeterministicVoiceSettings(client.page); await installWebRTCTracking(client.page); await installAutoResumeAudioContext(client.page); clients.push({ ...client, displayName: `Device Voice ${index + 1}`, username: `device_voice_${Date.now()}_${index + 1}` }); } await test.step('Register both 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(/\/dashboard/, { timeout: 20_000 }); } }); await test.step('Create and join the server', async () => { await new ServerSearchPage(clients[0].page).createServer(serverName, { description: 'Live audio device change 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 client of clients) { await waitForConnectedPeerCount(client.page, 1, 90_000); await waitForOpenDataChannelCount(client.page, 1, 90_000); await waitForAudioStatsPresent(client.page, 30_000); } }); return clients; } async function assertMeshAudio(clients: readonly VoiceClient[], label: string): Promise { for (const client of clients) { try { await waitForAllPeerAudioFlow(client.page, 1, 60_000); } catch (error) { console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`); throw error; } } }