- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine suspend; the reload client otherwise destroys the session under test. - `dev-peer.sh` plus a separate userdata dir runs a second local peer. - `tools/voice-probe.js` samples peer state and RTP counters from a live window, persisting to localStorage so a renderer reload cannot erase it. - e2e helpers for voice pairs, peer-role election, and a TURN relay. - Electron single-instance and dev-client-load decisions move into rules files with colocated specs.
189 lines
5.6 KiB
TypeScript
189 lines
5.6 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<RTCConfiguration[]> {
|
|
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<SelectedCandidatePair[]> {
|
|
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<string, string>();
|
|
|
|
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<SelectedCandidatePair[]> {
|
|
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)}`
|
|
);
|
|
}
|