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

This commit is contained in:
2026-04-18 21:27:04 +02:00
parent 167c45ba8d
commit 44588e8789
60 changed files with 2404 additions and 365 deletions

View File

@@ -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;

View File

@@ -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> {

View File

@@ -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) {