import { expect, type Page } from '@playwright/test'; import type { Client } from '../fixtures/multi-client'; import { ChatRoomPage } from '../pages/chat-room.page'; import { RegisterPage } from '../pages/register.page'; import { ServerSearchPage } from '../pages/server-search.page'; import { installAutoResumeAudioContext, installWebRTCTracking, waitForAudioStatsPresent, waitForConnectedPeerCount, waitForOpenDataChannelCount } from './webrtc-helpers'; const PAIR_PASSWORD = 'TestPass123!'; export interface VoicePairClient extends Client { displayName: string; username: string; } /** * Register two fresh users, put them in a new server, and connect both to one voice * channel with the WebRTC tracking harness installed. Returns once both sides report a * connected peer, an open data channel, and live audio stats. */ export async function createVoicePairInNewServer( createClient: () => Promise, serverName: string, options: { channelName?: string; namePrefix?: string } = {} ): Promise { const channelName = options.channelName ?? 'General'; const namePrefix = options.namePrefix ?? 'Voice Pair'; const uniqueSuffix = Date.now(); const clients: VoicePairClient[] = []; 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: `${namePrefix} ${index + 1}`, username: `voice_pair_${uniqueSuffix}_${index + 1}` }); } for (const client of clients) { const registerPage = new RegisterPage(client.page); await registerPage.goto(); await registerPage.register(client.username, client.displayName, PAIR_PASSWORD); await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 }); } await new ServerSearchPage(clients[0].page).createServer(serverName, { description: `${namePrefix} voice session` }); 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 new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(channelName); for (const client of clients) { const room = new ChatRoomPage(client.page); await room.joinVoiceChannel(channelName); 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; } /** Pin voice settings so audio levels and codecs do not vary between runs. */ export 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 })); }); } export async function joinRoomFromSearch(page: Page, roomName: string): Promise { await page.goto('/servers', { waitUntil: 'domcontentloaded' }); const searchInput = page.getByPlaceholder('Search servers...'); await expect(searchInput).toBeVisible({ timeout: 20_000 }); await searchInput.fill(roomName); const roomCard = page.locator('div[title]', { hasText: roomName }).first(); await expect(roomCard).toBeVisible({ timeout: 20_000 }); await roomCard.dblclick(); 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); } export 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); } export 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 } ); } export 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}`); } export 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 } ); }