import { expect, type Page } from '@playwright/test'; import { test, type Client } from '../../fixtures/multi-client'; import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint'; import { startTestServer } from '../../helpers/test-server'; import { dumpRtcDiagnostics, getConnectedPeerCount, installWebRTCTracking, waitForAllPeerAudioFlow, waitForAudioStatsPresent, waitForConnectedPeerCount, waitForPeerConnected } from '../../helpers/webrtc-helpers'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; const PRIMARY_SIGNAL_ID = 'e2e-test-server-a'; const SECONDARY_SIGNAL_ID = 'e2e-test-server-b'; const PRIMARY_ROOM_NAME = `Dual Signal Voice A ${Date.now()}`; const SECONDARY_ROOM_NAME = `Dual Signal Voice B ${Date.now()}`; const VOICE_CHANNEL = 'General'; const USER_PASSWORD = 'TestPass123!'; const USER_COUNT = 8; const EXPECTED_REMOTE_PEERS = USER_COUNT - 1; const STABILITY_WINDOW_MS = 20_000; type TestUser = { username: string; displayName: string; password: string; }; type TestClient = Client & { user: TestUser; }; test.describe('Dual-signal multi-user voice', () => { test('keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds', async ({ createClient, testServer }) => { test.setTimeout(720_000); const secondaryServer = await startTestServer(); try { const endpoints: SeededEndpointInput[] = [ { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, status: 'online' }, { id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryServer.url, isActive: true, status: 'online' } ]; const users = buildUsers(); const clients = await createTrackedClients(createClient, users, endpoints); await test.step('Register every user with both active endpoints available', async () => { for (const client of clients) { const registerPage = new RegisterPage(client.page); await registerPage.goto(); await registerPage.serverSelect.selectOption(PRIMARY_SIGNAL_ID); await registerPage.register(client.user.username, client.user.displayName, client.user.password); await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 }); } }); await test.step('Create primary and secondary rooms on different signal endpoints', async () => { const searchPage = new ServerSearchPage(clients[0].page); await searchPage.createServer(PRIMARY_ROOM_NAME, { description: 'Primary signal room for 8-user voice mesh', sourceId: PRIMARY_SIGNAL_ID }); await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); await searchPage.createServer(SECONDARY_ROOM_NAME, { description: 'Secondary signal room for dual-socket coverage', sourceId: SECONDARY_SIGNAL_ID }); await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); }); await test.step('Every user joins both rooms to keep 2 signal sockets open', async () => { for (const client of clients.slice(1)) { await joinRoomFromSearch(client.page, PRIMARY_ROOM_NAME); } for (const client of clients.slice(1)) { await openSearchView(client.page); await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME); } for (const client of clients) { await openSavedRoomByName(client.page, PRIMARY_ROOM_NAME); await waitForConnectedSignalManagerCount(client.page, 2); } }); await test.step('Create voice channel and join all 8 users', async () => { const hostRoom = new ChatRoomPage(clients[0].page); await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL); for (const client of clients) { await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL); } for (const client of clients) { await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT); } }); await test.step('All users discover all peers and audio flows pairwise', async () => { // Wait for all clients to have at least one connected peer (fast) await Promise.all(clients.map((client) => waitForPeerConnected(client.page, 45_000) )); // Wait for all clients to have all 7 peers connected await Promise.all(clients.map((client) => waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000) )); // Wait for audio stats to appear on all clients await Promise.all(clients.map((client) => waitForAudioStatsPresent(client.page, 30_000) )); // Allow the mesh to settle — voice routing, allowed-peer-id // propagation and renegotiation all need time after the last // user joins. await clients[0].page.waitForTimeout(5_000); // Check bidirectional audio flow on each client await Promise.all(clients.map((client) => waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000) )); }); await test.step('Voice workspace and side panel show all 8 users on every client', async () => { for (const client of clients) { const room = new ChatRoomPage(client.page); await openVoiceWorkspace(client.page); await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 }); await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT); await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT); await waitForConnectedSignalManagerCount(client.page, 2); } }); await test.step('Voice stays stable for more than 20 seconds across both signals', async () => { const deadline = Date.now() + STABILITY_WINDOW_MS; while (Date.now() < deadline) { for (const client of clients) { await expect.poll(async () => await getConnectedPeerCount(client.page), { timeout: 10_000, intervals: [500, 1_000] }).toBe(EXPECTED_REMOTE_PEERS); await expect.poll(async () => await getConnectedSignalManagerCount(client.page), { timeout: 10_000, intervals: [500, 1_000] }).toBe(2); } if (Date.now() < deadline) { await clients[0].page.waitForTimeout(5_000); } } for (const client of clients) { try { await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000); } catch (error) { console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`); throw error; } } }); await test.step('Mute state propagates for every user across all clients', async () => { for (const client of clients) { const room = new ChatRoomPage(client.page); await room.muteButton.click(); await waitForVoiceStateAcrossPages(clients, client.user.displayName, { isMuted: true, isDeafened: false }); await room.muteButton.click(); await waitForVoiceStateAcrossPages(clients, client.user.displayName, { isMuted: false, isDeafened: false }); } }); await test.step('Audio still flows on all peers after mute cycling', async () => { for (const client of clients) { try { await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000); } catch (error) { console.log(`[${client.user.displayName} post-mute RTC]\n${await dumpRtcDiagnostics(client.page)}`); throw error; } } }); await test.step('Deafen state propagates for every user across all clients', async () => { for (const client of clients) { const room = new ChatRoomPage(client.page); await room.deafenButton.click(); await client.page.waitForTimeout(500); await waitForVoiceStateAcrossPages(clients, client.user.displayName, { isMuted: true, isDeafened: true }); await room.deafenButton.click(); await client.page.waitForTimeout(500); // Un-deafen does NOT restore mute – the user stays muted await waitForVoiceStateAcrossPages(clients, client.user.displayName, { isMuted: true, isDeafened: false }); } }); await test.step('Unmute all users and verify audio flows end-to-end', async () => { // Every user is left muted after deafen cycling — unmute them all for (const client of clients) { const room = new ChatRoomPage(client.page); await room.muteButton.click(); await waitForVoiceStateAcrossPages(clients, client.user.displayName, { isMuted: false, isDeafened: false }); } // Final audio flow check on every peer — confirms the full // send/receive pipeline still works after mute+deafen cycling for (const client of clients) { try { await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000); } catch (error) { console.log(`[${client.user.displayName} final RTC]\n${await dumpRtcDiagnostics(client.page)}`); throw error; } } }); } finally { await secondaryServer.stop(); } }); }); function buildUsers(): TestUser[] { return Array.from({ length: USER_COUNT }, (_value, index) => ({ username: `voice8_user_${Date.now()}_${index + 1}`, displayName: `Voice User ${index + 1}`, password: USER_PASSWORD })); } async function createTrackedClients( createClient: () => Promise, users: TestUser[], endpoints: ReadonlyArray ): Promise { const clients: TestClient[] = []; for (const user of users) { const client = await createClient(); await installTestServerEndpoints(client.context, endpoints); await installDeterministicVoiceSettings(client.page); await installWebRTCTracking(client.page); clients.push({ ...client, user }); } 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 openSearchView(page: Page): Promise { const searchInput = page.getByPlaceholder('Search servers...'); if (await searchInput.isVisible().catch(() => false)) { return; } await page.locator('button[title="Create Server"]').click(); await expect(searchInput).toBeVisible({ timeout: 20_000 }); } async function joinRoomFromSearch(page: Page, roomName: string): Promise { const searchInput = page.getByPlaceholder('Search servers...'); await expect(searchInput).toBeVisible({ timeout: 20_000 }); await searchInput.fill(roomName); const roomCard = page.locator('button', { hasText: roomName }).first(); await expect(roomCard).toBeVisible({ timeout: 20_000 }); await roomCard.click(); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await waitForCurrentRoomName(page, roomName); } async function openSavedRoomByName(page: Page, roomName: string): Promise { const roomButton = page.locator(`button[title="${roomName}"]`); await expect(roomButton).toBeVisible({ timeout: 20_000 }); await roomButton.click(); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await waitForCurrentRoomName(page, roomName); } async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise { await page.waitForFunction( (expectedRoomName) => { interface RoomShape { name?: string; } 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 currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null; return currentRoom?.name === expectedRoomName; }, roomName, { timeout } ); } async function openVoiceWorkspace(page: Page): Promise { const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i }).first(); if (await page.locator('app-voice-workspace').isVisible().catch(() => false)) { return; } await expect(viewButton).toBeVisible({ timeout: 10_000 }); await viewButton.click(); } async function joinVoiceChannelUntilConnected(page: Page, channelName: string, attempts = 3): Promise { const room = new ChatRoomPage(page); let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { await room.joinVoiceChannel(channelName); try { await waitForLocalVoiceChannelConnection(page, channelName, 20_000); await expect(room.muteButton).toBeVisible({ timeout: 10_000 }); return; } catch (error) { lastError = error; await page.waitForTimeout(1_000); } } const diagnostics = await getVoiceJoinDiagnostics(page, channelName); const displayName = diagnostics.currentUser?.displayName ?? 'Unknown user'; throw new Error([ `Failed to connect ${displayName} to voice channel ${channelName}.`, lastError instanceof Error ? `Last error: ${lastError.message}` : 'Last error: unavailable', `Current room: ${diagnostics.currentRoom?.name ?? 'none'} (${diagnostics.currentRoom?.id ?? 'n/a'})`, `Current user id: ${diagnostics.currentUser?.id ?? 'none'} / ${diagnostics.currentUser?.oderId ?? 'none'}`, `Current user voice state: ${JSON.stringify(diagnostics.currentUser?.voiceState ?? null)}`, `Voice channel id: ${diagnostics.voiceChannel?.id ?? 'missing'}`, `Visible voice roster: ${diagnostics.voiceUsers.join(', ') || 'none'}`, `Connected signaling managers: ${diagnostics.connectedSignalCount}`, `Local voice facade state: ${JSON.stringify(diagnostics.localVoiceState)}`, `Voice connection error: ${diagnostics.connectionErrorMessage ?? 'none'}` ].join('\n')); } async function waitForLocalVoiceChannelConnection(page: Page, channelName: string, timeout = 20_000): Promise { await page.waitForFunction( (name) => { interface VoiceStateShape { isConnected?: boolean; roomId?: string; serverId?: string; } interface UserShape { voiceState?: VoiceStateShape; } interface ChannelShape { id: string; name: string; type: 'text' | 'voice'; } interface RoomShape { id: string; channels?: ChannelShape[]; } 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 currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null; const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null; const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name); const voiceState = currentUser?.voiceState; return !!voiceChannel && voiceState?.isConnected === true && voiceState.roomId === voiceChannel.id && voiceState.serverId === currentRoom.id; }, channelName, { timeout } ); } async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise<{ connectedSignalCount: number; connectionErrorMessage: string | null; currentRoom: { id?: string; name?: string } | null; currentUser: { id?: string; oderId?: string; displayName?: string; voiceState?: Record } | null; localVoiceState: { isVoiceConnected: boolean; localStreamTracks: number; rawMicTracks: number; }; voiceChannel: { id?: string; name?: string } | null; voiceUsers: string[]; }> { return await page.evaluate((name) => { interface VoiceStateShape { isConnected?: boolean; isMuted?: boolean; isDeafened?: boolean; roomId?: string; serverId?: string; } interface UserShape { id?: string; oderId?: string; displayName?: string; voiceState?: VoiceStateShape; } interface ChannelShape { id: string; name: string; type: 'text' | 'voice'; } interface RoomShape { id?: string; name?: string; channels?: ChannelShape[]; } 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 { connectedSignalCount: 0, connectionErrorMessage: 'Angular debug API unavailable', currentRoom: null, currentUser: null, localVoiceState: { isVoiceConnected: false, localStreamTracks: 0, rawMicTracks: 0 }, voiceChannel: null, voiceUsers: [] }; } const component = debugApi.getComponent(host); const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null; const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null; const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name) ?? null; const voiceUsers = voiceChannel ? ((component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? []) .map((user) => user.displayName ?? 'Unknown user') : []; const voiceConnection = component['voiceConnection'] as { getLocalStream?: () => MediaStream | null; getRawMicStream?: () => MediaStream | null; isVoiceConnected?: () => boolean; } | undefined; const realtime = component['realtime'] as { connectionErrorMessage?: () => string | null; signalingTransportHandler?: { getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>; }; } | undefined; return { connectedSignalCount: realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0, connectionErrorMessage: realtime?.connectionErrorMessage?.() ?? null, currentRoom, currentUser, localVoiceState: { isVoiceConnected: voiceConnection?.isVoiceConnected?.() ?? false, localStreamTracks: voiceConnection?.getLocalStream?.()?.getTracks().length ?? 0, rawMicTracks: voiceConnection?.getRawMicStream?.()?.getTracks().length ?? 0 }, voiceChannel, voiceUsers }; }, channelName); } async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise { await page.waitForFunction( (count) => { 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 realtime = component['realtime'] as { signalingTransportHandler?: { getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>; }; } | undefined; const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0; return countValue === count; }, expectedCount, { timeout: 30_000 } ); } async function getConnectedSignalManagerCount(page: Page): Promise { return await page.evaluate(() => { 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 0; } const component = debugApi.getComponent(host); const realtime = component['realtime'] as { signalingTransportHandler?: { getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>; }; } | undefined; return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0; }); } async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise { await page.waitForFunction( (count) => { interface AngularDebugApi { getComponent: (element: Element) => Record; } const host = document.querySelector('app-voice-workspace'); const debugApi = (window as { ng?: AngularDebugApi }).ng; if (!host || !debugApi?.getComponent) { return false; } const component = debugApi.getComponent(host); const connectedUsers = (component['connectedVoiceUsers'] as (() => Array) | undefined)?.() ?? []; return connectedUsers.length === count; }, expectedCount, { timeout: 45_000 } ); } async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise { await page.waitForFunction( ({ expected, name }) => { interface ChannelShape { id: string; name: string; type: 'text' | 'voice'; } interface RoomShape { channels?: ChannelShape[]; } 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 currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null; const channelId = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name)?.id; if (!channelId) { return false; } const roster = (component['voiceUsersInRoom'] as ((roomId: string) => Array) | undefined)?.(channelId) ?? []; return roster.length === expected; }, { expected: expectedCount, name: channelName }, { timeout: 30_000 } ); } async function waitForVoiceStateAcrossPages( clients: ReadonlyArray, displayName: string, expectedState: { isMuted: boolean; isDeafened: boolean } ): Promise { for (const client of clients) { await client.page.waitForFunction( ({ expectedDisplayName, expectedMuted, expectedDeafened }) => { interface VoiceStateShape { isMuted?: boolean; isDeafened?: boolean; } interface ChannelShape { id: string; name: string; type: 'text' | 'voice'; } interface UserShape { displayName: string; voiceState?: VoiceStateShape; } interface RoomShape { channels?: ChannelShape[]; } 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 currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null; const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice'); if (!voiceChannel) { return false; } const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? []; const entry = roster.find((user) => user.displayName === expectedDisplayName); return entry?.voiceState?.isMuted === expectedMuted && entry?.voiceState?.isDeafened === expectedDeafened; }, { expectedDisplayName: displayName, expectedMuted: expectedState.isMuted, expectedDeafened: expectedState.isDeafened }, { timeout: 30_000 } ); } }