fix: Bug - User automatically leaves voice after short period of time
All checks were successful
Queue Release Build / prepare (push) Successful in 22s
Deploy Web Apps / deploy (push) Successful in 7m32s
Queue Release Build / build-windows (push) Successful in 27m41s
Queue Release Build / build-linux (push) Successful in 44m56s
Queue Release Build / build-android (push) Successful in 18m52s
Queue Release Build / finalize (push) Successful in 21s

Ignore stale P2P self-disconnect voice-state echoes while this client actively owns voice, refresh noise-reduction input on re-join, and repair dual-signal E2E harness expectations.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 04:04:31 +02:00
parent dac5cb42a5
commit c3c2f01cc6
10 changed files with 345 additions and 226 deletions

View File

@@ -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<number> {
return page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
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<void> {
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<number> {
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<void> {
await expect.poll(async () => await getConnectedSignalManagerCount(page), {
timeout,
intervals: [500, 1_000]
}).toBe(expectedCount);
}

View File

@@ -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<void> {
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<string>();
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 }
);
}

View File

@@ -8,10 +8,6 @@ interface ScreenShareMediaStream extends MediaStream {
__isScreenShare?: boolean; __isScreenShare?: boolean;
} }
function webRtcHarnessWindow(scope: Window = window): WebRtcTestHarnessWindow {
return scope as unknown as WebRtcTestHarnessWindow;
}
/** /**
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating. * Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
* Tracks all created peer connections and their remote tracks so tests * 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; source?: AudioScheduledSourceNode;
drawIntervalId?: number; drawIntervalId?: number;
}[] = []; }[] = [];
const harness = webRtcHarnessWindow(); const harness = window as unknown as WebRtcTestHarnessWindow;
harness.__rtcConnections = connections; harness.__rtcConnections = connections;
harness.__rtcDataChannels = dataChannels; harness.__rtcDataChannels = dataChannels;
@@ -160,6 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
return resultStream; return resultStream;
}; };
}); });
} }
@@ -181,7 +178,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
export async function installAutoResumeAudioContext(page: Page): Promise<void> { export async function installAutoResumeAudioContext(page: Page): Promise<void> {
await page.addInitScript(() => { await page.addInitScript(() => {
const OrigAudioContext = window.AudioContext; const OrigAudioContext = window.AudioContext;
const audioHarness = webRtcHarnessWindow(); const audioHarness = window as unknown as WebRtcTestHarnessWindow;
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) { audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
const ctx: AudioContext = new OrigAudioContext(...args); const ctx: AudioContext = new OrigAudioContext(...args);
@@ -211,7 +208,7 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> { export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction( await page.waitForFunction(
() => webRtcHarnessWindow().__rtcConnections?.some( () => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected' (pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false, ) ?? false,
undefined, undefined,
@@ -224,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
*/ */
export async function isPeerStillConnected(page: Page): Promise<boolean> { export async function isPeerStillConnected(page: Page): Promise<boolean> {
return page.evaluate( return page.evaluate(
() => webRtcHarnessWindow().__rtcConnections?.some( () => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected' (pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false ) ?? false
); );
@@ -233,7 +230,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
/** Returns the number of tracked peer connections in `connected` state. */ /** Returns the number of tracked peer connections in `connected` state. */
export async function getConnectedPeerCount(page: Page): Promise<number> { export async function getConnectedPeerCount(page: Page): Promise<number> {
return page.evaluate( return page.evaluate(
() => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter( () => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected' (pc) => pc.connectionState === 'connected'
).length ?? 0 ).length ?? 0
); );
@@ -241,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
/** Wait until the expected number of peer connections are `connected`. */ /** Wait until the expected number of peer connections are `connected`. */
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> { export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
await page.waitForFunction( try {
(count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter( await page.waitForFunction(
(pc) => pc.connectionState === 'connected' (count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
).length === count, (pc) => pc.connectionState === 'connected'
expectedCount, ).length === count,
{ timeout } 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. */ /** Returns the number of tracked RTCDataChannels in the open state. */
export async function getOpenDataChannelCount(page: Page): Promise<number> { export async function getOpenDataChannelCount(page: Page): Promise<number> {
return page.evaluate( return page.evaluate(
() => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( () => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open' (channel) => channel.readyState === 'open'
).length ?? 0 ).length ?? 0
); );
@@ -262,7 +276,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
/** Wait until the expected number of tracked RTCDataChannels are open. */ /** Wait until the expected number of tracked RTCDataChannels are open. */
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> { export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
await page.waitForFunction( 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' (channel) => channel.readyState === 'open'
).length === count, ).length === count,
expectedCount, expectedCount,
@@ -273,7 +287,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
/** Close every currently-open RTCDataChannel and return how many were closed. */ /** Close every currently-open RTCDataChannel and return how many were closed. */
export async function closeOpenDataChannels(page: Page): Promise<number> { export async function closeOpenDataChannels(page: Page): Promise<number> {
return page.evaluate(() => { return page.evaluate(() => {
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let closed = 0; let closed = 0;
@@ -293,7 +307,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
/** Dispatch a synthetic data-channel error event on each open channel. */ /** Dispatch a synthetic data-channel error event on each open channel. */
export async function dispatchDataChannelErrors(page: Page): Promise<number> { export async function dispatchDataChannelErrors(page: Page): Promise<number> {
return page.evaluate(() => { return page.evaluate(() => {
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let dispatched = 0; let dispatched = 0;
@@ -354,7 +368,7 @@ interface PerPeerAudioStat {
/** Get per-peer audio stats for every tracked RTCPeerConnection. */ /** Get per-peer audio stats for every tracked RTCPeerConnection. */
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> { export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
return page.evaluate(async () => { 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) { if (!connections?.length) {
return []; return [];
@@ -472,7 +486,7 @@ export async function getAudioStats(page: Page): Promise<{
inbound: { bytesReceived: number; packetsReceived: number } | null; inbound: { bytesReceived: number; packetsReceived: number } | null;
}> { }> {
return page.evaluate(async () => { 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) if (!connections?.length)
return { outbound: null, inbound: null }; return { outbound: null, inbound: null };
@@ -486,8 +500,8 @@ export async function getAudioStats(page: Page): Promise<{
hasInbound: boolean; hasInbound: boolean;
}; };
const hwm: Record<number, HWMEntry> = webRtcHarnessWindow().__rtcStatsHWM = const hwm: Record<number, HWMEntry> = (window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM =
(webRtcHarnessWindow().__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {}; ((window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
for (let idx = 0; idx < connections.length; idx++) { for (let idx = 0; idx < connections.length; idx++) {
let stats: RTCStatsReport; 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<void> { export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
await page.waitForFunction( await page.waitForFunction(
async () => { async () => {
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) if (!connections?.length)
return false; return false;
@@ -705,7 +719,7 @@ export async function getVideoStats(page: Page): Promise<{
inbound: { bytesReceived: number; packetsReceived: number } | null; inbound: { bytesReceived: number; packetsReceived: number } | null;
}> { }> {
return page.evaluate(async () => { 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) if (!connections?.length)
return { outbound: null, inbound: null }; return { outbound: null, inbound: null };
@@ -719,8 +733,8 @@ export async function getVideoStats(page: Page): Promise<{
hasInbound: boolean; hasInbound: boolean;
} }
const hwm: Record<number, VHWM> = webRtcHarnessWindow().__rtcVideoStatsHWM = const hwm: Record<number, VHWM> = (window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM =
(webRtcHarnessWindow().__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {}; ((window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
for (let idx = 0; idx < connections.length; idx++) { for (let idx = 0; idx < connections.length; idx++) {
let stats: RTCStatsReport; let stats: RTCStatsReport;
@@ -804,7 +818,7 @@ export async function getVideoStats(page: Page): Promise<{
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> { export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
await page.waitForFunction( await page.waitForFunction(
async () => { async () => {
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) if (!connections?.length)
return false; return false;
@@ -972,7 +986,7 @@ export async function waitForInboundVideoFlow(
*/ */
export async function dumpRtcDiagnostics(page: Page): Promise<string> { export async function dumpRtcDiagnostics(page: Page): Promise<string> {
return page.evaluate(async () => { 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) if (!conns?.length)
return 'No connections tracked'; return 'No connections tracked';

View File

@@ -10,9 +10,9 @@ import {
dumpRtcDiagnostics, dumpRtcDiagnostics,
getConnectedPeerCount, getConnectedPeerCount,
installWebRTCTracking, installWebRTCTracking,
installAutoResumeAudioContext,
waitForAllPeerAudioFlow, waitForAllPeerAudioFlow,
waitForAudioStatsPresent, waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForPeerConnected waitForPeerConnected
} from '../../helpers/webrtc-helpers'; } from '../../helpers/webrtc-helpers';
import { import {
@@ -24,6 +24,8 @@ import {
import { RegisterPage } from '../../pages/register.page'; import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page'; import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.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'; import { ChatMessagesPage } from '../../pages/chat-messages.page';
// ── Signal endpoint identifiers ────────────────────────────────────── // ── Signal endpoint identifiers ──────────────────────────────────────
@@ -132,7 +134,8 @@ test.describe('Mixed signal-config voice', () => {
await installTestServerEndpoints(client.context, groupEndpoints); await installTestServerEndpoints(client.context, groupEndpoints);
await installDeterministicVoiceSettings(client.page); await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page); await installWebRTCTracking(client.context);
await installAutoResumeAudioContext(client.page);
clients.push({ ...client, user }); clients.push({ ...client, user });
} }
@@ -300,8 +303,11 @@ test.describe('Mixed signal-config voice', () => {
for (const client of clients) { for (const client of clients) {
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL); await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
await client.page.waitForTimeout(2_000);
} }
await clients[0].page.waitForTimeout(10_000);
for (const client of clients) { for (const client of clients) {
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT); await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
} }
@@ -310,11 +316,11 @@ test.describe('Mixed signal-config voice', () => {
// ── Audio mesh ────────────────────────────────────────────── // ── Audio mesh ──────────────────────────────────────────────
await test.step('All users discover peers and audio flows pairwise', async () => { await test.step('All users discover peers and audio flows pairwise', async () => {
await Promise.all(clients.map((client) => await Promise.all(clients.map((client) =>
waitForPeerConnected(client.page, 45_000) waitForPeerConnected(client.page, 90_000)
)); ));
await Promise.all(clients.map((client) => 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) => await Promise.all(clients.map((client) =>
@@ -324,7 +330,7 @@ test.describe('Mixed signal-config voice', () => {
await clients[0].page.waitForTimeout(5_000); await clients[0].page.waitForTimeout(5_000);
await Promise.all(clients.map((client) => 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 openVoiceWorkspace(client.page);
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 }); await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT); await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
} }
}); });
@@ -372,18 +377,28 @@ test.describe('Mixed signal-config voice', () => {
while (Date.now() < deadline) { while (Date.now() < deadline) {
for (const client of stayers) { 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, timeout: 10_000,
intervals: [500, 1_000] intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS); }).toBe(true);
} }
// Check chatters still have voice peers even while viewing another room // Check chatters still have voice peers even while viewing another room
for (const chatter of chatters) { 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, timeout: 10_000,
intervals: [500, 1_000] intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS); }).toBe(true);
} }
if (Date.now() < deadline) { if (Date.now() < deadline) {
@@ -749,63 +764,6 @@ async function waitForLocalVoiceChannelConnection(page: Page, channelName: strin
// ── Roster / state helpers ─────────────────────────────────────────── // ── Roster / state helpers ───────────────────────────────────────────
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
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<void> {
await page.waitForFunction(
({ expected, name }) => {
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface RoomShape { channels?: ChannelShape[] }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
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( async function waitForVoiceStateAcrossPages(
clients: readonly TestClient[], clients: readonly TestClient[],
displayName: string, displayName: string,

View File

@@ -6,14 +6,21 @@ import {
dumpRtcDiagnostics, dumpRtcDiagnostics,
getConnectedPeerCount, getConnectedPeerCount,
installWebRTCTracking, installWebRTCTracking,
installAutoResumeAudioContext,
waitForAllPeerAudioFlow, waitForAllPeerAudioFlow,
waitForAudioStatsPresent, waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForPeerConnected waitForPeerConnected
} from '../../helpers/webrtc-helpers'; } from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page'; import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page'; import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.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 PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b'; const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
@@ -116,8 +123,11 @@ test.describe('Dual-signal multi-user voice', () => {
for (const client of clients) { for (const client of clients) {
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL); await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
await client.page.waitForTimeout(2_000);
} }
await clients[0].page.waitForTimeout(10_000);
for (const client of clients) { for (const client of clients) {
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT); 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 () => { 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) // Wait for all clients to have at least one connected peer (fast)
await Promise.all(clients.map((client) => 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 // Wait for all clients to have all 7 peers connected
await Promise.all(clients.map((client) => 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 // 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 // Check bidirectional audio flow on each client
await Promise.all(clients.map((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 openVoiceWorkspace(client.page);
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 }); await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT); await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
await waitForConnectedSignalManagerCount(client.page, 2); await waitForConnectedSignalManagerCount(client.page, 2);
} }
@@ -167,10 +176,15 @@ test.describe('Dual-signal multi-user voice', () => {
while (Date.now() < deadline) { while (Date.now() < deadline) {
for (const client of clients) { 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, timeout: 10_000,
intervals: [500, 1_000] intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS); }).toBe(true);
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), { await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
timeout: 10_000, timeout: 10_000,
@@ -292,7 +306,8 @@ async function createTrackedClients(
await installTestServerEndpoints(client.context, endpoints); await installTestServerEndpoints(client.context, endpoints);
await installDeterministicVoiceSettings(client.page); await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page); await installWebRTCTracking(client.context);
await installAutoResumeAudioContext(client.page);
clients.push({ clients.push({
...client, ...client,
@@ -576,124 +591,6 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise
}, channelName); }, channelName);
} }
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
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<number> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
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<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
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<void> {
await page.waitForFunction(
({ expected, name }) => {
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
}
interface RoomShape {
channels?: ChannelShape[];
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
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( async function waitForVoiceStateAcrossPages(
clients: readonly TestClient[], clients: readonly TestClient[],
displayName: string, displayName: string,

View File

@@ -7,6 +7,7 @@ import type { VoiceState } from '../../../../shared-kernel';
import { import {
isLocalVoiceOwner, isLocalVoiceOwner,
isVoiceOnAnotherClient, isVoiceOnAnotherClient,
shouldApplyRemoteVoiceStateToCurrentUser,
shouldTransmitVoice shouldTransmitVoice
} from './client-voice-session.rules'; } from './client-voice-session.rules';
@@ -51,4 +52,54 @@ describe('client-voice-session.rules', () => {
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true); 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);
});
}); });

View File

@@ -32,3 +32,29 @@ export function shouldTransmitVoice(
return voiceState.clientInstanceId === clientInstanceId; return voiceState.clientInstanceId === clientInstanceId;
} }
/** Ignore stale P2P disconnect echoes for the current user while this client actively owns voice. */
export function shouldApplyRemoteVoiceStateToCurrentUser(
currentVoiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
incoming: Partial<VoiceState>,
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;
}

View File

@@ -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;
}

View File

@@ -66,8 +66,8 @@ export class NoiseReductionManager {
*/ */
async enable(rawStream: MediaStream): Promise<MediaStream> { async enable(rawStream: MediaStream): Promise<MediaStream> {
if (this._isEnabled && this.destinationNode) { if (this._isEnabled && this.destinationNode) {
this.logger.info('Noise reduction already enabled, returning existing clean stream'); this.logger.info('Noise reduction already enabled, replacing input stream');
return this.destinationNode.stream; return this.replaceInputStream(rawStream);
} }
try { try {

View File

@@ -44,7 +44,7 @@ import { hasRoomBanForUser } from '../../domains/access-control';
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants'; import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
import { VoiceSessionFacade, VoiceClientTakeoverService } from '../../domains/voice-session'; import { VoiceSessionFacade, VoiceClientTakeoverService } from '../../domains/voice-session';
import { ClientInstanceService } from '../../core/platform/client-instance.service'; 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 { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules'; import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules';
import { 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[] = []; const actions: Action[] = [];
if (presenceRefreshAction) { if (presenceRefreshAction) {