livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
768 lines (629 loc) • 27.2 kB
text/typescript
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);
}
});
});