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
217 lines
7.8 KiB
TypeScript
217 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|