Files
myxelium e49b3ec112 chore: dev-stack switches, shared e2e harness, and desktop shell rules
- `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.
2026-08-14 03:19:29 +02:00

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)}`
);
}