diff --git a/e2e/fixtures/multi-client.ts b/e2e/fixtures/multi-client.ts index f093231..1b384b6 100644 --- a/e2e/fixtures/multi-client.ts +++ b/e2e/fixtures/multi-client.ts @@ -5,23 +5,15 @@ import { type BrowserContext, type Browser } from '@playwright/test'; -import { spawn, type ChildProcess } from 'node:child_process'; -import { once } from 'node:events'; -import { createServer } from 'node:net'; import { join } from 'node:path'; import { installTestServerEndpoint } from '../helpers/seed-test-endpoint'; +import { startTestServer, type TestServerHandle } from '../helpers/test-server'; export interface Client { page: Page; context: BrowserContext; } -interface TestServerHandle { - port: number; - url: string; - stop: () => Promise; -} - interface MultiClientFixture { createClient: () => Promise; testServer: TestServerHandle; @@ -31,10 +23,9 @@ const FAKE_AUDIO_FILE = join(__dirname, 'test-tone.wav'); const CHROMIUM_FAKE_MEDIA_ARGS = [ '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', - `--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}` + `--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}`, + '--autoplay-policy=no-user-gesture-required' ]; -const E2E_DIR = join(__dirname, '..'); -const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js'); export const test = base.extend({ testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise) => { @@ -81,122 +72,3 @@ export const test = base.extend({ }); export { expect } from '@playwright/test'; - -async function startTestServer(retries = 3): Promise { - for (let attempt = 1; attempt <= retries; attempt++) { - const port = await allocatePort(); - const child = spawn(process.execPath, [START_SERVER_SCRIPT], { - cwd: E2E_DIR, - env: { - ...process.env, - TEST_SERVER_PORT: String(port) - }, - stdio: 'pipe' - }); - - child.stdout?.on('data', (chunk: Buffer | string) => { - process.stdout.write(chunk.toString()); - }); - - child.stderr?.on('data', (chunk: Buffer | string) => { - process.stderr.write(chunk.toString()); - }); - - try { - await waitForServerReady(port, child); - } catch (error) { - await stopServer(child); - - if (attempt < retries) { - console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`); - continue; - } - - throw error; - } - - return { - port, - url: `http://localhost:${port}`, - stop: async () => { - await stopServer(child); - } - }; - } - - throw new Error('startTestServer: unreachable'); -} - -async function allocatePort(): Promise { - return new Promise((resolve, reject) => { - const probe = createServer(); - - probe.once('error', reject); - probe.listen(0, '127.0.0.1', () => { - const address = probe.address(); - - if (!address || typeof address === 'string') { - probe.close(); - reject(new Error('Failed to resolve an ephemeral test server port')); - return; - } - - const { port } = address; - - probe.close((error) => { - if (error) { - reject(error); - return; - } - - resolve(port); - }); - }); - }); -} - -async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise { - const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`; - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (child.exitCode !== null) { - throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`); - } - - try { - const response = await fetch(readyUrl); - - if (response.ok) { - return; - } - } catch { - // Server still starting. - } - - await wait(250); - } - - throw new Error(`Timed out waiting for test server on port ${port}`); -} - -async function stopServer(child: ChildProcess): Promise { - if (child.exitCode !== null) { - return; - } - - child.kill('SIGTERM'); - - const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]); - - if (!exited && child.exitCode === null) { - child.kill('SIGKILL'); - await once(child, 'exit'); - } -} - -function wait(durationMs: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, durationMs); - }); -} diff --git a/e2e/helpers/seed-test-endpoint.ts b/e2e/helpers/seed-test-endpoint.ts index 7626089..2b1a69a 100644 --- a/e2e/helpers/seed-test-endpoint.ts +++ b/e2e/helpers/seed-test-endpoint.ts @@ -3,6 +3,15 @@ import { type BrowserContext, type Page } from '@playwright/test'; const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys'; +export interface SeededEndpointInput { + id: string; + name: string; + url: string; + isActive?: boolean; + isDefault?: boolean; + status?: string; +} + interface SeededEndpointStorageState { key: string; removedKey: string; @@ -17,21 +26,32 @@ interface SeededEndpointStorageState { } function buildSeededEndpointStorageState( - port: number = Number(process.env.TEST_SERVER_PORT) || 3099 + endpointsOrPort: ReadonlyArray | number = Number(process.env.TEST_SERVER_PORT) || 3099 ): SeededEndpointStorageState { - const endpoint = { - id: 'e2e-test-server', - name: 'E2E Test Server', - url: `http://localhost:${port}`, - isActive: true, - isDefault: false, - status: 'unknown' - }; + const endpoints = Array.isArray(endpointsOrPort) + ? endpointsOrPort.map((endpoint) => ({ + id: endpoint.id, + name: endpoint.name, + url: endpoint.url, + isActive: endpoint.isActive ?? true, + isDefault: endpoint.isDefault ?? false, + status: endpoint.status ?? 'unknown' + })) + : [ + { + id: 'e2e-test-server', + name: 'E2E Test Server', + url: `http://localhost:${endpointsOrPort}`, + isActive: true, + isDefault: false, + status: 'unknown' + } + ]; return { key: SERVER_ENDPOINTS_STORAGE_KEY, removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY, - endpoints: [endpoint] + endpoints }; } @@ -59,6 +79,15 @@ export async function installTestServerEndpoint( await context.addInitScript(applySeededEndpointStorageState, storageState); } +export async function installTestServerEndpoints( + context: BrowserContext, + endpoints: ReadonlyArray +): Promise { + const storageState = buildSeededEndpointStorageState(endpoints); + + await context.addInitScript(applySeededEndpointStorageState, storageState); +} + /** * Seed localStorage with a single signal endpoint pointing at the test server. * Must be called AFTER navigating to the app origin (localStorage is per-origin) @@ -79,3 +108,12 @@ export async function seedTestServerEndpoint( await page.evaluate(applySeededEndpointStorageState, storageState); } + +export async function seedTestServerEndpoints( + page: Page, + endpoints: ReadonlyArray +): Promise { + const storageState = buildSeededEndpointStorageState(endpoints); + + await page.evaluate(applySeededEndpointStorageState, storageState); +} diff --git a/e2e/helpers/test-server.ts b/e2e/helpers/test-server.ts new file mode 100644 index 0000000..bab9005 --- /dev/null +++ b/e2e/helpers/test-server.ts @@ -0,0 +1,132 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:net'; +import { join } from 'node:path'; + +export interface TestServerHandle { + port: number; + url: string; + stop: () => Promise; +} + +const E2E_DIR = join(__dirname, '..'); +const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js'); + +export async function startTestServer(retries = 3): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + const port = await allocatePort(); + const child = spawn(process.execPath, [START_SERVER_SCRIPT], { + cwd: E2E_DIR, + env: { + ...process.env, + TEST_SERVER_PORT: String(port) + }, + stdio: 'pipe' + }); + + child.stdout?.on('data', (chunk: Buffer | string) => { + process.stdout.write(chunk.toString()); + }); + + child.stderr?.on('data', (chunk: Buffer | string) => { + process.stderr.write(chunk.toString()); + }); + + try { + await waitForServerReady(port, child); + } catch (error) { + await stopServer(child); + + if (attempt < retries) { + console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`); + continue; + } + + throw error; + } + + return { + port, + url: `http://localhost:${port}`, + stop: async () => { + await stopServer(child); + } + }; + } + + throw new Error('startTestServer: unreachable'); +} + +async function allocatePort(): Promise { + return await new Promise((resolve, reject) => { + const probe = createServer(); + + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + + if (!address || typeof address === 'string') { + probe.close(); + reject(new Error('Failed to resolve an ephemeral test server port')); + return; + } + + const { port } = address; + + probe.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(port); + }); + }); + }); +} + +async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise { + const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`); + } + + try { + const response = await fetch(readyUrl); + + if (response.ok) { + return; + } + } catch { + // Server still starting. + } + + await wait(250); + } + + throw new Error(`Timed out waiting for test server on port ${port}`); +} + +async function stopServer(child: ChildProcess): Promise { + if (child.exitCode !== null) { + return; + } + + child.kill('SIGTERM'); + + const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]); + + if (!exited && child.exitCode === null) { + child.kill('SIGKILL'); + await once(child, 'exit'); + } +} + +function wait(durationMs: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, durationMs); + }); +} diff --git a/e2e/helpers/webrtc-helpers.ts b/e2e/helpers/webrtc-helpers.ts index 40028c8..1b0fa11 100644 --- a/e2e/helpers/webrtc-helpers.ts +++ b/e2e/helpers/webrtc-helpers.ts @@ -46,75 +46,6 @@ export async function installWebRTCTracking(page: Page): Promise { (window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype; Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection); - // Patch getUserMedia to use an AudioContext oscillator for audio - // instead of the hardware capture device. Chromium's fake audio - // device intermittently fails to produce frames after renegotiation. - const origGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); - - navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => { - const wantsAudio = !!constraints?.audio; - - if (!wantsAudio) { - return origGetUserMedia(constraints); - } - - // Get the original stream (may include video) - const originalStream = await origGetUserMedia(constraints); - const audioCtx = new AudioContext(); - const noiseBuffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 2, audioCtx.sampleRate); - const noiseData = noiseBuffer.getChannelData(0); - - for (let sampleIndex = 0; sampleIndex < noiseData.length; sampleIndex++) { - noiseData[sampleIndex] = (Math.random() * 2 - 1) * 0.18; - } - - const source = audioCtx.createBufferSource(); - const gain = audioCtx.createGain(); - - source.buffer = noiseBuffer; - source.loop = true; - gain.gain.value = 0.12; - - const dest = audioCtx.createMediaStreamDestination(); - - source.connect(gain); - gain.connect(dest); - source.start(); - - if (audioCtx.state === 'suspended') { - try { - await audioCtx.resume(); - } catch {} - } - - const synthAudioTrack = dest.stream.getAudioTracks()[0]; - const resultStream = new MediaStream(); - - syntheticMediaResources.push({ audioCtx, source }); - - resultStream.addTrack(synthAudioTrack); - - // Keep any video tracks from the original stream - for (const videoTrack of originalStream.getVideoTracks()) { - resultStream.addTrack(videoTrack); - } - - // Stop original audio tracks since we're not using them - for (const track of originalStream.getAudioTracks()) { - track.stop(); - } - - synthAudioTrack.addEventListener('ended', () => { - try { - source.stop(); - } catch {} - - void audioCtx.close().catch(() => {}); - }, { once: true }); - - return resultStream; - }; - // Patch getDisplayMedia to return a synthetic screen share stream // (canvas-based video + 880Hz oscillator audio) so the browser // picker dialog is never shown. @@ -218,6 +149,177 @@ export async function isPeerStillConnected(page: Page): Promise { ); } +/** Returns the number of tracked peer connections in `connected` state. */ +export async function getConnectedPeerCount(page: Page): Promise { + return page.evaluate( + () => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( + (pc) => pc.connectionState === 'connected' + ).length ?? 0 + ); +} + +/** Wait until the expected number of peer connections are `connected`. */ +export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise { + await page.waitForFunction( + (count) => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( + (pc) => pc.connectionState === 'connected' + ).length === count, + expectedCount, + { timeout } + ); +} + +/** + * Resume all suspended AudioContext instances created by the synthetic + * media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so + * Chrome treats the call as a user-gesture — this satisfies the autoplay + * policy that otherwise blocks `AudioContext.resume()`. + */ +export async function resumeSyntheticAudioContexts(page: Page): Promise { + const cdpSession = await page.context().newCDPSession(page); + + try { + const result = await cdpSession.send('Runtime.evaluate', { + expression: `(async () => { + const resources = window.__rtcSyntheticMediaResources; + if (!resources) return 0; + let resumed = 0; + for (const r of resources) { + if (r.audioCtx.state === 'suspended') { + await r.audioCtx.resume(); + resumed++; + } + } + return resumed; + })()`, + awaitPromise: true, + userGesture: true + }); + + return result.result.value ?? 0; + } finally { + await cdpSession.detach(); + } +} + +interface PerPeerAudioStat { + connectionState: string; + inboundBytes: number; + inboundPackets: number; + outboundBytes: number; + outboundPackets: number; +} + +/** Get per-peer audio stats for every tracked RTCPeerConnection. */ +export async function getPerPeerAudioStats(page: Page): Promise { + return page.evaluate(async () => { + const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; + + if (!connections?.length) { + return []; + } + + const snapshots: PerPeerAudioStat[] = []; + + for (const pc of connections) { + let inboundBytes = 0; + let inboundPackets = 0; + let outboundBytes = 0; + let outboundPackets = 0; + + try { + const stats = await pc.getStats(); + + stats.forEach((report: any) => { + const kind = report.kind ?? report.mediaType; + + if (report.type === 'outbound-rtp' && kind === 'audio') { + outboundBytes += report.bytesSent ?? 0; + outboundPackets += report.packetsSent ?? 0; + } + + if (report.type === 'inbound-rtp' && kind === 'audio') { + inboundBytes += report.bytesReceived ?? 0; + inboundPackets += report.packetsReceived ?? 0; + } + }); + } catch { + // Closed connection. + } + + snapshots.push({ + connectionState: pc.connectionState, + inboundBytes, + inboundPackets, + outboundBytes, + outboundPackets + }); + } + + return snapshots; + }); +} + +/** Wait until every connected peer connection shows inbound and outbound audio flow. */ +export async function waitForAllPeerAudioFlow( + page: Page, + expectedConnectedPeers: number, + timeoutMs = 45_000, + pollIntervalMs = 1_000 +): Promise { + const deadline = Date.now() + timeoutMs; + // Track which peer indices have been confirmed flowing at least once. + // This prevents a peer from being missed just because it briefly paused + // during one specific poll interval. + const confirmedFlowing = new Set(); + + let previous = await getPerPeerAudioStats(page); + + while (Date.now() < deadline) { + await page.waitForTimeout(pollIntervalMs); + const current = await getPerPeerAudioStats(page); + const connectedPeers = current.filter((stat) => stat.connectionState === 'connected'); + + if (connectedPeers.length >= expectedConnectedPeers) { + for (let index = 0; index < current.length; index++) { + const curr = current[index]; + + if (!curr || curr.connectionState !== 'connected') { + continue; + } + + const prev = previous[index] ?? { + connectionState: 'new', + inboundBytes: 0, + inboundPackets: 0, + outboundBytes: 0, + outboundPackets: 0 + }; + const inboundFlowing = curr.inboundBytes > prev.inboundBytes || curr.inboundPackets > prev.inboundPackets; + const outboundFlowing = curr.outboundBytes > prev.outboundBytes || curr.outboundPackets > prev.outboundPackets; + + if (inboundFlowing && outboundFlowing) { + confirmedFlowing.add(index); + } + } + + // Check if enough peers have been confirmed across all samples + const connectedIndices = current + .map((stat, idx) => stat.connectionState === 'connected' ? idx : -1) + .filter((idx) => idx >= 0); + const confirmedCount = connectedIndices.filter((idx) => confirmedFlowing.has(idx)).length; + + if (confirmedCount >= expectedConnectedPeers) { + return; + } + } + + previous = current; + } + + throw new Error(`Timed out waiting for ${expectedConnectedPeers} peers with bidirectional audio flow`); +} + /** * Get outbound and inbound audio RTP stats aggregated across all peer * connections. Uses a per-connection high water mark stored on `window` so diff --git a/e2e/pages/chat-room.page.ts b/e2e/pages/chat-room.page.ts index 0f50554..88067fe 100644 --- a/e2e/pages/chat-room.page.ts +++ b/e2e/pages/chat-room.page.ts @@ -19,13 +19,65 @@ export class ChatRoomPage { /** Click a voice channel by name in the channels sidebar to join voice. */ async joinVoiceChannel(channelName: string) { - const channelButton = this.page.locator('app-rooms-side-panel') - .getByRole('button', { name: channelName, exact: true }); + const channelButton = this.getVoiceChannelButton(channelName); + + if (await channelButton.count() === 0) { + await this.refreshRoomMetadata(); + } + + if (await channelButton.count() === 0) { + // Second attempt - metadata might still be syncing + await this.page.waitForTimeout(2_000); + await this.refreshRoomMetadata(); + } await expect(channelButton).toBeVisible({ timeout: 15_000 }); await channelButton.click(); } + /** Creates a voice channel if it is not already present in the current room. */ + async ensureVoiceChannelExists(channelName: string) { + const channelButton = this.getVoiceChannelButton(channelName); + + if (await channelButton.count() > 0) { + return; + } + + await this.refreshRoomMetadata(); + + // Wait a bit longer for Angular to render the channel list after refresh + try { + await expect(channelButton).toBeVisible({ timeout: 5_000 }); + return; + } catch { + // Channel genuinely doesn't exist - create it + } + + await this.openCreateVoiceChannelDialog(); + + try { + await this.createChannel(channelName); + } catch { + // If the dialog didn't close (e.g. duplicate name validation), dismiss it + const dialog = this.page.locator('app-confirm-dialog'); + + if (await dialog.isVisible()) { + const cancelButton = dialog.getByRole('button', { name: 'Cancel' }); + const closeButton = dialog.getByRole('button', { name: 'Close dialog' }); + + if (await cancelButton.isVisible()) { + await cancelButton.click(); + } else if (await closeButton.isVisible()) { + await closeButton.click(); + } + + await expect(dialog).not.toBeVisible({ timeout: 5_000 }).catch(() => {}); + } + } + + await expect(channelButton).toBeVisible({ timeout: 15_000 }); + } + /** Click a text channel by name in the channels sidebar to switch chat rooms. */ async joinTextChannel(channelName: string) { const channelButton = this.getTextChannelButton(channelName); @@ -100,6 +152,11 @@ export class ChatRoomPage { return this.voiceControls.locator('button:has(ng-icon[name="lucideMic"]), button:has(ng-icon[name="lucideMicOff"])').first(); } + /** Get the deafen toggle button inside voice controls. */ + get deafenButton() { + return this.voiceControls.locator('button:has(ng-icon[name="lucideHeadphones"])').first(); + } + /** Get the disconnect/hang-up button (destructive styled). */ get disconnectButton() { return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first(); @@ -112,10 +169,9 @@ export class ChatRoomPage { /** Get the count of voice users listed under a voice channel. */ async getVoiceUserCountInChannel(channelName: string): Promise { - const channelSection = this.page.locator('app-rooms-side-panel') - .getByRole('button', { name: channelName }) - .locator('..'); - const userAvatars = channelSection.locator('app-user-avatar'); + // The voice channel button is inside a wrapper div; user avatars are siblings within that wrapper + const channelWrapper = this.getVoiceChannelButton(channelName).locator('xpath=ancestor::div[1]'); + const userAvatars = channelWrapper.locator('app-user-avatar'); return userAvatars.count(); } @@ -154,9 +210,11 @@ export class ChatRoomPage { } private getTextChannelButton(channelName: string): Locator { - const channelPattern = new RegExp(`#\\s*${escapeRegExp(channelName)}$`, 'i'); + return this.channelsSidePanel.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`).first(); + } - return this.channelsSidePanel.getByRole('button', { name: channelPattern }).first(); + private getVoiceChannelButton(channelName: string): Locator { + return this.channelsSidePanel.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`).first(); } private async createTextChannelThroughComponent(channelName: string): Promise { @@ -384,7 +442,3 @@ export class ChatRoomPage { await this.page.waitForTimeout(500); } } - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/e2e/pages/server-search.page.ts b/e2e/pages/server-search.page.ts index 14679f2..7553735 100644 --- a/e2e/pages/server-search.page.ts +++ b/e2e/pages/server-search.page.ts @@ -7,6 +7,8 @@ import { export class ServerSearchPage { readonly searchInput: Locator; readonly createServerButton: Locator; + readonly railCreateServerButton: Locator; + readonly searchCreateServerButton: Locator; readonly settingsButton: Locator; // Create server dialog @@ -21,7 +23,9 @@ export class ServerSearchPage { constructor(private page: Page) { this.searchInput = page.getByPlaceholder('Search servers...'); - this.createServerButton = page.getByRole('button', { name: 'Create New Server' }); + this.railCreateServerButton = page.locator('button[title="Create Server"]'); + this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' }); + this.createServerButton = this.searchCreateServerButton; this.settingsButton = page.locator('button[title="Settings"]'); // Create dialog elements @@ -39,8 +43,20 @@ export class ServerSearchPage { await this.page.goto('/search'); } - async createServer(name: string, options?: { description?: string; topic?: string }) { - await this.createServerButton.click(); + async createServer(name: string, options?: { description?: string; topic?: string; sourceId?: string }) { + if (!await this.serverNameInput.isVisible()) { + if (await this.searchCreateServerButton.isVisible()) { + await this.searchCreateServerButton.click(); + } else { + await this.railCreateServerButton.click(); + + if (!await this.serverNameInput.isVisible()) { + await expect(this.searchCreateServerButton).toBeVisible({ timeout: 10_000 }); + await this.searchCreateServerButton.click(); + } + } + } + await expect(this.serverNameInput).toBeVisible(); await this.serverNameInput.fill(name); @@ -52,6 +68,10 @@ export class ServerSearchPage { await this.serverTopicInput.fill(options.topic); } + if (options?.sourceId) { + await this.signalEndpointSelect.selectOption(options.sourceId); + } + await this.dialogCreateButton.click(); } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 3748b1d..c1ac927 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ ...devices['Desktop Chrome'], permissions: ['microphone', 'camera'], launchOptions: { - args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] + args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] } } } diff --git a/e2e/tests/voice/mixed-signal-config-voice.spec.ts b/e2e/tests/voice/mixed-signal-config-voice.spec.ts new file mode 100644 index 0000000..36f3942 --- /dev/null +++ b/e2e/tests/voice/mixed-signal-config-voice.spec.ts @@ -0,0 +1,781 @@ +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 allEndpoints: 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: 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((c) => c.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((c) => !chatters.includes(c)); + + // 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); + } + } + + throw new Error([ + `Failed to connect ${page.url()} to voice channel ${channelName}.`, + lastError instanceof Error ? `Last error: ${lastError.message}` : 'Last error: unavailable' + ].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((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 (() => 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((ch) => ch.type === 'voice' && ch.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((ch) => ch.type === 'voice'); + + if (!voiceChannel) { + return false; + } + + const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? []; + const entry = roster.find((u) => u.displayName === expectedDisplayName); + + return entry?.voiceState?.isMuted === expectedMuted + && entry?.voiceState?.isDeafened === expectedDeafened; + }, + { + expectedDisplayName: displayName, + expectedMuted: expectedState.isMuted, + expectedDeafened: expectedState.isDeafened + }, + { timeout: 30_000 } + ); + } +} diff --git a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts new file mode 100644 index 0000000..ce5f0d4 --- /dev/null +++ b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts @@ -0,0 +1,761 @@ +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 } + ); + } +} diff --git a/electron/migrations/1000000000007-AddUserProfileMetadata.ts b/electron/migrations/1000000000007-AddUserProfileMetadata.ts index 45a8b3a..0f4d7ba 100644 --- a/electron/migrations/1000000000007-AddUserProfileMetadata.ts +++ b/electron/migrations/1000000000007-AddUserProfileMetadata.ts @@ -13,4 +13,4 @@ export class AddUserProfileMetadata1000000000007 implements MigrationInterface { public async down(): Promise { // SQLite column removal requires table rebuilds. Keep rollback no-op. } -} \ No newline at end of file +} diff --git a/toju-app/src/app/domains/voice-connection/application/facades/voice-connection.facade.ts b/toju-app/src/app/domains/voice-connection/application/facades/voice-connection.facade.ts index e1e9703..3815c29 100644 --- a/toju-app/src/app/domains/voice-connection/application/facades/voice-connection.facade.ts +++ b/toju-app/src/app/domains/voice-connection/application/facades/voice-connection.facade.ts @@ -53,6 +53,14 @@ export class VoiceConnectionFacade { return this.realtime.getRawMicStream(); } + reportConnectionError(message: string): void { + this.realtime.reportConnectionError(message); + } + + clearConnectionError(): void { + this.realtime.clearConnectionError(); + } + async enableVoice(): Promise { return await this.realtime.enableVoice(); } diff --git a/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts b/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts index d1d8cc1..c1ac380 100644 --- a/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts +++ b/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts @@ -235,8 +235,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { this.voicePlayback.playPendingStreams(this.playbackOptions()); // Persist settings after successful connection + this.webrtcService.clearConnectionError(); this.saveSettings(); - } catch (_error) {} + } catch (error) { + const message = error instanceof Error + ? error.message + : 'Failed to connect voice session.'; + + this.webrtcService.reportConnectionError(message); + } } // Retry connection when there's a connection error diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index d985ecd..f0e28a7 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -61,6 +61,8 @@ [class.hover:text-foreground/80]="activeChannelId() !== ch.id" (click)="selectTextChannel(ch.id)" (contextmenu)="openChannelContextMenu($event, ch)" + data-channel-type="text" + [attr.data-channel-name]="ch.name" > # @if (renamingChannelId() === ch.id) { @@ -129,6 +131,8 @@ [class.bg-secondary]="isCurrentRoom(ch.id)" [disabled]="!voiceEnabled()" [title]="isCurrentRoom(ch.id) ? 'Open stream workspace' : 'Join voice channel'" + data-channel-type="voice" + [attr.data-channel-name]="ch.name" > this.onVoiceJoinSucceeded(roomId, room, current ?? null)) - .catch(() => undefined); + .catch((error) => this.handleVoiceJoinFailure(error)); } private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void { + this.voiceConnection.clearConnectionError(); this.updateVoiceStateStore(roomId, room, current); this.trackCurrentUserMic(); this.startVoiceHeartbeat(roomId, room); @@ -583,6 +592,14 @@ export class RoomsSidePanelComponent { this.startVoiceSession(roomId, room); } + private handleVoiceJoinFailure(error: unknown): void { + const message = error instanceof Error + ? error.message + : 'Failed to join voice channel.'; + + this.voiceConnection.reportConnectionError(message); + } + private trackCurrentUserMic(): void { const userId = this.currentUser()?.oderId || this.currentUser()?.id; const micStream = this.voiceConnection.getRawMicStream(); diff --git a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts index b6931cd..ed6bbcf 100644 --- a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts +++ b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts @@ -492,6 +492,14 @@ export class WebRTCService implements OnDestroy { return this.peerMediaFacade.getRawMicStream(); } + reportConnectionError(message: string): void { + this.state.setConnectionError(message); + } + + clearConnectionError(): void { + this.state.clearConnectionError(); + } + /** * Request microphone access and start sending audio to all peers. * diff --git a/toju-app/src/app/infrastructure/realtime/state/webrtc-state-controller.ts b/toju-app/src/app/infrastructure/realtime/state/webrtc-state-controller.ts index 0a17aa9..9c722cb 100644 --- a/toju-app/src/app/infrastructure/realtime/state/webrtc-state-controller.ts +++ b/toju-app/src/app/infrastructure/realtime/state/webrtc-state-controller.ts @@ -120,6 +120,16 @@ export class WebRtcStateController { this._isNoiseReductionEnabled.set(enabled); } + setConnectionError(message: string | null): void { + this._hasConnectionError.set(!!message); + this._connectionErrorMessage.set(message); + } + + clearConnectionError(): void { + this._hasConnectionError.set(false); + this._connectionErrorMessage.set(null); + } + setConnectedPeers(peers: string[]): void { this._connectedPeers.set(peers); } diff --git a/toju-app/src/app/shared/components/profile-card/profile-card.component.html b/toju-app/src/app/shared/components/profile-card/profile-card.component.html index 69ab2fe..2027e4b 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card.component.html +++ b/toju-app/src/app/shared/components/profile-card/profile-card.component.html @@ -44,7 +44,6 @@