@stream-io/video-client 2.0.0-beta.0 → 2.0.0-beta.1

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 (90) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.browser.es.js +1126 -791
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1126 -791
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +1126 -791
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +37 -11
  10. package/dist/src/StreamVideoClient.d.ts +15 -21
  11. package/dist/src/coordinator/connection/types.d.ts +16 -0
  12. package/dist/src/events/call.d.ts +0 -11
  13. package/dist/src/gen/coordinator/index.d.ts +73 -0
  14. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  15. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  16. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  17. package/dist/src/ringing/index.d.ts +4 -0
  18. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  19. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  20. package/dist/src/rtc/Publisher.d.ts +18 -0
  21. package/dist/src/rtc/TransceiverCache.d.ts +10 -0
  22. package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
  23. package/dist/src/stats/utils.d.ts +6 -0
  24. package/dist/src/store/CallState.d.ts +118 -267
  25. package/dist/src/store/ClientState.d.ts +62 -0
  26. package/dist/src/store/index.d.ts +1 -1
  27. package/dist/src/store/subjects.d.ts +15 -0
  28. package/dist/src/store/types.d.ts +22 -0
  29. package/dist/src/types.d.ts +33 -4
  30. package/index.ts +1 -0
  31. package/package.json +2 -2
  32. package/src/Call.ts +285 -125
  33. package/src/StreamSfuClient.ts +6 -2
  34. package/src/StreamVideoClient.ts +40 -47
  35. package/src/__tests__/Call.autodrop.test.ts +118 -66
  36. package/src/__tests__/Call.getRingState.test.ts +87 -0
  37. package/src/__tests__/Call.lifecycle.test.ts +499 -6
  38. package/src/__tests__/Call.publishing.test.ts +2 -2
  39. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  40. package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  42. package/src/__tests__/StreamVideoClient.disconnect.test.ts +62 -0
  43. package/src/coordinator/connection/types.ts +19 -0
  44. package/src/devices/__tests__/CameraManager.test.ts +2 -2
  45. package/src/devices/__tests__/DeviceManager.test.ts +2 -2
  46. package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -2
  47. package/src/devices/__tests__/MicrophoneManager.test.ts +2 -2
  48. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -2
  49. package/src/devices/__tests__/ScreenShareManager.test.ts +3 -3
  50. package/src/devices/__tests__/SpeakerManager.test.ts +4 -4
  51. package/src/events/__tests__/call.test.ts +5 -220
  52. package/src/events/call.ts +1 -73
  53. package/src/events/callEventHandlers.ts +12 -23
  54. package/src/gen/coordinator/index.ts +67 -0
  55. package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +2 -2
  56. package/src/helpers/__tests__/DynascaleManager.test.ts +2 -2
  57. package/src/helpers/__tests__/ViewportTracker.test.ts +2 -2
  58. package/src/reporting/ClientEventReporter.ts +30 -4
  59. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  60. package/src/ringing/RingStatePoller.ts +177 -0
  61. package/src/ringing/RingTimeout.ts +90 -0
  62. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  63. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  64. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  65. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  66. package/src/ringing/index.ts +4 -0
  67. package/src/ringing/reconcileRingState.ts +108 -0
  68. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  69. package/src/rtc/Publisher.ts +66 -12
  70. package/src/rtc/TransceiverCache.ts +17 -0
  71. package/src/rtc/__tests__/Call.reconnect.test.ts +5 -5
  72. package/src/rtc/__tests__/Publisher.test.ts +209 -0
  73. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
  74. package/src/rtc/e2ee/E2EEManager.ts +10 -1
  75. package/src/rtc/e2ee/EncryptionManager.ts +1 -1
  76. package/src/rtc/e2ee/SPEC.md +1 -1
  77. package/src/stats/__tests__/utils.test.ts +33 -0
  78. package/src/stats/utils.ts +15 -0
  79. package/src/store/CallState.ts +305 -550
  80. package/src/store/ClientState.ts +101 -0
  81. package/src/store/__tests__/CallState.test.ts +60 -1
  82. package/src/store/__tests__/ClientState.test.ts +35 -0
  83. package/src/store/__tests__/rxUtils.test.ts +67 -1
  84. package/src/store/index.ts +1 -1
  85. package/src/store/rxUtils.ts +8 -4
  86. package/src/store/subjects.ts +30 -0
  87. package/src/store/types.ts +45 -0
  88. package/src/types.ts +40 -4
  89. package/dist/src/store/stateStore.d.ts +0 -84
  90. package/src/store/stateStore.ts +0 -156
@@ -39,7 +39,8 @@ import {
39
39
  } from './helpers/promise';
40
40
  import { withoutConcurrency } from './helpers/concurrency';
41
41
  import { getTimers } from './timers';
42
- import { Tracer, TraceSlice } from './stats';
42
+ import { getSdkInfo } from './helpers/client-details';
43
+ import { getStreamClientId, Tracer, TraceSlice } from './stats';
43
44
  import { SfuJoinError, SfuTimeoutError } from './errors';
44
45
 
45
46
  export type StreamSfuClientConstructor = {
@@ -271,7 +272,10 @@ export class StreamSfuClient {
271
272
  this.rpc = createSignalClient({
272
273
  baseUrl: server.url,
273
274
  interceptors: [
274
- withHeaders({ Authorization: `Bearer ${token}` }),
275
+ withHeaders({
276
+ Authorization: `Bearer ${token}`,
277
+ 'X-Stream-Client': getStreamClientId(getSdkInfo()),
278
+ }),
275
279
  this.tracer && withRequestTracer(this.tracer.trace),
276
280
  this.logger.getLogLevel() === 'trace' && withRequestLogger(this.logger),
277
281
  withTimeout(rpcRequestTimeout, this.tracer?.trace),
@@ -1,10 +1,6 @@
1
1
  import { Call } from './Call';
2
2
  import { StreamClient } from './coordinator/connection/client';
3
- import {
4
- CallingState,
5
- StreamVideoReadOnlyStateStore,
6
- StreamVideoWriteableStateStore,
7
- } from './store';
3
+ import { CallingState, ClientState } from './store';
8
4
  import type {
9
5
  CallCreatedEvent,
10
6
  CallRingEvent,
@@ -48,19 +44,14 @@ import { ClientEventReporter } from './reporting';
48
44
  */
49
45
  export class StreamVideoClient {
50
46
  /**
51
- * A reactive store that exposes all the state variables reactively.
47
+ * The reactive state of this client.
52
48
  * You can subscribe to changes of the different state variables.
53
- * Our library is built in a way that all state changes are exposed in this store,
54
- * o all UI changes in your application should be handled by subscribing to these variables.
55
- *
56
- * @deprecated use the `client.state` getter.
57
49
  */
58
- readonly readOnlyStateStore: StreamVideoReadOnlyStateStore;
50
+ readonly state = new ClientState();
59
51
  readonly logger: ScopedLogger;
60
52
 
61
- protected readonly writeableStateStore: StreamVideoWriteableStateStore;
62
- streamClient: StreamClient;
63
- readonly clientEventReporter: ClientEventReporter;
53
+ readonly streamClient: StreamClient;
54
+ private readonly clientEventReporter: ClientEventReporter;
64
55
 
65
56
  private effectsRegistered = false;
66
57
  private eventHandlersToUnregister: Array<() => void> = [];
@@ -103,11 +94,6 @@ export class StreamVideoClient {
103
94
  enabled: clientOptions?.clientEventsReportingEnabled ?? true,
104
95
  });
105
96
 
106
- this.writeableStateStore = new StreamVideoWriteableStateStore();
107
- this.readOnlyStateStore = new StreamVideoReadOnlyStateStore(
108
- this.writeableStateStore,
109
- );
110
-
111
97
  if (typeof apiKeyOrArgs !== 'string' && apiKeyOrArgs.user) {
112
98
  const user = apiKeyOrArgs.user;
113
99
  if (user.type === 'anonymous') user.id = '!anon';
@@ -158,13 +144,6 @@ export class StreamVideoClient {
158
144
  StreamVideoClient._instances.set(instanceKey, this);
159
145
  };
160
146
 
161
- /**
162
- * Return the reactive state store, use this if you want to be notified about changes to the client state
163
- */
164
- get state() {
165
- return this.readOnlyStateStore;
166
- }
167
-
168
147
  private registerEffects = () => {
169
148
  if (this.effectsRegistered) return;
170
149
 
@@ -174,7 +153,7 @@ export class StreamVideoClient {
174
153
  this.on('connection.changed', (event) => {
175
154
  if (!event.online) return;
176
155
 
177
- const callsToReWatch = this.writeableStateStore.calls
156
+ const callsToReWatch = this.state.calls
178
157
  .filter((call) => call.watching)
179
158
  .map((call) => call.cid);
180
159
  if (callsToReWatch.length <= 0) return;
@@ -197,7 +176,7 @@ export class StreamVideoClient {
197
176
  const concurrencyTag = getCallInitConcurrencyTag(e.call_cid);
198
177
  await withoutConcurrency(concurrencyTag, async () => {
199
178
  const ringing = e.type === 'call.ring';
200
- let call = this.writeableStateStore.findCall(e.call.type, e.call.id);
179
+ let call = this.state.findCall(e.call.type, e.call.id);
201
180
  if (call) {
202
181
  if (ringing) {
203
182
  if (this.shouldRejectCall(call.cid)) {
@@ -224,7 +203,7 @@ export class StreamVideoClient {
224
203
  type: e.call.type,
225
204
  id: e.call.id,
226
205
  members: e.members,
227
- clientStore: this.writeableStateStore,
206
+ clientState: this.state,
228
207
  ringing,
229
208
  });
230
209
 
@@ -239,7 +218,7 @@ export class StreamVideoClient {
239
218
  }
240
219
  } else {
241
220
  call.state.updateFromCallResponse(e.call);
242
- this.writeableStateStore.registerCall(call);
221
+ this.state.registerCall(call);
243
222
  this.logger.info(`New call created and registered: ${call.cid}`);
244
223
  }
245
224
  });
@@ -278,10 +257,7 @@ export class StreamVideoClient {
278
257
  });
279
258
 
280
259
  for (const c of response.calls) {
281
- const call = this.writeableStateStore.findCall(
282
- c.call.type,
283
- c.call.id,
284
- );
260
+ const call = this.state.findCall(c.call.type, c.call.id);
285
261
 
286
262
  if (call) {
287
263
  call.updateFromCallStateResponse(c);
@@ -310,8 +286,6 @@ export class StreamVideoClient {
310
286
 
311
287
  /**
312
288
  * Connects the given user to the client.
313
- * Only one user can connect at a time, if you want to change users, call `disconnectUser` before connecting a new user.
314
- * If the connection is successful, the connected user [state variable](#readonlystatestore) will be updated accordingly.
315
289
  *
316
290
  * @param user the user to connect.
317
291
  * @param tokenOrProvider a token or a function that returns a token.
@@ -369,7 +343,7 @@ export class StreamVideoClient {
369
343
 
370
344
  // connectUserResponse will be void if connectUser called twice for the same user
371
345
  if (connectUserResponse?.me) {
372
- this.writeableStateStore.setConnectedUser(connectUserResponse.me);
346
+ this.state.setConnectedUser(connectUserResponse.me);
373
347
  }
374
348
 
375
349
  this.registerEffects();
@@ -377,19 +351,36 @@ export class StreamVideoClient {
377
351
  return connectUserResponse;
378
352
  };
379
353
 
354
+ /**
355
+ * Leaves every call this client still tracks. Runs while the coordinator
356
+ * connection is alive, so that the leave requests each call sends reach the
357
+ * backend.
358
+ */
359
+ private leaveAllCalls = async () => {
360
+ for (const call of this.state.calls) {
361
+ if (call.state.callingState === CallingState.LEFT) continue;
362
+
363
+ this.logger.info(`User disconnected, leaving call: ${call.cid}`);
364
+ await call
365
+ .leave({ message: 'client.disconnectUser() called' })
366
+ .catch((err) => {
367
+ this.logger.error(`Error leaving call: ${call.cid}`, err);
368
+ });
369
+ }
370
+ };
371
+
380
372
  /**
381
373
  * Disconnects the currently connected user from the client.
382
- *
383
- * If the connection is successfully disconnected, the connected user [state variable](#readonlystatestore) will be updated accordingly
374
+ * Leaves every call the user is still in, and updates `client.state`accordingly.
384
375
  *
385
376
  * @param timeout Max number of ms, to wait for close event of websocket, before forcefully assuming successful disconnection.
386
- * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
387
377
  */
388
378
  disconnectUser = async (timeout?: number) => {
389
379
  await withoutConcurrency(this.connectionConcurrencyTag, async () => {
390
380
  const { user, key } = this.streamClient;
391
381
  if (!user) return;
392
382
 
383
+ await this.leaveAllCalls();
393
384
  await this.streamClient.disconnectUser(timeout);
394
385
 
395
386
  if (user.id) {
@@ -398,14 +389,16 @@ export class StreamVideoClient {
398
389
  this.eventHandlersToUnregister.forEach((unregister) => unregister());
399
390
  this.eventHandlersToUnregister = [];
400
391
  this.effectsRegistered = false;
401
- this.writeableStateStore.setConnectedUser(undefined);
392
+ this.state.setConnectedUser(undefined);
402
393
  });
403
394
  };
404
395
 
405
396
  /**
406
397
  * You can subscribe to WebSocket events provided by the API.
407
398
  * To remove a subscription, call the `off` method or, execute the returned unsubscribe function.
408
- * Please note that subscribing to WebSocket events is an advanced use-case, for most use-cases it should be enough to watch for changes in the reactive [state store](#readonlystatestore).
399
+ * Please note that subscribing to WebSocket events is an advanced use-case,
400
+ * for most use-cases it should be enough to watch for changes in the reactive
401
+ * `client.state`.
409
402
  *
410
403
  * @param eventName the event name or 'all'.
411
404
  * @param callback the callback which will be called when the event is emitted.
@@ -444,7 +437,7 @@ export class StreamVideoClient {
444
437
  options: { reuseInstance?: boolean } = {},
445
438
  ) => {
446
439
  const call = options.reuseInstance
447
- ? this.writeableStateStore.findCall(type, id)
440
+ ? this.state.findCall(type, id)
448
441
  : undefined;
449
442
  return (
450
443
  call ??
@@ -453,7 +446,7 @@ export class StreamVideoClient {
453
446
  clientEventReporter: this.clientEventReporter,
454
447
  id: id,
455
448
  type: type,
456
- clientStore: this.writeableStateStore,
449
+ clientState: this.state,
457
450
  })
458
451
  );
459
452
  };
@@ -492,7 +485,7 @@ export class StreamVideoClient {
492
485
  members: c.members,
493
486
  ownCapabilities: c.own_capabilities,
494
487
  watching: data.watch,
495
- clientStore: this.writeableStateStore,
488
+ clientState: this.state,
496
489
  });
497
490
  call.state.updateFromCallResponse(c.call);
498
491
  await call.applyDeviceConfig(c.call.settings, {
@@ -501,7 +494,7 @@ export class StreamVideoClient {
501
494
  });
502
495
  if (data.watch) {
503
496
  await call.setup();
504
- this.writeableStateStore.registerCall(call);
497
+ this.state.registerCall(call);
505
498
  }
506
499
  calls.push(call);
507
500
  }
@@ -636,7 +629,7 @@ export class StreamVideoClient {
636
629
  clientEventReporter: this.clientEventReporter,
637
630
  type: callType,
638
631
  id: callId,
639
- clientStore: this.writeableStateStore,
632
+ clientState: this.state,
640
633
  ringing: true,
641
634
  });
642
635
  await call.get();
@@ -1,104 +1,156 @@
1
1
  import '../rtc/__tests__/mocks/webrtc.mocks';
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { fromPartial } from '@total-typescript/shoehorn';
4
5
  import { Call } from '../Call';
5
6
  import { StreamClient } from '../coordinator/connection/client';
6
7
  import { ClientEventReporter } from '../reporting';
7
8
  import { generateUUIDv4 } from '../coordinator/connection/utils';
8
- import { CallingState, StreamVideoWriteableStateStore } from '../store';
9
+ import { CallingState, ClientState } from '../store';
10
+ import { CallSettingsResponse } from '../gen/coordinator';
9
11
 
12
+ const TIMEOUT_MS = 30_000;
13
+
14
+ // The timeout's own behaviour is covered by `ringing/__tests__/RingTimeout`.
15
+ // This file covers the wiring: that a ringing call arms one and that leaving
16
+ // cancels it.
10
17
  describe('Auto drop ringing calls', () => {
11
- let call: Call;
12
18
  const userId = 'jane';
19
+ let call: Call;
13
20
 
14
- beforeEach(async () => {
15
- vi.useFakeTimers();
16
-
17
- const clientStore = new StreamVideoWriteableStateStore();
21
+ const ringingCall = () => {
22
+ const clientState = new ClientState();
18
23
  const streamClient = new StreamClient('abc');
19
- call = new Call({
24
+ const newCall = new Call({
20
25
  type: 'test',
21
26
  id: generateUUIDv4(),
22
27
  streamClient,
23
28
  clientEventReporter: new ClientEventReporter({ streamClient }),
24
- clientStore: clientStore,
29
+ clientState,
30
+ ringing: true,
25
31
  });
26
32
 
27
- // @ts-expect-error mocking only what we need for the test
28
- clientStore.connectedUserSubject.next({
29
- id: userId,
30
- });
31
-
32
- call.state['callingStateSubject'].next(CallingState.RINGING);
33
-
34
- vi.spyOn(call, 'leave').mockImplementation(async () => {
35
- console.log(`TEST: leave() called`);
36
- });
37
- });
38
-
39
- it('caller should drop ringing calls after a timeout if no one accepted', async () => {
40
- call.state['settingsSubject'].next({
41
- // @ts-expect-error mocking only what we need for the test, we use fake timers, so undefined for timeout works
42
- ring: {},
43
- // @ts-expect-error mocking only what we need for the test
44
- screensharing: {
45
- enabled: false,
46
- target_resolution: {
47
- width: 100,
48
- height: 100,
33
+ clientState.setConnectedUser(fromPartial({ id: userId }));
34
+ newCall.state['createdBySubject'].next(fromPartial({ id: userId }));
35
+ newCall.state['settingsSubject'].next(
36
+ fromPartial<CallSettingsResponse>({
37
+ ring: {
38
+ auto_cancel_timeout_ms: TIMEOUT_MS,
39
+ incoming_call_timeout_ms: TIMEOUT_MS,
40
+ missed_call_timeout_ms: TIMEOUT_MS,
49
41
  },
50
- },
51
- });
42
+ screensharing: {
43
+ enabled: false,
44
+ target_resolution: { width: 100, height: 100 },
45
+ },
46
+ }),
47
+ );
48
+ newCall.state['sessionSubject'].next(
49
+ fromPartial({
50
+ id: 'session-1',
51
+ accepted_by: {},
52
+ rejected_by: {},
53
+ missed_by: {},
54
+ }),
55
+ );
56
+ newCall.state['callingStateSubject'].next(CallingState.RINGING);
57
+
58
+ vi.spyOn(newCall, 'leave').mockResolvedValue(undefined);
59
+ return newCall;
60
+ };
61
+
62
+ beforeEach(() => {
63
+ vi.useFakeTimers();
64
+ call = ringingCall();
65
+ });
52
66
 
53
- // @ts-expect-error mocking only what we need for the test
54
- call.state['createdBySubject'].next({
55
- id: userId,
56
- });
67
+ afterEach(() => {
68
+ call['cancelAutoDrop']();
69
+ vi.useRealTimers();
70
+ vi.restoreAllMocks();
71
+ });
57
72
 
58
- // black-box test, calling private method
59
- call['scheduleAutoDrop']();
73
+ it('is armed when a call starts ringing', async () => {
74
+ call.state['callingStateSubject'].next(CallingState.IDLE);
60
75
 
61
- await vi.runAllTimersAsync();
76
+ call['handleRingingCall']();
62
77
 
78
+ expect(call['ringTimeout']).toBeDefined();
79
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
63
80
  expect(call.leave).toHaveBeenCalledWith({
64
81
  reject: true,
65
82
  reason: 'timeout',
66
- message: `ringing timeout - no one accepted`,
83
+ message: 'ringing timeout - no one accepted',
67
84
  });
68
85
  });
69
86
 
70
- it(`callee should drop ringing calls after a timeout if user didn't interact with incoming call screen`, async () => {
71
- call.state['settingsSubject'].next({
72
- // @ts-expect-error mocking only what we need for the test, we use fake timers, so undefined for timeout works
73
- ring: {},
74
- // @ts-expect-error mocking only what we need for the test
75
- screensharing: {
76
- enabled: false,
77
- target_resolution: {
78
- width: 100,
79
- height: 100,
80
- },
81
- },
82
- });
87
+ it('is cancelled by cancelAutoDrop', async () => {
88
+ call['scheduleAutoDrop']();
83
89
 
84
- // @ts-expect-error mocking only what we need for the test
85
- call.state['createdBySubject'].next({
86
- id: 'not-' + userId,
87
- });
90
+ call['cancelAutoDrop']();
88
91
 
89
- // black-box test, calling private method
92
+ expect(call['ringTimeout']).toBeUndefined();
93
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
94
+ expect(call.leave).not.toHaveBeenCalled();
95
+ });
96
+
97
+ // the calling state stays RINGING well into teardown, so the watchdogs have
98
+ // to be paused before `leave` awaits anything
99
+ it('pauses the watchdogs synchronously when leave starts', async () => {
90
100
  call['scheduleAutoDrop']();
101
+ call['scheduleRingStatePolling']();
102
+ const timeout = call['ringTimeout'];
103
+ vi.spyOn(call, 'leave').mockRestore();
104
+ vi.spyOn(call, 'reject').mockResolvedValue(fromPartial({}));
91
105
 
92
- await vi.runAllTimersAsync();
106
+ const poller = call['ringStatePoller'];
93
107
 
94
- expect(call.leave).toHaveBeenCalledWith({
95
- reject: true,
96
- reason: 'timeout',
97
- message: `ringing timeout - user didn't interact with incoming call screen`,
98
- });
108
+ const leaving = call.leave({ reject: false });
109
+
110
+ // paused, not cancelled: both keep the window this leave is ending
111
+ expect(call['ringTimeout']).toBe(timeout);
112
+ expect(call['ringStatePoller']).toBe(poller);
113
+ expect(timeout!['timeoutId']).toBeUndefined();
114
+ expect(timeout!['stopped']).toBe(false);
115
+ expect(poller!['idleTimeoutId']).toBeUndefined();
116
+ expect(poller!['stopped']).toBe(false);
117
+
118
+ await leaving.catch(() => {});
119
+
120
+ expect(timeout!['stopped']).toBe(true);
121
+ expect(poller!['stopped']).toBe(true);
99
122
  });
100
123
 
101
- afterEach(() => {
102
- vi.useRealTimers();
124
+ it('restores both watchdogs when rejecting the ring fails', async () => {
125
+ call['scheduleAutoDrop']();
126
+ call['scheduleRingStatePolling']();
127
+ const timeout = call['ringTimeout'];
128
+ const deadlineAt = timeout!['deadlineAt'];
129
+ const poller = call['ringStatePoller'];
130
+ const pollDeadlineAt = poller!['deadlineAt'];
131
+ vi.spyOn(call, 'leave').mockRestore();
132
+ vi.spyOn(call, 'reject').mockRejectedValueOnce(new Error('transient'));
133
+
134
+ await expect(call.leave({ reject: true })).rejects.toThrow('transient');
135
+
136
+ expect(call.state.callingState).toBe(CallingState.RINGING);
137
+ expect(call['ringTimeout']).toBe(timeout);
138
+ expect(timeout!['timeoutId']).toBeDefined();
139
+ expect(timeout!['deadlineAt']).toBe(deadlineAt);
140
+ expect(call['ringStatePoller']).toBe(poller);
141
+ expect(poller!['deadlineAt']).toBe(pollDeadlineAt);
142
+ expect(poller!['stopped']).toBe(false);
143
+ });
144
+
145
+ it('replaces a previously armed timeout', async () => {
146
+ call['scheduleAutoDrop']();
147
+ const first = call['ringTimeout'];
148
+
149
+ call['scheduleAutoDrop']();
150
+
151
+ expect(call['ringTimeout']).not.toBe(first);
152
+ expect(first!['stopped']).toBe(true);
153
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
154
+ expect(call.leave).toHaveBeenCalledTimes(1);
103
155
  });
104
156
  });
@@ -0,0 +1,87 @@
1
+ import '../rtc/__tests__/mocks/webrtc.mocks';
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { fromPartial } from '@total-typescript/shoehorn';
5
+ import { Call } from '../Call';
6
+ import { StreamClient } from '../coordinator/connection/client';
7
+ import { ClientEventReporter } from '../reporting';
8
+ import { generateUUIDv4 } from '../coordinator/connection/utils';
9
+ import { ClientState } from '../store';
10
+ import { CallSessionResponse } from '../gen/coordinator';
11
+
12
+ describe('Call.getRingState', () => {
13
+ const callId = generateUUIDv4();
14
+
15
+ const fakeCall = (sessionId?: string) => {
16
+ const streamClient = new StreamClient('abc');
17
+ const call = new Call({
18
+ type: 'test',
19
+ id: callId,
20
+ streamClient,
21
+ clientEventReporter: new ClientEventReporter({ streamClient }),
22
+ clientState: new ClientState(),
23
+ });
24
+
25
+ if (sessionId) {
26
+ call.state['sessionSubject'].next(
27
+ fromPartial<CallSessionResponse>({ id: sessionId }),
28
+ );
29
+ }
30
+
31
+ const get = vi
32
+ .spyOn(streamClient, 'get')
33
+ .mockResolvedValue(fromPartial({ session_id: sessionId }));
34
+
35
+ return { call, get };
36
+ };
37
+
38
+ afterEach(() => {
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ it('reads the ring state of the current session', async () => {
43
+ const { call, get } = fakeCall('session-1');
44
+
45
+ await call.getRingState();
46
+
47
+ expect(get).toHaveBeenCalledWith(`/call/test/${callId}/ring_state`, {
48
+ call_session_id: 'session-1',
49
+ });
50
+ });
51
+
52
+ it('reads the ring state of an explicitly named session', async () => {
53
+ // ending a call clears its current session, so a caller reconciling after
54
+ // `call.ended` has to name the session it rang on
55
+ const { call, get } = fakeCall('current-session');
56
+
57
+ await call.getRingState('ended-session');
58
+
59
+ expect(get).toHaveBeenCalledWith(`/call/test/${callId}/ring_state`, {
60
+ call_session_id: 'ended-session',
61
+ });
62
+ });
63
+
64
+ it('returns the coordinator response', async () => {
65
+ const { call, get } = fakeCall('session-1');
66
+ get.mockResolvedValue(
67
+ fromPartial({
68
+ session_id: 'session-1',
69
+ accepted_by: { bob: '2026-08-24T10:00:04Z' },
70
+ }),
71
+ );
72
+
73
+ await expect(call.getRingState()).resolves.toMatchObject({
74
+ session_id: 'session-1',
75
+ accepted_by: { bob: '2026-08-24T10:00:04Z' },
76
+ });
77
+ });
78
+
79
+ it('rejects when the call has no session to read', async () => {
80
+ const { call, get } = fakeCall();
81
+
82
+ await expect(call.getRingState()).rejects.toThrow(
83
+ 'Cannot read the ring state: the call has no session',
84
+ );
85
+ expect(get).not.toHaveBeenCalled();
86
+ });
87
+ });