UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

768 lines (629 loc) 27.2 kB
import { DisconnectReason, JoinResponse, LeaveRequest, ReconnectResponse, SignalRequest, SignalResponse, } from '@livekit/protocol'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConnectionError, ConnectionErrorReason } from '../room/errors'; import { SignalClient, SignalConnectionState } from './SignalClient'; import type { WebSocketCloseInfo, WebSocketConnection } from './WebSocketStream'; import { WebSocketStream } from './WebSocketStream'; // Mock the WebSocketStream vi.mock('./WebSocketStream'); // Mock fetch for validation endpoint global.fetch = vi.fn(); // Test Helpers function createJoinResponse() { return new JoinResponse({ room: { name: 'test-room', sid: 'room-sid' }, participant: { sid: 'participant-sid', identity: 'test-user' }, pingTimeout: 30, pingInterval: 10, }); } function createSignalResponse( messageCase: 'join' | 'reconnect' | 'leave' | 'update', value: any, ): SignalResponse { return new SignalResponse({ message: { case: messageCase, value }, }); } function createMockReadableStream(responses: SignalResponse[]): ReadableStream<ArrayBuffer> { return new ReadableStream<ArrayBuffer>({ async start(controller) { for (const response of responses) { controller.enqueue(response.toBinary().buffer as ArrayBuffer); } }, }); } function createMockConnection(readable: ReadableStream<ArrayBuffer>): WebSocketConnection { return { readable, writable: new WritableStream(), protocol: '', extensions: '', }; } interface MockWebSocketStreamOptions { connection?: WebSocketConnection; opened?: Promise<WebSocketConnection>; closed?: Promise<WebSocketCloseInfo>; readyState?: number; } function mockWebSocketStream(options: MockWebSocketStreamOptions = {}) { const { connection, opened = connection ? Promise.resolve(connection) : new Promise(() => {}), closed = new Promise(() => {}), readyState = 1, } = options; return vi.mocked(WebSocketStream).mockImplementationOnce( () => ({ url: 'wss://test.livekit.io', opened, closed, close: vi.fn(), readyState, }) as any, ); } describe('SignalClient.connect', () => { let signalClient: SignalClient; const defaultOptions = { autoSubscribe: true, maxRetries: 0, e2eeEnabled: false, websocketTimeout: 1000, singlePeerConnection: false, }; beforeEach(() => { vi.clearAllMocks(); signalClient = new SignalClient(false); }); describe('Happy Path - Initial Join', () => { it('should successfully connect and receive join response', async () => { const joinResponse = createJoinResponse(); const signalResponse = createSignalResponse('join', joinResponse); const mockReadable = createMockReadableStream([signalResponse]); const mockConnection = createMockConnection(mockReadable); mockWebSocketStream({ connection: mockConnection }); const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); expect(result).toEqual(joinResponse); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }); }); describe('Happy Path - Reconnect', () => { it('should successfully reconnect and receive reconnect response', async () => { // First, set up initial connection const joinResponse = createJoinResponse(); const joinSignalResponse = createSignalResponse('join', joinResponse); const initialMockReadable = createMockReadableStream([joinSignalResponse]); const initialMockConnection = createMockConnection(initialMockReadable); mockWebSocketStream({ connection: initialMockConnection }); await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); // Now test reconnect const reconnectResponse = new ReconnectResponse({ iceServers: [], }); const reconnectSignalResponse = createSignalResponse('reconnect', reconnectResponse); const reconnectMockReadable = createMockReadableStream([reconnectSignalResponse]); const reconnectMockConnection = createMockConnection(reconnectMockReadable); mockWebSocketStream({ connection: reconnectMockConnection }); const result = await signalClient.reconnect('wss://test.livekit.io', 'test-token', 'sid-123'); expect(result).toEqual(reconnectResponse); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }); it('should handle reconnect with non-reconnect message (edge case)', async () => { // First, initial connection const joinResponse = createJoinResponse(); const joinSignalResponse = createSignalResponse('join', joinResponse); const initialMockReadable = createMockReadableStream([joinSignalResponse]); const initialMockConnection = createMockConnection(initialMockReadable); mockWebSocketStream({ connection: initialMockConnection }); await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); // Setup reconnect with non-reconnect message (e.g., participant update) const updateSignalResponse = createSignalResponse('update', { participants: [] }); const reconnectMockReadable = createMockReadableStream([updateSignalResponse]); const reconnectMockConnection = createMockConnection(reconnectMockReadable); mockWebSocketStream({ connection: reconnectMockConnection }); const result = await signalClient.reconnect('wss://test.livekit.io', 'test-token', 'sid-123'); // This is an edge case: reconnect resolves with undefined when non-reconnect message is received expect(result).toBeUndefined(); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }, 1000); }); describe('Failure Case - Timeout', () => { it('should reject with timeout error when websocket connection takes too long', async () => { mockWebSocketStream({ readyState: 0 }); // Never resolves const shortTimeoutOptions = { ...defaultOptions, websocketTimeout: 100, }; const error = await signalClient .join('wss://test.livekit.io', 'test-token', shortTimeoutOptions) .catch((e) => e); expect(error).toBeInstanceOf(ConnectionError); expect(error.reason).toBe(ConnectionErrorReason.Cancelled); }); }); describe('Failure Case - AbortSignal', () => { it('should reject when AbortSignal is triggered', async () => { const abortController = new AbortController(); vi.mocked(WebSocketStream).mockImplementation(() => { // Simulate abort setTimeout(() => abortController.abort(new Error('User aborted connection')), 50); return { url: 'wss://test.livekit.io', opened: new Promise(() => {}), // Never resolves closed: new Promise(() => {}), close: vi.fn(), readyState: 0, } as any; }); await expect( signalClient.join( 'wss://test.livekit.io', 'test-token', defaultOptions, abortController.signal, ), ).rejects.toThrow('User aborted connection'); }); it('should send leave request before closing when AbortSignal is triggered during connection', async () => { const abortController = new AbortController(); const writtenMessages: Array<ArrayBuffer | string> = []; let streamWriterReady: (() => void) | undefined; const streamWriterReadyPromise = new Promise<void>((resolve) => { streamWriterReady = resolve; }); // Create a mock writable stream that captures writes const mockWritable = new WritableStream({ write(chunk) { writtenMessages.push(chunk); return Promise.resolve(); }, }); // Override getWriter to signal when streamWriter is assigned const originalGetWriter = mockWritable.getWriter.bind(mockWritable); mockWritable.getWriter = () => { const writer = originalGetWriter(); streamWriterReady?.(); return writer; }; const mockReadable = new ReadableStream<ArrayBuffer>({ async start() { // Keep connection open but don't send join response yet // This simulates aborting during connection (after WS opens, before join response) }, }); const mockConnection = { readable: mockReadable, writable: mockWritable, protocol: '', extensions: '', }; vi.mocked(WebSocketStream).mockImplementation(() => { return { url: 'wss://test.livekit.io', opened: Promise.resolve(mockConnection), closed: new Promise(() => {}), close: vi.fn(), readyState: 1, } as any; }); // Start the connection const joinPromise = signalClient.join( 'wss://test.livekit.io', 'test-token', defaultOptions, abortController.signal, ); // Wait for streamWriter to be assigned await streamWriterReadyPromise; // Now abort the connection (after WS opens, before join response) abortController.abort(new Error('User aborted connection')); // joinPromise should reject await expect(joinPromise).rejects.toThrow('User aborted connection'); // Verify that a leave request was sent before closing const leaveRequestSent = writtenMessages.some((data) => { if (typeof data === 'string') { return false; } try { const request = SignalRequest.fromBinary( data instanceof ArrayBuffer ? new Uint8Array(data) : data, ); return request.message?.case === 'leave'; } catch { return false; } }); expect(leaveRequestSent).toBe(true); }); }); describe('Failure Case - WebSocket Connection Errors', () => { it('should reject with NotAllowed error for 4xx HTTP status', async () => { const openedPromise = Promise.reject(new Error('Connection failed')); openedPromise.catch(() => {}); // prevent unhandled rejection before join attaches its handler mockWebSocketStream({ opened: openedPromise, readyState: 3, }); // Mock fetch to return 403 (global.fetch as any).mockResolvedValueOnce({ status: 403, text: async () => 'Forbidden', }); await expect( signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), ).rejects.toMatchObject({ message: 'Forbidden', reason: ConnectionErrorReason.NotAllowed, status: 403, }); }); it('should reject with ServerUnreachable when fetch fails', async () => { const openedPromise = Promise.reject(new Error('Connection failed')); openedPromise.catch(() => {}); // prevent unhandled rejection before join attaches its handler mockWebSocketStream({ opened: openedPromise, readyState: 3, }); // Mock fetch to throw (network error) (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); await expect( signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), ).rejects.toMatchObject({ reason: ConnectionErrorReason.ServerUnreachable, }); }); it('should handle ConnectionError from WebSocket rejection', async () => { const customError = ConnectionError.internal('Custom error', { status: 500 }); const openedPromise = Promise.reject(customError); openedPromise.catch(() => {}); // prevent unhandled rejection before join attaches its handler mockWebSocketStream({ opened: openedPromise, readyState: 3, }); // Mock fetch to return 500 (global.fetch as any).mockResolvedValueOnce({ status: 500, text: async () => 'Internal Server Error', }); await expect( signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), ).rejects.toMatchObject({ reason: ConnectionErrorReason.InternalError, }); }); }); describe('Failure Case - No First Message', () => { it('should reject when no first message is received', async () => { // Close the stream immediately without sending a message const mockReadable = new ReadableStream<ArrayBuffer>({ async start(controller) { controller.close(); }, }); const mockConnection = createMockConnection(mockReadable); mockWebSocketStream({ connection: mockConnection }); await expect( signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), ).rejects.toMatchObject({ message: 'no message received as first message', reason: ConnectionErrorReason.InternalError, }); }); }); describe('Failure Case - Leave Request During Connection', () => { it('should reject when receiving leave request during initial join', async () => { const leaveRequest = new LeaveRequest({ reason: 1, // Some disconnect reason }); const signalResponse = createSignalResponse('leave', leaveRequest); const mockReadable = createMockReadableStream([signalResponse]); const mockConnection = createMockConnection(mockReadable); mockWebSocketStream({ connection: mockConnection }); await expect( signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), ).rejects.toMatchObject( ConnectionError.leaveRequest( 'Received leave request while trying to (re)connect', DisconnectReason.CLIENT_INITIATED, ), ); }); }); describe('Failure Case - Wrong Message Type for Non-Reconnect', () => { it('should reject when receiving non-join message on initial connection', async () => { // Send a reconnect response instead of join (wrong for initial connection) const reconnectResponse = new ReconnectResponse({ iceServers: [], }); const signalResponse = createSignalResponse('reconnect', reconnectResponse); const mockReadable = createMockReadableStream([signalResponse]); const mockConnection = createMockConnection(mockReadable); mockWebSocketStream({ connection: mockConnection }); await expect( signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), ).rejects.toMatchObject({ message: 'did not receive join response, got reconnect instead', reason: ConnectionErrorReason.InternalError, }); }); }); describe('Failure Case - WebSocket Closed During Connection', () => { it('should reject when WebSocket closes during connection attempt', async () => { let closedResolve: (value: WebSocketCloseInfo) => void; const closedPromise = new Promise<WebSocketCloseInfo>((resolve) => { closedResolve = resolve; }); vi.mocked(WebSocketStream).mockImplementation(() => { // Simulate close during connection queueMicrotask(() => { closedResolve({ closeCode: 1006, reason: 'Connection lost' }); }); return { url: 'wss://test.livekit.io', opened: new Promise(() => {}), // Never resolves closed: closedPromise, close: vi.fn(), readyState: 2, // CLOSING } as any; }); await expect( signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), ).rejects.toMatchObject({ message: 'Websocket got closed during a (re)connection attempt: Connection lost', reason: ConnectionErrorReason.InternalError, }); }); }); describe('Edge Cases and State Management', () => { it('should set state to CONNECTING when joining', async () => { expect(signalClient.currentState).toBe(SignalConnectionState.DISCONNECTED); const joinResponse = createJoinResponse(); const signalResponse = createSignalResponse('join', joinResponse); const mockReadable = createMockReadableStream([signalResponse]); const mockConnection = createMockConnection(mockReadable); mockWebSocketStream({ connection: mockConnection }); const joinPromise = signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); // State should be CONNECTING before connection completes expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTING); await joinPromise; expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }); }); }); describe('SignalClient utility functions', () => { describe('toProtoSessionDescription', () => { it('should convert RTCSessionDescriptionInit to proto SessionDescription', async () => { const { toProtoSessionDescription } = await import('./SignalClient'); const rtcDesc: RTCSessionDescriptionInit = { type: 'offer', sdp: 'v=0\r\no=- 123 456 IN IP4 127.0.0.1\r\n', }; const protoDesc = toProtoSessionDescription(rtcDesc, 42); expect(protoDesc.type).toBe('offer'); expect(protoDesc.sdp).toBe('v=0\r\no=- 123 456 IN IP4 127.0.0.1\r\n'); expect(protoDesc.id).toBe(42); }); it('should handle answer type', async () => { const { toProtoSessionDescription } = await import('./SignalClient'); const rtcDesc: RTCSessionDescriptionInit = { type: 'answer', sdp: 'v=0\r\n', }; const protoDesc = toProtoSessionDescription(rtcDesc); expect(protoDesc.type).toBe('answer'); expect(protoDesc.sdp).toBe('v=0\r\n'); }); }); }); describe('SignalClient.handleSignalConnected', () => { let signalClient: SignalClient; const defaultOptions = { autoSubscribe: true, maxRetries: 0, e2eeEnabled: false, websocketTimeout: 1000, singlePeerConnection: false, }; beforeEach(() => { vi.clearAllMocks(); signalClient = new SignalClient(false); }); it('should set state to CONNECTED', () => { const mockReadable = new ReadableStream<ArrayBuffer>(); const mockConnection = createMockConnection(mockReadable); // Access the method through a type assertion for testing const handleMethod = (signalClient as any).handleSignalConnected; if (handleMethod) { handleMethod.call(signalClient, mockConnection); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); } }); it('should start reading loop without first message', async () => { const joinResponse = createJoinResponse(); const signalResponse = createSignalResponse('join', joinResponse); const mockReadable = createMockReadableStream([signalResponse]); const mockConnection = createMockConnection(mockReadable); mockWebSocketStream({ connection: mockConnection }); await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); // Verify connection was established successfully expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }); it('should start reading loop with first message', async () => { const joinResponse = createJoinResponse(); const signalResponse = createSignalResponse('join', joinResponse); const mockReadable = createMockReadableStream([signalResponse]); const mockConnection = createMockConnection(mockReadable); mockWebSocketStream({ connection: mockConnection }); await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }); }); describe('SignalClient.validateFirstMessage', () => { let signalClient: SignalClient; const defaultOptions = { autoSubscribe: true, maxRetries: 0, e2eeEnabled: false, websocketTimeout: 1000, singlePeerConnection: false, }; beforeEach(() => { vi.clearAllMocks(); signalClient = new SignalClient(false); }); it('should accept join response for initial connection', () => { const joinResponse = createJoinResponse(); const signalResponse = createSignalResponse('join', joinResponse); const validateMethod = (signalClient as any).validateFirstMessage; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, false); expect(result.isValid).toBe(true); expect(result.response).toEqual(joinResponse); } }); it('should accept reconnect response for reconnection', async () => { // First establish a connection to set options const joinResponse = createJoinResponse(); const joinSignalResponse = createSignalResponse('join', joinResponse); const initialMockReadable = createMockReadableStream([joinSignalResponse]); const initialMockConnection = createMockConnection(initialMockReadable); mockWebSocketStream({ connection: initialMockConnection }); await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); // Set state to RECONNECTING to match the validation logic (signalClient as any).state = SignalConnectionState.RECONNECTING; const reconnectResponse = new ReconnectResponse({ iceServers: [] }); const signalResponse = createSignalResponse('reconnect', reconnectResponse); const validateMethod = (signalClient as any).validateFirstMessage; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, true); expect(result.isValid).toBe(true); expect(result.response).toEqual(reconnectResponse); } }); it('should accept non-reconnect message during reconnecting state', async () => { // First establish a connection const joinResponse = createJoinResponse(); const joinSignalResponse = createSignalResponse('join', joinResponse); const initialMockReadable = createMockReadableStream([joinSignalResponse]); const initialMockConnection = createMockConnection(initialMockReadable); mockWebSocketStream({ connection: initialMockConnection }); await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); // Set state to reconnecting (signalClient as any).state = SignalConnectionState.RECONNECTING; const updateSignalResponse = createSignalResponse('update', { participants: [] }); const validateMethod = (signalClient as any).validateFirstMessage; if (validateMethod) { const result = validateMethod.call(signalClient, updateSignalResponse, true); expect(result.isValid).toBe(true); expect(result.response).toBeUndefined(); expect(result.shouldProcessFirstMessage).toBe(true); } }); it('should reject leave request during connection attempt', () => { // Set state to CONNECTING to be in establishing connection state (signalClient as any).state = SignalConnectionState.CONNECTING; const leaveRequest = new LeaveRequest({ reason: 1 }); const signalResponse = createSignalResponse('leave', leaveRequest); const validateMethod = (signalClient as any).validateFirstMessage; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, false); expect(result.isValid).toBe(false); expect(result.error).toBeInstanceOf(ConnectionError); expect(result.error?.reason).toBe(ConnectionErrorReason.LeaveRequest); } }); it('should reject non-join message for initial connection', () => { const reconnectResponse = new ReconnectResponse({ iceServers: [] }); const signalResponse = createSignalResponse('reconnect', reconnectResponse); const validateMethod = (signalClient as any).validateFirstMessage; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, false); expect(result.isValid).toBe(false); expect(result.error).toBeInstanceOf(ConnectionError); expect(result.error?.reason).toBe(ConnectionErrorReason.InternalError); } }); }); describe('SignalClient.handleConnectionError', () => { let signalClient: SignalClient; beforeEach(() => { vi.clearAllMocks(); signalClient = new SignalClient(false); }); it('should return NotAllowed error for 4xx HTTP status', async () => { (global.fetch as any).mockResolvedValueOnce({ status: 403, text: async () => 'Forbidden', }); const handleMethod = (signalClient as any).handleConnectionError; if (handleMethod) { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); expect(result).toBeInstanceOf(ConnectionError); expect(result.reason).toBe(ConnectionErrorReason.NotAllowed); expect(result.status).toBe(403); expect(result.message).toBe('Forbidden'); } }); it('should return ConnectionError as-is if it is already a ConnectionError', async () => { const connectionError = ConnectionError.internal('Custom error'); (global.fetch as any).mockResolvedValueOnce({ status: 500, text: async () => 'Internal Server Error', }); const handleMethod = (signalClient as any).handleConnectionError; if (handleMethod) { const result = await handleMethod.call( signalClient, connectionError, 'wss://test.livekit.io/validate', ); expect(result).toBe(connectionError); expect(result.reason).toBe(ConnectionErrorReason.InternalError); } }); it('should return InternalError for non-4xx HTTP status', async () => { (global.fetch as any).mockResolvedValueOnce({ status: 500, text: async () => 'Internal Server Error', }); const handleMethod = (signalClient as any).handleConnectionError; if (handleMethod) { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); expect(result).toBeInstanceOf(ConnectionError); expect(result.reason).toBe(ConnectionErrorReason.InternalError); } }); it('should return ServerUnreachable when fetch fails', async () => { (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); const handleMethod = (signalClient as any).handleConnectionError; if (handleMethod) { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); expect(result).toBeInstanceOf(ConnectionError); expect(result.reason).toBe(ConnectionErrorReason.ServerUnreachable); } }); it('should handle fetch throwing ConnectionError', async () => { const fetchError = ConnectionError.serverUnreachable('Fetch failed'); (global.fetch as any).mockRejectedValueOnce(fetchError); const handleMethod = (signalClient as any).handleConnectionError; if (handleMethod) { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); expect(result).toBe(fetchError); } }); });