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:
2026-08-14 03:19:29 +02:00
parent d71e3a98da
commit e49b3ec112
41 changed files with 35947 additions and 50 deletions
+112
View File
@@ -0,0 +1,112 @@
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);
}
}