feat: Add TURN server support
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
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
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
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 { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
|
||||
import { startTestServer } from '../../helpers/test-server';
|
||||
import {
|
||||
dumpRtcDiagnostics,
|
||||
@@ -22,12 +19,10 @@ 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;
|
||||
@@ -37,7 +32,7 @@ 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.
|
||||
// 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
|
||||
@@ -66,23 +61,43 @@ function endpointsForGroup(
|
||||
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' }
|
||||
{
|
||||
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' }
|
||||
];
|
||||
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' }
|
||||
{
|
||||
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' }
|
||||
];
|
||||
return [{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' }];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +111,6 @@ test.describe('Mixed signal-config voice', () => {
|
||||
const secondaryServer = await startTestServer();
|
||||
|
||||
try {
|
||||
const allEndpoints: 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: TestClient[] = [];
|
||||
|
||||
@@ -139,12 +149,14 @@ test.describe('Mixed signal-config voice', () => {
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -164,7 +176,6 @@ test.describe('Mixed signal-config voice', () => {
|
||||
// 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,
|
||||
@@ -172,6 +183,7 @@ test.describe('Mixed signal-config voice', () => {
|
||||
userId,
|
||||
clients[0].user.displayName
|
||||
);
|
||||
|
||||
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
|
||||
|
||||
// Create invite for secondary room (chat) via API
|
||||
@@ -181,12 +193,13 @@ test.describe('Mixed signal-config voice', () => {
|
||||
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((c) => c.user.group === 'both-then-remove-secondary')) {
|
||||
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' });
|
||||
@@ -197,11 +210,11 @@ test.describe('Mixed signal-config voice', () => {
|
||||
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
|
||||
// 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
|
||||
// Groups A, B, C: have primary signal -> join via search
|
||||
await joinRoomFromSearch(client.page, VOICE_ROOM_NAME);
|
||||
}
|
||||
}
|
||||
@@ -213,11 +226,11 @@ test.describe('Mixed signal-config voice', () => {
|
||||
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
|
||||
// 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
|
||||
// Group D: has secondary -> join via search
|
||||
await openSearchView(client.page);
|
||||
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
|
||||
} else {
|
||||
@@ -285,7 +298,7 @@ test.describe('Mixed signal-config voice', () => {
|
||||
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((c) => !chatters.includes(c));
|
||||
const stayers = clients.filter((clientItem) => !chatters.includes(clientItem));
|
||||
|
||||
// Chatters navigate to secondary room and send messages
|
||||
for (const chatter of chatters) {
|
||||
@@ -303,11 +316,12 @@ test.describe('Mixed signal-config voice', () => {
|
||||
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)
|
||||
// Meanwhile stability loop on all clients (including chatters - voice still active)
|
||||
const deadline = Date.now() + STABILITY_WINDOW_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
@@ -391,7 +405,7 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
await room.deafenButton.click();
|
||||
await client.page.waitForTimeout(500);
|
||||
// Un-deafen does NOT restore mute – user stays muted
|
||||
// Un-deafen does NOT restore mute - user stays muted
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: true,
|
||||
isDeafened: false
|
||||
@@ -429,10 +443,14 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
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
|
||||
'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) => ({
|
||||
@@ -574,7 +592,7 @@ async function openSavedRoomByName(page: Page, roomName: string): Promise<void>
|
||||
}
|
||||
|
||||
async function waitForInviteJoin(page: Page): Promise<void> {
|
||||
// Invite page loads → auto-joins → redirects to room
|
||||
// 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 });
|
||||
}
|
||||
@@ -605,11 +623,13 @@ async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20
|
||||
}
|
||||
|
||||
async function openVoiceWorkspace(page: Page): Promise<void> {
|
||||
if (await page.locator('app-voice-workspace').isVisible().catch(() => false)) {
|
||||
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();
|
||||
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();
|
||||
@@ -619,6 +639,7 @@ async function openVoiceWorkspace(page: Page): Promise<void> {
|
||||
|
||||
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++) {
|
||||
@@ -634,10 +655,11 @@ async function joinVoiceChannelUntilConnected(page: Page, channelName: string, a
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error([
|
||||
`Failed to connect ${page.url()} to voice channel ${channelName}.`,
|
||||
lastError instanceof Error ? `Last error: ${lastError.message}` : 'Last error: unavailable'
|
||||
].join('\n'));
|
||||
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> {
|
||||
@@ -691,7 +713,7 @@ async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number)
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => Array<unknown>) | undefined)?.() ?? [];
|
||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
||||
|
||||
return connectedUsers.length === count;
|
||||
},
|
||||
@@ -724,7 +746,7 @@ async function waitForVoiceRosterCount(page: Page, channelName: string, expected
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => Array<unknown>) | undefined)?.(channelId) ?? [];
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
||||
|
||||
return roster.length === expected;
|
||||
},
|
||||
@@ -734,7 +756,7 @@ async function waitForVoiceRosterCount(page: Page, channelName: string, expected
|
||||
}
|
||||
|
||||
async function waitForVoiceStateAcrossPages(
|
||||
clients: ReadonlyArray<TestClient>,
|
||||
clients: readonly TestClient[],
|
||||
displayName: string,
|
||||
expectedState: { isMuted: boolean; isDeafened: boolean }
|
||||
): Promise<void> {
|
||||
@@ -765,7 +787,7 @@ async function waitForVoiceStateAcrossPages(
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
|
||||
const entry = roster.find((u) => u.displayName === expectedDisplayName);
|
||||
const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName);
|
||||
|
||||
return entry?.voiceState?.isMuted === expectedMuted
|
||||
&& entry?.voiceState?.isDeafened === expectedDeafened;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
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 { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
|
||||
import { startTestServer } from '../../helpers/test-server';
|
||||
import {
|
||||
dumpRtcDiagnostics,
|
||||
@@ -28,11 +25,11 @@ const USER_COUNT = 8;
|
||||
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
|
||||
const STABILITY_WINDOW_MS = 20_000;
|
||||
|
||||
type TestUser = {
|
||||
interface TestUser {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
type TestClient = Client & {
|
||||
user: TestUser;
|
||||
@@ -64,7 +61,6 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
status: 'online'
|
||||
}
|
||||
];
|
||||
|
||||
const users = buildUsers();
|
||||
const clients = await createTrackedClients(createClient, users, endpoints);
|
||||
|
||||
@@ -86,12 +82,14 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -141,7 +139,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
waitForAudioStatsPresent(client.page, 30_000)
|
||||
));
|
||||
|
||||
// Allow the mesh to settle — voice routing, allowed-peer-id
|
||||
// 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);
|
||||
@@ -173,6 +171,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
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]
|
||||
@@ -236,7 +235,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
await room.deafenButton.click();
|
||||
await client.page.waitForTimeout(500);
|
||||
// Un-deafen does NOT restore mute – the user stays muted
|
||||
// Un-deafen does NOT restore mute - the user stays muted
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: true,
|
||||
isDeafened: false
|
||||
@@ -245,7 +244,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
});
|
||||
|
||||
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
|
||||
// Every user is left muted after deafen cycling - unmute them all
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
@@ -256,7 +255,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Final audio flow check on every peer — confirms the full
|
||||
// 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 {
|
||||
@@ -284,7 +283,7 @@ function buildUsers(): TestUser[] {
|
||||
async function createTrackedClients(
|
||||
createClient: () => Promise<Client>,
|
||||
users: TestUser[],
|
||||
endpoints: ReadonlyArray<SeededEndpointInput>
|
||||
endpoints: readonly SeededEndpointInput[]
|
||||
): Promise<TestClient[]> {
|
||||
const clients: TestClient[] = [];
|
||||
|
||||
@@ -384,9 +383,11 @@ async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20
|
||||
}
|
||||
|
||||
async function openVoiceWorkspace(page: Page): Promise<void> {
|
||||
const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i }).first();
|
||||
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)) {
|
||||
if (await page.locator('app-voice-workspace').isVisible()
|
||||
.catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -396,6 +397,7 @@ async function openVoiceWorkspace(page: Page): Promise<void> {
|
||||
|
||||
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++) {
|
||||
@@ -559,7 +561,7 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise
|
||||
const realtime = component['realtime'] as {
|
||||
connectionErrorMessage?: () => string | null;
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>;
|
||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
||||
};
|
||||
} | undefined;
|
||||
|
||||
@@ -596,7 +598,7 @@ async function waitForConnectedSignalManagerCount(page: Page, expectedCount: num
|
||||
const component = debugApi.getComponent(host);
|
||||
const realtime = component['realtime'] as {
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>;
|
||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
||||
};
|
||||
} | undefined;
|
||||
const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||
@@ -624,7 +626,7 @@ async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
||||
const component = debugApi.getComponent(host);
|
||||
const realtime = component['realtime'] as {
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>;
|
||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
||||
};
|
||||
} | undefined;
|
||||
|
||||
@@ -647,7 +649,7 @@ async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number)
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => Array<unknown>) | undefined)?.() ?? [];
|
||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
||||
|
||||
return connectedUsers.length === count;
|
||||
},
|
||||
@@ -688,7 +690,7 @@ async function waitForVoiceRosterCount(page: Page, channelName: string, expected
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => Array<unknown>) | undefined)?.(channelId) ?? [];
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
||||
|
||||
return roster.length === expected;
|
||||
},
|
||||
@@ -698,7 +700,7 @@ async function waitForVoiceRosterCount(page: Page, channelName: string, expected
|
||||
}
|
||||
|
||||
async function waitForVoiceStateAcrossPages(
|
||||
clients: ReadonlyArray<TestClient>,
|
||||
clients: readonly TestClient[],
|
||||
displayName: string,
|
||||
expectedState: { isMuted: boolean; isDeafened: boolean }
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import {
|
||||
installWebRTCTracking,
|
||||
installAutoResumeAudioContext,
|
||||
waitForPeerConnected,
|
||||
isPeerStillConnected,
|
||||
getAudioStatsDelta,
|
||||
@@ -13,7 +14,7 @@ import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
|
||||
/**
|
||||
* Full user journey: register → create server → join → voice → verify audio
|
||||
* Full user journey: register -> create server -> join -> voice -> verify audio
|
||||
* for 10+ seconds of stable connectivity.
|
||||
*
|
||||
* Uses two independent browser contexts (Alice & Bob) to simulate real
|
||||
@@ -25,7 +26,7 @@ const BOB = { username: `bob_${Date.now()}`, displayName: 'Bob', password: 'Test
|
||||
const SERVER_NAME = `E2E Test Server ${Date.now()}`;
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
test.describe('Full user journey: register → server → voice chat', () => {
|
||||
test.describe('Full user journey: register -> server -> voice chat', () => {
|
||||
test('two users register, create server, join voice, and stay connected 10+ seconds with audio', async ({ createClient }) => {
|
||||
test.setTimeout(180_000); // 3 min - covers registration, server creation, voice establishment, and 10s stability check
|
||||
|
||||
@@ -35,6 +36,20 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
||||
// Install WebRTC tracking before any navigation
|
||||
await installWebRTCTracking(alice.page);
|
||||
await installWebRTCTracking(bob.page);
|
||||
await installAutoResumeAudioContext(alice.page);
|
||||
await installAutoResumeAudioContext(bob.page);
|
||||
|
||||
// Seed deterministic voice settings so noise reduction doesn't
|
||||
// swallow the fake audio tone.
|
||||
const voiceSettings = JSON.stringify({
|
||||
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
|
||||
latencyProfile: 'balanced', includeSystemAudio: false,
|
||||
noiseReduction: false, screenShareQuality: 'balanced',
|
||||
askScreenShareQuality: false
|
||||
});
|
||||
|
||||
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
|
||||
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
|
||||
|
||||
// Forward browser console for debugging
|
||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||
@@ -146,8 +161,38 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
||||
// ── Step 7: Verify audio is flowing in both directions ───────────
|
||||
|
||||
await test.step('Audio packets are flowing between Alice and Bob', async () => {
|
||||
const aliceDelta = await waitForAudioFlow(alice.page, 30_000);
|
||||
const bobDelta = await waitForAudioFlow(bob.page, 30_000);
|
||||
// Chromium's --use-fake-device-for-media-stream can produce a
|
||||
// silent capture track on the very first getUserMedia call. If
|
||||
// bidirectional audio doesn't flow within a short window, leave
|
||||
// and rejoin voice to re-acquire the mic.
|
||||
let aliceDelta = await waitForAudioFlow(alice.page, 15_000);
|
||||
let bobDelta = await waitForAudioFlow(bob.page, 15_000);
|
||||
|
||||
const isFlowing = (delta: typeof aliceDelta) =>
|
||||
(delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0) &&
|
||||
(delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0);
|
||||
|
||||
if (!isFlowing(aliceDelta) || !isFlowing(bobDelta)) {
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
|
||||
await aliceRoom.disconnectButton.click();
|
||||
await bobRoom.disconnectButton.click();
|
||||
await alice.page.waitForTimeout(2_000);
|
||||
|
||||
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await waitForPeerConnected(alice.page, 30_000);
|
||||
await waitForPeerConnected(bob.page, 30_000);
|
||||
await waitForAudioStatsPresent(alice.page, 20_000);
|
||||
await waitForAudioStatsPresent(bob.page, 20_000);
|
||||
|
||||
aliceDelta = await waitForAudioFlow(alice.page, 30_000);
|
||||
bobDelta = await waitForAudioFlow(bob.page, 30_000);
|
||||
}
|
||||
|
||||
if (aliceDelta.outboundBytesDelta === 0 || aliceDelta.inboundBytesDelta === 0
|
||||
|| bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) {
|
||||
|
||||
Reference in New Issue
Block a user