762 lines
25 KiB
TypeScript
762 lines
25 KiB
TypeScript
import { expect, type Page } from '@playwright/test';
|
||
import { test, type Client } from '../../fixtures/multi-client';
|
||
import {
|
||
installTestServerEndpoints,
|
||
type SeededEndpointInput
|
||
} from '../../helpers/seed-test-endpoint';
|
||
import { startTestServer } from '../../helpers/test-server';
|
||
import {
|
||
dumpRtcDiagnostics,
|
||
getConnectedPeerCount,
|
||
installWebRTCTracking,
|
||
waitForAllPeerAudioFlow,
|
||
waitForAudioStatsPresent,
|
||
waitForConnectedPeerCount,
|
||
waitForPeerConnected
|
||
} from '../../helpers/webrtc-helpers';
|
||
import { RegisterPage } from '../../pages/register.page';
|
||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||
|
||
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
||
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
||
const PRIMARY_ROOM_NAME = `Dual Signal Voice A ${Date.now()}`;
|
||
const SECONDARY_ROOM_NAME = `Dual Signal Voice B ${Date.now()}`;
|
||
const VOICE_CHANNEL = 'General';
|
||
const USER_PASSWORD = 'TestPass123!';
|
||
const USER_COUNT = 8;
|
||
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
|
||
const STABILITY_WINDOW_MS = 20_000;
|
||
|
||
type TestUser = {
|
||
username: string;
|
||
displayName: string;
|
||
password: string;
|
||
};
|
||
|
||
type TestClient = Client & {
|
||
user: TestUser;
|
||
};
|
||
|
||
test.describe('Dual-signal multi-user voice', () => {
|
||
test('keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds', async ({
|
||
createClient,
|
||
testServer
|
||
}) => {
|
||
test.setTimeout(720_000);
|
||
|
||
const secondaryServer = await startTestServer();
|
||
|
||
try {
|
||
const endpoints: SeededEndpointInput[] = [
|
||
{
|
||
id: PRIMARY_SIGNAL_ID,
|
||
name: 'E2E Signal A',
|
||
url: testServer.url,
|
||
isActive: true,
|
||
status: 'online'
|
||
},
|
||
{
|
||
id: SECONDARY_SIGNAL_ID,
|
||
name: 'E2E Signal B',
|
||
url: secondaryServer.url,
|
||
isActive: true,
|
||
status: 'online'
|
||
}
|
||
];
|
||
|
||
const users = buildUsers();
|
||
const clients = await createTrackedClients(createClient, users, endpoints);
|
||
|
||
await test.step('Register every user with both active endpoints available', async () => {
|
||
for (const client of clients) {
|
||
const registerPage = new RegisterPage(client.page);
|
||
|
||
await registerPage.goto();
|
||
await registerPage.serverSelect.selectOption(PRIMARY_SIGNAL_ID);
|
||
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
|
||
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
|
||
}
|
||
});
|
||
|
||
await test.step('Create primary and secondary rooms on different signal endpoints', async () => {
|
||
const searchPage = new ServerSearchPage(clients[0].page);
|
||
|
||
await searchPage.createServer(PRIMARY_ROOM_NAME, {
|
||
description: 'Primary signal room for 8-user voice mesh',
|
||
sourceId: PRIMARY_SIGNAL_ID
|
||
});
|
||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||
|
||
await searchPage.createServer(SECONDARY_ROOM_NAME, {
|
||
description: 'Secondary signal room for dual-socket coverage',
|
||
sourceId: SECONDARY_SIGNAL_ID
|
||
});
|
||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||
});
|
||
|
||
await test.step('Every user joins both rooms to keep 2 signal sockets open', async () => {
|
||
for (const client of clients.slice(1)) {
|
||
await joinRoomFromSearch(client.page, PRIMARY_ROOM_NAME);
|
||
}
|
||
|
||
for (const client of clients.slice(1)) {
|
||
await openSearchView(client.page);
|
||
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
|
||
}
|
||
|
||
for (const client of clients) {
|
||
await openSavedRoomByName(client.page, PRIMARY_ROOM_NAME);
|
||
await waitForConnectedSignalManagerCount(client.page, 2);
|
||
}
|
||
});
|
||
|
||
await test.step('Create voice channel and join all 8 users', async () => {
|
||
const hostRoom = new ChatRoomPage(clients[0].page);
|
||
|
||
await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||
|
||
for (const client of clients) {
|
||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||
}
|
||
|
||
for (const client of clients) {
|
||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||
}
|
||
});
|
||
|
||
await test.step('All users discover all peers and audio flows pairwise', async () => {
|
||
// Wait for all clients to have at least one connected peer (fast)
|
||
await Promise.all(clients.map((client) =>
|
||
waitForPeerConnected(client.page, 45_000)
|
||
));
|
||
|
||
// Wait for all clients to have all 7 peers connected
|
||
await Promise.all(clients.map((client) =>
|
||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||
));
|
||
|
||
// Wait for audio stats to appear on all clients
|
||
await Promise.all(clients.map((client) =>
|
||
waitForAudioStatsPresent(client.page, 30_000)
|
||
));
|
||
|
||
// Allow the mesh to settle — voice routing, allowed-peer-id
|
||
// propagation and renegotiation all need time after the last
|
||
// user joins.
|
||
await clients[0].page.waitForTimeout(5_000);
|
||
|
||
// Check bidirectional audio flow on each client
|
||
await Promise.all(clients.map((client) =>
|
||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||
));
|
||
});
|
||
|
||
await test.step('Voice workspace and side panel show all 8 users on every client', async () => {
|
||
for (const client of clients) {
|
||
const room = new ChatRoomPage(client.page);
|
||
|
||
await openVoiceWorkspace(client.page);
|
||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||
await waitForConnectedSignalManagerCount(client.page, 2);
|
||
}
|
||
});
|
||
|
||
await test.step('Voice stays stable for more than 20 seconds across both signals', async () => {
|
||
const deadline = Date.now() + STABILITY_WINDOW_MS;
|
||
|
||
while (Date.now() < deadline) {
|
||
for (const client of clients) {
|
||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
||
timeout: 10_000,
|
||
intervals: [500, 1_000]
|
||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
||
timeout: 10_000,
|
||
intervals: [500, 1_000]
|
||
}).toBe(2);
|
||
}
|
||
|
||
if (Date.now() < deadline) {
|
||
await clients[0].page.waitForTimeout(5_000);
|
||
}
|
||
}
|
||
|
||
for (const client of clients) {
|
||
try {
|
||
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
|
||
} catch (error) {
|
||
console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||
throw error;
|
||
}
|
||
}
|
||
});
|
||
|
||
await test.step('Mute state propagates for every user across all clients', async () => {
|
||
for (const client of clients) {
|
||
const room = new ChatRoomPage(client.page);
|
||
|
||
await room.muteButton.click();
|
||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||
isMuted: true,
|
||
isDeafened: false
|
||
});
|
||
|
||
await room.muteButton.click();
|
||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||
isMuted: false,
|
||
isDeafened: false
|
||
});
|
||
}
|
||
});
|
||
|
||
await test.step('Audio still flows on all peers after mute cycling', async () => {
|
||
for (const client of clients) {
|
||
try {
|
||
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
|
||
} catch (error) {
|
||
console.log(`[${client.user.displayName} post-mute RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||
throw error;
|
||
}
|
||
}
|
||
});
|
||
|
||
await test.step('Deafen state propagates for every user across all clients', async () => {
|
||
for (const client of clients) {
|
||
const room = new ChatRoomPage(client.page);
|
||
|
||
await room.deafenButton.click();
|
||
await client.page.waitForTimeout(500);
|
||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||
isMuted: true,
|
||
isDeafened: true
|
||
});
|
||
|
||
await room.deafenButton.click();
|
||
await client.page.waitForTimeout(500);
|
||
// Un-deafen does NOT restore mute – the user stays muted
|
||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||
isMuted: true,
|
||
isDeafened: false
|
||
});
|
||
}
|
||
});
|
||
|
||
await test.step('Unmute all users and verify audio flows end-to-end', async () => {
|
||
// Every user is left muted after deafen cycling — unmute them all
|
||
for (const client of clients) {
|
||
const room = new ChatRoomPage(client.page);
|
||
|
||
await room.muteButton.click();
|
||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||
isMuted: false,
|
||
isDeafened: false
|
||
});
|
||
}
|
||
|
||
// Final audio flow check on every peer — confirms the full
|
||
// send/receive pipeline still works after mute+deafen cycling
|
||
for (const client of clients) {
|
||
try {
|
||
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
|
||
} catch (error) {
|
||
console.log(`[${client.user.displayName} final RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||
throw error;
|
||
}
|
||
}
|
||
});
|
||
} finally {
|
||
await secondaryServer.stop();
|
||
}
|
||
});
|
||
});
|
||
|
||
function buildUsers(): TestUser[] {
|
||
return Array.from({ length: USER_COUNT }, (_value, index) => ({
|
||
username: `voice8_user_${Date.now()}_${index + 1}`,
|
||
displayName: `Voice User ${index + 1}`,
|
||
password: USER_PASSWORD
|
||
}));
|
||
}
|
||
|
||
async function createTrackedClients(
|
||
createClient: () => Promise<Client>,
|
||
users: TestUser[],
|
||
endpoints: ReadonlyArray<SeededEndpointInput>
|
||
): Promise<TestClient[]> {
|
||
const clients: TestClient[] = [];
|
||
|
||
for (const user of users) {
|
||
const client = await createClient();
|
||
|
||
await installTestServerEndpoints(client.context, endpoints);
|
||
await installDeterministicVoiceSettings(client.page);
|
||
await installWebRTCTracking(client.page);
|
||
|
||
clients.push({
|
||
...client,
|
||
user
|
||
});
|
||
}
|
||
|
||
return clients;
|
||
}
|
||
|
||
async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
||
await page.addInitScript(() => {
|
||
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
|
||
inputVolume: 100,
|
||
outputVolume: 100,
|
||
audioBitrate: 96,
|
||
latencyProfile: 'balanced',
|
||
includeSystemAudio: false,
|
||
noiseReduction: false,
|
||
screenShareQuality: 'balanced',
|
||
askScreenShareQuality: false
|
||
}));
|
||
});
|
||
}
|
||
|
||
async function openSearchView(page: Page): Promise<void> {
|
||
const searchInput = page.getByPlaceholder('Search servers...');
|
||
|
||
if (await searchInput.isVisible().catch(() => false)) {
|
||
return;
|
||
}
|
||
|
||
await page.locator('button[title="Create Server"]').click();
|
||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||
}
|
||
|
||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||
const searchInput = page.getByPlaceholder('Search servers...');
|
||
|
||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||
await searchInput.fill(roomName);
|
||
|
||
const roomCard = page.locator('button', { hasText: roomName }).first();
|
||
|
||
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||
await roomCard.click();
|
||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||
await waitForCurrentRoomName(page, roomName);
|
||
}
|
||
|
||
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
|
||
const roomButton = page.locator(`button[title="${roomName}"]`);
|
||
|
||
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
||
await roomButton.click();
|
||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||
await waitForCurrentRoomName(page, roomName);
|
||
}
|
||
|
||
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
|
||
await page.waitForFunction(
|
||
(expectedRoomName) => {
|
||
interface RoomShape {
|
||
name?: string;
|
||
}
|
||
|
||
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;
|
||
|
||
return currentRoom?.name === expectedRoomName;
|
||
},
|
||
roomName,
|
||
{ timeout }
|
||
);
|
||
}
|
||
|
||
async function openVoiceWorkspace(page: Page): Promise<void> {
|
||
const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i }).first();
|
||
|
||
if (await page.locator('app-voice-workspace').isVisible().catch(() => false)) {
|
||
return;
|
||
}
|
||
|
||
await expect(viewButton).toBeVisible({ timeout: 10_000 });
|
||
await viewButton.click();
|
||
}
|
||
|
||
async function joinVoiceChannelUntilConnected(page: Page, channelName: string, attempts = 3): Promise<void> {
|
||
const room = new ChatRoomPage(page);
|
||
let lastError: unknown;
|
||
|
||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||
await room.joinVoiceChannel(channelName);
|
||
|
||
try {
|
||
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
|
||
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
|
||
return;
|
||
} catch (error) {
|
||
lastError = error;
|
||
await page.waitForTimeout(1_000);
|
||
}
|
||
}
|
||
|
||
const diagnostics = await getVoiceJoinDiagnostics(page, channelName);
|
||
const displayName = diagnostics.currentUser?.displayName ?? 'Unknown user';
|
||
|
||
throw new Error([
|
||
`Failed to connect ${displayName} to voice channel ${channelName}.`,
|
||
lastError instanceof Error ? `Last error: ${lastError.message}` : 'Last error: unavailable',
|
||
`Current room: ${diagnostics.currentRoom?.name ?? 'none'} (${diagnostics.currentRoom?.id ?? 'n/a'})`,
|
||
`Current user id: ${diagnostics.currentUser?.id ?? 'none'} / ${diagnostics.currentUser?.oderId ?? 'none'}`,
|
||
`Current user voice state: ${JSON.stringify(diagnostics.currentUser?.voiceState ?? null)}`,
|
||
`Voice channel id: ${diagnostics.voiceChannel?.id ?? 'missing'}`,
|
||
`Visible voice roster: ${diagnostics.voiceUsers.join(', ') || 'none'}`,
|
||
`Connected signaling managers: ${diagnostics.connectedSignalCount}`,
|
||
`Local voice facade state: ${JSON.stringify(diagnostics.localVoiceState)}`,
|
||
`Voice connection error: ${diagnostics.connectionErrorMessage ?? 'none'}`
|
||
].join('\n'));
|
||
}
|
||
|
||
async function waitForLocalVoiceChannelConnection(page: Page, channelName: string, timeout = 20_000): Promise<void> {
|
||
await page.waitForFunction(
|
||
(name) => {
|
||
interface VoiceStateShape {
|
||
isConnected?: boolean;
|
||
roomId?: string;
|
||
serverId?: string;
|
||
}
|
||
|
||
interface UserShape {
|
||
voiceState?: VoiceStateShape;
|
||
}
|
||
|
||
interface ChannelShape {
|
||
id: string;
|
||
name: string;
|
||
type: 'text' | 'voice';
|
||
}
|
||
|
||
interface RoomShape {
|
||
id: string;
|
||
channels?: ChannelShape[];
|
||
}
|
||
|
||
interface AngularDebugApi {
|
||
getComponent: (element: Element) => Record<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 currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
|
||
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name);
|
||
const voiceState = currentUser?.voiceState;
|
||
|
||
return !!voiceChannel
|
||
&& voiceState?.isConnected === true
|
||
&& voiceState.roomId === voiceChannel.id
|
||
&& voiceState.serverId === currentRoom.id;
|
||
},
|
||
channelName,
|
||
{ timeout }
|
||
);
|
||
}
|
||
|
||
async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise<{
|
||
connectedSignalCount: number;
|
||
connectionErrorMessage: string | null;
|
||
currentRoom: { id?: string; name?: string } | null;
|
||
currentUser: { id?: string; oderId?: string; displayName?: string; voiceState?: Record<string, unknown> } | null;
|
||
localVoiceState: {
|
||
isVoiceConnected: boolean;
|
||
localStreamTracks: number;
|
||
rawMicTracks: number;
|
||
};
|
||
voiceChannel: { id?: string; name?: string } | null;
|
||
voiceUsers: string[];
|
||
}> {
|
||
return await page.evaluate((name) => {
|
||
interface VoiceStateShape {
|
||
isConnected?: boolean;
|
||
isMuted?: boolean;
|
||
isDeafened?: boolean;
|
||
roomId?: string;
|
||
serverId?: string;
|
||
}
|
||
|
||
interface UserShape {
|
||
id?: string;
|
||
oderId?: string;
|
||
displayName?: string;
|
||
voiceState?: VoiceStateShape;
|
||
}
|
||
|
||
interface ChannelShape {
|
||
id: string;
|
||
name: string;
|
||
type: 'text' | 'voice';
|
||
}
|
||
|
||
interface RoomShape {
|
||
id?: string;
|
||
name?: string;
|
||
channels?: ChannelShape[];
|
||
}
|
||
|
||
interface AngularDebugApi {
|
||
getComponent: (element: Element) => Record<string, unknown>;
|
||
}
|
||
|
||
const host = document.querySelector('app-rooms-side-panel');
|
||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||
|
||
if (!host || !debugApi?.getComponent) {
|
||
return {
|
||
connectedSignalCount: 0,
|
||
connectionErrorMessage: 'Angular debug API unavailable',
|
||
currentRoom: null,
|
||
currentUser: null,
|
||
localVoiceState: {
|
||
isVoiceConnected: false,
|
||
localStreamTracks: 0,
|
||
rawMicTracks: 0
|
||
},
|
||
voiceChannel: null,
|
||
voiceUsers: []
|
||
};
|
||
}
|
||
|
||
const component = debugApi.getComponent(host);
|
||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
|
||
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name) ?? null;
|
||
const voiceUsers = voiceChannel
|
||
? ((component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [])
|
||
.map((user) => user.displayName ?? 'Unknown user')
|
||
: [];
|
||
const voiceConnection = component['voiceConnection'] as {
|
||
getLocalStream?: () => MediaStream | null;
|
||
getRawMicStream?: () => MediaStream | null;
|
||
isVoiceConnected?: () => boolean;
|
||
} | undefined;
|
||
const realtime = component['realtime'] as {
|
||
connectionErrorMessage?: () => string | null;
|
||
signalingTransportHandler?: {
|
||
getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>;
|
||
};
|
||
} | undefined;
|
||
|
||
return {
|
||
connectedSignalCount: realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0,
|
||
connectionErrorMessage: realtime?.connectionErrorMessage?.() ?? null,
|
||
currentRoom,
|
||
currentUser,
|
||
localVoiceState: {
|
||
isVoiceConnected: voiceConnection?.isVoiceConnected?.() ?? false,
|
||
localStreamTracks: voiceConnection?.getLocalStream?.()?.getTracks().length ?? 0,
|
||
rawMicTracks: voiceConnection?.getRawMicStream?.()?.getTracks().length ?? 0
|
||
},
|
||
voiceChannel,
|
||
voiceUsers
|
||
};
|
||
}, channelName);
|
||
}
|
||
|
||
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<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?: () => Array<{ 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?: () => Array<{ 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 (() => Array<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) => Array<unknown>) | undefined)?.(channelId) ?? [];
|
||
|
||
return roster.length === expected;
|
||
},
|
||
{ expected: expectedCount, name: channelName },
|
||
{ timeout: 30_000 }
|
||
);
|
||
}
|
||
|
||
async function waitForVoiceStateAcrossPages(
|
||
clients: ReadonlyArray<TestClient>,
|
||
displayName: string,
|
||
expectedState: { isMuted: boolean; isDeafened: boolean }
|
||
): Promise<void> {
|
||
for (const client of clients) {
|
||
await client.page.waitForFunction(
|
||
({ expectedDisplayName, expectedMuted, expectedDeafened }) => {
|
||
interface VoiceStateShape {
|
||
isMuted?: boolean;
|
||
isDeafened?: boolean;
|
||
}
|
||
|
||
interface ChannelShape {
|
||
id: string;
|
||
name: string;
|
||
type: 'text' | 'voice';
|
||
}
|
||
|
||
interface UserShape {
|
||
displayName: string;
|
||
voiceState?: VoiceStateShape;
|
||
}
|
||
|
||
interface RoomShape {
|
||
channels?: ChannelShape[];
|
||
}
|
||
|
||
interface AngularDebugApi {
|
||
getComponent: (element: Element) => Record<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 voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice');
|
||
|
||
if (!voiceChannel) {
|
||
return false;
|
||
}
|
||
|
||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
|
||
const entry = roster.find((user) => user.displayName === expectedDisplayName);
|
||
|
||
return entry?.voiceState?.isMuted === expectedMuted
|
||
&& entry?.voiceState?.isDeafened === expectedDeafened;
|
||
},
|
||
{
|
||
expectedDisplayName: displayName,
|
||
expectedMuted: expectedState.isMuted,
|
||
expectedDeafened: expectedState.isDeafened
|
||
},
|
||
{ timeout: 30_000 }
|
||
);
|
||
}
|
||
}
|