import { type BrowserContext, type Page } from '@playwright/test'; import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types'; /** Same shape `IceServerSettingsService` persists under `metoyou_ice_servers`. */ interface StoredIceServerEntry { id: string; type: 'stun' | 'turn'; urls: string; username?: string; credential?: string; } export interface TurnCredentials { urls: string; username: string; credential: string; } const ICE_SERVERS_STORAGE_KEY = 'metoyou_ice_servers'; /** * Configure the app with a single TURN server, the way a user would in * Settings -> ICE servers. Nothing test-specific reads this back: the app loads * it through `IceServerSettingsService`, so the call really is configured the * product way. * * Call BEFORE any `goto()`. */ export async function seedTurnOnlyIceServers( target: BrowserContext | Page, turn: TurnCredentials ): Promise { const entries: StoredIceServerEntry[] = [ { credential: turn.credential, id: 'e2e-turn', type: 'turn', urls: turn.urls, username: turn.username } ]; await target.addInitScript( ([key, value]) => { localStorage.setItem(key, value); }, [ICE_SERVERS_STORAGE_KEY, JSON.stringify(entries)] as const ); } /** * Take away the direct path. Every `RTCPeerConnection` is built with * `iceTransportPolicy: 'relay'`, so host and server-reflexive candidates are * discarded and the call can only succeed by relaying through the configured * TURN server - which is what a user behind symmetric NAT is forced to do. * * Install AFTER `installWebRTCTracking` (it wraps whatever constructor is * current) and BEFORE any `goto()`. */ export async function forceRelayOnlyIce(target: BrowserContext | Page): Promise { await target.addInitScript(() => { const harness = window as unknown as WebRtcTestHarnessWindow & { __relayIceConfigs?: RTCConfiguration[]; }; const Wrapped = harness.RTCPeerConnection; harness.__relayIceConfigs = []; const RelayOnly = function(this: RTCPeerConnection, config?: RTCConfiguration) { const relayConfig: RTCConfiguration = { ...config, iceTransportPolicy: 'relay' }; harness.__relayIceConfigs?.push(relayConfig); return new Wrapped(relayConfig); } as unknown as typeof RTCPeerConnection; RelayOnly.prototype = Wrapped.prototype; Object.setPrototypeOf(RelayOnly, Wrapped); harness.RTCPeerConnection = RelayOnly; }); } /** * The configuration each peer connection was actually built with. A relay-only * run that connects nothing usually means the app handed over no TURN server at * all, which looks identical to a broken relay from the outside. */ export async function getRelayIceConfigs(page: Page): Promise { return await page.evaluate(() => (window as unknown as { __relayIceConfigs?: RTCConfiguration[] }).__relayIceConfigs ?? [] ); } export interface SelectedCandidatePair { localCandidateType: string; remoteCandidateType: string; } /** * The candidate pair each connection actually settled on. `relay` on the local * side means our packets left through the TURN server rather than going direct. */ export async function getSelectedCandidatePairs(page: Page): Promise { return await page.evaluate(async () => { const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? []; const pairs: SelectedCandidatePair[] = []; for (const pc of connections) { let stats: RTCStatsReport; try { stats = await pc.getStats(); } catch { continue; } const candidates = new Map(); let selected: { localCandidateId?: string; remoteCandidateId?: string } | null = null; stats.forEach((report) => { if (report.type === 'local-candidate' || report.type === 'remote-candidate') { candidates.set(report.id as string, (report as { candidateType?: string }).candidateType ?? 'unknown'); } }); stats.forEach((report) => { if (report.type !== 'candidate-pair') { return; } const pair = report as unknown as { state?: string; nominated?: boolean; selected?: boolean; localCandidateId?: string; remoteCandidateId?: string; }; if (pair.state === 'succeeded' && (pair.nominated || pair.selected)) { selected = pair; } }); if (!selected) { continue; } const pair = selected as { localCandidateId?: string; remoteCandidateId?: string }; pairs.push({ localCandidateType: candidates.get(pair.localCandidateId ?? '') ?? 'unknown', remoteCandidateType: candidates.get(pair.remoteCandidateId ?? '') ?? 'unknown' }); } return pairs; }); } /** * Wait until `expectedPairs` connections report a settled candidate pair whose * local candidate is a TURN relay. */ export async function waitForRelayedCandidatePairs( page: Page, expectedPairs: number, timeoutMs = 60_000 ): Promise { const deadline = Date.now() + timeoutMs; let latest: SelectedCandidatePair[] = []; while (Date.now() < deadline) { latest = await getSelectedCandidatePairs(page); const relayed = latest.filter((pair) => pair.localCandidateType === 'relay'); if (relayed.length >= expectedPairs) { return latest; } await page.waitForTimeout(1_000); } throw new Error( `Timed out waiting for ${expectedPairs} relayed candidate pairs. Last seen: ${JSON.stringify(latest)}` ); }