All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s
764 lines
25 KiB
TypeScript
764 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;
|
|
|
|
interface 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: readonly 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?: () => { 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?: () => { 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(
|
|
clients: readonly 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 }
|
|
);
|
|
}
|
|
}
|