215 lines
7.7 KiB
TypeScript
215 lines
7.7 KiB
TypeScript
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<typeof vi.fn>;
|
|
createPeerConnection: ReturnType<typeof vi.fn>;
|
|
removePeer: ReturnType<typeof vi.fn>;
|
|
replaceDataChannel: ReturnType<typeof vi.fn>;
|
|
} {
|
|
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<string>(),
|
|
remoteScreenShareStreamIds: new Set<string>(),
|
|
remoteVoiceStreamIds: new Set<string>(),
|
|
videoSender: undefined
|
|
};
|
|
}
|
|
|
|
function createDataChannel(readyState: RTCDataChannelState): RTCDataChannel {
|
|
return {
|
|
bufferedAmount: 0,
|
|
label: 'chat',
|
|
readyState
|
|
} as unknown as RTCDataChannel;
|
|
}
|