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[] = []; 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) => { 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[] }; 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); }); });