@stream-io/video-client 1.59.0 → 1.60.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 (57) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +544 -111
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +544 -110
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +544 -111
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +23 -3
  9. package/dist/src/coordinator/connection/types.d.ts +16 -0
  10. package/dist/src/coordinator/connection/utils.d.ts +10 -0
  11. package/dist/src/events/call.d.ts +0 -11
  12. package/dist/src/gen/coordinator/index.d.ts +73 -0
  13. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  14. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  15. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  16. package/dist/src/ringing/index.d.ts +4 -0
  17. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  18. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  19. package/dist/src/sorting/participants.d.ts +13 -0
  20. package/dist/src/stats/utils.d.ts +6 -0
  21. package/dist/src/store/CallState.d.ts +10 -1
  22. package/package.json +1 -1
  23. package/src/Call.ts +93 -61
  24. package/src/StreamSfuClient.ts +6 -2
  25. package/src/__tests__/Call.autodrop.test.ts +116 -64
  26. package/src/__tests__/Call.getRingState.test.ts +87 -0
  27. package/src/__tests__/Call.lifecycle.test.ts +23 -0
  28. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  29. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  30. package/src/__tests__/StreamVideoClient.ringing.test.ts +32 -20
  31. package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
  32. package/src/coordinator/connection/client.ts +3 -2
  33. package/src/coordinator/connection/types.ts +19 -0
  34. package/src/coordinator/connection/utils.ts +28 -0
  35. package/src/events/__tests__/call.test.ts +2 -217
  36. package/src/events/call.ts +1 -73
  37. package/src/events/callEventHandlers.ts +12 -23
  38. package/src/gen/coordinator/index.ts +67 -0
  39. package/src/reporting/ClientEventReporter.ts +30 -4
  40. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  41. package/src/ringing/RingStatePoller.ts +177 -0
  42. package/src/ringing/RingTimeout.ts +90 -0
  43. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  44. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  45. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  46. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  47. package/src/ringing/index.ts +4 -0
  48. package/src/ringing/reconcileRingState.ts +108 -0
  49. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  50. package/src/sorting/__tests__/presets.test.ts +188 -3
  51. package/src/sorting/__tests__/sorting.test.ts +22 -1
  52. package/src/sorting/participants.ts +20 -0
  53. package/src/sorting/presets.ts +4 -0
  54. package/src/stats/__tests__/utils.test.ts +33 -0
  55. package/src/stats/utils.ts +15 -0
  56. package/src/store/CallState.ts +25 -0
  57. package/src/store/__tests__/CallState.test.ts +61 -2
@@ -0,0 +1,462 @@
1
+ import '../../rtc/__tests__/mocks/webrtc.mocks';
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { fromPartial } from '@total-typescript/shoehorn';
5
+ import { Call } from '../../Call';
6
+ import { RingStatePoller } from '../RingStatePoller';
7
+ import { StreamClient } from '../../coordinator/connection/client';
8
+ import { ClientEventReporter } from '../../reporting';
9
+ import { generateUUIDv4 } from '../../coordinator/connection/utils';
10
+ import { CallingState, StreamVideoWriteableStateStore } from '../../store';
11
+ import {
12
+ ErrorFromResponse,
13
+ type StreamClientOptions,
14
+ } from '../../coordinator/connection/types';
15
+ import type { GetCallRingStateResponse } from '../../gen/coordinator';
16
+
17
+ const SESSION_ID = 'session-1';
18
+ const START_AFTER_MS = 15_000;
19
+ const INTERVAL_MS = 5_000;
20
+
21
+ const ringState = (
22
+ overrides: Partial<GetCallRingStateResponse> = {},
23
+ ): GetCallRingStateResponse => ({
24
+ duration: '1ms',
25
+ call_cid: 'test:call',
26
+ session_id: SESSION_ID,
27
+ created_by_user_id: 'jane',
28
+ accepted_by: {},
29
+ rejected_by: {},
30
+ missed_by: {},
31
+ ...overrides,
32
+ });
33
+
34
+ describe('RingStatePoller', () => {
35
+ const userId = 'jane';
36
+ let call: Call;
37
+ let poller: RingStatePoller;
38
+
39
+ const createCall = (options?: StreamClientOptions) => {
40
+ const clientStore = new StreamVideoWriteableStateStore();
41
+ const streamClient = new StreamClient('abc', options);
42
+ const newCall = new Call({
43
+ type: 'test',
44
+ id: generateUUIDv4(),
45
+ streamClient,
46
+ clientEventReporter: new ClientEventReporter({ streamClient }),
47
+ clientStore,
48
+ });
49
+
50
+ // @ts-expect-error mocking only what we need for the test
51
+ clientStore.setConnectedUser({ id: userId });
52
+ // @ts-expect-error mocking only what we need for the test
53
+ newCall.state['createdBySubject'].next({ id: userId });
54
+ // @ts-expect-error mocking only what we need for the test
55
+ newCall.state['sessionSubject'].next({
56
+ id: SESSION_ID,
57
+ accepted_by: {},
58
+ rejected_by: {},
59
+ missed_by: {},
60
+ participants: [],
61
+ });
62
+ newCall.state['settingsSubject'].next({
63
+ ring: {
64
+ auto_cancel_timeout_ms: 30_000,
65
+ incoming_call_timeout_ms: 30_000,
66
+ missed_call_timeout_ms: 30_000,
67
+ },
68
+ // @ts-expect-error mocking only what we need for the test
69
+ screensharing: {
70
+ enabled: false,
71
+ target_resolution: { width: 100, height: 100 },
72
+ },
73
+ });
74
+ newCall.state.setMembers(
75
+ fromPartial([{ user_id: userId }, { user_id: 'john' }]),
76
+ );
77
+ newCall.state['callingStateSubject'].next(CallingState.RINGING);
78
+
79
+ vi.spyOn(newCall, 'join').mockResolvedValue(undefined);
80
+ vi.spyOn(newCall, 'leave').mockResolvedValue(undefined);
81
+
82
+ return newCall;
83
+ };
84
+
85
+ const startPolling = (response = ringState()) => {
86
+ const getRingState = vi
87
+ .spyOn(call, 'getRingState')
88
+ .mockResolvedValue(response);
89
+ poller = new RingStatePoller(call);
90
+ poller.start();
91
+ return getRingState;
92
+ };
93
+
94
+ beforeEach(() => {
95
+ vi.useFakeTimers();
96
+ call = createCall();
97
+ });
98
+
99
+ afterEach(() => {
100
+ poller?.stop();
101
+ vi.useRealTimers();
102
+ vi.restoreAllMocks();
103
+ });
104
+
105
+ it('does not poll before the quiet period elapses', async () => {
106
+ const getRingState = startPolling();
107
+
108
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS - 1);
109
+ expect(getRingState).not.toHaveBeenCalled();
110
+
111
+ await vi.advanceTimersByTimeAsync(1);
112
+ expect(getRingState).toHaveBeenCalledWith(SESSION_ID);
113
+ });
114
+
115
+ it('keeps polling on the configured interval while the ring is pending', async () => {
116
+ const getRingState = startPolling();
117
+
118
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
119
+ expect(getRingState).toHaveBeenCalledTimes(1);
120
+
121
+ await vi.advanceTimersByTimeAsync(INTERVAL_MS);
122
+ expect(getRingState).toHaveBeenCalledTimes(2);
123
+
124
+ await vi.advanceTimersByTimeAsync(INTERVAL_MS);
125
+ expect(getRingState).toHaveBeenCalledTimes(3);
126
+ });
127
+
128
+ it('joins the call once when someone else accepted', async () => {
129
+ const getRingState = startPolling(
130
+ ringState({ accepted_by: { john: '2026-08-24T10:00:04Z' } }),
131
+ );
132
+
133
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS + 3 * INTERVAL_MS);
134
+
135
+ expect(call.join).toHaveBeenCalledTimes(1);
136
+ // pins this as the poller's call site, not the WebSocket handlers'
137
+ expect(call.join).toHaveBeenCalledWith({ joinSource: 'ring-poll-api' });
138
+ expect(getRingState).toHaveBeenCalledTimes(1);
139
+ });
140
+
141
+ it('ignores an acceptance by the current user', async () => {
142
+ startPolling(
143
+ ringState({ accepted_by: { [userId]: '2026-08-24T10:00:04Z' } }),
144
+ );
145
+
146
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
147
+
148
+ expect(call.join).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it('cancels the call when everyone else rejected', async () => {
152
+ startPolling(ringState({ rejected_by: { john: '2026-08-24T10:00:09Z' } }));
153
+
154
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
155
+
156
+ expect(call.leave).toHaveBeenCalledWith({
157
+ reject: true,
158
+ reason: 'cancel',
159
+ message: 'ring: everyone rejected',
160
+ });
161
+ });
162
+
163
+ it('keeps the ring deadline across a pause', async () => {
164
+ startPolling();
165
+ const deadlineAt = poller['deadlineAt'];
166
+
167
+ poller.pause();
168
+ await vi.advanceTimersByTimeAsync(10_000);
169
+ poller.resume();
170
+
171
+ expect(poller['deadlineAt']).toBe(deadlineAt);
172
+ });
173
+
174
+ it('resumes straight back into polling when it was already past the quiet period', async () => {
175
+ const getRingState = startPolling();
176
+
177
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
178
+ expect(getRingState).toHaveBeenCalledTimes(1);
179
+
180
+ poller.pause();
181
+ await vi.advanceTimersByTimeAsync(INTERVAL_MS);
182
+ expect(getRingState).toHaveBeenCalledTimes(1);
183
+
184
+ // no second quiet period: the next poll lands on the interval
185
+ poller.resume();
186
+ expect(getRingState).toHaveBeenCalledTimes(2);
187
+ await vi.advanceTimersByTimeAsync(INTERVAL_MS);
188
+ expect(getRingState).toHaveBeenCalledTimes(3);
189
+ });
190
+
191
+ it('stops on the first tick after the ring window, even across a pause', async () => {
192
+ const getRingState = startPolling();
193
+
194
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
195
+ poller.pause();
196
+ // the pause outlasts the ring window
197
+ await vi.advanceTimersByTimeAsync(30_000);
198
+ poller.resume();
199
+
200
+ expect(getRingState).toHaveBeenCalledTimes(1);
201
+ expect(poller['stopped']).toBe(true);
202
+ });
203
+
204
+ it('does not resume a stopped poller', async () => {
205
+ const getRingState = startPolling();
206
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
207
+ // paused mid-poll, so resuming would take the interval branch
208
+ poller.pause();
209
+ poller.stop();
210
+
211
+ poller.resume();
212
+
213
+ // no timer left behind on a stopped poller
214
+ expect(poller['intervalId']).toBeUndefined();
215
+ expect(poller['idleTimeoutId']).toBeUndefined();
216
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS + INTERVAL_MS);
217
+ expect(getRingState).toHaveBeenCalledTimes(1);
218
+ });
219
+
220
+ it('keeps polling when everyone else is marked missed before auto-cancel', async () => {
221
+ startPolling(ringState({ missed_by: { john: '2026-08-24T10:00:35Z' } }));
222
+
223
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
224
+
225
+ expect(call.leave).not.toHaveBeenCalled();
226
+ expect(poller['stopped']).toBe(false);
227
+ });
228
+
229
+ it('leaves without rejecting when the call has ended, even if it was accepted', async () => {
230
+ startPolling(
231
+ ringState({
232
+ accepted_by: { john: '2026-08-24T10:00:04Z' },
233
+ call_ended_at: '2026-08-24T10:00:20Z',
234
+ }),
235
+ );
236
+
237
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
238
+
239
+ expect(call.join).not.toHaveBeenCalled();
240
+ expect(call.leave).toHaveBeenCalledWith({
241
+ reject: false,
242
+ reason: 'ended',
243
+ message: 'ring: call ended',
244
+ });
245
+ });
246
+
247
+ it('restarts the quiet period when a ring event arrives over the WebSocket', async () => {
248
+ const getRingState = startPolling();
249
+
250
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS - 1_000);
251
+ call['streamClient'].dispatchEvent({
252
+ type: 'call.rejected',
253
+ call_cid: call.cid,
254
+ // @ts-expect-error mocking only what we need for the test
255
+ user: { id: 'john' },
256
+ });
257
+
258
+ await vi.advanceTimersByTimeAsync(1_000);
259
+ expect(getRingState).not.toHaveBeenCalled();
260
+
261
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS - 1_000);
262
+ expect(getRingState).toHaveBeenCalledTimes(1);
263
+ });
264
+
265
+ it('stops polling once the call has left the ringing state', async () => {
266
+ const getRingState = startPolling();
267
+
268
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
269
+ expect(getRingState).toHaveBeenCalledTimes(1);
270
+
271
+ call.state['callingStateSubject'].next(CallingState.JOINED);
272
+ await vi.advanceTimersByTimeAsync(3 * INTERVAL_MS);
273
+ expect(getRingState).toHaveBeenCalledTimes(1);
274
+ });
275
+
276
+ it('stops polling as soon as the caller joins, without waiting for a tick', async () => {
277
+ const getRingState = startPolling();
278
+
279
+ // the caller joining an accepted call never goes through `leave`
280
+ call.state['callingStateSubject'].next(CallingState.JOINED);
281
+ expect(poller['stopped']).toBe(true);
282
+
283
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS + 3 * INTERVAL_MS);
284
+ expect(getRingState).not.toHaveBeenCalled();
285
+ });
286
+
287
+ // `doJoin` restores the ringing state when a join fails, so JOINING is
288
+ // transient: giving up on it would abandon the ring the poller exists for.
289
+ it('keeps polling when a join attempt failed and left the call ringing', async () => {
290
+ const getRingState = startPolling();
291
+
292
+ call.state['callingStateSubject'].next(CallingState.JOINING);
293
+ expect(poller['stopped']).toBe(false);
294
+ call.state['callingStateSubject'].next(CallingState.RINGING);
295
+
296
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
297
+ expect(getRingState).toHaveBeenCalled();
298
+ });
299
+
300
+ it('retries the join on the next poll when it failed', async () => {
301
+ const getRingState = startPolling(
302
+ ringState({ accepted_by: { john: '2026-08-24T10:00:04Z' } }),
303
+ );
304
+ vi.mocked(call.join).mockRejectedValueOnce(new Error('transient'));
305
+
306
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
307
+ expect(call.join).toHaveBeenCalledTimes(1);
308
+ expect(poller['stopped']).toBe(false);
309
+
310
+ await vi.advanceTimersByTimeAsync(INTERVAL_MS);
311
+ expect(call.join).toHaveBeenCalledTimes(2);
312
+ expect(getRingState).toHaveBeenCalledTimes(2);
313
+ });
314
+
315
+ // `Call.join({ ring: true })` arms the poller while the call is already
316
+ // JOINING, so `start` has to refuse rather than arm and stop on the next tick.
317
+ it('does not arm when the call is not ringing', async () => {
318
+ call.state['callingStateSubject'].next(CallingState.JOINING);
319
+ const getRingState = startPolling();
320
+
321
+ expect(poller['sessionId']).toBeUndefined();
322
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS + INTERVAL_MS);
323
+ expect(getRingState).not.toHaveBeenCalled();
324
+ });
325
+
326
+ it('stops polling once the ring window has closed', async () => {
327
+ const getRingState = startPolling();
328
+
329
+ await vi.advanceTimersByTimeAsync(30_000);
330
+ const callsWithinWindow = getRingState.mock.calls.length;
331
+ expect(callsWithinWindow).toBeGreaterThan(0);
332
+
333
+ await vi.advanceTimersByTimeAsync(3 * INTERVAL_MS);
334
+ expect(getRingState).toHaveBeenCalledTimes(callsWithinWindow);
335
+ });
336
+
337
+ it('stops polling on an unrecoverable response, but not on a transient one', async () => {
338
+ const getRingState = vi.spyOn(call, 'getRingState');
339
+ const error = (status: number) =>
340
+ new ErrorFromResponse({
341
+ message: 'boom',
342
+ code: 16,
343
+ status,
344
+ // @ts-expect-error mocking only what we need for the test
345
+ response: {},
346
+ unrecoverable: false,
347
+ });
348
+
349
+ getRingState.mockRejectedValueOnce(error(500));
350
+ poller = new RingStatePoller(call);
351
+ poller.start();
352
+
353
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS);
354
+ expect(getRingState).toHaveBeenCalledTimes(1);
355
+
356
+ getRingState.mockRejectedValueOnce(error(404));
357
+ await vi.advanceTimersByTimeAsync(INTERVAL_MS);
358
+ expect(getRingState).toHaveBeenCalledTimes(2);
359
+
360
+ await vi.advanceTimersByTimeAsync(3 * INTERVAL_MS);
361
+ expect(getRingState).toHaveBeenCalledTimes(2);
362
+ });
363
+
364
+ it('does not start without a call session', async () => {
365
+ call.state['sessionSubject'].next(undefined);
366
+ const getRingState = startPolling();
367
+
368
+ await vi.advanceTimersByTimeAsync(START_AFTER_MS + INTERVAL_MS);
369
+ expect(getRingState).not.toHaveBeenCalled();
370
+ });
371
+
372
+ it('honors the configured timings', async () => {
373
+ const getRingState = vi
374
+ .spyOn(call, 'getRingState')
375
+ .mockResolvedValue(ringState());
376
+ poller = new RingStatePoller(call, {
377
+ startAfterMs: 1_000,
378
+ intervalMs: 500,
379
+ });
380
+ poller.start();
381
+
382
+ await vi.advanceTimersByTimeAsync(1_000);
383
+ expect(getRingState).toHaveBeenCalledTimes(1);
384
+
385
+ await vi.advanceTimersByTimeAsync(500);
386
+ expect(getRingState).toHaveBeenCalledTimes(2);
387
+ });
388
+ });
389
+
390
+ describe('Call ring state polling', () => {
391
+ const userId = 'jane';
392
+
393
+ const createRingingCall = (options?: StreamClientOptions) => {
394
+ const clientStore = new StreamVideoWriteableStateStore();
395
+ const streamClient = new StreamClient('abc', options);
396
+ const call = new Call({
397
+ type: 'test',
398
+ id: generateUUIDv4(),
399
+ streamClient,
400
+ clientEventReporter: new ClientEventReporter({ streamClient }),
401
+ clientStore,
402
+ ringing: true,
403
+ });
404
+
405
+ // @ts-expect-error mocking only what we need for the test
406
+ clientStore.setConnectedUser({ id: userId });
407
+ // @ts-expect-error mocking only what we need for the test
408
+ call.state['sessionSubject'].next({
409
+ id: SESSION_ID,
410
+ accepted_by: {},
411
+ rejected_by: {},
412
+ missed_by: {},
413
+ participants: [],
414
+ });
415
+ call.state['settingsSubject'].next({
416
+ // @ts-expect-error mocking only what we need for the test
417
+ ring: { auto_cancel_timeout_ms: 30_000 },
418
+ // @ts-expect-error mocking only what we need for the test
419
+ screensharing: {
420
+ enabled: false,
421
+ target_resolution: { width: 100, height: 100 },
422
+ },
423
+ });
424
+ return call;
425
+ };
426
+
427
+ beforeEach(() => {
428
+ vi.useFakeTimers();
429
+ });
430
+
431
+ afterEach(() => {
432
+ vi.useRealTimers();
433
+ vi.restoreAllMocks();
434
+ });
435
+
436
+ it('polls for the caller', () => {
437
+ const call = createRingingCall();
438
+ // @ts-expect-error mocking only what we need for the test
439
+ call.state['createdBySubject'].next({ id: userId });
440
+
441
+ call['handleRingingCall']();
442
+ expect(call['ringStatePoller']).toBeDefined();
443
+ });
444
+
445
+ it('does not poll for the callee', () => {
446
+ const call = createRingingCall();
447
+ // @ts-expect-error mocking only what we need for the test
448
+ call.state['createdBySubject'].next({ id: 'not-' + userId });
449
+
450
+ call['handleRingingCall']();
451
+ expect(call['ringStatePoller']).toBeUndefined();
452
+ });
453
+
454
+ it('does not poll when disabled through the client options', () => {
455
+ const call = createRingingCall({ ringStatePolling: false });
456
+ // @ts-expect-error mocking only what we need for the test
457
+ call.state['createdBySubject'].next({ id: userId });
458
+
459
+ call['handleRingingCall']();
460
+ expect(call['ringStatePoller']).toBeUndefined();
461
+ });
462
+ });
@@ -0,0 +1,199 @@
1
+ import '../../rtc/__tests__/mocks/webrtc.mocks';
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { fromPartial } from '@total-typescript/shoehorn';
5
+ import { Call } from '../../Call';
6
+ import { RingTimeout } from '../RingTimeout';
7
+ import { StreamClient } from '../../coordinator/connection/client';
8
+ import { ClientEventReporter } from '../../reporting';
9
+ import { generateUUIDv4 } from '../../coordinator/connection/utils';
10
+ import { CallingState, StreamVideoWriteableStateStore } from '../../store';
11
+ import { CallSettingsResponse } from '../../gen/coordinator';
12
+
13
+ const TIMEOUT_MS = 30_000;
14
+
15
+ describe('RingTimeout', () => {
16
+ const userId = 'jane';
17
+ let call: Call;
18
+ let ringTimeout: RingTimeout;
19
+
20
+ const ringingCall = ({
21
+ createdById = userId,
22
+ ring = {
23
+ auto_cancel_timeout_ms: TIMEOUT_MS,
24
+ incoming_call_timeout_ms: TIMEOUT_MS,
25
+ missed_call_timeout_ms: TIMEOUT_MS,
26
+ },
27
+ }: {
28
+ createdById?: string;
29
+ ring?: Partial<CallSettingsResponse['ring']> | null;
30
+ } = {}) => {
31
+ const clientStore = new StreamVideoWriteableStateStore();
32
+ const streamClient = new StreamClient('abc');
33
+ const newCall = new Call({
34
+ type: 'test',
35
+ id: generateUUIDv4(),
36
+ streamClient,
37
+ clientEventReporter: new ClientEventReporter({ streamClient }),
38
+ clientStore,
39
+ });
40
+
41
+ clientStore.setConnectedUser(fromPartial({ id: userId }));
42
+ newCall.state['createdBySubject'].next(fromPartial({ id: createdById }));
43
+ // leaving `settings` unset is how a call built from a push notification
44
+ // looks until `get()` resolves
45
+ if (ring) {
46
+ newCall.state['settingsSubject'].next(
47
+ fromPartial<CallSettingsResponse>({
48
+ ring,
49
+ screensharing: {
50
+ enabled: false,
51
+ target_resolution: { width: 100, height: 100 },
52
+ },
53
+ }),
54
+ );
55
+ }
56
+ newCall.state['callingStateSubject'].next(CallingState.RINGING);
57
+
58
+ vi.spyOn(newCall, 'leave').mockResolvedValue(undefined);
59
+ return newCall;
60
+ };
61
+
62
+ const arm = () => {
63
+ ringTimeout = new RingTimeout(call);
64
+ ringTimeout.start();
65
+ return ringTimeout;
66
+ };
67
+
68
+ beforeEach(() => {
69
+ vi.useFakeTimers();
70
+ });
71
+
72
+ afterEach(() => {
73
+ ringTimeout?.stop();
74
+ vi.useRealTimers();
75
+ vi.restoreAllMocks();
76
+ });
77
+
78
+ it('drops the call for the caller when no one accepted', async () => {
79
+ call = ringingCall();
80
+ arm();
81
+
82
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
83
+
84
+ expect(call.leave).toHaveBeenCalledWith({
85
+ reject: true,
86
+ reason: 'timeout',
87
+ message: 'ringing timeout - no one accepted',
88
+ });
89
+ });
90
+
91
+ it('drops the call for a callee that never interacted', async () => {
92
+ call = ringingCall({ createdById: 'not-' + userId });
93
+ arm();
94
+
95
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
96
+
97
+ expect(call.leave).toHaveBeenCalledWith({
98
+ reject: true,
99
+ reason: 'timeout',
100
+ message: `ringing timeout - user didn't interact with incoming call screen`,
101
+ });
102
+ });
103
+
104
+ it('does not drop the call before the timeout elapses', async () => {
105
+ call = ringingCall();
106
+ arm();
107
+
108
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS - 1);
109
+ expect(call.leave).not.toHaveBeenCalled();
110
+
111
+ await vi.advanceTimersByTimeAsync(1);
112
+ expect(call.leave).toHaveBeenCalled();
113
+ });
114
+
115
+ it('does not drop a call that stopped ringing before the deadline', async () => {
116
+ call = ringingCall();
117
+ arm();
118
+
119
+ call.state['callingStateSubject'].next(CallingState.JOINED);
120
+
121
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
122
+ expect(call.leave).not.toHaveBeenCalled();
123
+ });
124
+
125
+ // `doJoin` restores the ringing state when a join fails, so a transition to
126
+ // JOINING must not disarm the drop for good, or the call rings forever.
127
+ it('still drops the call after a join attempt failed and left it ringing', async () => {
128
+ call = ringingCall();
129
+ arm();
130
+
131
+ call.state['callingStateSubject'].next(CallingState.JOINING);
132
+ call.state['callingStateSubject'].next(CallingState.RINGING);
133
+
134
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
135
+ expect(call.leave).toHaveBeenCalledWith({
136
+ reject: true,
137
+ reason: 'timeout',
138
+ message: 'ringing timeout - no one accepted',
139
+ });
140
+ });
141
+
142
+ it('does not drop the call once stopped', async () => {
143
+ call = ringingCall();
144
+ arm().stop();
145
+
146
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
147
+ expect(call.leave).not.toHaveBeenCalled();
148
+ });
149
+
150
+ it('resumes from the original deadline after being paused', async () => {
151
+ call = ringingCall();
152
+ arm();
153
+
154
+ await vi.advanceTimersByTimeAsync(10_000);
155
+ ringTimeout.pause();
156
+ await vi.advanceTimersByTimeAsync(5_000);
157
+ ringTimeout.start();
158
+
159
+ await vi.advanceTimersByTimeAsync(14_999);
160
+ expect(call.leave).not.toHaveBeenCalled();
161
+
162
+ await vi.advanceTimersByTimeAsync(1);
163
+ expect(call.leave).toHaveBeenCalled();
164
+ });
165
+
166
+ it('does not arm when the call is not ringing', async () => {
167
+ call = ringingCall();
168
+ call.state['callingStateSubject'].next(CallingState.JOINED);
169
+ arm();
170
+
171
+ expect(ringTimeout['timeoutId']).toBeUndefined();
172
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
173
+ expect(call.leave).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it('does not arm before the call settings have loaded', async () => {
177
+ call = ringingCall({ ring: null });
178
+ arm();
179
+
180
+ expect(ringTimeout['timeoutId']).toBeUndefined();
181
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
182
+ expect(call.leave).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it('treats a zero timeout as no auto-drop', async () => {
186
+ call = ringingCall({
187
+ ring: {
188
+ auto_cancel_timeout_ms: 0,
189
+ incoming_call_timeout_ms: 0,
190
+ missed_call_timeout_ms: TIMEOUT_MS,
191
+ },
192
+ });
193
+ arm();
194
+
195
+ expect(ringTimeout['timeoutId']).toBeUndefined();
196
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
197
+ expect(call.leave).not.toHaveBeenCalled();
198
+ });
199
+ });