346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi
|
|
} from 'vitest';
|
|
import {
|
|
SIGNALING_CONNECT_TIMEOUT_MS,
|
|
SIGNALING_HEALTH_PROBE_INTERVAL_MS,
|
|
SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS,
|
|
SIGNALING_KEEPALIVE_INTERVAL_MS,
|
|
SIGNALING_RECONNECT_BASE_DELAY_MS,
|
|
STATE_HEARTBEAT_INTERVAL_MS
|
|
} from '../realtime.constants';
|
|
import { WebRTCLogger } from '../logging/webrtc-logger';
|
|
import { SignalingManager } from './signaling.manager';
|
|
import { probeSignalingEndpointHealth } from './signaling-endpoint-health.rules';
|
|
|
|
vi.mock('./signaling-endpoint-health.rules', () => ({
|
|
probeSignalingEndpointHealth: vi.fn(async () => ({ ok: true, serverInstanceId: 'instance-a' }))
|
|
}));
|
|
|
|
type MockSocketHandler = (event?: { code?: number; reason?: string; wasClean?: boolean }) => void;
|
|
|
|
class MockWebSocket {
|
|
static readonly CONNECTING = 0;
|
|
static readonly OPEN = 1;
|
|
static readonly CLOSING = 2;
|
|
static readonly CLOSED = 3;
|
|
|
|
static instances: MockWebSocket[] = [];
|
|
|
|
shouldFailSend = false;
|
|
autoAckKeepalive = true;
|
|
readonly url: string;
|
|
readyState = MockWebSocket.CONNECTING;
|
|
onopen: MockSocketHandler | null = null;
|
|
onclose: MockSocketHandler | null = null;
|
|
onerror: MockSocketHandler | null = null;
|
|
onmessage: ((event: { data: string }) => void) | null = null;
|
|
|
|
constructor(url: string) {
|
|
this.url = url;
|
|
MockWebSocket.instances.push(this);
|
|
}
|
|
|
|
close(): void {
|
|
if (this.readyState === MockWebSocket.CLOSED) {
|
|
return;
|
|
}
|
|
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
this.onclose?.({ code: 1000, reason: 'closed', wasClean: true });
|
|
}
|
|
|
|
send(payload: string): void {
|
|
if (this.shouldFailSend) {
|
|
throw new Error('mock send failure');
|
|
}
|
|
|
|
if (!this.autoAckKeepalive) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const message = JSON.parse(payload) as { type?: string };
|
|
|
|
if (message.type === 'keepalive') {
|
|
this.onmessage?.({ data: JSON.stringify({ type: 'keepalive_ack' }) });
|
|
}
|
|
} catch {
|
|
// ignore malformed payloads in tests
|
|
}
|
|
}
|
|
|
|
simulateOpen(): void {
|
|
this.readyState = MockWebSocket.OPEN;
|
|
this.onopen?.();
|
|
}
|
|
|
|
simulateError(): void {
|
|
this.onerror?.(new Event('error'));
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
describe('SignalingManager reconnection', () => {
|
|
const serverUrl = 'ws://localhost:3001';
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
MockWebSocket.instances = [];
|
|
vi.stubGlobal('WebSocket', MockWebSocket);
|
|
vi.mocked(probeSignalingEndpointHealth).mockResolvedValue({ ok: true, serverInstanceId: 'instance-a' });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function createManager(
|
|
identifyCredentials: {
|
|
oderId: string;
|
|
displayName: string;
|
|
description?: string;
|
|
profileUpdatedAt?: number;
|
|
homeSignalServerUrl?: string;
|
|
} = {
|
|
oderId: 'peer-a',
|
|
displayName: 'Peer A',
|
|
description: 'hello',
|
|
profileUpdatedAt: 42,
|
|
homeSignalServerUrl: 'https://signal.example'
|
|
}
|
|
): SignalingManager {
|
|
const sentMessages: Record<string, unknown>[] = [];
|
|
const manager = new SignalingManager(
|
|
new WebRTCLogger(false),
|
|
() => identifyCredentials,
|
|
() => ({ serverId: 'server-1', userId: 'peer-a' }),
|
|
() => new Set(['server-1'])
|
|
);
|
|
const originalSendRawMessage = manager.sendRawMessage.bind(manager);
|
|
|
|
manager.sendRawMessage = (message: Record<string, unknown>) => {
|
|
sentMessages.push(message);
|
|
originalSendRawMessage(message);
|
|
};
|
|
|
|
Object.assign(manager, { sentMessages });
|
|
|
|
return manager;
|
|
}
|
|
|
|
it('schedules reconnect after an established socket closes', async () => {
|
|
const manager = createManager();
|
|
const connected = vi.fn();
|
|
|
|
manager.connect(serverUrl).subscribe({ next: connected });
|
|
MockWebSocket.instances[0]?.simulateOpen();
|
|
expect(connected).toHaveBeenCalledWith(true);
|
|
|
|
MockWebSocket.instances[0]?.close();
|
|
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
|
|
|
expect(MockWebSocket.instances).toHaveLength(2);
|
|
expect(MockWebSocket.instances[1]?.readyState).toBe(MockWebSocket.CONNECTING);
|
|
});
|
|
|
|
it('keeps retrying after a failed reconnect attempt until the server accepts the socket', async () => {
|
|
const manager = createManager();
|
|
|
|
manager.connect(serverUrl).subscribe();
|
|
MockWebSocket.instances[0]?.simulateOpen();
|
|
MockWebSocket.instances[0]?.close();
|
|
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
|
MockWebSocket.instances[1]?.simulateError();
|
|
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
|
|
expect(MockWebSocket.instances).toHaveLength(3);
|
|
|
|
MockWebSocket.instances[2]?.simulateOpen();
|
|
expect(manager.isSocketOpen()).toBe(true);
|
|
});
|
|
|
|
it('discards a stale connecting socket and retries after the connect timeout', async () => {
|
|
const manager = createManager();
|
|
|
|
manager.connect(serverUrl).subscribe();
|
|
MockWebSocket.instances[0]?.simulateOpen();
|
|
MockWebSocket.instances[0]?.close();
|
|
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
|
expect(MockWebSocket.instances).toHaveLength(2);
|
|
|
|
vi.advanceTimersByTime(SIGNALING_CONNECT_TIMEOUT_MS);
|
|
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
|
|
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('does not treat a timed-out waitForOpen result as a successful reconnect', async () => {
|
|
const manager = createManager();
|
|
|
|
manager.connect(serverUrl).subscribe();
|
|
MockWebSocket.instances[0]?.simulateOpen();
|
|
MockWebSocket.instances[0]?.close();
|
|
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
|
expect(MockWebSocket.instances).toHaveLength(2);
|
|
|
|
const secondAttempt = vi.fn();
|
|
|
|
manager.connect(serverUrl).subscribe({ next: secondAttempt });
|
|
|
|
vi.advanceTimersByTime(SIGNALING_CONNECT_TIMEOUT_MS);
|
|
expect(secondAttempt).toHaveBeenCalledWith(false);
|
|
expect(manager.isSocketOpen()).toBe(false);
|
|
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
|
|
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('replays the full identify payload after reconnect', () => {
|
|
const manager = createManager() as SignalingManager & { sentMessages: Record<string, unknown>[] };
|
|
|
|
manager.connect(serverUrl).subscribe();
|
|
MockWebSocket.instances[0]?.simulateOpen();
|
|
manager.sentMessages.length = 0;
|
|
MockWebSocket.instances[0]?.close();
|
|
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
|
MockWebSocket.instances[1]?.simulateOpen();
|
|
|
|
const identifyMessage = manager.sentMessages.find((message) => message['type'] === 'identify');
|
|
|
|
expect(identifyMessage).toMatchObject({
|
|
type: 'identify',
|
|
oderId: 'peer-a',
|
|
displayName: 'Peer A',
|
|
description: 'hello',
|
|
profileUpdatedAt: 42,
|
|
homeSignalServerUrl: 'https://signal.example'
|
|
});
|
|
});
|
|
|
|
it('does not force reconnect on servers that never send keepalive acknowledgements', () => {
|
|
const manager = createManager();
|
|
|
|
manager.connect(serverUrl).subscribe();
|
|
const socket = MockWebSocket.instances[0];
|
|
|
|
if (socket) {
|
|
socket.autoAckKeepalive = false;
|
|
}
|
|
|
|
socket?.simulateOpen();
|
|
|
|
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
|
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS);
|
|
|
|
expect(MockWebSocket.instances).toHaveLength(1);
|
|
expect(manager.isSocketOpen()).toBe(true);
|
|
});
|
|
|
|
it('forces reconnect when keepalive acknowledgements stop arriving on ack-capable servers', () => {
|
|
const manager = createManager();
|
|
|
|
manager.connect(serverUrl).subscribe();
|
|
const socket = MockWebSocket.instances[0];
|
|
|
|
socket?.simulateOpen();
|
|
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
|
|
|
if (socket) {
|
|
socket.autoAckKeepalive = false;
|
|
}
|
|
|
|
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_INTERVAL_MS + STATE_HEARTBEAT_INTERVAL_MS);
|
|
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS);
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
|
|
|
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
|
expect(manager.isSocketOpen()).toBe(false);
|
|
});
|
|
|
|
it('silently drops transient negotiation messages while the socket is reconnecting', () => {
|
|
const logger = new WebRTCLogger(false);
|
|
const errorSpy = vi.spyOn(logger, 'error');
|
|
const manager = new SignalingManager(
|
|
logger,
|
|
() => ({ oderId: 'peer-a', displayName: 'Peer A' }),
|
|
() => ({ serverId: 'server-1', userId: 'peer-a' }),
|
|
() => new Set(['server-1'])
|
|
);
|
|
|
|
expect(manager.sendRawMessage({
|
|
type: 'ice_candidate',
|
|
targetUserId: 'peer-b',
|
|
payload: { candidate: { candidate: 'candidate:1' } }
|
|
})).toBe(false);
|
|
|
|
expect(errorSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forces reconnect when the signaling health probe fails on an open socket', async () => {
|
|
vi.mocked(probeSignalingEndpointHealth).mockResolvedValue({ ok: false });
|
|
const manager = createManager();
|
|
|
|
manager.connect(serverUrl).subscribe();
|
|
MockWebSocket.instances[0]?.simulateOpen();
|
|
await Promise.resolve();
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
|
|
|
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
|
expect(manager.isSocketOpen()).toBe(false);
|
|
});
|
|
|
|
it('forces reconnect when the signaling server instance changes under an open socket', async () => {
|
|
const manager = createManager();
|
|
|
|
manager.connect(serverUrl).subscribe();
|
|
MockWebSocket.instances[0]?.simulateOpen();
|
|
|
|
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
|
await Promise.resolve();
|
|
|
|
vi.mocked(probeSignalingEndpointHealth).mockResolvedValueOnce({
|
|
ok: true,
|
|
serverInstanceId: 'instance-b'
|
|
});
|
|
|
|
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
|
await Promise.resolve();
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
|
|
|
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
|
expect(manager.isSocketOpen()).toBe(false);
|
|
});
|
|
|
|
it('forces reconnect when outbound signaling sends fail on a stale socket', () => {
|
|
const manager = createManager();
|
|
|
|
manager.connect(serverUrl).subscribe();
|
|
const socket = MockWebSocket.instances[0];
|
|
|
|
socket?.simulateOpen();
|
|
|
|
if (socket) {
|
|
socket.shouldFailSend = true;
|
|
}
|
|
|
|
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_INTERVAL_MS + STATE_HEARTBEAT_INTERVAL_MS);
|
|
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
|
|
|
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
|
expect(manager.isSocketOpen()).toBe(false);
|
|
});
|
|
});
|