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
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:
72
e2e/helpers/signal-manager.ts
Normal file
72
e2e/helpers/signal-manager.ts
Normal 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);
|
||||||
|
}
|
||||||
49
e2e/helpers/voice-roster.ts
Normal file
49
e2e/helpers/voice-roster.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user