livekit-client 2.15.8 → 2.15.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/livekit-client.esm.mjs +589 -205
  2. package/dist/livekit-client.esm.mjs.map +1 -1
  3. package/dist/livekit-client.umd.js +1 -1
  4. package/dist/livekit-client.umd.js.map +1 -1
  5. package/dist/src/api/SignalClient.d.ts +31 -2
  6. package/dist/src/api/SignalClient.d.ts.map +1 -1
  7. package/dist/src/api/WebSocketStream.d.ts +29 -0
  8. package/dist/src/api/WebSocketStream.d.ts.map +1 -0
  9. package/dist/src/api/utils.d.ts +2 -0
  10. package/dist/src/api/utils.d.ts.map +1 -1
  11. package/dist/src/connectionHelper/checks/turn.d.ts.map +1 -1
  12. package/dist/src/connectionHelper/checks/websocket.d.ts.map +1 -1
  13. package/dist/src/index.d.ts +2 -2
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/options.d.ts +6 -0
  16. package/dist/src/options.d.ts.map +1 -1
  17. package/dist/src/room/PCTransport.d.ts +1 -0
  18. package/dist/src/room/PCTransport.d.ts.map +1 -1
  19. package/dist/src/room/PCTransportManager.d.ts +6 -4
  20. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  21. package/dist/src/room/RTCEngine.d.ts +1 -1
  22. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  23. package/dist/src/room/Room.d.ts.map +1 -1
  24. package/dist/src/room/defaults.d.ts.map +1 -1
  25. package/dist/src/room/token-source/utils.d.ts +1 -1
  26. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  27. package/dist/src/room/utils.d.ts +6 -0
  28. package/dist/src/room/utils.d.ts.map +1 -1
  29. package/dist/ts4.2/api/SignalClient.d.ts +31 -2
  30. package/dist/ts4.2/api/WebSocketStream.d.ts +29 -0
  31. package/dist/ts4.2/api/utils.d.ts +2 -0
  32. package/dist/ts4.2/index.d.ts +2 -2
  33. package/dist/ts4.2/options.d.ts +6 -0
  34. package/dist/ts4.2/room/PCTransport.d.ts +1 -0
  35. package/dist/ts4.2/room/PCTransportManager.d.ts +6 -4
  36. package/dist/ts4.2/room/RTCEngine.d.ts +1 -1
  37. package/dist/ts4.2/room/token-source/utils.d.ts +1 -1
  38. package/dist/ts4.2/room/utils.d.ts +6 -0
  39. package/package.json +1 -1
  40. package/src/api/SignalClient.test.ts +769 -0
  41. package/src/api/SignalClient.ts +319 -162
  42. package/src/api/WebSocketStream.test.ts +625 -0
  43. package/src/api/WebSocketStream.ts +118 -0
  44. package/src/api/utils.ts +10 -0
  45. package/src/connectionHelper/checks/turn.ts +1 -0
  46. package/src/connectionHelper/checks/webrtc.ts +1 -1
  47. package/src/connectionHelper/checks/websocket.ts +1 -0
  48. package/src/index.ts +2 -0
  49. package/src/options.ts +7 -0
  50. package/src/room/PCTransport.ts +7 -3
  51. package/src/room/PCTransportManager.ts +39 -35
  52. package/src/room/RTCEngine.ts +59 -17
  53. package/src/room/Room.ts +5 -2
  54. package/src/room/defaults.ts +1 -0
  55. package/src/room/participant/LocalParticipant.ts +2 -2
  56. package/src/room/token-source/TokenSource.ts +2 -2
  57. package/src/room/token-source/utils.test.ts +63 -0
  58. package/src/room/token-source/utils.ts +10 -5
  59. package/src/room/utils.ts +29 -0
@@ -0,0 +1,769 @@
1
+ import {
2
+ JoinResponse,
3
+ LeaveRequest,
4
+ ReconnectResponse,
5
+ SignalRequest,
6
+ SignalResponse,
7
+ } from '@livekit/protocol';
8
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { ConnectionError, ConnectionErrorReason } from '../room/errors';
10
+ import { SignalClient, SignalConnectionState } from './SignalClient';
11
+ import type { WebSocketCloseInfo, WebSocketConnection } from './WebSocketStream';
12
+ import { WebSocketStream } from './WebSocketStream';
13
+
14
+ // Mock the WebSocketStream
15
+ vi.mock('./WebSocketStream');
16
+
17
+ // Mock fetch for validation endpoint
18
+ global.fetch = vi.fn();
19
+
20
+ // Test Helpers
21
+ function createJoinResponse() {
22
+ return new JoinResponse({
23
+ room: { name: 'test-room', sid: 'room-sid' },
24
+ participant: { sid: 'participant-sid', identity: 'test-user' },
25
+ pingTimeout: 30,
26
+ pingInterval: 10,
27
+ });
28
+ }
29
+
30
+ function createSignalResponse(
31
+ messageCase: 'join' | 'reconnect' | 'leave' | 'update',
32
+ value: any,
33
+ ): SignalResponse {
34
+ return new SignalResponse({
35
+ message: { case: messageCase, value },
36
+ });
37
+ }
38
+
39
+ function createMockReadableStream(responses: SignalResponse[]): ReadableStream<ArrayBuffer> {
40
+ return new ReadableStream<ArrayBuffer>({
41
+ async start(controller) {
42
+ for (const response of responses) {
43
+ controller.enqueue(response.toBinary().buffer as ArrayBuffer);
44
+ }
45
+ },
46
+ });
47
+ }
48
+
49
+ function createMockConnection(readable: ReadableStream<ArrayBuffer>): WebSocketConnection {
50
+ return {
51
+ readable,
52
+ writable: new WritableStream(),
53
+ protocol: '',
54
+ extensions: '',
55
+ };
56
+ }
57
+
58
+ interface MockWebSocketStreamOptions {
59
+ connection?: WebSocketConnection;
60
+ opened?: Promise<WebSocketConnection>;
61
+ closed?: Promise<WebSocketCloseInfo>;
62
+ readyState?: number;
63
+ }
64
+
65
+ function mockWebSocketStream(options: MockWebSocketStreamOptions = {}) {
66
+ const {
67
+ connection,
68
+ opened = connection ? Promise.resolve(connection) : new Promise(() => {}),
69
+ closed = new Promise(() => {}),
70
+ readyState = 1,
71
+ } = options;
72
+
73
+ return vi.mocked(WebSocketStream).mockImplementationOnce(
74
+ () =>
75
+ ({
76
+ url: 'wss://test.livekit.io',
77
+ opened,
78
+ closed,
79
+ close: vi.fn(),
80
+ readyState,
81
+ }) as any,
82
+ );
83
+ }
84
+
85
+ describe('SignalClient.connect', () => {
86
+ let signalClient: SignalClient;
87
+
88
+ const defaultOptions = {
89
+ autoSubscribe: true,
90
+ maxRetries: 0,
91
+ e2eeEnabled: false,
92
+ websocketTimeout: 1000,
93
+ singlePeerConnection: false,
94
+ };
95
+
96
+ beforeEach(() => {
97
+ vi.clearAllMocks();
98
+ signalClient = new SignalClient(false);
99
+ });
100
+
101
+ describe('Happy Path - Initial Join', () => {
102
+ it('should successfully connect and receive join response', async () => {
103
+ const joinResponse = createJoinResponse();
104
+ const signalResponse = createSignalResponse('join', joinResponse);
105
+ const mockReadable = createMockReadableStream([signalResponse]);
106
+ const mockConnection = createMockConnection(mockReadable);
107
+
108
+ mockWebSocketStream({ connection: mockConnection });
109
+
110
+ const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
111
+
112
+ expect(result).toEqual(joinResponse);
113
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
114
+ });
115
+ });
116
+
117
+ describe('Happy Path - Reconnect', () => {
118
+ it('should successfully reconnect and receive reconnect response', async () => {
119
+ // First, set up initial connection
120
+ const joinResponse = createJoinResponse();
121
+ const joinSignalResponse = createSignalResponse('join', joinResponse);
122
+ const initialMockReadable = createMockReadableStream([joinSignalResponse]);
123
+ const initialMockConnection = createMockConnection(initialMockReadable);
124
+
125
+ mockWebSocketStream({ connection: initialMockConnection });
126
+
127
+ await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
128
+
129
+ // Now test reconnect
130
+ const reconnectResponse = new ReconnectResponse({
131
+ iceServers: [],
132
+ });
133
+ const reconnectSignalResponse = createSignalResponse('reconnect', reconnectResponse);
134
+ const reconnectMockReadable = createMockReadableStream([reconnectSignalResponse]);
135
+ const reconnectMockConnection = createMockConnection(reconnectMockReadable);
136
+
137
+ mockWebSocketStream({ connection: reconnectMockConnection });
138
+
139
+ const result = await signalClient.reconnect('wss://test.livekit.io', 'test-token', 'sid-123');
140
+
141
+ expect(result).toEqual(reconnectResponse);
142
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
143
+ });
144
+
145
+ it('should handle reconnect with non-reconnect message (edge case)', async () => {
146
+ // First, initial connection
147
+ const joinResponse = createJoinResponse();
148
+ const joinSignalResponse = createSignalResponse('join', joinResponse);
149
+ const initialMockReadable = createMockReadableStream([joinSignalResponse]);
150
+ const initialMockConnection = createMockConnection(initialMockReadable);
151
+
152
+ mockWebSocketStream({ connection: initialMockConnection });
153
+
154
+ await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
155
+
156
+ // Setup reconnect with non-reconnect message (e.g., participant update)
157
+ const updateSignalResponse = createSignalResponse('update', { participants: [] });
158
+ const reconnectMockReadable = createMockReadableStream([updateSignalResponse]);
159
+ const reconnectMockConnection = createMockConnection(reconnectMockReadable);
160
+
161
+ mockWebSocketStream({ connection: reconnectMockConnection });
162
+
163
+ const result = await signalClient.reconnect('wss://test.livekit.io', 'test-token', 'sid-123');
164
+
165
+ // This is an edge case: reconnect resolves with undefined when non-reconnect message is received
166
+ expect(result).toBeUndefined();
167
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
168
+ }, 1000);
169
+ });
170
+
171
+ describe('Failure Case - Timeout', () => {
172
+ it('should reject with timeout error when websocket connection takes too long', async () => {
173
+ mockWebSocketStream({ readyState: 0 }); // Never resolves
174
+
175
+ const shortTimeoutOptions = {
176
+ ...defaultOptions,
177
+ websocketTimeout: 100,
178
+ };
179
+
180
+ await expect(
181
+ signalClient.join('wss://test.livekit.io', 'test-token', shortTimeoutOptions),
182
+ ).rejects.toThrow(ConnectionError);
183
+ });
184
+ });
185
+
186
+ describe('Failure Case - AbortSignal', () => {
187
+ it('should reject when AbortSignal is triggered', async () => {
188
+ const abortController = new AbortController();
189
+
190
+ vi.mocked(WebSocketStream).mockImplementation(() => {
191
+ // Simulate abort
192
+ setTimeout(() => abortController.abort(new Error('User aborted connection')), 50);
193
+
194
+ return {
195
+ url: 'wss://test.livekit.io',
196
+ opened: new Promise(() => {}), // Never resolves
197
+ closed: new Promise(() => {}),
198
+ close: vi.fn(),
199
+ readyState: 0,
200
+ } as any;
201
+ });
202
+
203
+ await expect(
204
+ signalClient.join(
205
+ 'wss://test.livekit.io',
206
+ 'test-token',
207
+ defaultOptions,
208
+ abortController.signal,
209
+ ),
210
+ ).rejects.toThrow('User aborted connection');
211
+ });
212
+
213
+ it('should send leave request before closing when AbortSignal is triggered during connection', async () => {
214
+ const abortController = new AbortController();
215
+ const writtenMessages: Array<ArrayBuffer | string> = [];
216
+ let streamWriterReady: (() => void) | undefined;
217
+ const streamWriterReadyPromise = new Promise<void>((resolve) => {
218
+ streamWriterReady = resolve;
219
+ });
220
+
221
+ // Create a mock writable stream that captures writes
222
+ const mockWritable = new WritableStream({
223
+ write(chunk) {
224
+ writtenMessages.push(chunk);
225
+ return Promise.resolve();
226
+ },
227
+ });
228
+
229
+ // Override getWriter to signal when streamWriter is assigned
230
+ const originalGetWriter = mockWritable.getWriter.bind(mockWritable);
231
+ mockWritable.getWriter = () => {
232
+ const writer = originalGetWriter();
233
+ streamWriterReady?.();
234
+ return writer;
235
+ };
236
+
237
+ const mockReadable = new ReadableStream<ArrayBuffer>({
238
+ async start() {
239
+ // Keep connection open but don't send join response yet
240
+ // This simulates aborting during connection (after WS opens, before join response)
241
+ },
242
+ });
243
+
244
+ const mockConnection = {
245
+ readable: mockReadable,
246
+ writable: mockWritable,
247
+ protocol: '',
248
+ extensions: '',
249
+ };
250
+
251
+ vi.mocked(WebSocketStream).mockImplementation(() => {
252
+ return {
253
+ url: 'wss://test.livekit.io',
254
+ opened: Promise.resolve(mockConnection),
255
+ closed: new Promise(() => {}),
256
+ close: vi.fn(),
257
+ readyState: 1,
258
+ } as any;
259
+ });
260
+
261
+ // Start the connection
262
+ const joinPromise = signalClient.join(
263
+ 'wss://test.livekit.io',
264
+ 'test-token',
265
+ defaultOptions,
266
+ abortController.signal,
267
+ );
268
+
269
+ // Wait for streamWriter to be assigned
270
+ await streamWriterReadyPromise;
271
+
272
+ // Now abort the connection (after WS opens, before join response)
273
+ abortController.abort(new Error('User aborted connection'));
274
+
275
+ // joinPromise should reject
276
+ await expect(joinPromise).rejects.toThrow('User aborted connection');
277
+
278
+ // Verify that a leave request was sent before closing
279
+ const leaveRequestSent = writtenMessages.some((data) => {
280
+ if (typeof data === 'string') {
281
+ return false;
282
+ }
283
+ try {
284
+ const request = SignalRequest.fromBinary(
285
+ data instanceof ArrayBuffer ? new Uint8Array(data) : data,
286
+ );
287
+ return request.message?.case === 'leave';
288
+ } catch {
289
+ return false;
290
+ }
291
+ });
292
+
293
+ expect(leaveRequestSent).toBe(true);
294
+ });
295
+ });
296
+
297
+ describe('Failure Case - WebSocket Connection Errors', () => {
298
+ it('should reject with NotAllowed error for 4xx HTTP status', async () => {
299
+ mockWebSocketStream({
300
+ opened: Promise.reject(new Error('Connection failed')),
301
+ readyState: 3,
302
+ });
303
+
304
+ // Mock fetch to return 403
305
+ (global.fetch as any).mockResolvedValueOnce({
306
+ status: 403,
307
+ text: async () => 'Forbidden',
308
+ });
309
+
310
+ await expect(
311
+ signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions),
312
+ ).rejects.toMatchObject({
313
+ message: 'Forbidden',
314
+ reason: ConnectionErrorReason.NotAllowed,
315
+ status: 403,
316
+ });
317
+ });
318
+
319
+ it('should reject with ServerUnreachable when fetch fails', async () => {
320
+ mockWebSocketStream({
321
+ opened: Promise.reject(new Error('Connection failed')),
322
+ readyState: 3,
323
+ });
324
+
325
+ // Mock fetch to throw (network error)
326
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
327
+
328
+ await expect(
329
+ signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions),
330
+ ).rejects.toMatchObject({
331
+ reason: ConnectionErrorReason.ServerUnreachable,
332
+ });
333
+ });
334
+
335
+ it('should handle ConnectionError from WebSocket rejection', async () => {
336
+ const customError = new ConnectionError(
337
+ 'Custom error',
338
+ ConnectionErrorReason.InternalError,
339
+ 500,
340
+ );
341
+
342
+ mockWebSocketStream({
343
+ opened: Promise.reject(customError),
344
+ readyState: 3,
345
+ });
346
+
347
+ // Mock fetch to return 500
348
+ (global.fetch as any).mockResolvedValueOnce({
349
+ status: 500,
350
+ text: async () => 'Internal Server Error',
351
+ });
352
+
353
+ await expect(
354
+ signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions),
355
+ ).rejects.toMatchObject({
356
+ reason: ConnectionErrorReason.InternalError,
357
+ });
358
+ });
359
+ });
360
+
361
+ describe('Failure Case - No First Message', () => {
362
+ it('should reject when no first message is received', async () => {
363
+ // Close the stream immediately without sending a message
364
+ const mockReadable = new ReadableStream<ArrayBuffer>({
365
+ async start(controller) {
366
+ controller.close();
367
+ },
368
+ });
369
+ const mockConnection = createMockConnection(mockReadable);
370
+
371
+ mockWebSocketStream({ connection: mockConnection });
372
+
373
+ await expect(
374
+ signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions),
375
+ ).rejects.toMatchObject({
376
+ message: 'no message received as first message',
377
+ reason: ConnectionErrorReason.InternalError,
378
+ });
379
+ });
380
+ });
381
+
382
+ describe('Failure Case - Leave Request During Connection', () => {
383
+ it('should reject when receiving leave request during initial join', async () => {
384
+ const leaveRequest = new LeaveRequest({
385
+ reason: 1, // Some disconnect reason
386
+ });
387
+ const signalResponse = createSignalResponse('leave', leaveRequest);
388
+ const mockReadable = createMockReadableStream([signalResponse]);
389
+ const mockConnection = createMockConnection(mockReadable);
390
+
391
+ mockWebSocketStream({ connection: mockConnection });
392
+
393
+ await expect(
394
+ signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions),
395
+ ).rejects.toMatchObject(
396
+ new ConnectionError(
397
+ 'Received leave request while trying to (re)connect',
398
+ ConnectionErrorReason.LeaveRequest,
399
+ undefined,
400
+ 1,
401
+ ),
402
+ );
403
+ });
404
+ });
405
+
406
+ describe('Failure Case - Wrong Message Type for Non-Reconnect', () => {
407
+ it('should reject when receiving non-join message on initial connection', async () => {
408
+ // Send a reconnect response instead of join (wrong for initial connection)
409
+ const reconnectResponse = new ReconnectResponse({
410
+ iceServers: [],
411
+ });
412
+ const signalResponse = createSignalResponse('reconnect', reconnectResponse);
413
+ const mockReadable = createMockReadableStream([signalResponse]);
414
+ const mockConnection = createMockConnection(mockReadable);
415
+
416
+ mockWebSocketStream({ connection: mockConnection });
417
+
418
+ await expect(
419
+ signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions),
420
+ ).rejects.toMatchObject({
421
+ message: 'did not receive join response, got reconnect instead',
422
+ reason: ConnectionErrorReason.InternalError,
423
+ });
424
+ });
425
+ });
426
+
427
+ describe('Failure Case - WebSocket Closed During Connection', () => {
428
+ it('should reject when WebSocket closes during connection attempt', async () => {
429
+ let closedResolve: (value: WebSocketCloseInfo) => void;
430
+ const closedPromise = new Promise<WebSocketCloseInfo>((resolve) => {
431
+ closedResolve = resolve;
432
+ });
433
+
434
+ vi.mocked(WebSocketStream).mockImplementation(() => {
435
+ // Simulate close during connection
436
+ queueMicrotask(() => {
437
+ closedResolve({ closeCode: 1006, reason: 'Connection lost' });
438
+ });
439
+
440
+ return {
441
+ url: 'wss://test.livekit.io',
442
+ opened: new Promise(() => {}), // Never resolves
443
+ closed: closedPromise,
444
+ close: vi.fn(),
445
+ readyState: 2, // CLOSING
446
+ } as any;
447
+ });
448
+
449
+ await expect(
450
+ signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions),
451
+ ).rejects.toMatchObject({
452
+ message: 'Websocket got closed during a (re)connection attempt: Connection lost',
453
+ reason: ConnectionErrorReason.InternalError,
454
+ });
455
+ });
456
+ });
457
+
458
+ describe('Edge Cases and State Management', () => {
459
+ it('should set state to CONNECTING when joining', async () => {
460
+ expect(signalClient.currentState).toBe(SignalConnectionState.DISCONNECTED);
461
+
462
+ const joinResponse = createJoinResponse();
463
+ const signalResponse = createSignalResponse('join', joinResponse);
464
+ const mockReadable = createMockReadableStream([signalResponse]);
465
+ const mockConnection = createMockConnection(mockReadable);
466
+
467
+ mockWebSocketStream({ connection: mockConnection });
468
+
469
+ const joinPromise = signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
470
+
471
+ // State should be CONNECTING before connection completes
472
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTING);
473
+
474
+ await joinPromise;
475
+
476
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
477
+ });
478
+ });
479
+ });
480
+
481
+ describe('SignalClient utility functions', () => {
482
+ describe('toProtoSessionDescription', () => {
483
+ it('should convert RTCSessionDescriptionInit to proto SessionDescription', async () => {
484
+ const { toProtoSessionDescription } = await import('./SignalClient');
485
+
486
+ const rtcDesc: RTCSessionDescriptionInit = {
487
+ type: 'offer',
488
+ sdp: 'v=0\r\no=- 123 456 IN IP4 127.0.0.1\r\n',
489
+ };
490
+
491
+ const protoDesc = toProtoSessionDescription(rtcDesc, 42);
492
+
493
+ expect(protoDesc.type).toBe('offer');
494
+ expect(protoDesc.sdp).toBe('v=0\r\no=- 123 456 IN IP4 127.0.0.1\r\n');
495
+ expect(protoDesc.id).toBe(42);
496
+ });
497
+
498
+ it('should handle answer type', async () => {
499
+ const { toProtoSessionDescription } = await import('./SignalClient');
500
+
501
+ const rtcDesc: RTCSessionDescriptionInit = {
502
+ type: 'answer',
503
+ sdp: 'v=0\r\n',
504
+ };
505
+
506
+ const protoDesc = toProtoSessionDescription(rtcDesc);
507
+
508
+ expect(protoDesc.type).toBe('answer');
509
+ expect(protoDesc.sdp).toBe('v=0\r\n');
510
+ });
511
+ });
512
+ });
513
+
514
+ describe('SignalClient.handleSignalConnected', () => {
515
+ let signalClient: SignalClient;
516
+
517
+ const defaultOptions = {
518
+ autoSubscribe: true,
519
+ maxRetries: 0,
520
+ e2eeEnabled: false,
521
+ websocketTimeout: 1000,
522
+ singlePeerConnection: false,
523
+ };
524
+
525
+ beforeEach(() => {
526
+ vi.clearAllMocks();
527
+ signalClient = new SignalClient(false);
528
+ });
529
+
530
+ it('should set state to CONNECTED', () => {
531
+ const mockReadable = new ReadableStream<ArrayBuffer>();
532
+ const mockConnection = createMockConnection(mockReadable);
533
+
534
+ // Access the method through a type assertion for testing
535
+ const handleMethod = (signalClient as any).handleSignalConnected;
536
+ if (handleMethod) {
537
+ handleMethod.call(signalClient, mockConnection);
538
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
539
+ }
540
+ });
541
+
542
+ it('should start reading loop without first message', async () => {
543
+ const joinResponse = createJoinResponse();
544
+ const signalResponse = createSignalResponse('join', joinResponse);
545
+ const mockReadable = createMockReadableStream([signalResponse]);
546
+ const mockConnection = createMockConnection(mockReadable);
547
+
548
+ mockWebSocketStream({ connection: mockConnection });
549
+
550
+ await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
551
+
552
+ // Verify connection was established successfully
553
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
554
+ });
555
+
556
+ it('should start reading loop with first message', async () => {
557
+ const joinResponse = createJoinResponse();
558
+ const signalResponse = createSignalResponse('join', joinResponse);
559
+ const mockReadable = createMockReadableStream([signalResponse]);
560
+ const mockConnection = createMockConnection(mockReadable);
561
+
562
+ mockWebSocketStream({ connection: mockConnection });
563
+
564
+ await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
565
+
566
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
567
+ });
568
+ });
569
+
570
+ describe('SignalClient.validateFirstMessage', () => {
571
+ let signalClient: SignalClient;
572
+
573
+ const defaultOptions = {
574
+ autoSubscribe: true,
575
+ maxRetries: 0,
576
+ e2eeEnabled: false,
577
+ websocketTimeout: 1000,
578
+ singlePeerConnection: false,
579
+ };
580
+
581
+ beforeEach(() => {
582
+ vi.clearAllMocks();
583
+ signalClient = new SignalClient(false);
584
+ });
585
+
586
+ it('should accept join response for initial connection', () => {
587
+ const joinResponse = createJoinResponse();
588
+ const signalResponse = createSignalResponse('join', joinResponse);
589
+
590
+ const validateMethod = (signalClient as any).validateFirstMessage;
591
+ if (validateMethod) {
592
+ const result = validateMethod.call(signalClient, signalResponse, false);
593
+ expect(result.isValid).toBe(true);
594
+ expect(result.response).toEqual(joinResponse);
595
+ }
596
+ });
597
+
598
+ it('should accept reconnect response for reconnection', async () => {
599
+ // First establish a connection to set options
600
+ const joinResponse = createJoinResponse();
601
+ const joinSignalResponse = createSignalResponse('join', joinResponse);
602
+ const initialMockReadable = createMockReadableStream([joinSignalResponse]);
603
+ const initialMockConnection = createMockConnection(initialMockReadable);
604
+
605
+ mockWebSocketStream({ connection: initialMockConnection });
606
+ await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
607
+
608
+ // Set state to RECONNECTING to match the validation logic
609
+ (signalClient as any).state = SignalConnectionState.RECONNECTING;
610
+
611
+ const reconnectResponse = new ReconnectResponse({ iceServers: [] });
612
+ const signalResponse = createSignalResponse('reconnect', reconnectResponse);
613
+
614
+ const validateMethod = (signalClient as any).validateFirstMessage;
615
+ if (validateMethod) {
616
+ const result = validateMethod.call(signalClient, signalResponse, true);
617
+ expect(result.isValid).toBe(true);
618
+ expect(result.response).toEqual(reconnectResponse);
619
+ }
620
+ });
621
+
622
+ it('should accept non-reconnect message during reconnecting state', async () => {
623
+ // First establish a connection
624
+ const joinResponse = createJoinResponse();
625
+ const joinSignalResponse = createSignalResponse('join', joinResponse);
626
+ const initialMockReadable = createMockReadableStream([joinSignalResponse]);
627
+ const initialMockConnection = createMockConnection(initialMockReadable);
628
+
629
+ mockWebSocketStream({ connection: initialMockConnection });
630
+ await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
631
+
632
+ // Set state to reconnecting
633
+ (signalClient as any).state = SignalConnectionState.RECONNECTING;
634
+
635
+ const updateSignalResponse = createSignalResponse('update', { participants: [] });
636
+
637
+ const validateMethod = (signalClient as any).validateFirstMessage;
638
+ if (validateMethod) {
639
+ const result = validateMethod.call(signalClient, updateSignalResponse, true);
640
+ expect(result.isValid).toBe(true);
641
+ expect(result.response).toBeUndefined();
642
+ expect(result.shouldProcessFirstMessage).toBe(true);
643
+ }
644
+ });
645
+
646
+ it('should reject leave request during connection attempt', () => {
647
+ // Set state to CONNECTING to be in establishing connection state
648
+ (signalClient as any).state = SignalConnectionState.CONNECTING;
649
+
650
+ const leaveRequest = new LeaveRequest({ reason: 1 });
651
+ const signalResponse = createSignalResponse('leave', leaveRequest);
652
+
653
+ const validateMethod = (signalClient as any).validateFirstMessage;
654
+ if (validateMethod) {
655
+ const result = validateMethod.call(signalClient, signalResponse, false);
656
+ expect(result.isValid).toBe(false);
657
+ expect(result.error).toBeInstanceOf(ConnectionError);
658
+ expect(result.error?.reason).toBe(ConnectionErrorReason.LeaveRequest);
659
+ }
660
+ });
661
+
662
+ it('should reject non-join message for initial connection', () => {
663
+ const reconnectResponse = new ReconnectResponse({ iceServers: [] });
664
+ const signalResponse = createSignalResponse('reconnect', reconnectResponse);
665
+
666
+ const validateMethod = (signalClient as any).validateFirstMessage;
667
+ if (validateMethod) {
668
+ const result = validateMethod.call(signalClient, signalResponse, false);
669
+ expect(result.isValid).toBe(false);
670
+ expect(result.error).toBeInstanceOf(ConnectionError);
671
+ expect(result.error?.reason).toBe(ConnectionErrorReason.InternalError);
672
+ }
673
+ });
674
+ });
675
+
676
+ describe('SignalClient.handleConnectionError', () => {
677
+ let signalClient: SignalClient;
678
+
679
+ beforeEach(() => {
680
+ vi.clearAllMocks();
681
+ signalClient = new SignalClient(false);
682
+ });
683
+
684
+ it('should return NotAllowed error for 4xx HTTP status', async () => {
685
+ (global.fetch as any).mockResolvedValueOnce({
686
+ status: 403,
687
+ text: async () => 'Forbidden',
688
+ });
689
+
690
+ const handleMethod = (signalClient as any).handleConnectionError;
691
+ if (handleMethod) {
692
+ const error = new Error('Connection failed');
693
+ const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate');
694
+
695
+ expect(result).toBeInstanceOf(ConnectionError);
696
+ expect(result.reason).toBe(ConnectionErrorReason.NotAllowed);
697
+ expect(result.status).toBe(403);
698
+ expect(result.message).toBe('Forbidden');
699
+ }
700
+ });
701
+
702
+ it('should return ConnectionError as-is if it is already a ConnectionError', async () => {
703
+ const connectionError = new ConnectionError(
704
+ 'Custom error',
705
+ ConnectionErrorReason.InternalError,
706
+ 500,
707
+ );
708
+
709
+ (global.fetch as any).mockResolvedValueOnce({
710
+ status: 500,
711
+ text: async () => 'Internal Server Error',
712
+ });
713
+
714
+ const handleMethod = (signalClient as any).handleConnectionError;
715
+ if (handleMethod) {
716
+ const result = await handleMethod.call(
717
+ signalClient,
718
+ connectionError,
719
+ 'wss://test.livekit.io/validate',
720
+ );
721
+
722
+ expect(result).toBe(connectionError);
723
+ expect(result.reason).toBe(ConnectionErrorReason.InternalError);
724
+ }
725
+ });
726
+
727
+ it('should return InternalError for non-4xx HTTP status', async () => {
728
+ (global.fetch as any).mockResolvedValueOnce({
729
+ status: 500,
730
+ text: async () => 'Internal Server Error',
731
+ });
732
+
733
+ const handleMethod = (signalClient as any).handleConnectionError;
734
+ if (handleMethod) {
735
+ const error = new Error('Connection failed');
736
+ const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate');
737
+
738
+ expect(result).toBeInstanceOf(ConnectionError);
739
+ expect(result.reason).toBe(ConnectionErrorReason.InternalError);
740
+ expect(result.status).toBe(500);
741
+ }
742
+ });
743
+
744
+ it('should return ServerUnreachable when fetch fails', async () => {
745
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
746
+
747
+ const handleMethod = (signalClient as any).handleConnectionError;
748
+ if (handleMethod) {
749
+ const error = new Error('Connection failed');
750
+ const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate');
751
+
752
+ expect(result).toBeInstanceOf(ConnectionError);
753
+ expect(result.reason).toBe(ConnectionErrorReason.ServerUnreachable);
754
+ }
755
+ });
756
+
757
+ it('should handle fetch throwing ConnectionError', async () => {
758
+ const fetchError = new ConnectionError('Fetch failed', ConnectionErrorReason.ServerUnreachable);
759
+ (global.fetch as any).mockRejectedValueOnce(fetchError);
760
+
761
+ const handleMethod = (signalClient as any).handleConnectionError;
762
+ if (handleMethod) {
763
+ const error = new Error('Connection failed');
764
+ const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate');
765
+
766
+ expect(result).toBe(fetchError);
767
+ }
768
+ });
769
+ });