livekit-client 2.15.8 → 2.15.9

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