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:
227
e2e/tests/settings/connectivity-warning.spec.ts
Normal file
227
e2e/tests/settings/connectivity-warning.spec.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import {
|
||||
installAutoResumeAudioContext,
|
||||
installWebRTCTracking,
|
||||
waitForConnectedPeerCount
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
|
||||
const VOICE_SETTINGS = JSON.stringify({
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
audioBitrate: 96,
|
||||
latencyProfile: 'balanced',
|
||||
includeSystemAudio: false,
|
||||
noiseReduction: false,
|
||||
screenShareQuality: 'balanced',
|
||||
askScreenShareQuality: false
|
||||
});
|
||||
|
||||
/**
|
||||
* Seed deterministic voice settings on a page so noise reduction and
|
||||
* input gating don't interfere with the fake audio tone.
|
||||
*/
|
||||
async function seedVoiceSettings(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.addInitScript((settings: string) => {
|
||||
localStorage.setItem('metoyou_voice_settings', settings);
|
||||
}, VOICE_SETTINGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all of a client's RTCPeerConnections and prevent any
|
||||
* reconnection by sabotaging the SDP negotiation methods on the
|
||||
* prototype - new connections get created but can never complete ICE.
|
||||
*
|
||||
* Chromium doesn't fire `connectionstatechange` on programmatic
|
||||
* `close()`, so we dispatch the event manually so the app's recovery
|
||||
* code runs and updates the connected-peers signal.
|
||||
*/
|
||||
async function killAndBlockPeerConnections(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
// Sabotage SDP methods so no NEW connections can negotiate.
|
||||
const proto = RTCPeerConnection.prototype;
|
||||
|
||||
proto.createOffer = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
|
||||
proto.createAnswer = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
|
||||
proto.setLocalDescription = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
|
||||
proto.setRemoteDescription = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
|
||||
|
||||
// Close every existing connection and manually fire the event
|
||||
// Chromium omits when close() is called from JS.
|
||||
const connections = (window as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
|
||||
|
||||
for (const pc of connections) {
|
||||
try {
|
||||
pc.close();
|
||||
pc.dispatchEvent(new Event('connectionstatechange'));
|
||||
} catch { /* already closed */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Connectivity warning', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('shows warning icon when a peer loses all connections', async ({ createClient }) => {
|
||||
const suffix = `connwarn_${Date.now()}`;
|
||||
const serverName = `ConnWarn ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
const charlie = await createClient();
|
||||
|
||||
// ── Install WebRTC tracking & AudioContext auto-resume ──
|
||||
for (const client of [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
]) {
|
||||
await installWebRTCTracking(client.page);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
await seedVoiceSettings(client.page);
|
||||
}
|
||||
|
||||
// ── Register all three users ──
|
||||
await test.step('Register Alice', async () => {
|
||||
const register = new RegisterPage(alice.page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Register Bob', async () => {
|
||||
const register = new RegisterPage(bob.page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Register Charlie', async () => {
|
||||
const register = new RegisterPage(charlie.page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
|
||||
await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
// ── Create server and have everyone join ──
|
||||
await test.step('Alice creates a server', async () => {
|
||||
const search = new ServerSearchPage(alice.page);
|
||||
|
||||
await search.createServer(serverName);
|
||||
});
|
||||
|
||||
await test.step('Bob joins the server', async () => {
|
||||
const search = new ServerSearchPage(bob.page);
|
||||
|
||||
await search.searchInput.fill(serverName);
|
||||
const card = bob.page.locator('button', { hasText: serverName }).first();
|
||||
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Charlie joins the server', async () => {
|
||||
const search = new ServerSearchPage(charlie.page);
|
||||
|
||||
await search.searchInput.fill(serverName);
|
||||
const card = charlie.page.locator('button', { hasText: serverName }).first();
|
||||
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
const charlieRoom = new ChatRoomPage(charlie.page);
|
||||
|
||||
// ── Everyone joins voice ──
|
||||
await test.step('All three join voice', async () => {
|
||||
await aliceRoom.joinVoiceChannel('General');
|
||||
await bobRoom.joinVoiceChannel('General');
|
||||
await charlieRoom.joinVoiceChannel('General');
|
||||
});
|
||||
|
||||
await test.step('All users see each other in voice', async () => {
|
||||
// Each user should see the other two in the voice channel list.
|
||||
await expect(
|
||||
aliceRoom.channelsSidePanel.getByText('Bob')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(
|
||||
aliceRoom.channelsSidePanel.getByText('Charlie')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(
|
||||
bobRoom.channelsSidePanel.getByText('Alice')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(
|
||||
bobRoom.channelsSidePanel.getByText('Charlie')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(
|
||||
charlieRoom.channelsSidePanel.getByText('Alice')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(
|
||||
charlieRoom.channelsSidePanel.getByText('Bob')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
// ── Wait for full mesh to establish ──
|
||||
await test.step('All peer connections establish', async () => {
|
||||
// Each client should have 2 connected peers (full mesh of 3).
|
||||
await waitForConnectedPeerCount(alice.page, 2, 30_000);
|
||||
await waitForConnectedPeerCount(bob.page, 2, 30_000);
|
||||
await waitForConnectedPeerCount(charlie.page, 2, 30_000);
|
||||
});
|
||||
|
||||
// ── Break Charlie's connections ──
|
||||
await test.step('Kill Charlie peer connections and block reconnection', async () => {
|
||||
await killAndBlockPeerConnections(charlie.page);
|
||||
|
||||
// Give the health service time to detect the desync.
|
||||
// Peer latency pings stop -> connectedPeers updates -> desyncPeerIds recalculates.
|
||||
await alice.page.waitForTimeout(15_000);
|
||||
});
|
||||
|
||||
// ── Assert connectivity warnings ──
|
||||
//
|
||||
// The warning icon (lucideAlertTriangle) is a direct sibling of the
|
||||
// user-name span inside the same voice-row div. Using the CSS
|
||||
// general-sibling combinator (~) avoids accidentally matching a
|
||||
// parent container that holds multiple rows.
|
||||
await test.step('Alice sees warning icon next to Charlie', async () => {
|
||||
const charlieWarning = aliceRoom.channelsSidePanel
|
||||
.locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]');
|
||||
|
||||
await expect(charlieWarning).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Bob sees warning icon next to Charlie', async () => {
|
||||
const charlieWarning = bobRoom.channelsSidePanel
|
||||
.locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]');
|
||||
|
||||
await expect(charlieWarning).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Alice does NOT see warning icon next to Bob', async () => {
|
||||
const bobWarning = aliceRoom.channelsSidePanel
|
||||
.locator('span.truncate:has-text("Bob") ~ ng-icon[name="lucideAlertTriangle"]');
|
||||
|
||||
await expect(bobWarning).not.toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Charlie sees local desync banner', async () => {
|
||||
const desyncBanner = charlie.page.locator('text=You may have connectivity issues');
|
||||
|
||||
await expect(desyncBanner).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
126
e2e/tests/settings/ice-server-settings.spec.ts
Normal file
126
e2e/tests/settings/ice-server-settings.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
|
||||
test.describe('ICE server settings', () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
async function registerAndOpenNetworkSettings(page: import('@playwright/test').Page, suffix: string) {
|
||||
const register = new RegisterPage(page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
|
||||
await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTitle('Settings').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'Network' }).click();
|
||||
}
|
||||
|
||||
test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = `ice_${Date.now()}`;
|
||||
|
||||
await test.step('Register and open Network settings', async () => {
|
||||
await registerAndOpenNetworkSettings(page, suffix);
|
||||
});
|
||||
|
||||
const iceSection = page.getByTestId('ice-server-settings');
|
||||
|
||||
await test.step('Default STUN servers are listed', async () => {
|
||||
await expect(iceSection).toBeVisible({ timeout: 5_000 });
|
||||
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
|
||||
|
||||
await expect(entries.first()).toBeVisible({ timeout: 5_000 });
|
||||
const count = await entries.count();
|
||||
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
await test.step('Add a STUN server', async () => {
|
||||
await page.getByTestId('ice-type-select').selectOption('stun');
|
||||
await page.getByTestId('ice-url-input').fill('stun:custom.example.com:3478');
|
||||
await page.getByTestId('ice-add-button').click();
|
||||
await expect(page.getByText('stun:custom.example.com:3478')).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
await test.step('Add a TURN server with credentials', async () => {
|
||||
await page.getByTestId('ice-type-select').selectOption('turn');
|
||||
await page.getByTestId('ice-url-input').fill('turn:relay.example.com:443');
|
||||
await page.getByTestId('ice-username-input').fill('testuser');
|
||||
await page.getByTestId('ice-credential-input').fill('testpass');
|
||||
await page.getByTestId('ice-add-button').click();
|
||||
await expect(page.getByText('turn:relay.example.com:443')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText('User: testuser')).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
await test.step('Remove first entry and verify count decreases', async () => {
|
||||
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
|
||||
const countBefore = await entries.count();
|
||||
|
||||
await entries.first().getByTitle('Remove')
|
||||
.click();
|
||||
|
||||
await expect(entries).toHaveCount(countBefore - 1, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
await test.step('Reorder: move second entry up', async () => {
|
||||
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
|
||||
const count = await entries.count();
|
||||
|
||||
if (count >= 2) {
|
||||
const secondText = await entries.nth(1).locator('p')
|
||||
.first()
|
||||
.textContent();
|
||||
|
||||
if (!secondText) {
|
||||
throw new Error('Expected ICE server entry text before reordering');
|
||||
}
|
||||
|
||||
await entries.nth(1).getByTitle('Move up (higher priority)')
|
||||
.click();
|
||||
|
||||
// Wait for the moved entry text to appear at position 0
|
||||
await expect(entries.first().locator('p')
|
||||
.first()).toHaveText(secondText, { timeout: 5_000 });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Restore defaults resets list', async () => {
|
||||
await page.getByTestId('ice-restore-defaults').click();
|
||||
await expect(page.getByText('turn:relay.example.com:443')).not.toBeVisible({ timeout: 3_000 });
|
||||
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
|
||||
|
||||
await expect(entries.first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
await test.step('Settings persist after page reload', async () => {
|
||||
await page.getByTestId('ice-type-select').selectOption('stun');
|
||||
await page.getByTestId('ice-url-input').fill('stun:persist-test.example.com:3478');
|
||||
await page.getByTestId('ice-add-button').click();
|
||||
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.getByTitle('Settings').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'Network' }).click();
|
||||
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('validates TURN entries require credentials', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = `iceval_${Date.now()}`;
|
||||
|
||||
await test.step('Register and open Network settings', async () => {
|
||||
await registerAndOpenNetworkSettings(page, suffix);
|
||||
});
|
||||
|
||||
await test.step('Adding TURN without credentials shows error', async () => {
|
||||
await page.getByTestId('ice-type-select').selectOption('turn');
|
||||
await page.getByTestId('ice-url-input').fill('turn:noncred.example.com:443');
|
||||
await page.getByTestId('ice-add-button').click();
|
||||
await expect(page.getByText('Username is required for TURN servers')).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
216
e2e/tests/settings/stun-turn-fallback.spec.ts
Normal file
216
e2e/tests/settings/stun-turn-fallback.spec.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import {
|
||||
dumpRtcDiagnostics,
|
||||
installAutoResumeAudioContext,
|
||||
installWebRTCTracking,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForPeerConnected,
|
||||
waitForConnectedPeerCount,
|
||||
waitForAudioStatsPresent
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
|
||||
const ICE_STORAGE_KEY = 'metoyou_ice_servers';
|
||||
|
||||
interface StoredIceServerEntry {
|
||||
type?: string;
|
||||
urls?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that user-configured ICE servers are persisted and used by peer connections.
|
||||
*
|
||||
* On localhost TURN relay is never needed (direct always succeeds), so this test:
|
||||
* 1. Seeds Bob's browser with an additional TURN entry via localStorage.
|
||||
* 2. Has both users join voice with differing ICE configs.
|
||||
* 3. Verifies both can connect and Bob's TURN entry is still in storage.
|
||||
*/
|
||||
test.describe('STUN/TURN fallback behaviour', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('users with different ICE configs can voice chat together', async ({ createClient }) => {
|
||||
const suffix = `turnfb_${Date.now()}`;
|
||||
const serverName = `Fallback ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
// Install WebRTC tracking before any navigation so we can inspect
|
||||
// peer connections and audio stats.
|
||||
await installWebRTCTracking(alice.page);
|
||||
await installWebRTCTracking(bob.page);
|
||||
|
||||
// Ensure AudioContexts auto-resume so the input-gain pipeline
|
||||
// (source -> gain -> destination) never stalls in "suspended" state.
|
||||
await installAutoResumeAudioContext(alice.page);
|
||||
await installAutoResumeAudioContext(bob.page);
|
||||
|
||||
// Set deterministic voice settings so noise reduction and input gating
|
||||
// don'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((settings: string) => {
|
||||
localStorage.setItem('metoyou_voice_settings', settings);
|
||||
}, voiceSettings);
|
||||
|
||||
await bob.page.addInitScript((settings: string) => {
|
||||
localStorage.setItem('metoyou_voice_settings', settings);
|
||||
}, voiceSettings);
|
||||
|
||||
// Seed Bob with an extra TURN entry before the app reads localStorage.
|
||||
await bob.context.addInitScript((key: string) => {
|
||||
try {
|
||||
const existing = JSON.parse(localStorage.getItem(key) || '[]');
|
||||
|
||||
existing.push({
|
||||
id: 'e2e-turn',
|
||||
type: 'turn',
|
||||
urls: 'turn:localhost:3478',
|
||||
username: 'e2euser',
|
||||
credential: 'e2epass'
|
||||
});
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(existing));
|
||||
} catch { /* noop */ }
|
||||
}, ICE_STORAGE_KEY);
|
||||
|
||||
await test.step('Register Alice', async () => {
|
||||
const register = new RegisterPage(alice.page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Register Bob', async () => {
|
||||
const register = new RegisterPage(bob.page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Alice creates a server', async () => {
|
||||
const search = new ServerSearchPage(alice.page);
|
||||
|
||||
await search.createServer(serverName);
|
||||
});
|
||||
|
||||
await test.step('Bob joins Alice server', async () => {
|
||||
const search = new ServerSearchPage(bob.page);
|
||||
|
||||
await search.searchInput.fill(serverName);
|
||||
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.click();
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
|
||||
await test.step('Both join voice', async () => {
|
||||
await aliceRoom.joinVoiceChannel('General');
|
||||
await bobRoom.joinVoiceChannel('General');
|
||||
});
|
||||
|
||||
await test.step('Both users see each other in voice', async () => {
|
||||
await expect(
|
||||
aliceRoom.channelsSidePanel.getByText('Bob')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(
|
||||
bobRoom.channelsSidePanel.getByText('Alice')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Peer connections establish and audio flows bidirectionally', async () => {
|
||||
await waitForPeerConnected(alice.page, 30_000);
|
||||
await waitForPeerConnected(bob.page, 30_000);
|
||||
await waitForConnectedPeerCount(alice.page, 1, 30_000);
|
||||
await waitForConnectedPeerCount(bob.page, 1, 30_000);
|
||||
|
||||
// Wait for audio RTP stats to appear (tracks negotiated)
|
||||
await waitForAudioStatsPresent(alice.page, 30_000);
|
||||
await waitForAudioStatsPresent(bob.page, 30_000);
|
||||
|
||||
// Allow mesh to settle - voice routing and renegotiation can
|
||||
// cause a second offer/answer cycle after the initial connection.
|
||||
await alice.page.waitForTimeout(5_000);
|
||||
|
||||
// Chromium's --use-fake-device-for-media-stream can produce a
|
||||
// silent capture track on the very first getUserMedia call. If
|
||||
// bidirectional audio does not flow within a short window, leave
|
||||
// and rejoin voice to re-acquire the mic (the second getUserMedia
|
||||
// on a warm device always works).
|
||||
let audioFlowing = false;
|
||||
|
||||
try {
|
||||
await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 15_000), waitForAllPeerAudioFlow(bob.page, 1, 15_000)]);
|
||||
|
||||
audioFlowing = true;
|
||||
} catch {
|
||||
// Silent sender detected - rejoin voice to work around Chromium bug
|
||||
}
|
||||
|
||||
if (!audioFlowing) {
|
||||
// Leave voice
|
||||
await aliceRoom.disconnectButton.click();
|
||||
await bobRoom.disconnectButton.click();
|
||||
await alice.page.waitForTimeout(2_000);
|
||||
|
||||
// Rejoin
|
||||
await aliceRoom.joinVoiceChannel('General');
|
||||
await bobRoom.joinVoiceChannel('General');
|
||||
|
||||
await expect(
|
||||
aliceRoom.channelsSidePanel.getByText('Bob')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(
|
||||
bobRoom.channelsSidePanel.getByText('Alice')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await waitForPeerConnected(alice.page, 30_000);
|
||||
await waitForPeerConnected(bob.page, 30_000);
|
||||
await waitForConnectedPeerCount(alice.page, 1, 30_000);
|
||||
await waitForConnectedPeerCount(bob.page, 1, 30_000);
|
||||
await waitForAudioStatsPresent(alice.page, 30_000);
|
||||
await waitForAudioStatsPresent(bob.page, 30_000);
|
||||
await alice.page.waitForTimeout(3_000);
|
||||
}
|
||||
|
||||
// Final assertion - must succeed after the (optional) rejoin.
|
||||
try {
|
||||
await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 60_000), waitForAllPeerAudioFlow(bob.page, 1, 60_000)]);
|
||||
} catch (error) {
|
||||
console.log('[Alice RTC Diagnostics]\n' + await dumpRtcDiagnostics(alice.page));
|
||||
console.log('[Bob RTC Diagnostics]\n' + await dumpRtcDiagnostics(bob.page));
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Bob still has TURN entry in localStorage', async () => {
|
||||
const stored: StoredIceServerEntry[] = await bob.page.evaluate(
|
||||
(key) => JSON.parse(localStorage.getItem(key) || '[]') as StoredIceServerEntry[],
|
||||
ICE_STORAGE_KEY
|
||||
);
|
||||
const hasTurn = stored.some(
|
||||
(entry) => entry.type === 'turn' && entry.urls === 'turn:localhost:3478'
|
||||
);
|
||||
|
||||
expect(hasTurn).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user