import { DATA_CHANNEL_RECOVERY_GRACE_MS, DATA_CHANNEL_STATE_OPEN } from '../../realtime.constants'; import type { PeerData } from '../../realtime.types'; import { createPeerConnectionManagerState, PeerConnectionManagerContext, RecoveryHandlers } from '../shared'; import { scheduleDataChannelRecovery } from './peer-recovery'; describe('peer recovery', () => { afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); }); it('recreates a peer immediately when the data channel is already closed', () => { vi.useFakeTimers(); const channel = createDataChannel('closed'); const context = createContext('alice'); const handlers = createRecoveryHandlers(context); context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected')); scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers); expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true }); expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', true); expect(handlers.createAndSendOffer).toHaveBeenCalledWith('bob'); expect(context.state.dataChannelRecoveryTimers.has('bob')).toBe(false); }); it('waits a short grace period before recreating a peer with a closing data channel', () => { vi.useFakeTimers(); const channel = createDataChannel('closing'); const context = createContext('alice'); const handlers = createRecoveryHandlers(context); context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected')); scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers); vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS - 1); expect(handlers.removePeer).not.toHaveBeenCalled(); expect(handlers.createPeerConnection).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true }); expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', true); expect(handlers.createAndSendOffer).toHaveBeenCalledWith('bob'); }); it('does not recreate a peer when a replacement data channel is adopted before the grace expires', () => { vi.useFakeTimers(); const staleChannel = createDataChannel('closing'); const replacementChannel = createDataChannel(DATA_CHANNEL_STATE_OPEN); const context = createContext('alice'); const handlers = createRecoveryHandlers(context); context.state.activePeerConnections.set('bob', createPeerData(staleChannel, 'connected')); scheduleDataChannelRecovery(context, 'bob', staleChannel, 'close', handlers); context.state.activePeerConnections.set('bob', createPeerData(replacementChannel, 'connected')); vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS); expect(handlers.removePeer).not.toHaveBeenCalled(); expect(handlers.replaceDataChannel).not.toHaveBeenCalled(); expect(handlers.createPeerConnection).not.toHaveBeenCalled(); }); it('does not schedule recovery for an open data channel', () => { vi.useFakeTimers(); const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN); const context = createContext('alice'); const handlers = createRecoveryHandlers(context); context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected')); scheduleDataChannelRecovery(context, 'bob', channel, 'error', handlers); vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS); expect(handlers.removePeer).not.toHaveBeenCalled(); expect(handlers.replaceDataChannel).not.toHaveBeenCalled(); expect(handlers.createPeerConnection).not.toHaveBeenCalled(); }); it('recreates a connected non-initiator peer and waits for the remote initiator offer', () => { vi.useFakeTimers(); const channel = createDataChannel('closed'); const context = createContext('zoe'); const handlers = createRecoveryHandlers(context); context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected', false)); scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers); expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true }); expect(handlers.replaceDataChannel).not.toHaveBeenCalled(); expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', false); expect(handlers.createAndSendOffer).not.toHaveBeenCalled(); }); it('waits for the remote initiator when a non-connected peer needs full reconnect', () => { vi.useFakeTimers(); const channel = createDataChannel('closed'); const context = createContext('zoe'); const handlers = createRecoveryHandlers(context); context.state.activePeerConnections.set('bob', createPeerData(channel, 'disconnected')); scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers); vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS); expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', false); expect(handlers.createAndSendOffer).not.toHaveBeenCalled(); }); }); function createContext(localOderId: string): PeerConnectionManagerContext { return { logger: { error: vi.fn(), info: vi.fn(), logStream: vi.fn(), traffic: vi.fn(), warn: vi.fn() } as unknown as PeerConnectionManagerContext['logger'], callbacks: { getIceServers: vi.fn(() => []), getIdentifyCredentials: vi.fn(() => ({ oderId: localOderId, displayName: localOderId })), getLocalMediaStream: vi.fn(() => null), getLocalPeerId: vi.fn(() => localOderId), getVoiceStateSnapshot: vi.fn(() => ({ isConnected: true, isMuted: false, isDeafened: false, isScreenSharing: false, roomId: 'voice-room', serverId: 'server-1' })), isCameraEnabled: vi.fn(() => false), isScreenSharingActive: vi.fn(() => false), isSignalingConnected: vi.fn(() => true), sendRawMessage: vi.fn() }, state: createPeerConnectionManagerState() }; } function createRecoveryHandlers(context: PeerConnectionManagerContext): RecoveryHandlers & { createAndSendOffer: ReturnType; createPeerConnection: ReturnType; removePeer: ReturnType; replaceDataChannel: ReturnType; } { return { createAndSendOffer: vi.fn(async () => undefined), createPeerConnection: vi.fn((peerId: string, isInitiator: boolean) => { const peerData = createPeerData(createDataChannel(isInitiator ? 'connecting' : 'closed'), 'new', isInitiator); context.state.activePeerConnections.set(peerId, peerData); return peerData; }), removePeer: vi.fn((peerId: string) => { context.state.activePeerConnections.delete(peerId); }), replaceDataChannel: vi.fn((peerId: string, expectedChannel: RTCDataChannel) => { const peerData = context.state.activePeerConnections.get(peerId); if (!peerData || peerData.dataChannel !== expectedChannel) { return false; } peerData.dataChannel = createDataChannel('connecting'); return true; }) }; } function createPeerData( dataChannel: RTCDataChannel, connectionState: RTCPeerConnectionState, isInitiator = true ): PeerData { return { audioSender: undefined, connection: { close: vi.fn(), connectionState } as unknown as RTCPeerConnection, createdAt: Date.now(), dataChannel, isInitiator, pendingIceCandidates: [], remoteCameraStreamIds: new Set(), remoteScreenShareStreamIds: new Set(), remoteVoiceStreamIds: new Set(), videoSender: undefined }; } function createDataChannel(readyState: RTCDataChannelState): RTCDataChannel { return { bufferedAmount: 0, label: 'chat', readyState } as unknown as RTCDataChannel; }