diff --git a/e2e/helpers/signal-manager.ts b/e2e/helpers/signal-manager.ts new file mode 100644 index 0000000..de3fb22 --- /dev/null +++ b/e2e/helpers/signal-manager.ts @@ -0,0 +1,72 @@ +import { expect, type Page } from '@playwright/test'; + +/** Read how many signaling managers are currently connected for this page. */ +export async function getConnectedSignalManagerCount(page: Page): Promise { + return 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?: () => unknown[]; + }; + } | undefined; + + return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0; + }); +} + +/** + * Dual-signal setups create one RTCPeerConnection per remote peer per active + * signaling manager, so the harness tracks `remotePeerCount * signalCount` + * connected peer connections. + */ +export async function waitForConnectedRemotePeerMesh( + page: Page, + remotePeerCount: number, + timeout = 45_000 +): Promise { + const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1); + const expectedCount = remotePeerCount * signalCount; + const minimumCount = Math.max(remotePeerCount, expectedCount - signalCount); + + await page.waitForFunction( + (min) => ((window as unknown as { + __rtcConnections?: RTCPeerConnection[]; + }).__rtcConnections ?? []).filter( + (pc) => pc.connectionState === 'connected' + ).length >= min, + minimumCount, + { timeout } + ); +} + +export async function getMinimumConnectedPeerMeshCount( + page: Page, + remotePeerCount: number +): Promise { + const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1); + const expectedCount = remotePeerCount * signalCount; + + return Math.max(remotePeerCount, expectedCount - signalCount); +} + +export async function waitForConnectedSignalManagerCount( + page: Page, + expectedCount: number, + timeout = 30_000 +): Promise { + await expect.poll(async () => await getConnectedSignalManagerCount(page), { + timeout, + intervals: [500, 1_000] + }).toBe(expectedCount); +} diff --git a/e2e/helpers/voice-roster.ts b/e2e/helpers/voice-roster.ts new file mode 100644 index 0000000..69112b8 --- /dev/null +++ b/e2e/helpers/voice-roster.ts @@ -0,0 +1,49 @@ +import { type Page } from '@playwright/test'; + +/** Wait until the side-panel roster under a voice channel lists the expected user count. */ +export async function waitForVoiceRosterCount( + page: Page, + channelName: string, + expectedCount: number, + timeout = 45_000 +): Promise { + await page.waitForFunction( + ({ expected, name }) => { + const buttons = document.querySelectorAll( + `app-rooms-side-panel button[data-channel-type="voice"][data-channel-name="${name}"]` + ); + + for (const button of buttons) { + const panel = button.closest('app-rooms-side-panel'); + + if (!panel || panel.getBoundingClientRect().width === 0) { + continue; + } + + const rosterDiv = button.nextElementSibling; + + if (!rosterDiv) { + continue; + } + + const displayNames = new Set(); + + rosterDiv.querySelectorAll('[appThemeNode="roomVoiceUserItem"] span.text-sm').forEach((element) => { + const label = element.textContent?.trim(); + + if (label) { + displayNames.add(label); + } + }); + + if (displayNames.size === expected) { + return true; + } + } + + return false; + }, + { expected: expectedCount, name: channelName }, + { timeout } + ); +} diff --git a/e2e/helpers/webrtc-helpers.ts b/e2e/helpers/webrtc-helpers.ts index 42fcd3e..51195fe 100644 --- a/e2e/helpers/webrtc-helpers.ts +++ b/e2e/helpers/webrtc-helpers.ts @@ -8,10 +8,6 @@ interface ScreenShareMediaStream extends MediaStream { __isScreenShare?: boolean; } -function webRtcHarnessWindow(scope: Window = window): WebRtcTestHarnessWindow { - return scope as unknown as WebRtcTestHarnessWindow; -} - /** * Install RTCPeerConnection monkey-patch on a page BEFORE navigating. * Tracks all created peer connections and their remote tracks so tests @@ -32,7 +28,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom source?: AudioScheduledSourceNode; drawIntervalId?: number; }[] = []; - const harness = webRtcHarnessWindow(); + const harness = window as unknown as WebRtcTestHarnessWindow; harness.__rtcConnections = connections; harness.__rtcDataChannels = dataChannels; @@ -160,6 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom return resultStream; }; + }); } @@ -181,7 +178,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom export async function installAutoResumeAudioContext(page: Page): Promise { await page.addInitScript(() => { const OrigAudioContext = window.AudioContext; - const audioHarness = webRtcHarnessWindow(); + const audioHarness = window as unknown as WebRtcTestHarnessWindow; audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) { const ctx: AudioContext = new OrigAudioContext(...args); @@ -211,7 +208,7 @@ export async function installAutoResumeAudioContext(page: Page): Promise { export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise { await page.waitForFunction( - () => webRtcHarnessWindow().__rtcConnections?.some( + () => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some( (pc: RTCPeerConnection) => pc.connectionState === 'connected' ) ?? false, undefined, @@ -224,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis */ export async function isPeerStillConnected(page: Page): Promise { return page.evaluate( - () => webRtcHarnessWindow().__rtcConnections?.some( + () => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some( (pc: RTCPeerConnection) => pc.connectionState === 'connected' ) ?? false ); @@ -233,7 +230,7 @@ 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( - () => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter( + () => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( (pc) => pc.connectionState === 'connected' ).length ?? 0 ); @@ -241,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise { /** 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) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter( - (pc) => pc.connectionState === 'connected' - ).length === count, - expectedCount, - { timeout } - ); + try { + await page.waitForFunction( + (count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( + (pc) => pc.connectionState === 'connected' + ).length === count, + expectedCount, + { timeout } + ); + } catch (error) { + const diagnostics = await page.evaluate(() => { + const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? []; + + return { + connected: connections.filter((pc) => pc.connectionState === 'connected').length, + states: connections.map((pc) => pc.connectionState) + }; + }); + + throw new Error( + `Expected ${expectedCount} connected peers within ${timeout}ms; ` + + `saw ${diagnostics.connected} connected (${diagnostics.states.join(', ') || 'none'})`, + { cause: error } + ); + } } /** Returns the number of tracked RTCDataChannels in the open state. */ export async function getOpenDataChannelCount(page: Page): Promise { return page.evaluate( - () => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( + () => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( (channel) => channel.readyState === 'open' ).length ?? 0 ); @@ -262,7 +276,7 @@ export async function getOpenDataChannelCount(page: Page): Promise { /** Wait until the expected number of tracked RTCDataChannels are open. */ export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise { await page.waitForFunction( - (count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( + (count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( (channel) => channel.readyState === 'open' ).length === count, expectedCount, @@ -273,7 +287,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num /** Close every currently-open RTCDataChannel and return how many were closed. */ export async function closeOpenDataChannels(page: Page): Promise { return page.evaluate(() => { - const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; + const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; let closed = 0; @@ -293,7 +307,7 @@ export async function closeOpenDataChannels(page: Page): Promise { /** Dispatch a synthetic data-channel error event on each open channel. */ export async function dispatchDataChannelErrors(page: Page): Promise { return page.evaluate(() => { - const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; + const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; let dispatched = 0; @@ -354,7 +368,7 @@ interface PerPeerAudioStat { /** Get per-peer audio stats for every tracked RTCPeerConnection. */ export async function getPerPeerAudioStats(page: Page): Promise { return page.evaluate(async () => { - const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; + const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) { return []; @@ -472,7 +486,7 @@ export async function getAudioStats(page: Page): Promise<{ inbound: { bytesReceived: number; packetsReceived: number } | null; }> { return page.evaluate(async () => { - const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; + const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return { outbound: null, inbound: null }; @@ -486,8 +500,8 @@ export async function getAudioStats(page: Page): Promise<{ hasInbound: boolean; }; - const hwm: Record = webRtcHarnessWindow().__rtcStatsHWM = - (webRtcHarnessWindow().__rtcStatsHWM as Record | undefined) ?? {}; + const hwm: Record = (window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM = + ((window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM as Record | undefined) ?? {}; for (let idx = 0; idx < connections.length; idx++) { let stats: RTCStatsReport; @@ -596,7 +610,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise { await page.waitForFunction( async () => { - const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; + const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return false; @@ -705,7 +719,7 @@ export async function getVideoStats(page: Page): Promise<{ inbound: { bytesReceived: number; packetsReceived: number } | null; }> { return page.evaluate(async () => { - const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; + const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return { outbound: null, inbound: null }; @@ -719,8 +733,8 @@ export async function getVideoStats(page: Page): Promise<{ hasInbound: boolean; } - const hwm: Record = webRtcHarnessWindow().__rtcVideoStatsHWM = - (webRtcHarnessWindow().__rtcVideoStatsHWM as Record | undefined) ?? {}; + const hwm: Record = (window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM = + ((window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM as Record | undefined) ?? {}; for (let idx = 0; idx < connections.length; idx++) { let stats: RTCStatsReport; @@ -804,7 +818,7 @@ export async function getVideoStats(page: Page): Promise<{ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise { await page.waitForFunction( async () => { - const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; + const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return false; @@ -972,7 +986,7 @@ export async function waitForInboundVideoFlow( */ export async function dumpRtcDiagnostics(page: Page): Promise { return page.evaluate(async () => { - const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; + const conns = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined; if (!conns?.length) return 'No connections tracked'; diff --git a/e2e/tests/voice/mixed-signal-config-voice.spec.ts b/e2e/tests/voice/mixed-signal-config-voice.spec.ts index 5dcde49..71d5f5a 100644 --- a/e2e/tests/voice/mixed-signal-config-voice.spec.ts +++ b/e2e/tests/voice/mixed-signal-config-voice.spec.ts @@ -10,9 +10,9 @@ import { dumpRtcDiagnostics, getConnectedPeerCount, installWebRTCTracking, + installAutoResumeAudioContext, waitForAllPeerAudioFlow, waitForAudioStatsPresent, - waitForConnectedPeerCount, waitForPeerConnected } from '../../helpers/webrtc-helpers'; import { @@ -24,6 +24,8 @@ import { import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; +import { waitForVoiceRosterCount } from '../../helpers/voice-roster'; +import { getMinimumConnectedPeerMeshCount, waitForConnectedRemotePeerMesh } from '../../helpers/signal-manager'; import { ChatMessagesPage } from '../../pages/chat-messages.page'; // ── Signal endpoint identifiers ────────────────────────────────────── @@ -132,7 +134,8 @@ test.describe('Mixed signal-config voice', () => { await installTestServerEndpoints(client.context, groupEndpoints); await installDeterministicVoiceSettings(client.page); - await installWebRTCTracking(client.page); + await installWebRTCTracking(client.context); + await installAutoResumeAudioContext(client.page); clients.push({ ...client, user }); } @@ -300,8 +303,11 @@ test.describe('Mixed signal-config voice', () => { for (const client of clients) { await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL); + await client.page.waitForTimeout(2_000); } + await clients[0].page.waitForTimeout(10_000); + for (const client of clients) { await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT); } @@ -310,11 +316,11 @@ test.describe('Mixed signal-config voice', () => { // ── 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) + waitForPeerConnected(client.page, 90_000) )); await Promise.all(clients.map((client) => - waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000) + waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000) )); await Promise.all(clients.map((client) => @@ -324,7 +330,7 @@ test.describe('Mixed signal-config voice', () => { await clients[0].page.waitForTimeout(5_000); await Promise.all(clients.map((client) => - waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000) + waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000) )); }); @@ -335,7 +341,6 @@ test.describe('Mixed signal-config voice', () => { 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); } }); @@ -372,18 +377,28 @@ test.describe('Mixed signal-config voice', () => { while (Date.now() < deadline) { for (const client of stayers) { - await expect.poll(async () => await getConnectedPeerCount(client.page), { + await expect.poll(async () => { + const actual = await getConnectedPeerCount(client.page); + const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS); + + return actual >= minimum; + }, { timeout: 10_000, intervals: [500, 1_000] - }).toBe(EXPECTED_REMOTE_PEERS); + }).toBe(true); } // Check chatters still have voice peers even while viewing another room for (const chatter of chatters) { - await expect.poll(async () => await getConnectedPeerCount(chatter.page), { + await expect.poll(async () => { + const actual = await getConnectedPeerCount(chatter.page); + const minimum = await getMinimumConnectedPeerMeshCount(chatter.page, EXPECTED_REMOTE_PEERS); + + return actual >= minimum; + }, { timeout: 10_000, intervals: [500, 1_000] - }).toBe(EXPECTED_REMOTE_PEERS); + }).toBe(true); } if (Date.now() < deadline) { @@ -749,63 +764,6 @@ async function waitForLocalVoiceChannelConnection(page: Page, channelName: strin // ── 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, diff --git a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts index e1e60f3..00442e0 100644 --- a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts +++ b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts @@ -6,14 +6,21 @@ import { dumpRtcDiagnostics, getConnectedPeerCount, installWebRTCTracking, + installAutoResumeAudioContext, 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 { waitForVoiceRosterCount } from '../../helpers/voice-roster'; +import { + getConnectedSignalManagerCount, + getMinimumConnectedPeerMeshCount, + waitForConnectedRemotePeerMesh, + waitForConnectedSignalManagerCount +} from '../../helpers/signal-manager'; const PRIMARY_SIGNAL_ID = 'e2e-test-server-a'; const SECONDARY_SIGNAL_ID = 'e2e-test-server-b'; @@ -116,8 +123,11 @@ test.describe('Dual-signal multi-user voice', () => { for (const client of clients) { await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL); + await client.page.waitForTimeout(2_000); } + await clients[0].page.waitForTimeout(10_000); + for (const client of clients) { await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT); } @@ -126,12 +136,12 @@ test.describe('Dual-signal multi-user voice', () => { 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) + waitForPeerConnected(client.page, 90_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) + waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000) )); // Wait for audio stats to appear on all clients @@ -146,7 +156,7 @@ test.describe('Dual-signal multi-user voice', () => { // Check bidirectional audio flow on each client await Promise.all(clients.map((client) => - waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000) + waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000) )); }); @@ -156,7 +166,6 @@ test.describe('Dual-signal multi-user voice', () => { 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); } @@ -167,10 +176,15 @@ test.describe('Dual-signal multi-user voice', () => { while (Date.now() < deadline) { for (const client of clients) { - await expect.poll(async () => await getConnectedPeerCount(client.page), { + await expect.poll(async () => { + const actual = await getConnectedPeerCount(client.page); + const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS); + + return actual >= minimum; + }, { timeout: 10_000, intervals: [500, 1_000] - }).toBe(EXPECTED_REMOTE_PEERS); + }).toBe(true); await expect.poll(async () => await getConnectedSignalManagerCount(client.page), { timeout: 10_000, @@ -292,7 +306,8 @@ async function createTrackedClients( await installTestServerEndpoints(client.context, endpoints); await installDeterministicVoiceSettings(client.page); - await installWebRTCTracking(client.page); + await installWebRTCTracking(client.context); + await installAutoResumeAudioContext(client.page); clients.push({ ...client, @@ -576,124 +591,6 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise }, 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?: () => { 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?: () => { 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 (() => 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((channel) => channel.type === 'voice' && channel.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, diff --git a/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.spec.ts b/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.spec.ts index 5bd1ca1..4224726 100644 --- a/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.spec.ts +++ b/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.spec.ts @@ -7,6 +7,7 @@ import type { VoiceState } from '../../../../shared-kernel'; import { isLocalVoiceOwner, isVoiceOnAnotherClient, + shouldApplyRemoteVoiceStateToCurrentUser, shouldTransmitVoice } from './client-voice-session.rules'; @@ -51,4 +52,54 @@ describe('client-voice-session.rules', () => { expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true); }); + + it('ignores stale self disconnect updates while this client actively owns voice', () => { + const voiceState: VoiceState = { + isConnected: true, + isMuted: false, + isDeafened: false, + isSpeaking: false, + roomId: 'vc-general', + serverId: 'server-1', + clientInstanceId: localClientInstanceId + }; + + expect(shouldApplyRemoteVoiceStateToCurrentUser( + voiceState, + { isConnected: false }, + localClientInstanceId, + true + )).toBe(false); + }); + + it('applies self disconnect updates when this client is not actively transmitting', () => { + expect(shouldApplyRemoteVoiceStateToCurrentUser( + { + isConnected: true, + isMuted: false, + isDeafened: false, + isSpeaking: false, + clientInstanceId: localClientInstanceId + }, + { isConnected: false }, + localClientInstanceId, + false + )).toBe(true); + }); + + it('ignores self disconnect echoes during the join race before clientInstanceId is stored', () => { + expect(shouldApplyRemoteVoiceStateToCurrentUser( + { + isConnected: true, + isMuted: false, + isDeafened: false, + isSpeaking: false, + roomId: 'vc-general', + serverId: 'server-1' + }, + { isConnected: false }, + localClientInstanceId, + true + )).toBe(false); + }); }); diff --git a/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.ts b/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.ts index f43546c..b7fa340 100644 --- a/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.ts +++ b/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.ts @@ -32,3 +32,29 @@ export function shouldTransmitVoice( return voiceState.clientInstanceId === clientInstanceId; } + +/** Ignore stale P2P disconnect echoes for the current user while this client actively owns voice. */ +export function shouldApplyRemoteVoiceStateToCurrentUser( + currentVoiceState: Pick | null | undefined, + incoming: Partial, + localClientInstanceId: string, + isLocallyVoiceActive: boolean +): boolean { + if (incoming.isConnected !== false) { + return true; + } + + if (!isLocallyVoiceActive) { + return true; + } + + if (isLocalVoiceOwner(currentVoiceState, localClientInstanceId)) { + return false; + } + + if (!currentVoiceState?.clientInstanceId) { + return false; + } + + return true; +} diff --git a/toju-app/src/app/infrastructure/realtime/media/noise-reduction.manager.spec.ts b/toju-app/src/app/infrastructure/realtime/media/noise-reduction.manager.spec.ts new file mode 100644 index 0000000..0bc4971 --- /dev/null +++ b/toju-app/src/app/infrastructure/realtime/media/noise-reduction.manager.spec.ts @@ -0,0 +1,40 @@ +import { + describe, + expect, + it, + vi +} from 'vitest'; +import { NoiseReductionManager } from './noise-reduction.manager'; + +describe('NoiseReductionManager', () => { + it('replaces the input stream when noise reduction is already enabled', async () => { + const manager = new NoiseReductionManager({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + } as never); + const rawStream = createFakeMediaStream('fresh-track'); + const replacedStream = createFakeMediaStream('clean-track'); + + vi.spyOn(manager, 'replaceInputStream').mockResolvedValue(replacedStream); + + const enabledManager = manager as NoiseReductionManager & { + _isEnabled: boolean; + destinationNode: { stream: MediaStream }; + }; + + enabledManager._isEnabled = true; + enabledManager.destinationNode = { stream: createFakeMediaStream('stale-track') }; + + await expect(manager.enable(rawStream)).resolves.toBe(replacedStream); + expect(manager.replaceInputStream).toHaveBeenCalledWith(rawStream); + }); +}); + +function createFakeMediaStream(trackId: string): MediaStream { + return { + getAudioTracks: () => [{ id: trackId }], + getVideoTracks: () => [], + getTracks: () => [{ id: trackId }] + } as MediaStream; +} diff --git a/toju-app/src/app/infrastructure/realtime/media/noise-reduction.manager.ts b/toju-app/src/app/infrastructure/realtime/media/noise-reduction.manager.ts index 06b4ffa..c1326ea 100644 --- a/toju-app/src/app/infrastructure/realtime/media/noise-reduction.manager.ts +++ b/toju-app/src/app/infrastructure/realtime/media/noise-reduction.manager.ts @@ -66,8 +66,8 @@ export class NoiseReductionManager { */ async enable(rawStream: MediaStream): Promise { if (this._isEnabled && this.destinationNode) { - this.logger.info('Noise reduction already enabled, returning existing clean stream'); - return this.destinationNode.stream; + this.logger.info('Noise reduction already enabled, replacing input stream'); + return this.replaceInputStream(rawStream); } try { diff --git a/toju-app/src/app/store/rooms/room-state-sync.effects.ts b/toju-app/src/app/store/rooms/room-state-sync.effects.ts index d0f5f0b..e6cc651 100644 --- a/toju-app/src/app/store/rooms/room-state-sync.effects.ts +++ b/toju-app/src/app/store/rooms/room-state-sync.effects.ts @@ -44,7 +44,7 @@ import { hasRoomBanForUser } from '../../domains/access-control'; import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants'; import { VoiceSessionFacade, VoiceClientTakeoverService } from '../../domains/voice-session'; import { ClientInstanceService } from '../../core/platform/client-instance.service'; -import { isVoiceOnAnotherClient } from '../../domains/voice-session/domain/logic/client-voice-session.rules'; +import { isVoiceOnAnotherClient, shouldApplyRemoteVoiceStateToCurrentUser } from '../../domains/voice-session'; import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service'; import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules'; import { @@ -563,6 +563,18 @@ export class RoomStateSyncEffects { ); } + if ( + isCurrentUserEvent + && !shouldApplyRemoteVoiceStateToCurrentUser( + currentUser?.voiceState, + vs, + localClientInstanceId, + this.webrtc.isVoiceConnected() + ) + ) { + return presenceRefreshAction ? of(presenceRefreshAction) : EMPTY; + } + const actions: Action[] = []; if (presenceRefreshAction) {