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'; import { ChatMessagesPage } from '../../pages/chat-messages.page'; // ── Signal endpoint identifiers ────────────────────────────────────── const PRIMARY_SIGNAL_ID = 'e2e-mixed-signal-a'; const SECONDARY_SIGNAL_ID = 'e2e-mixed-signal-b'; // ── Room / channel names ───────────────────────────────────────────── const VOICE_ROOM_NAME = `Mixed Signal Voice ${Date.now()}`; const SECONDARY_ROOM_NAME = `Mixed Signal Chat ${Date.now()}`; const VOICE_CHANNEL = 'General'; // ── User constants ─────────────────────────────────────────────────── const USER_PASSWORD = 'TestPass123!'; const USER_COUNT = 8; const EXPECTED_REMOTE_PEERS = USER_COUNT - 1; const STABILITY_WINDOW_MS = 20_000; // ── User signal configuration groups ───────────────────────────────── // // Group A (users 0-1): Both signal servers in network config (normal) // Group B (users 2-3): Only primary signal - secondary NOT in config. // They join the secondary room via invite link, // which auto-adds the endpoint. // Group C (users 4-5): Both signals initially, but secondary is removed // after registration. They still see the room from // search because the primary signal can discover it // via findServerAcrossActiveEndpoints fallback. // Group D (users 6-7): Only secondary signal in config. They join the // primary room via invite link. type SignalGroup = 'both' | 'primary-only' | 'both-then-remove-secondary' | 'secondary-only'; interface TestUser { username: string; displayName: string; password: string; group: SignalGroup; } type TestClient = Client & { user: TestUser }; function endpointsForGroup( group: SignalGroup, primaryUrl: string, secondaryUrl: string ): SeededEndpointInput[] { switch (group) { case 'both': return [ { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }, { id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' } ]; case 'primary-only': return [{ id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }]; case 'both-then-remove-secondary': // Seed both initially; test will remove secondary after registration. return [ { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }, { id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' } ]; case 'secondary-only': return [{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' }]; } } test.describe('Mixed signal-config voice', () => { test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({ createClient, testServer }) => { test.setTimeout(720_000); const secondaryServer = await startTestServer(); try { const users = buildUsers(); const clients: TestClient[] = []; // ── Create clients with per-group endpoint configs ─────────── for (const user of users) { const client = await createClient(); const groupEndpoints = endpointsForGroup(user.group, testServer.url, secondaryServer.url); await installTestServerEndpoints(client.context, groupEndpoints); await installDeterministicVoiceSettings(client.page); await installWebRTCTracking(client.page); clients.push({ ...client, user }); } // ── Register ───────────────────────────────────────────────── await test.step('Register each user on their configured signal endpoint', async () => { for (const client of clients) { const registerPage = new RegisterPage(client.page); const registrationEndpointId = client.user.group === 'secondary-only' ? SECONDARY_SIGNAL_ID : PRIMARY_SIGNAL_ID; await registerPage.goto(); await registerPage.serverSelect.selectOption(registrationEndpointId); await registerPage.register(client.user.username, client.user.displayName, client.user.password); await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 }); } }); // ── Create rooms ──────────────────────────────────────────── await test.step('Create voice room on primary and chat room on secondary', async () => { // Use a "both" user (client 0) to create both rooms const searchPage = new ServerSearchPage(clients[0].page); await searchPage.createServer(VOICE_ROOM_NAME, { description: 'Voice room on primary signal', sourceId: PRIMARY_SIGNAL_ID }); await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); await searchPage.createServer(SECONDARY_ROOM_NAME, { description: 'Chat room on secondary signal', sourceId: SECONDARY_SIGNAL_ID }); await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); }); // ── Create invite links ───────────────────────────────────── // // Group B (primary-only) needs invite to secondary room. // Group D (secondary-only) needs invite to primary room. let primaryRoomInviteUrl: string; let secondaryRoomInviteUrl: string; await test.step('Create invite links for cross-signal rooms', async () => { // Navigate to voice room to get its ID await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME); const primaryRoomId = await getCurrentRoomId(clients[0].page); const userId = await getCurrentUserId(clients[0].page); // Navigate to secondary room to get its ID await openSavedRoomByName(clients[0].page, SECONDARY_ROOM_NAME); const secondaryRoomId = await getCurrentRoomId(clients[0].page); // Create invite for primary room (voice) via API const primaryInvite = await createInviteViaApi( testServer.url, primaryRoomId, userId, clients[0].user.displayName ); primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`; // Create invite for secondary room (chat) via API const secondaryInvite = await createInviteViaApi( secondaryServer.url, secondaryRoomId, userId, clients[0].user.displayName ); secondaryRoomInviteUrl = `/invite/${secondaryInvite.id}?server=${encodeURIComponent(secondaryServer.url)}`; }); // ── Remove secondary endpoint for group C ─────────────────── await test.step('Remove secondary signal from group C users', async () => { for (const client of clients.filter((clientItem) => clientItem.user.group === 'both-then-remove-secondary')) { await client.page.evaluate((primaryEndpoint) => { localStorage.setItem('metoyou_server_endpoints', JSON.stringify([primaryEndpoint])); }, { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, isDefault: false, status: 'online' }); } }); // ── Join rooms ────────────────────────────────────────────── await test.step('All users join the voice room (some via search, some via invite)', async () => { for (const client of clients.slice(1)) { if (client.user.group === 'secondary-only') { // Group D: no primary signal -> join voice room via invite await client.page.goto(primaryRoomInviteUrl); await waitForInviteJoin(client.page); } else { // Groups A, B, C: have primary signal -> join via search await joinRoomFromSearch(client.page, VOICE_ROOM_NAME); } } // Navigate client 0 back to voice room await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME); }); await test.step('All users also join the secondary chat room', async () => { for (const client of clients.slice(1)) { if (client.user.group === 'primary-only') { // Group B: no secondary signal -> join chat room via invite await client.page.goto(secondaryRoomInviteUrl); await waitForInviteJoin(client.page); } else if (client.user.group === 'secondary-only') { // Group D: has secondary -> join via search await openSearchView(client.page); await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME); } else { // Groups A, C: can search await openSearchView(client.page); await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME); } } // Ensure everyone navigates back to voice room for (const client of clients) { await openSavedRoomByName(client.page, VOICE_ROOM_NAME); } }); // ── Voice channel ─────────────────────────────────────────── 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); } }); // ── Audio mesh ────────────────────────────────────────────── await test.step('All users discover peers and audio flows pairwise', async () => { await Promise.all(clients.map((client) => waitForPeerConnected(client.page, 45_000) )); await Promise.all(clients.map((client) => waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000) )); await Promise.all(clients.map((client) => waitForAudioStatsPresent(client.page, 30_000) )); await clients[0].page.waitForTimeout(5_000); await Promise.all(clients.map((client) => waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000) )); }); // ── Voice workspace roster ────────────────────────────────── await test.step('Voice workspace shows 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); } }); // ── Stability + concurrent chat ───────────────────────────── await test.step('Voice stays stable 20s while some users navigate and chat on other servers', async () => { // Pick 2 users from different groups to navigate away and chat const chatters = [clients[2], clients[6]]; // group C + group D const stayers = clients.filter((clientItem) => !chatters.includes(clientItem)); // Chatters navigate to secondary room and send messages for (const chatter of chatters) { await openSavedRoomByName(chatter.page, SECONDARY_ROOM_NAME); await expect(chatter.page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 10_000 }); } const chatPage0 = new ChatMessagesPage(chatters[0].page); const chatPage1 = new ChatMessagesPage(chatters[1].page); await chatPage0.sendMessage(`Hello from ${chatters[0].user.displayName} while in voice!`); await chatPage1.sendMessage(`Reply from ${chatters[1].user.displayName} also in voice!`); // Verify messages arrive await expect( chatPage0.getMessageItemByText(`Reply from ${chatters[1].user.displayName}`) ).toBeVisible({ timeout: 15_000 }); await expect( chatPage1.getMessageItemByText(`Hello from ${chatters[0].user.displayName}`) ).toBeVisible({ timeout: 15_000 }); // Meanwhile stability loop on all clients (including chatters - voice still active) const deadline = Date.now() + STABILITY_WINDOW_MS; while (Date.now() < deadline) { for (const client of stayers) { await expect.poll(async () => await getConnectedPeerCount(client.page), { timeout: 10_000, intervals: [500, 1_000] }).toBe(EXPECTED_REMOTE_PEERS); } // Check chatters still have voice peers even while viewing another room for (const chatter of chatters) { await expect.poll(async () => await getConnectedPeerCount(chatter.page), { timeout: 10_000, intervals: [500, 1_000] }).toBe(EXPECTED_REMOTE_PEERS); } if (Date.now() < deadline) { await clients[0].page.waitForTimeout(5_000); } } // Navigate chatters back to voice room for (const chatter of chatters) { await openSavedRoomByName(chatter.page, VOICE_ROOM_NAME); } // Verify audio still flowing after stability window 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; } } }); // ── Mute ──────────────────────────────────────────────────── 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; } } }); // ── Deafen ────────────────────────────────────────────────── 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 - 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 () => { 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 }); } 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(); } }); }); // ── User builders ──────────────────────────────────────────────────── function buildUsers(): TestUser[] { const groups: SignalGroup[] = [ 'both', 'both', // 0-1 'primary-only', 'primary-only', // 2-3 'both-then-remove-secondary', 'both-then-remove-secondary', // 4-5 'secondary-only', 'secondary-only' // 6-7 ]; return groups.map((group, index) => ({ username: `mixed_sig_${Date.now()}_${index + 1}`, displayName: `Mixed User ${index + 1}`, password: USER_PASSWORD, group })); } // ── API helpers ────────────────────────────────────────────────────── async function createInviteViaApi( serverBaseUrl: string, roomId: string, userId: string, displayName: string ): Promise<{ id: string }> { const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requesterUserId: userId, requesterDisplayName: displayName }) }); if (!response.ok) { throw new Error(`Failed to create invite: ${response.status} ${await response.text()}`); } return await response.json() as { id: string }; } async function getCurrentRoomId(page: Page): Promise { return await page.evaluate(() => { interface RoomShape { id: 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) { throw new Error('Angular debug API unavailable'); } const component = debugApi.getComponent(host); const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.(); if (!currentRoom?.id) { throw new Error('No current room'); } return currentRoom.id; }); } async function getCurrentUserId(page: Page): Promise { return await page.evaluate(() => { interface AngularDebugApi { getComponent: (element: Element) => Record; } interface UserShape { id: string; } 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 unavailable'); } const component = debugApi.getComponent(host); const user = (component['currentUser'] as (() => UserShape | null) | undefined)?.(); if (!user?.id) { throw new Error('Current user not found'); } return user.id; }); } // ── Navigation helpers ─────────────────────────────────────────────── 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 waitForInviteJoin(page: Page): Promise { // Invite page loads -> auto-joins -> redirects to room await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); } 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 { if (await page.locator('app-voice-workspace').isVisible() .catch(() => false)) { return; } const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i }) .first(); await expect(viewButton).toBeVisible({ timeout: 10_000 }); await viewButton.click(); } // ── Voice helpers ──────────────────────────────────────────────────── 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 lastErrorMessage = lastError instanceof Error ? `Last error: ${lastError.message}` : 'Last error: unavailable'; throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`); } 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((ch) => ch.type === 'voice' && ch.name === name); const voiceState = currentUser?.voiceState; return !!voiceChannel && voiceState?.isConnected === true && voiceState.roomId === voiceChannel.id && voiceState.serverId === currentRoom.id; }, channelName, { timeout } ); } // ── Roster / state helpers ─────────────────────────────────────────── 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 (() => unknown[]) | 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((ch) => ch.type === 'voice' && ch.name === name)?.id; if (!channelId) { return false; } const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? []; return roster.length === expected; }, { expected: expectedCount, name: channelName }, { timeout: 30_000 } ); } async function waitForVoiceStateAcrossPages( clients: readonly TestClient[], 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((ch) => ch.type === 'voice'); if (!voiceChannel) { return false; } const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? []; const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName); return entry?.voiceState?.isMuted === expectedMuted && entry?.voiceState?.isDeafened === expectedDeafened; }, { expectedDisplayName: displayName, expectedMuted: expectedState.isMuted, expectedDeafened: expectedState.isDeafened }, { timeout: 30_000 } ); } }