import { expect } from '@playwright/test'; import { test, type Client } from '../../fixtures/multi-client'; import { expectDashboardReady } from '../../helpers/dashboard'; import { countCreatedPeerConnections, expectExactlyOneInitiatorPerPair, readPeerRoleEdges, type PeerRoleEdge } from '../../helpers/peer-role'; import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint'; import { startTestServer } from '../../helpers/test-server'; import { installDeterministicVoiceSettings, joinRoomFromSearch, joinVoiceChannelUntilConnected, openSavedRoomByName } from '../../helpers/voice-session'; import { waitForVoiceRosterCount } from '../../helpers/voice-roster'; import { dumpRtcDiagnostics, installAutoResumeAudioContext, installWebRTCTracking, waitForAllPeerAudioFlow, waitForAudioStatsPresent, waitForPeerConnected } from '../../helpers/webrtc-helpers'; import { ChatRoomPage } from '../../pages/chat-room.page'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; const SIGNAL_A_ID = 'e2e-cross-signal-a'; const SIGNAL_B_ID = 'e2e-cross-signal-b'; const VOICE_CHANNEL = 'General'; const USER_PASSWORD = 'TestPass123!'; const USER_COUNT = 4; const EXPECTED_REMOTE_PEERS = USER_COUNT - 1; interface TestUser { username: string; displayName: string; /** Signal server this human registered on - their home identity space. */ homeSignalId: string; } type TestClient = Client & { user: TestUser }; test.describe('Cross-signal WebRTC identity', () => { test.describe.configure({ timeout: 600_000 }); test('elects exactly one initiator per pair when peers have different home signal servers', async ({ createClient, testServer }) => { const signalB = await startTestServer(); try { const suffix = `cross_signal_${Date.now()}`; const roomName = `Cross Signal Voice ${suffix}`; const endpoints: SeededEndpointInput[] = [ { id: SIGNAL_A_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, status: 'online' }, { id: SIGNAL_B_ID, name: 'E2E Signal B', url: signalB.url, isActive: true, status: 'online' } ]; // The room is hosted on signal B. Two humans are at home there and two are // foreign guests, so most pairs must compare a foreign actor id against a // foreign actor id - never a home id against one. const users: TestUser[] = [ { username: `host_${suffix}`, displayName: 'Cross Host', homeSignalId: SIGNAL_B_ID }, { username: `native_${suffix}`, displayName: 'Cross Native', homeSignalId: SIGNAL_B_ID }, { username: `guest_a_${suffix}`, displayName: 'Cross Guest A', homeSignalId: SIGNAL_A_ID }, { username: `guest_b_${suffix}`, displayName: 'Cross Guest B', homeSignalId: SIGNAL_A_ID } ]; const clients: TestClient[] = []; for (const user of users) { const client = await createClient(); await installTestServerEndpoints(client.context, endpoints); await installDeterministicVoiceSettings(client.page); await installWebRTCTracking(client.context); await installAutoResumeAudioContext(client.page); clients.push({ ...client, user }); } const [host] = clients; await test.step('Each human registers on their own home signal server', async () => { for (const client of clients) { const register = new RegisterPage(client.page); await register.goto(); await register.serverSelect.selectOption(client.user.homeSignalId); await register.register(client.user.username, client.user.displayName, USER_PASSWORD); await expectDashboardReady(client.page); } }); await test.step('The host creates the voice room on signal B', async () => { await new ServerSearchPage(host.page).createServer(roomName, { description: 'Cross-signal initiator election coverage', sourceId: SIGNAL_B_ID }); await expect(host.page).toHaveURL(/\/room\//, { timeout: 20_000 }); await new ChatRoomPage(host.page).ensureVoiceChannelExists(VOICE_CHANNEL); }); await test.step('Everyone else joins the room, provisioning a foreign account when needed', async () => { for (const client of clients.slice(1)) { await joinRoomFromSearch(client.page, roomName); } await openSavedRoomByName(host.page, roomName); }); // Everyone reconnects at once, so every pair elects its initiator from the same // roster snapshot. Staggered arrivals let one side's 1s fallback-offer timer // serialize negotiation, which hides a wrong comparison; a reconnect storm - a // signal blip, or a channel everyone piles into - does not. await test.step('All four reconnect simultaneously', async () => { await Promise.all(clients.map(async (client) => { await client.page.reload({ waitUntil: 'domcontentloaded' }); await openSavedRoomByName(client.page, roomName); })); }); await test.step('All four join the same voice channel simultaneously', async () => { await Promise.all(clients.map((client) => joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL) )); for (const client of clients) { await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT); } }); await test.step('Every pair carries bidirectional audio', async () => { await Promise.all(clients.map((client) => waitForPeerConnected(client.page, 90_000))); await Promise.all(clients.map((client) => waitForAudioStatsPresent(client.page, 30_000))); for (const client of clients) { try { await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 120_000); } catch (error) { console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`); throw error; } } }); await test.step('Exactly one side of every pair elected itself initiator', async () => { const edgesByClient: Record = {}; for (const client of clients) { edgesByClient[client.user.displayName] = (await readPeerRoleEdges(client.page)) .filter((edge) => edge.connectionState === 'connected'); } // Comparing a home id against a foreign actor id is not antisymmetric, so both // peers could offer (glare) or neither could until a takeover timer fired. expectExactlyOneInitiatorPerPair(edgesByClient); }); await test.step('No peer had to be rebuilt to reach that state', async () => { for (const client of clients) { expect( await countCreatedPeerConnections(client.page), `${client.user.displayName} rebuilt a peer connection instead of connecting on the first offer` ).toBe(EXPECTED_REMOTE_PEERS); } }); } finally { await signalB.stop(); } }); });