@stream-io/video-client 1.48.0 → 1.50.0

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 (86) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/index.browser.es.js +1497 -677
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1497 -677
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1497 -677
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +77 -4
  9. package/dist/src/StreamSfuClient.d.ts +8 -1
  10. package/dist/src/coordinator/connection/client.d.ts +1 -1
  11. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  12. package/dist/src/coordinator/connection/types.d.ts +14 -0
  13. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +3 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +13 -1
  16. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  17. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  18. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  19. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  20. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  21. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  22. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  23. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  24. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  25. package/dist/src/helpers/browsers.d.ts +13 -0
  26. package/dist/src/helpers/concurrency.d.ts +6 -4
  27. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  28. package/dist/src/rtc/Publisher.d.ts +17 -0
  29. package/dist/src/rtc/Subscriber.d.ts +1 -0
  30. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/types.d.ts +33 -1
  33. package/dist/src/stats/rtc/types.d.ts +1 -1
  34. package/dist/src/store/rxUtils.d.ts +9 -0
  35. package/dist/src/types.d.ts +18 -0
  36. package/package.json +2 -2
  37. package/src/Call.ts +268 -40
  38. package/src/StreamSfuClient.ts +75 -12
  39. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  40. package/src/__tests__/Call.publishing.test.ts +103 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  42. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  43. package/src/coordinator/connection/client.ts +1 -1
  44. package/src/coordinator/connection/connection.ts +149 -96
  45. package/src/coordinator/connection/types.ts +15 -0
  46. package/src/coordinator/connection/utils.ts +15 -0
  47. package/src/devices/DeviceManager.ts +92 -32
  48. package/src/devices/DeviceManagerState.ts +20 -1
  49. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  50. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  51. package/src/devices/__tests__/mocks.ts +2 -0
  52. package/src/devices/devices.ts +2 -1
  53. package/src/gen/video/sfu/event/events.ts +15 -0
  54. package/src/gen/video/sfu/models/models.ts +44 -0
  55. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  56. package/src/helpers/BlockedAudioTracker.ts +74 -0
  57. package/src/helpers/DynascaleManager.ts +46 -337
  58. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  59. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  60. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  61. package/src/helpers/ViewportTracker.ts +74 -19
  62. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  63. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  64. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  65. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rpc/retryable.ts +0 -1
  72. package/src/rtc/BasePeerConnection.ts +96 -6
  73. package/src/rtc/Publisher.ts +49 -2
  74. package/src/rtc/Subscriber.ts +42 -14
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  76. package/src/rtc/__tests__/Publisher.test.ts +332 -10
  77. package/src/rtc/__tests__/Subscriber.test.ts +202 -1
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  80. package/src/rtc/helpers/degradationPreference.ts +22 -0
  81. package/src/rtc/index.ts +1 -0
  82. package/src/rtc/types.ts +38 -1
  83. package/src/stats/rtc/types.ts +1 -0
  84. package/src/store/__tests__/rxUtils.test.ts +276 -0
  85. package/src/store/rxUtils.ts +19 -0
  86. package/src/types.ts +19 -0
@@ -0,0 +1,761 @@
1
+ import './mocks/webrtc.mocks';
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { Call } from '../../Call';
5
+ import { StreamClient } from '../../coordinator/connection/client';
6
+ import { StreamVideoWriteableStateStore } from '../../store';
7
+ import { CallingState } from '../../store';
8
+ import { NegotiationError } from '../NegotiationError';
9
+ import { ReconnectReason } from '../types';
10
+ import {
11
+ PeerType,
12
+ WebsocketReconnectStrategy,
13
+ ErrorCode,
14
+ } from '../../gen/video/sfu/models/models';
15
+ import * as connectionUtils from '../../coordinator/connection/utils';
16
+ import { Publisher } from '../Publisher';
17
+ import { Subscriber } from '../Subscriber';
18
+ import { Dispatcher } from '../Dispatcher';
19
+ import { StreamSfuClient } from '../../StreamSfuClient';
20
+ import { IceTrickleBuffer } from '../IceTrickleBuffer';
21
+
22
+ vi.mock('../../StreamSfuClient', () => ({
23
+ StreamSfuClient: vi.fn(),
24
+ }));
25
+
26
+ const makeCall = () => {
27
+ const streamClient = new StreamClient('test-key');
28
+ const clientStore = new StreamVideoWriteableStateStore();
29
+ return new Call({
30
+ type: 'default',
31
+ id: 'test-call',
32
+ streamClient,
33
+ clientStore,
34
+ ringing: false,
35
+ watching: false,
36
+ });
37
+ };
38
+
39
+ /**
40
+ * Primes the call so the reconnect loop will actually enter the
41
+ * strategy branch (loop guards on callingState != JOINED/LEFT/RECONNECTING_FAILED)
42
+ * and behaves deterministically.
43
+ */
44
+ const primeForReconnect = (call: Call) => {
45
+ // put the call in a non-terminal, non-JOINED state so the do-while iterates
46
+ call.state.setCallingState(CallingState.JOINING);
47
+ // force the strategy-decider in the catch block to always pick REJOIN,
48
+ // so tests that care about the rejoin rate limiter don't bounce to FAST
49
+ // based on wall-clock timing. Individual tests that want to exercise the
50
+ // FAST branch reset this to a high value.
51
+ (
52
+ call as unknown as { fastReconnectDeadlineSeconds: number }
53
+ ).fastReconnectDeadlineSeconds = -1;
54
+ };
55
+
56
+ describe('Call reconnect stopping conditions', () => {
57
+ let call: Call;
58
+
59
+ beforeEach(() => {
60
+ call = makeCall();
61
+ // make sleep instant so the loop flushes quickly
62
+ vi.spyOn(connectionUtils, 'sleep').mockResolvedValue(undefined);
63
+ // stub leave so the terminal path doesn't attempt real teardown
64
+ vi.spyOn(call, 'leave').mockResolvedValue(undefined);
65
+ // avoid the `get()` call inside markAsReconnectingFailed hitting the network
66
+ vi.spyOn(call, 'get').mockResolvedValue({} as never);
67
+ // default-stub all three strategy implementations to reject. Individual
68
+ // tests override these with mockResolvedValue / mockImplementation as
69
+ // needed. This keeps the reconnect loop from reaching the real network.
70
+ vi.spyOn(
71
+ call as unknown as { reconnectFast: () => Promise<void> },
72
+ 'reconnectFast',
73
+ ).mockRejectedValue(new Error('fast stub'));
74
+ vi.spyOn(
75
+ call as unknown as { reconnectRejoin: () => Promise<void> },
76
+ 'reconnectRejoin',
77
+ ).mockRejectedValue(new Error('rejoin stub'));
78
+ vi.spyOn(
79
+ call as unknown as { reconnectMigrate: () => Promise<void> },
80
+ 'reconnectMigrate',
81
+ ).mockRejectedValue(new Error('migrate stub'));
82
+ primeForReconnect(call);
83
+ });
84
+
85
+ afterEach(() => {
86
+ vi.clearAllMocks();
87
+ });
88
+
89
+ describe('rejoin rate limit', () => {
90
+ it('triggers leave with rejoin_attempt_limit_exceeded after the budget is exhausted', async () => {
91
+ // tight cap for speed
92
+ call.setRejoinAttemptLimit(3, 60);
93
+ // every rejoin attempt fails so the loop stays in REJOIN
94
+ const rejoinSpy = vi
95
+ .spyOn(
96
+ call as unknown as { reconnectRejoin: () => Promise<void> },
97
+ 'reconnectRejoin',
98
+ )
99
+ .mockRejectedValue(new Error('rejoin failed'));
100
+
101
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
102
+
103
+ // budget = 3 → 3 registered attempts, 4th denied and triggers leave
104
+ expect(rejoinSpy).toHaveBeenCalledTimes(3);
105
+ expect(call.leave).toHaveBeenCalledWith({
106
+ message: 'rejoin_attempt_limit_exceeded',
107
+ });
108
+ });
109
+
110
+ it('does NOT trigger leave while under the rejoin budget', async () => {
111
+ call.setRejoinAttemptLimit(10, 60);
112
+ const rejoinSpy = vi
113
+ .spyOn(
114
+ call as unknown as { reconnectRejoin: () => Promise<void> },
115
+ 'reconnectRejoin',
116
+ )
117
+ .mockImplementationOnce(async () => {
118
+ // first call succeeds — loop exits
119
+ call.state.setCallingState(CallingState.JOINED);
120
+ });
121
+
122
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
123
+
124
+ expect(rejoinSpy).toHaveBeenCalledTimes(1);
125
+ expect(call.leave).not.toHaveBeenCalled();
126
+ });
127
+
128
+ it('FAST strategy is NOT counted against the rejoin budget', async () => {
129
+ call.setRejoinAttemptLimit(2, 60);
130
+ // stub FAST so it "succeeds" each time (we re-enter by resetting state)
131
+ vi.spyOn(
132
+ call as unknown as { reconnectFast: () => Promise<void> },
133
+ 'reconnectFast',
134
+ ).mockImplementation(async () => {
135
+ call.state.setCallingState(CallingState.JOINED);
136
+ });
137
+
138
+ for (let i = 0; i < 5; i++) {
139
+ call.state.setCallingState(CallingState.JOINING);
140
+ await call['reconnect'](WebsocketReconnectStrategy.FAST, 'test');
141
+ }
142
+
143
+ // the rejoin rate limiter should have no recorded attempts —
144
+ // FAST never registers an attempt, and because each FAST here
145
+ // "succeeds" on the first iteration, no REJOIN fallback kicks in
146
+ expect(call['rejoinRateLimiter']['timestamps']).toHaveLength(0);
147
+ expect(call.leave).not.toHaveBeenCalled();
148
+ });
149
+ });
150
+
151
+ describe('retryInterval backoff', () => {
152
+ it('invokes retryInterval(attempt) between failed iterations, not a fixed delay', async () => {
153
+ call.setRejoinAttemptLimit(3, 60);
154
+ const retryIntervalSpy = vi
155
+ .spyOn(connectionUtils, 'retryInterval')
156
+ .mockReturnValue(0);
157
+ vi.spyOn(
158
+ call as unknown as { reconnectRejoin: () => Promise<void> },
159
+ 'reconnectRejoin',
160
+ ).mockRejectedValue(new Error('rejoin failed'));
161
+
162
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
163
+
164
+ // 3 iterations → at least 3 backoff calls with increasing attempt index
165
+ const calls = retryIntervalSpy.mock.calls.map((c) => c[0]);
166
+ expect(calls.length).toBeGreaterThanOrEqual(3);
167
+ expect(calls[0]).toBe(0);
168
+ expect(calls[1]).toBe(1);
169
+ expect(calls[2]).toBe(2);
170
+ });
171
+ });
172
+
173
+ describe('unsupported-network detector', () => {
174
+ it('triggers leave with webrtc_unsupported_network after N ice_never_connected reasons', async () => {
175
+ call.setMaxIceFailuresWithoutConnect(2);
176
+ // reconnect no-ops (REJOIN not even attempted in this test because we
177
+ // bail out at the threshold check before the loop)
178
+ vi.spyOn(
179
+ call as unknown as { reconnectRejoin: () => Promise<void> },
180
+ 'reconnectRejoin',
181
+ ).mockImplementation(async () => {
182
+ call.state.setCallingState(CallingState.JOINED);
183
+ });
184
+
185
+ // first ice_never_connected: counter = 1, still under threshold (2)
186
+ await call['reconnect'](
187
+ WebsocketReconnectStrategy.REJOIN,
188
+ ReconnectReason.ICE_NEVER_CONNECTED,
189
+ );
190
+ expect(call.leave).not.toHaveBeenCalled();
191
+
192
+ // After a successful SFU join (no ICE-connected event yet), the
193
+ // counter must NOT be reset — the reset only happens once a peer
194
+ // connection actually reaches `connected`/`completed` end-to-end.
195
+ primeForReconnect(call);
196
+ await call['reconnect'](
197
+ WebsocketReconnectStrategy.REJOIN,
198
+ ReconnectReason.ICE_NEVER_CONNECTED,
199
+ );
200
+ // counter now 2 → threshold met → leave
201
+ expect(call.leave).toHaveBeenCalledWith({
202
+ message: 'webrtc_unsupported_network',
203
+ });
204
+ });
205
+
206
+ it('does NOT trigger leave when the reason is NOT ice_never_connected', async () => {
207
+ call.setMaxIceFailuresWithoutConnect(1);
208
+ vi.spyOn(
209
+ call as unknown as { reconnectRejoin: () => Promise<void> },
210
+ 'reconnectRejoin',
211
+ ).mockImplementation(async () => {
212
+ call.state.setCallingState(CallingState.JOINED);
213
+ });
214
+
215
+ await call['reconnect'](
216
+ WebsocketReconnectStrategy.REJOIN,
217
+ 'some_other_reason',
218
+ );
219
+
220
+ expect(call.leave).not.toHaveBeenCalled();
221
+ });
222
+ });
223
+
224
+ describe('consecutive negotiation failures', () => {
225
+ const makeNegotiationError = () =>
226
+ new NegotiationError({
227
+ code: ErrorCode.PARTICIPANT_NOT_FOUND,
228
+ message: 'test',
229
+ shouldRetry: true,
230
+ } as never);
231
+
232
+ it('triggers leave with repeated_negotiation_failures after the streak threshold', async () => {
233
+ call.setMaxConsecutiveNegotiationFailures(3);
234
+ call.setRejoinAttemptLimit(100, 60); // keep rejoin cap out of the way
235
+ vi.spyOn(
236
+ call as unknown as { reconnectRejoin: () => Promise<void> },
237
+ 'reconnectRejoin',
238
+ ).mockRejectedValue(makeNegotiationError());
239
+
240
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
241
+
242
+ expect(call.leave).toHaveBeenCalledWith({
243
+ message: 'repeated_negotiation_failures',
244
+ });
245
+ });
246
+
247
+ it('resets the streak counter on a successful iteration', async () => {
248
+ call.setMaxConsecutiveNegotiationFailures(3);
249
+ call.setRejoinAttemptLimit(100, 60);
250
+ let calls = 0;
251
+ vi.spyOn(
252
+ call as unknown as { reconnectRejoin: () => Promise<void> },
253
+ 'reconnectRejoin',
254
+ ).mockImplementation(async () => {
255
+ calls++;
256
+ if (calls <= 2) throw makeNegotiationError();
257
+ if (calls === 3) {
258
+ // success on the 3rd attempt — resets the streak
259
+ call.state.setCallingState(CallingState.JOINED);
260
+ return;
261
+ }
262
+ });
263
+
264
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
265
+
266
+ expect(calls).toBe(3);
267
+ expect(call.leave).not.toHaveBeenCalled();
268
+ expect(call['consecutiveNegotiationFailures']).toBe(0);
269
+ });
270
+ });
271
+
272
+ describe('counter reset semantics', () => {
273
+ it('rejoinRateLimiter does NOT reset on a successful SFU reconnect — the rolling window persists', async () => {
274
+ call.setRejoinAttemptLimit(3, 60);
275
+ // Each REJOIN succeeds on the first iteration; without the bad reset,
276
+ // the rolling window accumulates timestamps across successful cycles.
277
+ vi.spyOn(
278
+ call as unknown as { reconnectRejoin: () => Promise<void> },
279
+ 'reconnectRejoin',
280
+ ).mockImplementation(async () => {
281
+ call.state.setCallingState(CallingState.JOINED);
282
+ });
283
+
284
+ for (let i = 0; i < 3; i++) {
285
+ primeForReconnect(call);
286
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
287
+ }
288
+ expect(call['rejoinRateLimiter']['timestamps']).toHaveLength(3);
289
+
290
+ // 4th attempt is over the budget and triggers leave
291
+ primeForReconnect(call);
292
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
293
+ expect(call.leave).toHaveBeenCalledWith({
294
+ message: 'rejoin_attempt_limit_exceeded',
295
+ });
296
+ });
297
+
298
+ it('iceFailuresWithoutConnect does NOT reset on a successful SFU reconnect', async () => {
299
+ call.setMaxIceFailuresWithoutConnect(3);
300
+ // pre-load the counter as if a previous PC had failed before connecting
301
+ call['iceFailuresWithoutConnect'] = 2;
302
+
303
+ vi.spyOn(
304
+ call as unknown as { reconnectRejoin: () => Promise<void> },
305
+ 'reconnectRejoin',
306
+ ).mockImplementation(async () => {
307
+ call.state.setCallingState(CallingState.JOINED);
308
+ });
309
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
310
+
311
+ // Successful SFU join — but ICE never connected; counter must persist.
312
+ expect(call['iceFailuresWithoutConnect']).toBe(2);
313
+ expect(call.leave).not.toHaveBeenCalled();
314
+ });
315
+
316
+ it('consecutiveNegotiationFailures DOES reset on a successful reconnect iteration', async () => {
317
+ call['consecutiveNegotiationFailures'] = 2;
318
+ vi.spyOn(
319
+ call as unknown as { reconnectRejoin: () => Promise<void> },
320
+ 'reconnectRejoin',
321
+ ).mockImplementation(async () => {
322
+ call.state.setCallingState(CallingState.JOINED);
323
+ });
324
+
325
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
326
+
327
+ expect(call['consecutiveNegotiationFailures']).toBe(0);
328
+ });
329
+ });
330
+
331
+ describe('tunability setters', () => {
332
+ it('setRejoinAttemptLimit changes the budget in place', async () => {
333
+ call.setRejoinAttemptLimit(1, 60);
334
+ vi.spyOn(
335
+ call as unknown as { reconnectRejoin: () => Promise<void> },
336
+ 'reconnectRejoin',
337
+ ).mockRejectedValue(new Error('rejoin failed'));
338
+
339
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
340
+
341
+ // budget=1 → one attempt, then leave
342
+ expect(call.leave).toHaveBeenCalledWith({
343
+ message: 'rejoin_attempt_limit_exceeded',
344
+ });
345
+ });
346
+
347
+ it('setMaxIceFailuresWithoutConnect=1 trips on the first ice_never_connected', async () => {
348
+ call.setMaxIceFailuresWithoutConnect(1);
349
+
350
+ await call['reconnect'](
351
+ WebsocketReconnectStrategy.REJOIN,
352
+ ReconnectReason.ICE_NEVER_CONNECTED,
353
+ );
354
+
355
+ expect(call.leave).toHaveBeenCalledWith({
356
+ message: 'webrtc_unsupported_network',
357
+ });
358
+ });
359
+
360
+ it('setMaxConsecutiveNegotiationFailures=1 trips on the first NegotiationError', async () => {
361
+ call.setMaxConsecutiveNegotiationFailures(1);
362
+ call.setRejoinAttemptLimit(100, 60);
363
+ const err = new NegotiationError({
364
+ code: ErrorCode.PARTICIPANT_NOT_FOUND,
365
+ message: 'x',
366
+ shouldRetry: true,
367
+ } as never);
368
+ vi.spyOn(
369
+ call as unknown as { reconnectRejoin: () => Promise<void> },
370
+ 'reconnectRejoin',
371
+ ).mockRejectedValue(err);
372
+
373
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
374
+
375
+ expect(call.leave).toHaveBeenCalledWith({
376
+ message: 'repeated_negotiation_failures',
377
+ });
378
+ });
379
+
380
+ it('clamps zero/negative inputs to a floor of 1', () => {
381
+ // Without clamping, n=0 would trip immediately on the first event
382
+ // (1 >= 0 is true), turning the setter into an instant kill switch.
383
+ call.setMaxIceFailuresWithoutConnect(0);
384
+ expect(
385
+ (call as unknown as { maxIceFailuresWithoutConnect: number })
386
+ .maxIceFailuresWithoutConnect,
387
+ ).toBe(1);
388
+
389
+ call.setMaxConsecutiveNegotiationFailures(-5);
390
+ expect(
391
+ (call as unknown as { maxConsecutiveNegotiationFailures: number })
392
+ .maxConsecutiveNegotiationFailures,
393
+ ).toBe(1);
394
+
395
+ call.setRejoinAttemptLimit(0, 0);
396
+ const limiter = (
397
+ call as unknown as {
398
+ rejoinRateLimiter: { maxAttempts: number; windowMs: number };
399
+ }
400
+ ).rejoinRateLimiter;
401
+ expect(limiter.maxAttempts).toBe(1);
402
+ expect(limiter.windowMs).toBe(1000);
403
+ });
404
+ });
405
+ });
406
+
407
+ /**
408
+ * End-to-end-ish wiring tests: simulate failures at the peer-connection layer
409
+ * and verify they propagate through `onReconnectionNeeded` → `Call.reconnect` →
410
+ * counters → `leave({ message })`. This covers the gap between the unit tests
411
+ * above (which call `Call.reconnect` directly) and a true browser harness.
412
+ */
413
+ describe('Call reconnect wiring (PC event → leave)', () => {
414
+ let call: Call;
415
+ let sfuClient: StreamSfuClient;
416
+ let dispatcher: Dispatcher;
417
+
418
+ /** Builds a Publisher wired to forward onReconnectionNeeded + onIceConnected to Call. */
419
+ const makePublisherWiredToCall = () => {
420
+ const publisher = new Publisher(
421
+ {
422
+ sfuClient,
423
+ dispatcher,
424
+ state: call.state,
425
+ tag: 'test',
426
+ enableTracing: false,
427
+ onReconnectionNeeded: (kind, reason) => {
428
+ // mirror Call.ts:1409 wiring
429
+ call['reconnect'](kind, reason).catch(() => {});
430
+ },
431
+ onIceConnected: () => {
432
+ // mirror Call.ts:1416 wiring
433
+ call['iceFailuresWithoutConnect'] = 0;
434
+ },
435
+ },
436
+ [],
437
+ );
438
+ return publisher;
439
+ };
440
+
441
+ beforeEach(() => {
442
+ dispatcher = new Dispatcher();
443
+ sfuClient = new StreamSfuClient({
444
+ dispatcher,
445
+ sessionId: 'session-id-test',
446
+ streamClient: new StreamClient('abc'),
447
+ cid: 'test:123',
448
+ credentials: {
449
+ server: {
450
+ url: 'https://getstream.io/',
451
+ ws_endpoint: 'https://getstream.io/ws',
452
+ edge_name: 'sfu-1',
453
+ },
454
+ token: 'token',
455
+ ice_servers: [],
456
+ },
457
+ tag: 'test',
458
+ enableTracing: false,
459
+ });
460
+ // @ts-expect-error readonly field
461
+ sfuClient.iceTrickleBuffer = new IceTrickleBuffer();
462
+
463
+ const streamClient = new StreamClient('test-key');
464
+ const clientStore = new StreamVideoWriteableStateStore();
465
+ call = new Call({
466
+ type: 'default',
467
+ id: 'test-call',
468
+ streamClient,
469
+ clientStore,
470
+ ringing: false,
471
+ watching: false,
472
+ });
473
+ primeForReconnect(call);
474
+
475
+ // make the Call.reconnect loop deterministic
476
+ vi.spyOn(connectionUtils, 'sleep').mockResolvedValue(undefined);
477
+ vi.spyOn(call, 'leave').mockResolvedValue(undefined);
478
+ vi.spyOn(call, 'get').mockResolvedValue({} as never);
479
+ vi.spyOn(
480
+ call as unknown as { reconnectRejoin: () => Promise<void> },
481
+ 'reconnectRejoin',
482
+ ).mockImplementation(async () => {
483
+ // each REJOIN attempt "succeeds" so the loop exits without bouncing
484
+ call.state.setCallingState(CallingState.JOINED);
485
+ });
486
+ });
487
+
488
+ afterEach(() => {
489
+ vi.clearAllMocks();
490
+ });
491
+
492
+ /**
493
+ * Scenario 1 (manual smoke equivalent: 100% packet loss / blocked UDP):
494
+ * a publisher whose ICE never reaches `connected`/`completed` and goes to
495
+ * `failed` should make Call.reconnect count the reason. After
496
+ * `maxIceFailuresWithoutConnect` such failures, the call must `leave`.
497
+ */
498
+ it('publisher ICE failed (never-connected) drives Call.reconnect → webrtc_unsupported_network', async () => {
499
+ call.setMaxIceFailuresWithoutConnect(2);
500
+ const publisher = makePublisherWiredToCall();
501
+
502
+ const triggerIceFailedNeverConnected = async () => {
503
+ // @ts-expect-error private field
504
+ publisher['pc'].iceConnectionState = 'failed';
505
+ publisher['onIceConnectionStateChange']();
506
+ // flush the microtask queue so the async reconnect runs
507
+ await new Promise<void>((r) => setTimeout(r, 0));
508
+ };
509
+
510
+ await triggerIceFailedNeverConnected();
511
+ expect(call['iceFailuresWithoutConnect']).toBe(1);
512
+ expect(call.leave).not.toHaveBeenCalled();
513
+
514
+ // re-arm the loop guard for the second pass
515
+ primeForReconnect(call);
516
+ await triggerIceFailedNeverConnected();
517
+
518
+ expect(call['iceFailuresWithoutConnect']).toBe(2);
519
+ expect(call.leave).toHaveBeenCalledWith({
520
+ message: 'webrtc_unsupported_network',
521
+ });
522
+ });
523
+
524
+ /**
525
+ * Once ICE has reached `connected`, a subsequent `failed` is a normal
526
+ * recovery case — it should NOT count toward the unsupported-network
527
+ * threshold and should NOT cause leave.
528
+ */
529
+ it('publisher ICE failed AFTER prior connected does NOT count toward unsupported_network', async () => {
530
+ call.setMaxIceFailuresWithoutConnect(1);
531
+ const publisher = makePublisherWiredToCall();
532
+ // restartIce is invoked in the regular path; stub it
533
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
534
+
535
+ // simulate prior healthy ICE
536
+ // @ts-expect-error private field
537
+ publisher['pc'].iceConnectionState = 'connected';
538
+ publisher['onIceConnectionStateChange']();
539
+
540
+ // now ICE drops to failed
541
+ // @ts-expect-error private field
542
+ publisher['pc'].iceConnectionState = 'failed';
543
+ publisher['onIceConnectionStateChange']();
544
+ await new Promise<void>((r) => setTimeout(r, 0));
545
+
546
+ expect(call['iceFailuresWithoutConnect']).toBe(0);
547
+ expect(call.leave).not.toHaveBeenCalled();
548
+ // and a regular ICE restart was attempted
549
+ expect(publisher.restartIce).toHaveBeenCalled();
550
+ });
551
+
552
+ /**
553
+ * Scenario 4 (manual smoke equivalent: drop only the signal WS while the
554
+ * publisher PC stays `connected`): the FAST path should NOT call
555
+ * `publisher.restartIce()` because the PC is stable.
556
+ */
557
+ it('FAST path skips publisher.restartIce when publisher PC is stable', async () => {
558
+ const publisher = makePublisherWiredToCall();
559
+ // @ts-expect-error private field
560
+ publisher['pc'].iceConnectionState = 'connected';
561
+ publisher['onIceConnectionStateChange']();
562
+ // @ts-expect-error private field
563
+ publisher['pc'].connectionState = 'connected';
564
+
565
+ // pretend the publisher has tracks so isPublishing() would return true
566
+ vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
567
+ const restartIceSpy = vi.spyOn(publisher, 'restartIce').mockResolvedValue();
568
+ const setSfuSpy = vi.spyOn(publisher, 'setSfuClient');
569
+ call['publisher'] = publisher;
570
+
571
+ // mimic the FAST branch in doJoin: restoreICE is the gateway
572
+ const publisherIsStable = call['publisher']?.isStable() ?? true;
573
+ const includePublisher =
574
+ !!call['publisher']?.isPublishing() && !publisherIsStable;
575
+ await call['restoreICE'](sfuClient, {
576
+ includeSubscriber: false,
577
+ includePublisher,
578
+ });
579
+
580
+ expect(includePublisher).toBe(false);
581
+ expect(setSfuSpy).toHaveBeenCalledWith(sfuClient); // wire still updated
582
+ expect(restartIceSpy).not.toHaveBeenCalled(); // but NO ICE restart
583
+ });
584
+
585
+ /**
586
+ * Counterpart to the above: when the publisher PC is NOT stable (e.g.,
587
+ * `disconnected`), the FAST path SHOULD still issue an ICE restart.
588
+ */
589
+ it('FAST path DOES call publisher.restartIce when publisher PC is unstable', async () => {
590
+ const publisher = makePublisherWiredToCall();
591
+ // @ts-expect-error private field
592
+ publisher['pc'].iceConnectionState = 'connected';
593
+ publisher['onIceConnectionStateChange']();
594
+ // @ts-expect-error private field
595
+ publisher['pc'].iceConnectionState = 'disconnected';
596
+ // @ts-expect-error private field
597
+ publisher['pc'].connectionState = 'connected';
598
+
599
+ vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
600
+ const restartIceSpy = vi.spyOn(publisher, 'restartIce').mockResolvedValue();
601
+ call['publisher'] = publisher;
602
+
603
+ const publisherIsStable = call['publisher']?.isStable() ?? true;
604
+ const includePublisher =
605
+ !!call['publisher']?.isPublishing() && !publisherIsStable;
606
+ await call['restoreICE'](sfuClient, {
607
+ includeSubscriber: false,
608
+ includePublisher,
609
+ });
610
+
611
+ expect(includePublisher).toBe(true);
612
+ expect(restartIceSpy).toHaveBeenCalled();
613
+ });
614
+
615
+ /**
616
+ * Counter reset semantics — the fix from the Codex adversarial review:
617
+ * `iceFailuresWithoutConnect` must persist across SFU joins; only an
618
+ * actual ICE-connected event clears it.
619
+ */
620
+ it('iceFailuresWithoutConnect resets when the publisher PC reaches connected', () => {
621
+ call['iceFailuresWithoutConnect'] = 2;
622
+ const publisher = makePublisherWiredToCall();
623
+
624
+ // simulate ICE reaching `connected` end-to-end on the publisher
625
+ // @ts-expect-error private field
626
+ publisher['pc'].iceConnectionState = 'connected';
627
+ publisher['onIceConnectionStateChange']();
628
+
629
+ expect(call['iceFailuresWithoutConnect']).toBe(0);
630
+ });
631
+
632
+ it('onIceConnected fires exactly once per peer-connection lifetime', () => {
633
+ let calls = 0;
634
+ const publisher = new Publisher(
635
+ {
636
+ sfuClient,
637
+ dispatcher,
638
+ state: call.state,
639
+ tag: 'test',
640
+ enableTracing: false,
641
+ onReconnectionNeeded: () => {},
642
+ onIceConnected: () => {
643
+ calls++;
644
+ },
645
+ },
646
+ [],
647
+ );
648
+
649
+ // connected → counts
650
+ // @ts-expect-error private field
651
+ publisher['pc'].iceConnectionState = 'connected';
652
+ publisher['onIceConnectionStateChange']();
653
+ expect(calls).toBe(1);
654
+
655
+ // disconnected → connected → does NOT fire again
656
+ // @ts-expect-error private field
657
+ publisher['pc'].iceConnectionState = 'disconnected';
658
+ publisher['onIceConnectionStateChange']();
659
+ // @ts-expect-error private field
660
+ publisher['pc'].iceConnectionState = 'connected';
661
+ publisher['onIceConnectionStateChange']();
662
+ expect(calls).toBe(1);
663
+ });
664
+
665
+ /**
666
+ * Cross-peer count: a publisher AND a subscriber both failing without ever
667
+ * connecting should add up to the same `iceFailuresWithoutConnect` budget.
668
+ */
669
+ it('publisher + subscriber failures share the same unsupported_network budget', async () => {
670
+ call.setMaxIceFailuresWithoutConnect(2);
671
+ const publisher = makePublisherWiredToCall();
672
+ const subscriber = new Subscriber({
673
+ sfuClient,
674
+ dispatcher,
675
+ state: call.state,
676
+ tag: 'test',
677
+ enableTracing: false,
678
+ onReconnectionNeeded: (kind, reason) => {
679
+ call['reconnect'](kind, reason).catch(() => {});
680
+ },
681
+ });
682
+
683
+ // first failure on the publisher
684
+ // @ts-expect-error private field
685
+ publisher['pc'].iceConnectionState = 'failed';
686
+ publisher['onIceConnectionStateChange']();
687
+ await new Promise<void>((r) => setTimeout(r, 0));
688
+ expect(call['iceFailuresWithoutConnect']).toBe(1);
689
+ expect(call.leave).not.toHaveBeenCalled();
690
+
691
+ primeForReconnect(call);
692
+
693
+ // second failure on the subscriber — same shared counter, trips the limit
694
+ // @ts-expect-error private field
695
+ subscriber['pc'].iceConnectionState = 'failed';
696
+ subscriber['onIceConnectionStateChange']();
697
+ await new Promise<void>((r) => setTimeout(r, 0));
698
+
699
+ expect(call['iceFailuresWithoutConnect']).toBe(2);
700
+ expect(call.leave).toHaveBeenCalledWith({
701
+ message: 'webrtc_unsupported_network',
702
+ });
703
+ });
704
+
705
+ /**
706
+ * The peerType passed by Subscriber should be `SUBSCRIBER`. (Sanity check
707
+ * of the wiring contract.)
708
+ */
709
+ it('subscriber emits onReconnectionNeeded with PeerType.SUBSCRIBER', () => {
710
+ const onReconnectionNeeded = vi.fn();
711
+ const subscriber = new Subscriber({
712
+ sfuClient,
713
+ dispatcher,
714
+ state: call.state,
715
+ tag: 'test',
716
+ enableTracing: false,
717
+ onReconnectionNeeded,
718
+ });
719
+
720
+ // @ts-expect-error private field
721
+ subscriber['pc'].iceConnectionState = 'failed';
722
+ subscriber['onIceConnectionStateChange']();
723
+
724
+ expect(onReconnectionNeeded).toHaveBeenCalledWith(
725
+ WebsocketReconnectStrategy.REJOIN,
726
+ ReconnectReason.ICE_NEVER_CONNECTED,
727
+ PeerType.SUBSCRIBER,
728
+ );
729
+ });
730
+ });
731
+
732
+ /**
733
+ * `leave()` runs after both the success path (end of `joinFlow`) and the
734
+ * giveUpAndLeave path. Only the success path resets `reconnectStrategy` /
735
+ * `reconnectReason`. Without resetting them in `leave()` itself, a Call
736
+ * instance reused after a failed-reconnect terminal leave would still see
737
+ * `reconnectStrategy != UNSPECIFIED` on the next `join()` and would send
738
+ * a stale `ReconnectDetails` to the SFU.
739
+ */
740
+ describe('Call.leave() reconnect-state reset', () => {
741
+ it('clears reconnectStrategy, reconnectReason, and reconnectAttempts', async () => {
742
+ const call = makeCall();
743
+ call.state.setCallingState(CallingState.JOINED);
744
+
745
+ call['reconnectStrategy'] = WebsocketReconnectStrategy.REJOIN;
746
+ call['reconnectReason'] = ReconnectReason.ICE_NEVER_CONNECTED;
747
+ call['reconnectAttempts'] = 3;
748
+ call['iceFailuresWithoutConnect'] = 2;
749
+ call['consecutiveNegotiationFailures'] = 1;
750
+
751
+ await call.leave();
752
+
753
+ expect(call['reconnectStrategy']).toBe(
754
+ WebsocketReconnectStrategy.UNSPECIFIED,
755
+ );
756
+ expect(call['reconnectReason']).toBe('');
757
+ expect(call['reconnectAttempts']).toBe(0);
758
+ expect(call['iceFailuresWithoutConnect']).toBe(0);
759
+ expect(call['consecutiveNegotiationFailures']).toBe(0);
760
+ });
761
+ });