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
804 lines
31 KiB
TypeScript
804 lines
31 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';
|
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
|
|
|
// ── Signal endpoint identifiers ──────────────────────────────────────
|
|
const PRIMARY_SIGNAL_ID = 'e2e-mixed-signal-a';
|
|
const SECONDARY_SIGNAL_ID = 'e2e-mixed-signal-b';
|
|
// ── Room / channel names ─────────────────────────────────────────────
|
|
const VOICE_ROOM_NAME = `Mixed Signal Voice ${Date.now()}`;
|
|
const SECONDARY_ROOM_NAME = `Mixed Signal Chat ${Date.now()}`;
|
|
const VOICE_CHANNEL = 'General';
|
|
// ── User constants ───────────────────────────────────────────────────
|
|
const USER_PASSWORD = 'TestPass123!';
|
|
const USER_COUNT = 8;
|
|
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
|
|
const STABILITY_WINDOW_MS = 20_000;
|
|
|
|
// ── User signal configuration groups ─────────────────────────────────
|
|
//
|
|
// Group A (users 0-1): Both signal servers in network config (normal)
|
|
// Group B (users 2-3): Only primary signal - secondary NOT in config.
|
|
// They join the secondary room via invite link,
|
|
// which auto-adds the endpoint.
|
|
// Group C (users 4-5): Both signals initially, but secondary is removed
|
|
// after registration. They still see the room from
|
|
// search because the primary signal can discover it
|
|
// via findServerAcrossActiveEndpoints fallback.
|
|
// Group D (users 6-7): Only secondary signal in config. They join the
|
|
// primary room via invite link.
|
|
|
|
type SignalGroup = 'both' | 'primary-only' | 'both-then-remove-secondary' | 'secondary-only';
|
|
|
|
interface TestUser {
|
|
username: string;
|
|
displayName: string;
|
|
password: string;
|
|
group: SignalGroup;
|
|
}
|
|
|
|
type TestClient = Client & { user: TestUser };
|
|
|
|
function endpointsForGroup(
|
|
group: SignalGroup,
|
|
primaryUrl: string,
|
|
secondaryUrl: string
|
|
): SeededEndpointInput[] {
|
|
switch (group) {
|
|
case 'both':
|
|
return [
|
|
{
|
|
id: PRIMARY_SIGNAL_ID,
|
|
name: 'E2E Signal A',
|
|
url: primaryUrl,
|
|
isActive: true,
|
|
status: 'online'
|
|
},
|
|
{
|
|
id: SECONDARY_SIGNAL_ID,
|
|
name: 'E2E Signal B',
|
|
url: secondaryUrl,
|
|
isActive: true,
|
|
status: 'online'
|
|
}
|
|
];
|
|
case 'primary-only':
|
|
return [{ id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }];
|
|
case 'both-then-remove-secondary':
|
|
// Seed both initially; test will remove secondary after registration.
|
|
return [
|
|
{
|
|
id: PRIMARY_SIGNAL_ID,
|
|
name: 'E2E Signal A',
|
|
url: primaryUrl,
|
|
isActive: true,
|
|
status: 'online'
|
|
},
|
|
{
|
|
id: SECONDARY_SIGNAL_ID,
|
|
name: 'E2E Signal B',
|
|
url: secondaryUrl,
|
|
isActive: true,
|
|
status: 'online'
|
|
}
|
|
];
|
|
case 'secondary-only':
|
|
return [{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' }];
|
|
}
|
|
}
|
|
|
|
test.describe('Mixed signal-config voice', () => {
|
|
test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({
|
|
createClient,
|
|
testServer
|
|
}) => {
|
|
test.setTimeout(720_000);
|
|
|
|
const secondaryServer = await startTestServer();
|
|
|
|
try {
|
|
const users = buildUsers();
|
|
const clients: TestClient[] = [];
|
|
|
|
// ── Create clients with per-group endpoint configs ───────────
|
|
for (const user of users) {
|
|
const client = await createClient();
|
|
const groupEndpoints = endpointsForGroup(user.group, testServer.url, secondaryServer.url);
|
|
|
|
await installTestServerEndpoints(client.context, groupEndpoints);
|
|
await installDeterministicVoiceSettings(client.page);
|
|
await installWebRTCTracking(client.page);
|
|
|
|
clients.push({ ...client, user });
|
|
}
|
|
|
|
// ── Register ─────────────────────────────────────────────────
|
|
await test.step('Register each user on their configured signal endpoint', async () => {
|
|
for (const client of clients) {
|
|
const registerPage = new RegisterPage(client.page);
|
|
const registrationEndpointId =
|
|
client.user.group === 'secondary-only' ? SECONDARY_SIGNAL_ID : PRIMARY_SIGNAL_ID;
|
|
|
|
await registerPage.goto();
|
|
await registerPage.serverSelect.selectOption(registrationEndpointId);
|
|
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
|
|
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
|
|
}
|
|
});
|
|
|
|
// ── Create rooms ────────────────────────────────────────────
|
|
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
|
// Use a "both" user (client 0) to create both rooms
|
|
const searchPage = new ServerSearchPage(clients[0].page);
|
|
|
|
await searchPage.createServer(VOICE_ROOM_NAME, {
|
|
description: 'Voice room on primary signal',
|
|
sourceId: PRIMARY_SIGNAL_ID
|
|
});
|
|
|
|
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
|
|
await searchPage.createServer(SECONDARY_ROOM_NAME, {
|
|
description: 'Chat room on secondary signal',
|
|
sourceId: SECONDARY_SIGNAL_ID
|
|
});
|
|
|
|
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
});
|
|
|
|
// ── Create invite links ─────────────────────────────────────
|
|
//
|
|
// Group B (primary-only) needs invite to secondary room.
|
|
// Group D (secondary-only) needs invite to primary room.
|
|
let primaryRoomInviteUrl: string;
|
|
let secondaryRoomInviteUrl: string;
|
|
|
|
await test.step('Create invite links for cross-signal rooms', async () => {
|
|
// Navigate to voice room to get its ID
|
|
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
|
|
const primaryRoomId = await getCurrentRoomId(clients[0].page);
|
|
const userId = await getCurrentUserId(clients[0].page);
|
|
|
|
// Navigate to secondary room to get its ID
|
|
await openSavedRoomByName(clients[0].page, SECONDARY_ROOM_NAME);
|
|
const secondaryRoomId = await getCurrentRoomId(clients[0].page);
|
|
// Create invite for primary room (voice) via API
|
|
const primaryInvite = await createInviteViaApi(
|
|
testServer.url,
|
|
primaryRoomId,
|
|
userId,
|
|
clients[0].user.displayName
|
|
);
|
|
|
|
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
|
|
|
|
// Create invite for secondary room (chat) via API
|
|
const secondaryInvite = await createInviteViaApi(
|
|
secondaryServer.url,
|
|
secondaryRoomId,
|
|
userId,
|
|
clients[0].user.displayName
|
|
);
|
|
|
|
secondaryRoomInviteUrl = `/invite/${secondaryInvite.id}?server=${encodeURIComponent(secondaryServer.url)}`;
|
|
});
|
|
|
|
// ── Remove secondary endpoint for group C ───────────────────
|
|
await test.step('Remove secondary signal from group C users', async () => {
|
|
for (const client of clients.filter((clientItem) => clientItem.user.group === 'both-then-remove-secondary')) {
|
|
await client.page.evaluate((primaryEndpoint) => {
|
|
localStorage.setItem('metoyou_server_endpoints', JSON.stringify([primaryEndpoint]));
|
|
}, { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, isDefault: false, status: 'online' });
|
|
}
|
|
});
|
|
|
|
// ── Join rooms ──────────────────────────────────────────────
|
|
await test.step('All users join the voice room (some via search, some via invite)', async () => {
|
|
for (const client of clients.slice(1)) {
|
|
if (client.user.group === 'secondary-only') {
|
|
// Group D: no primary signal -> join voice room via invite
|
|
await client.page.goto(primaryRoomInviteUrl);
|
|
await waitForInviteJoin(client.page);
|
|
} else {
|
|
// Groups A, B, C: have primary signal -> join via search
|
|
await joinRoomFromSearch(client.page, VOICE_ROOM_NAME);
|
|
}
|
|
}
|
|
|
|
// Navigate client 0 back to voice room
|
|
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
|
|
});
|
|
|
|
await test.step('All users also join the secondary chat room', async () => {
|
|
for (const client of clients.slice(1)) {
|
|
if (client.user.group === 'primary-only') {
|
|
// Group B: no secondary signal -> join chat room via invite
|
|
await client.page.goto(secondaryRoomInviteUrl);
|
|
await waitForInviteJoin(client.page);
|
|
} else if (client.user.group === 'secondary-only') {
|
|
// Group D: has secondary -> join via search
|
|
await openSearchView(client.page);
|
|
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
|
|
} else {
|
|
// Groups A, C: can search
|
|
await openSearchView(client.page);
|
|
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
|
|
}
|
|
}
|
|
|
|
// Ensure everyone navigates back to voice room
|
|
for (const client of clients) {
|
|
await openSavedRoomByName(client.page, VOICE_ROOM_NAME);
|
|
}
|
|
});
|
|
|
|
// ── Voice channel ───────────────────────────────────────────
|
|
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);
|
|
}
|
|
});
|
|
|
|
// ── Audio mesh ──────────────────────────────────────────────
|
|
await test.step('All users discover peers and audio flows pairwise', async () => {
|
|
await Promise.all(clients.map((client) =>
|
|
waitForPeerConnected(client.page, 45_000)
|
|
));
|
|
|
|
await Promise.all(clients.map((client) =>
|
|
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
|
));
|
|
|
|
await Promise.all(clients.map((client) =>
|
|
waitForAudioStatsPresent(client.page, 30_000)
|
|
));
|
|
|
|
await clients[0].page.waitForTimeout(5_000);
|
|
|
|
await Promise.all(clients.map((client) =>
|
|
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
|
));
|
|
});
|
|
|
|
// ── Voice workspace roster ──────────────────────────────────
|
|
await test.step('Voice workspace shows 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);
|
|
}
|
|
});
|
|
|
|
// ── Stability + concurrent chat ─────────────────────────────
|
|
await test.step('Voice stays stable 20s while some users navigate and chat on other servers', async () => {
|
|
// Pick 2 users from different groups to navigate away and chat
|
|
const chatters = [clients[2], clients[6]]; // group C + group D
|
|
const stayers = clients.filter((clientItem) => !chatters.includes(clientItem));
|
|
|
|
// Chatters navigate to secondary room and send messages
|
|
for (const chatter of chatters) {
|
|
await openSavedRoomByName(chatter.page, SECONDARY_ROOM_NAME);
|
|
await expect(chatter.page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
const chatPage0 = new ChatMessagesPage(chatters[0].page);
|
|
const chatPage1 = new ChatMessagesPage(chatters[1].page);
|
|
|
|
await chatPage0.sendMessage(`Hello from ${chatters[0].user.displayName} while in voice!`);
|
|
await chatPage1.sendMessage(`Reply from ${chatters[1].user.displayName} also in voice!`);
|
|
|
|
// Verify messages arrive
|
|
await expect(
|
|
chatPage0.getMessageItemByText(`Reply from ${chatters[1].user.displayName}`)
|
|
).toBeVisible({ timeout: 15_000 });
|
|
|
|
await expect(
|
|
chatPage1.getMessageItemByText(`Hello from ${chatters[0].user.displayName}`)
|
|
).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Meanwhile stability loop on all clients (including chatters - voice still active)
|
|
const deadline = Date.now() + STABILITY_WINDOW_MS;
|
|
|
|
while (Date.now() < deadline) {
|
|
for (const client of stayers) {
|
|
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
|
timeout: 10_000,
|
|
intervals: [500, 1_000]
|
|
}).toBe(EXPECTED_REMOTE_PEERS);
|
|
}
|
|
|
|
// Check chatters still have voice peers even while viewing another room
|
|
for (const chatter of chatters) {
|
|
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
|
|
timeout: 10_000,
|
|
intervals: [500, 1_000]
|
|
}).toBe(EXPECTED_REMOTE_PEERS);
|
|
}
|
|
|
|
if (Date.now() < deadline) {
|
|
await clients[0].page.waitForTimeout(5_000);
|
|
}
|
|
}
|
|
|
|
// Navigate chatters back to voice room
|
|
for (const chatter of chatters) {
|
|
await openSavedRoomByName(chatter.page, VOICE_ROOM_NAME);
|
|
}
|
|
|
|
// Verify audio still flowing after stability window
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Mute ────────────────────────────────────────────────────
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Deafen ──────────────────────────────────────────────────
|
|
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 - 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 () => {
|
|
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
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── User builders ────────────────────────────────────────────────────
|
|
|
|
function buildUsers(): TestUser[] {
|
|
const groups: SignalGroup[] = [
|
|
'both',
|
|
'both', // 0-1
|
|
'primary-only',
|
|
'primary-only', // 2-3
|
|
'both-then-remove-secondary',
|
|
'both-then-remove-secondary', // 4-5
|
|
'secondary-only',
|
|
'secondary-only' // 6-7
|
|
];
|
|
|
|
return groups.map((group, index) => ({
|
|
username: `mixed_sig_${Date.now()}_${index + 1}`,
|
|
displayName: `Mixed User ${index + 1}`,
|
|
password: USER_PASSWORD,
|
|
group
|
|
}));
|
|
}
|
|
|
|
// ── API helpers ──────────────────────────────────────────────────────
|
|
|
|
async function createInviteViaApi(
|
|
serverBaseUrl: string,
|
|
roomId: string,
|
|
userId: string,
|
|
displayName: string
|
|
): Promise<{ id: string }> {
|
|
const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
requesterUserId: userId,
|
|
requesterDisplayName: displayName
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to create invite: ${response.status} ${await response.text()}`);
|
|
}
|
|
|
|
return await response.json() as { id: string };
|
|
}
|
|
|
|
async function getCurrentRoomId(page: Page): Promise<string> {
|
|
return await page.evaluate(() => {
|
|
interface RoomShape { id: 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) {
|
|
throw new Error('Angular debug API unavailable');
|
|
}
|
|
|
|
const component = debugApi.getComponent(host);
|
|
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.();
|
|
|
|
if (!currentRoom?.id) {
|
|
throw new Error('No current room');
|
|
}
|
|
|
|
return currentRoom.id;
|
|
});
|
|
}
|
|
|
|
async function getCurrentUserId(page: Page): Promise<string> {
|
|
return await page.evaluate(() => {
|
|
interface AngularDebugApi {
|
|
getComponent: (element: Element) => Record<string, unknown>;
|
|
}
|
|
|
|
interface UserShape {
|
|
id: string;
|
|
}
|
|
|
|
const host = document.querySelector('app-rooms-side-panel');
|
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
|
|
if (!host || !debugApi?.getComponent) {
|
|
throw new Error('Angular debug API unavailable');
|
|
}
|
|
|
|
const component = debugApi.getComponent(host);
|
|
const user = (component['currentUser'] as (() => UserShape | null) | undefined)?.();
|
|
|
|
if (!user?.id) {
|
|
throw new Error('Current user not found');
|
|
}
|
|
|
|
return user.id;
|
|
});
|
|
}
|
|
|
|
// ── Navigation helpers ───────────────────────────────────────────────
|
|
|
|
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 waitForInviteJoin(page: Page): Promise<void> {
|
|
// Invite page loads -> auto-joins -> redirects to room
|
|
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
|
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
|
}
|
|
|
|
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> {
|
|
if (await page.locator('app-voice-workspace').isVisible()
|
|
.catch(() => false)) {
|
|
return;
|
|
}
|
|
|
|
const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i })
|
|
.first();
|
|
|
|
await expect(viewButton).toBeVisible({ timeout: 10_000 });
|
|
await viewButton.click();
|
|
}
|
|
|
|
// ── Voice helpers ────────────────────────────────────────────────────
|
|
|
|
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 lastErrorMessage = lastError instanceof Error
|
|
? `Last error: ${lastError.message}`
|
|
: 'Last error: unavailable';
|
|
|
|
throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`);
|
|
}
|
|
|
|
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((ch) => ch.type === 'voice' && ch.name === name);
|
|
const voiceState = currentUser?.voiceState;
|
|
|
|
return !!voiceChannel
|
|
&& voiceState?.isConnected === true
|
|
&& voiceState.roomId === voiceChannel.id
|
|
&& voiceState.serverId === currentRoom.id;
|
|
},
|
|
channelName,
|
|
{ timeout }
|
|
);
|
|
}
|
|
|
|
// ── 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(
|
|
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((ch) => ch.type === 'voice');
|
|
|
|
if (!voiceChannel) {
|
|
return false;
|
|
}
|
|
|
|
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
|
|
const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName);
|
|
|
|
return entry?.voiceState?.isMuted === expectedMuted
|
|
&& entry?.voiceState?.isDeafened === expectedDeafened;
|
|
},
|
|
{
|
|
expectedDisplayName: displayName,
|
|
expectedMuted: expectedState.isMuted,
|
|
expectedDeafened: expectedState.isDeafened
|
|
},
|
|
{ timeout: 30_000 }
|
|
);
|
|
}
|
|
}
|