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.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user