- `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.
113 lines
3.9 KiB
TypeScript
113 lines
3.9 KiB
TypeScript
import { expect, type Page } from '@playwright/test';
|
|
|
|
export interface PeerRoleEdge {
|
|
/** Remote peer id, in the identity space of the signal server routing that peer. */
|
|
peerId: string;
|
|
/** Our own actor id in that same identity space. */
|
|
localActorId: string | null;
|
|
isInitiator: boolean;
|
|
connectionState: string;
|
|
}
|
|
|
|
/**
|
|
* Read who elected themselves initiator for every active peer, together with the local
|
|
* actor id in that peer's identity space. Both halves of a pair must describe the same
|
|
* two ids, which is what makes the election comparable in the first place.
|
|
*/
|
|
export async function readPeerRoleEdges(page: Page): Promise<PeerRoleEdge[]> {
|
|
return await page.evaluate(() => {
|
|
interface AngularDebugApi {
|
|
getComponent: (element: Element) => Record<string, unknown>;
|
|
}
|
|
interface PeerDataShape {
|
|
connection?: { connectionState?: string };
|
|
isInitiator?: boolean;
|
|
}
|
|
interface RealtimeShape {
|
|
peerManager?: { activePeerConnections?: Map<string, PeerDataShape> };
|
|
signalingTransportHandler?: {
|
|
getIdentifyCredentialsForPeer?: (peerId: string) => { oderId?: string } | null;
|
|
};
|
|
}
|
|
|
|
const host = document.querySelector('app-rooms-side-panel');
|
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
|
|
if (!host || !debugApi?.getComponent) {
|
|
return [];
|
|
}
|
|
|
|
const realtime = debugApi.getComponent(host)['realtime'] as RealtimeShape | undefined;
|
|
const peers = realtime?.peerManager?.activePeerConnections;
|
|
|
|
if (!peers) {
|
|
return [];
|
|
}
|
|
|
|
const edges: PeerRoleEdge[] = [];
|
|
|
|
peers.forEach((peerData, peerId) => {
|
|
const credentials = realtime?.signalingTransportHandler?.getIdentifyCredentialsForPeer?.(peerId);
|
|
|
|
edges.push({
|
|
connectionState: peerData.connection?.connectionState ?? 'unknown',
|
|
isInitiator: peerData.isInitiator === true,
|
|
localActorId: credentials?.oderId ?? null,
|
|
peerId
|
|
});
|
|
});
|
|
|
|
return edges;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* How many RTCPeerConnections this page has created since load. A clean session creates
|
|
* exactly one per remote peer; a rebuilt peer - for example a non-initiator that gave up
|
|
* waiting for an offer that was never elected to be sent - adds another.
|
|
*/
|
|
export async function countCreatedPeerConnections(page: Page): Promise<number> {
|
|
return await page.evaluate(() =>
|
|
((window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? []).length
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Every connected pair must have exactly one initiator. Comparing ids from two different
|
|
* identity spaces breaks the antisymmetry of the election, so both peers offer (glare) or
|
|
* neither does until a takeover timer fires.
|
|
*/
|
|
export function expectExactlyOneInitiatorPerPair(edgesByClient: Record<string, PeerRoleEdge[]>): void {
|
|
const directed = new Map<string, boolean>();
|
|
const pairs = new Set<string>();
|
|
|
|
for (const [clientName, edges] of Object.entries(edgesByClient)) {
|
|
for (const edge of edges) {
|
|
expect(
|
|
edge.localActorId,
|
|
`${clientName} has no local actor id in the identity space of peer ${edge.peerId}`
|
|
).toBeTruthy();
|
|
|
|
const localActorId = edge.localActorId as string;
|
|
|
|
directed.set(`${localActorId}->${edge.peerId}`, edge.isInitiator);
|
|
pairs.add([localActorId, edge.peerId].sort().join('<->'));
|
|
}
|
|
}
|
|
|
|
expect(pairs.size, 'expected at least one peer pair').toBeGreaterThan(0);
|
|
|
|
for (const pair of pairs) {
|
|
const [first, second] = pair.split('<->');
|
|
const forward = directed.get(`${first}->${second}`);
|
|
const backward = directed.get(`${second}->${first}`);
|
|
|
|
expect(forward, `missing peer connection ${first} -> ${second}`).not.toBeUndefined();
|
|
expect(backward, `missing peer connection ${second} -> ${first}`).not.toBeUndefined();
|
|
expect(
|
|
[forward, backward].filter(Boolean),
|
|
`expected exactly one initiator for pair ${pair}`
|
|
).toHaveLength(1);
|
|
}
|
|
}
|