@stream-io/video-client 1.59.1 → 1.61.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 (62) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.browser.es.js +744 -156
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +744 -155
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +744 -156
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +34 -8
  10. package/dist/src/coordinator/connection/types.d.ts +16 -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/rtc/Publisher.d.ts +18 -0
  20. package/dist/src/rtc/TransceiverCache.d.ts +10 -0
  21. package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
  22. package/dist/src/stats/utils.d.ts +6 -0
  23. package/dist/src/store/CallState.d.ts +10 -1
  24. package/dist/src/types.d.ts +30 -1
  25. package/index.ts +1 -0
  26. package/package.json +1 -1
  27. package/src/Call.ts +272 -108
  28. package/src/StreamSfuClient.ts +6 -2
  29. package/src/__tests__/Call.autodrop.test.ts +116 -64
  30. package/src/__tests__/Call.getRingState.test.ts +87 -0
  31. package/src/__tests__/Call.lifecycle.test.ts +498 -5
  32. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  33. package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
  34. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  35. package/src/coordinator/connection/types.ts +19 -0
  36. package/src/events/__tests__/call.test.ts +2 -217
  37. package/src/events/call.ts +1 -73
  38. package/src/events/callEventHandlers.ts +12 -23
  39. package/src/gen/coordinator/index.ts +67 -0
  40. package/src/reporting/ClientEventReporter.ts +30 -4
  41. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  42. package/src/ringing/RingStatePoller.ts +177 -0
  43. package/src/ringing/RingTimeout.ts +90 -0
  44. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  45. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  46. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  47. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  48. package/src/ringing/index.ts +4 -0
  49. package/src/ringing/reconcileRingState.ts +108 -0
  50. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  51. package/src/rtc/Publisher.ts +66 -12
  52. package/src/rtc/TransceiverCache.ts +17 -0
  53. package/src/rtc/__tests__/Publisher.test.ts +209 -0
  54. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
  55. package/src/rtc/e2ee/E2EEManager.ts +10 -1
  56. package/src/rtc/e2ee/EncryptionManager.ts +1 -1
  57. package/src/rtc/e2ee/SPEC.md +1 -1
  58. package/src/stats/__tests__/utils.test.ts +33 -0
  59. package/src/stats/utils.ts +15 -0
  60. package/src/store/CallState.ts +25 -0
  61. package/src/store/__tests__/CallState.test.ts +59 -0
  62. package/src/types.ts +37 -1
@@ -0,0 +1,90 @@
1
+ import type { Call } from '../Call';
2
+ import { CallingState } from '../store';
3
+ import { getTimers } from '../timers';
4
+ import { videoLoggerSystem } from '../logger';
5
+
6
+ /**
7
+ * Drops a call that has been ringing for too long: the caller cancels after
8
+ * `auto_cancel_timeout_ms`, a callee after `incoming_call_timeout_ms`.
9
+ *
10
+ * Either timeout being `0` means the call rings until something else settles it.
11
+ */
12
+ export class RingTimeout {
13
+ private readonly call: Call;
14
+ private timeoutId: number | undefined;
15
+ private deadlineAt: number | undefined;
16
+ private stopped: boolean = false;
17
+
18
+ constructor(call: Call) {
19
+ this.call = call;
20
+ }
21
+
22
+ /**
23
+ * Schedules an auto-drop timeout based on the call settings.
24
+ * Applicable only for ringing calls.
25
+ */
26
+ start = () => {
27
+ if (this.stopped || this.timeoutId !== undefined) return;
28
+ // ignore if the call is not ringing
29
+ if (this.call.state.callingState !== CallingState.RINGING) return;
30
+
31
+ const ring = this.call.state.settings?.ring;
32
+ if (!ring) return;
33
+
34
+ const isCaller = this.call.isCreatedByMe;
35
+ const timeoutMs = isCaller
36
+ ? ring.auto_cancel_timeout_ms
37
+ : ring.incoming_call_timeout_ms;
38
+ // 0 means no auto-drop
39
+ if (timeoutMs <= 0) return;
40
+
41
+ const now = Date.now();
42
+ this.deadlineAt ??= now + timeoutMs;
43
+ const delayMs = Math.max(0, this.deadlineAt - now);
44
+ const timers = getTimers();
45
+ this.timeoutId = timers.setTimeout(() => {
46
+ this.timeoutId = undefined;
47
+ // A failed timeout-triggered leave can arm a fresh retry window.
48
+ this.deadlineAt = undefined;
49
+ if (this.stopped) return;
50
+ // the call might have stopped ringing by this point, e.g. it was already
51
+ // accepted and joined
52
+ if (this.call.state.callingState !== CallingState.RINGING) return;
53
+ this.call
54
+ .leave({
55
+ reject: true,
56
+ reason: 'timeout',
57
+ message: `ringing timeout - ${
58
+ isCaller
59
+ ? 'no one accepted'
60
+ : `user didn't interact with incoming call screen`
61
+ }`,
62
+ })
63
+ .catch((err) => {
64
+ videoLoggerSystem
65
+ .getLogger('RingTimeout')
66
+ .error('Failed to drop the call', err);
67
+ });
68
+ }, delayMs);
69
+ };
70
+
71
+ /**
72
+ * Pauses the timeout while preserving its original deadline.
73
+ */
74
+ pause = () => {
75
+ if (this.stopped || this.timeoutId === undefined) return;
76
+ getTimers().clearTimeout(this.timeoutId);
77
+ this.timeoutId = undefined;
78
+ };
79
+
80
+ /**
81
+ * Cancels a scheduled auto-drop timeout. It cannot be armed again.
82
+ */
83
+ stop = () => {
84
+ if (this.stopped) return;
85
+ this.stopped = true;
86
+ getTimers().clearTimeout(this.timeoutId);
87
+ this.timeoutId = undefined;
88
+ this.deadlineAt = undefined;
89
+ };
90
+ }
@@ -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
+ });