Files
Toju/e2e/tests/voice/cross-signal-initiator-election.spec.ts
myxelium a83f5aa750 fix(realtime): survive signal outages and per-server identities
Peer recovery burned its whole retry budget while signaling was down, then
dropped the tracker with no re-arm, so a peer stayed dead until an unrelated
roster event healed it. Recovery now waits for a usable transport before
spending an attempt.

Initiator election also compared a home actor id against foreign roster ids,
which is not antisymmetric across signal servers - both sides offered, or
neither did. Election moves into `peer-role.rules` and compares ids only
within one signal server's identity space.
2026-08-14 03:19:29 +02:00

189 lines
7.1 KiB
TypeScript

import { expect } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { expectDashboardReady } from '../../helpers/dashboard';
import {
countCreatedPeerConnections,
expectExactlyOneInitiatorPerPair,
readPeerRoleEdges,
type PeerRoleEdge
} from '../../helpers/peer-role';
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import {
installDeterministicVoiceSettings,
joinRoomFromSearch,
joinVoiceChannelUntilConnected,
openSavedRoomByName
} from '../../helpers/voice-session';
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
import {
dumpRtcDiagnostics,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
const SIGNAL_A_ID = 'e2e-cross-signal-a';
const SIGNAL_B_ID = 'e2e-cross-signal-b';
const VOICE_CHANNEL = 'General';
const USER_PASSWORD = 'TestPass123!';
const USER_COUNT = 4;
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
interface TestUser {
username: string;
displayName: string;
/** Signal server this human registered on - their home identity space. */
homeSignalId: string;
}
type TestClient = Client & { user: TestUser };
test.describe('Cross-signal WebRTC identity', () => {
test.describe.configure({ timeout: 600_000 });
test('elects exactly one initiator per pair when peers have different home signal servers', async ({
createClient,
testServer
}) => {
const signalB = await startTestServer();
try {
const suffix = `cross_signal_${Date.now()}`;
const roomName = `Cross Signal Voice ${suffix}`;
const endpoints: SeededEndpointInput[] = [
{
id: SIGNAL_A_ID,
name: 'E2E Signal A',
url: testServer.url,
isActive: true,
status: 'online'
},
{
id: SIGNAL_B_ID,
name: 'E2E Signal B',
url: signalB.url,
isActive: true,
status: 'online'
}
];
// The room is hosted on signal B. Two humans are at home there and two are
// foreign guests, so most pairs must compare a foreign actor id against a
// foreign actor id - never a home id against one.
const users: TestUser[] = [
{ username: `host_${suffix}`, displayName: 'Cross Host', homeSignalId: SIGNAL_B_ID },
{ username: `native_${suffix}`, displayName: 'Cross Native', homeSignalId: SIGNAL_B_ID },
{ username: `guest_a_${suffix}`, displayName: 'Cross Guest A', homeSignalId: SIGNAL_A_ID },
{ username: `guest_b_${suffix}`, displayName: 'Cross Guest B', homeSignalId: SIGNAL_A_ID }
];
const clients: TestClient[] = [];
for (const user of users) {
const client = await createClient();
await installTestServerEndpoints(client.context, endpoints);
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.context);
await installAutoResumeAudioContext(client.page);
clients.push({ ...client, user });
}
const [host] = clients;
await test.step('Each human registers on their own home signal server', async () => {
for (const client of clients) {
const register = new RegisterPage(client.page);
await register.goto();
await register.serverSelect.selectOption(client.user.homeSignalId);
await register.register(client.user.username, client.user.displayName, USER_PASSWORD);
await expectDashboardReady(client.page);
}
});
await test.step('The host creates the voice room on signal B', async () => {
await new ServerSearchPage(host.page).createServer(roomName, {
description: 'Cross-signal initiator election coverage',
sourceId: SIGNAL_B_ID
});
await expect(host.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatRoomPage(host.page).ensureVoiceChannelExists(VOICE_CHANNEL);
});
await test.step('Everyone else joins the room, provisioning a foreign account when needed', async () => {
for (const client of clients.slice(1)) {
await joinRoomFromSearch(client.page, roomName);
}
await openSavedRoomByName(host.page, roomName);
});
// Everyone reconnects at once, so every pair elects its initiator from the same
// roster snapshot. Staggered arrivals let one side's 1s fallback-offer timer
// serialize negotiation, which hides a wrong comparison; a reconnect storm - a
// signal blip, or a channel everyone piles into - does not.
await test.step('All four reconnect simultaneously', async () => {
await Promise.all(clients.map(async (client) => {
await client.page.reload({ waitUntil: 'domcontentloaded' });
await openSavedRoomByName(client.page, roomName);
}));
});
await test.step('All four join the same voice channel simultaneously', async () => {
await Promise.all(clients.map((client) =>
joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL)
));
for (const client of clients) {
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
});
await test.step('Every pair carries bidirectional audio', async () => {
await Promise.all(clients.map((client) => waitForPeerConnected(client.page, 90_000)));
await Promise.all(clients.map((client) => waitForAudioStatsPresent(client.page, 30_000)));
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 120_000);
} catch (error) {
console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
await test.step('Exactly one side of every pair elected itself initiator', async () => {
const edgesByClient: Record<string, PeerRoleEdge[]> = {};
for (const client of clients) {
edgesByClient[client.user.displayName] = (await readPeerRoleEdges(client.page))
.filter((edge) => edge.connectionState === 'connected');
}
// Comparing a home id against a foreign actor id is not antisymmetric, so both
// peers could offer (glare) or neither could until a takeover timer fired.
expectExactlyOneInitiatorPerPair(edgesByClient);
});
await test.step('No peer had to be rebuilt to reach that state', async () => {
for (const client of clients) {
expect(
await countCreatedPeerConnections(client.page),
`${client.user.displayName} rebuilt a peer connection instead of connecting on the first offer`
).toBe(EXPECTED_REMOTE_PEERS);
}
});
} finally {
await signalB.stop();
}
});
});