@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
package/src/Call.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  EndCallResponse,
51
51
  GetCallReportResponse,
52
52
  GetCallResponse,
53
+ GetCallRingStateResponse,
53
54
  GetCallSessionParticipantStatsDetailsResponse,
54
55
  GetOrCreateCallRequest,
55
56
  GetOrCreateCallResponse,
@@ -152,7 +153,7 @@ import {
152
153
  StatsReporter,
153
154
  Tracer,
154
155
  } from './stats';
155
- import type { ClientEventReporter, JoinReason } from './reporting';
156
+ import type { ClientEventReporter, JoinReason, JoinSource } from './reporting';
156
157
  import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
157
158
  import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
158
159
  import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
@@ -160,6 +161,7 @@ import { DynascaleManager } from './helpers/DynascaleManager';
160
161
  import { createFirstVideoFrameDetector } from './helpers/firstVideoFrame';
161
162
  import { ViewportTracker } from './helpers/ViewportTracker';
162
163
  import { PermissionsContext } from './permissions';
164
+ import { RingStatePoller, RingTimeout, resolveOwnRingOutcome } from './ringing';
163
165
  import { CallTypes } from './CallType';
164
166
  import { StreamClient } from './coordinator/connection/client';
165
167
  import { retryInterval, sleep } from './coordinator/connection/utils';
@@ -302,7 +304,8 @@ export class Call {
302
304
  private statsReporter?: StatsReporter;
303
305
  private sfuStatsReporter?: SfuStatsReporter;
304
306
  private lastStatsOptions?: StatsOptions;
305
- private dropTimeout: ReturnType<typeof setTimeout> | undefined;
307
+ private ringTimeout: RingTimeout | undefined;
308
+ private ringStatePoller: RingStatePoller | undefined;
306
309
 
307
310
  private readonly clientStore: StreamVideoWriteableStateStore;
308
311
  public readonly streamClient: StreamClient;
@@ -537,33 +540,20 @@ export class Call {
537
540
  createSubscription(this.state.session$, (session) => {
538
541
  if (!this.ringing) return;
539
542
 
540
- const receiverId = this.clientStore.connectedUser?.id;
541
- if (!receiverId) return;
542
-
543
- const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
544
- const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
545
-
546
- if (isAcceptedByMe || isRejectedByMe) {
547
- this.cancelAutoDrop();
548
- }
549
-
550
- const isAcceptedElsewhere =
551
- isAcceptedByMe && this.state.callingState === CallingState.RINGING;
543
+ const { settledByMe, leaveReason } = resolveOwnRingOutcome({
544
+ session,
545
+ currentUserId: this.currentUserId,
546
+ callingState: this.state.callingState,
547
+ });
548
+ if (settledByMe) this.cancelAutoDrop();
549
+ if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag)) return;
552
550
 
553
- if (
554
- (isAcceptedElsewhere || isRejectedByMe) &&
555
- !hasPending(this.joinLeaveConcurrencyTag)
556
- ) {
557
- globalThis.streamRNVideoSDK?.callingX?.endCall(
558
- this,
559
- isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected',
551
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
552
+ this.leave().catch(() => {
553
+ this.logger.error(
554
+ 'Could not leave a call that was accepted or rejected elsewhere',
560
555
  );
561
- this.leave().catch(() => {
562
- this.logger.error(
563
- 'Could not leave a call that was accepted or rejected elsewhere',
564
- );
565
- });
566
- }
556
+ });
567
557
  }),
568
558
  );
569
559
  };
@@ -608,6 +598,7 @@ export class Call {
608
598
  this.state.setCallingState(CallingState.RINGING);
609
599
  }
610
600
  this.scheduleAutoDrop();
601
+ this.scheduleRingStatePolling();
611
602
  this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
612
603
  }
613
604
  };
@@ -713,6 +704,13 @@ export class Call {
713
704
  throw new Error('Cannot leave call that has already been left.');
714
705
  }
715
706
 
707
+ // before the first await: the calling state stays RINGING well into the
708
+ // teardown, so pause both watchdogs before they can race this leave. They
709
+ // keep their deadlines, so a failed leave resumes them without handing the
710
+ // ring another window.
711
+ this.ringTimeout?.pause();
712
+ this.ringStatePoller?.pause();
713
+
716
714
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
717
715
  const callingState = this.state.callingState;
718
716
 
@@ -821,6 +819,7 @@ export class Call {
821
819
  this.unifiedSessionId = undefined;
822
820
  this.ringingSubject.next(false);
823
821
  this.cancelAutoDrop();
822
+ this.cancelRingStatePolling();
824
823
  this.clientStore.unregisterCall(this);
825
824
 
826
825
  globalThis.streamRNVideoSDK?.callManager.stop({
@@ -863,6 +862,17 @@ export class Call {
863
862
  this.logger.warn('Failed to dispose media engine', err);
864
863
  });
865
864
  }
865
+ }).catch((err) => {
866
+ if (
867
+ !hasPending(this.joinLeaveConcurrencyTag) &&
868
+ this.state.callingState === CallingState.RINGING
869
+ ) {
870
+ // resume, never re-arm: a fresh watchdog would restart the ring
871
+ // window this leave was already most of the way through
872
+ this.ringTimeout?.start();
873
+ this.ringStatePoller?.resume();
874
+ }
875
+ throw err;
866
876
  });
867
877
  };
868
878
 
@@ -1056,6 +1066,27 @@ export class Call {
1056
1066
  );
1057
1067
  };
1058
1068
 
1069
+ /**
1070
+ * Returns who accepted, rejected or missed the ring for a call session.
1071
+ * Safe to poll: it performs no writes and emits no events.
1072
+ *
1073
+ * @param callSessionId the call session to read. Defaults to the current one.
1074
+ * Pass it explicitly to read a session that has already ended, as ending a
1075
+ * call clears its current session.
1076
+ */
1077
+ getRingState = async (
1078
+ callSessionId?: string,
1079
+ ): Promise<GetCallRingStateResponse> => {
1080
+ const sessionId = callSessionId ?? this.state.session?.id;
1081
+ if (!sessionId) {
1082
+ throw new Error('Cannot read the ring state: the call has no session');
1083
+ }
1084
+ return this.streamClient.get<GetCallRingStateResponse>(
1085
+ `${this.streamClientBasePath}/ring_state`,
1086
+ { call_session_id: sessionId },
1087
+ );
1088
+ };
1089
+
1059
1090
  /**
1060
1091
  * A shortcut for {@link Call.get} with `notify` parameter set to `true`.
1061
1092
  * Will send a `call.notification` event to the call members.
@@ -1112,12 +1143,14 @@ export class Call {
1112
1143
  joinResponseTimeout,
1113
1144
  rpcRequestTimeout,
1114
1145
  allowOwnTracksLoopback = false,
1146
+ joinSource,
1115
1147
  ...data
1116
1148
  }: JoinCallData & {
1117
1149
  maxJoinRetries?: number;
1118
1150
  joinResponseTimeout?: number;
1119
1151
  rpcRequestTimeout?: number;
1120
1152
  allowOwnTracksLoopback?: boolean;
1153
+ joinSource?: JoinSource;
1121
1154
  } = {}): Promise<void> => {
1122
1155
  const callingState = this.state.callingState;
1123
1156
 
@@ -1159,7 +1192,7 @@ export class Call {
1159
1192
  try {
1160
1193
  await this.clientEventReporter.withJoinLifecycle(
1161
1194
  this.cid,
1162
- 'first-attempt',
1195
+ { joinReason: 'first-attempt', joinSource },
1163
1196
  async () => {
1164
1197
  for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
1165
1198
  try {
@@ -2081,8 +2114,10 @@ export class Call {
2081
2114
  this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
2082
2115
  ? 'network-available'
2083
2116
  : 'full-rejoin';
2084
- await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () =>
2085
- this.doJoin(this.joinCallData),
2117
+ await this.clientEventReporter.withJoinLifecycle(
2118
+ this.cid,
2119
+ { joinReason },
2120
+ () => this.doJoin(this.joinCallData),
2086
2121
  );
2087
2122
  await this.restorePublishedTracks();
2088
2123
  this.restoreSubscribedTracks();
@@ -2117,7 +2152,7 @@ export class Call {
2117
2152
  const currentSfu = currentSfuClient.edgeName;
2118
2153
  await this.clientEventReporter.withJoinLifecycle(
2119
2154
  this.cid,
2120
- 'migration',
2155
+ { joinReason: 'migration' },
2121
2156
  () =>
2122
2157
  this.doJoin({
2123
2158
  ...this.joinCallData,
@@ -3114,42 +3149,39 @@ export class Call {
3114
3149
  */
3115
3150
  private scheduleAutoDrop = () => {
3116
3151
  this.cancelAutoDrop();
3117
-
3118
- const settings = this.state.settings;
3119
- if (!settings) return;
3120
- // ignore if the call is not ringing
3121
- if (this.state.callingState !== CallingState.RINGING) return;
3122
-
3123
- const timeoutInMs = this.isCreatedByMe
3124
- ? settings.ring.auto_cancel_timeout_ms
3125
- : settings.ring.incoming_call_timeout_ms;
3126
-
3127
- // 0 means no auto-drop
3128
- if (timeoutInMs <= 0) return;
3129
- this.dropTimeout = setTimeout(() => {
3130
- // the call might have stopped ringing by this point,
3131
- // e.g. it was already accepted and joined
3132
- if (this.state.callingState !== CallingState.RINGING) return;
3133
- this.leave({
3134
- reject: true,
3135
- reason: 'timeout',
3136
- message: `ringing timeout - ${
3137
- this.isCreatedByMe
3138
- ? 'no one accepted'
3139
- : `user didn't interact with incoming call screen`
3140
- }`,
3141
- }).catch((err) => {
3142
- this.logger.error('Failed to drop call', err);
3143
- });
3144
- }, timeoutInMs);
3152
+ this.ringTimeout = new RingTimeout(this);
3153
+ this.ringTimeout.start();
3145
3154
  };
3146
3155
 
3147
3156
  /**
3148
3157
  * Cancels a scheduled auto-drop timeout.
3149
3158
  */
3150
3159
  private cancelAutoDrop = () => {
3151
- clearTimeout(this.dropTimeout);
3152
- this.dropTimeout = undefined;
3160
+ this.ringTimeout?.stop();
3161
+ this.ringTimeout = undefined;
3162
+ };
3163
+
3164
+ /**
3165
+ * Starts polling for the ring outcome. Applicable only to ringing calls the
3166
+ * current user created.
3167
+ */
3168
+ private scheduleRingStatePolling = () => {
3169
+ this.cancelRingStatePolling();
3170
+
3171
+ if (!this.isCreatedByMe) return;
3172
+ const options = this.streamClient.options.ringStatePolling;
3173
+ if (options === false) return;
3174
+
3175
+ this.ringStatePoller = new RingStatePoller(this, options);
3176
+ this.ringStatePoller.start();
3177
+ };
3178
+
3179
+ /**
3180
+ * Cancels the ring state polling.
3181
+ */
3182
+ private cancelRingStatePolling = () => {
3183
+ this.ringStatePoller?.stop();
3184
+ this.ringStatePoller = undefined;
3153
3185
  };
3154
3186
 
3155
3187
  /**
@@ -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,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
9
  import { CallingState, StreamVideoWriteableStateStore } 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
-
21
+ const ringingCall = () => {
17
22
  const clientStore = new StreamVideoWriteableStateStore();
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
+ clientStore,
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
+ clientStore.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 { StreamVideoWriteableStateStore } 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
+ clientStore: new StreamVideoWriteableStateStore(),
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
+ });
@@ -79,6 +79,29 @@ describe('Call lifecycle wiring', () => {
79
79
  expect(audioBindingsOrder).toBeLessThan(dynascaleOrder);
80
80
  });
81
81
 
82
+ // `joinSource` is reporting-only: it must reach the event reporter and never
83
+ // the coordinator's join request.
84
+ it('call.join() reports joinSource without putting it on the wire', async () => {
85
+ vi.spyOn(call, 'setup').mockResolvedValue(undefined);
86
+ const doJoin = vi
87
+ .spyOn(call as unknown as { doJoin: Call['join'] }, 'doJoin')
88
+ .mockResolvedValue(undefined);
89
+ const withJoinLifecycle = vi.spyOn(
90
+ call.clientEventReporter,
91
+ 'withJoinLifecycle',
92
+ );
93
+
94
+ await call.join({ joinSource: 'ring-poll-api', ring: true });
95
+
96
+ expect(withJoinLifecycle).toHaveBeenCalledWith(
97
+ call.cid,
98
+ { joinReason: 'first-attempt', joinSource: 'ring-poll-api' },
99
+ expect.any(Function),
100
+ );
101
+ expect(doJoin).toHaveBeenCalledTimes(1);
102
+ expect('joinSource' in doJoin.mock.calls[0][0]!).toBe(false);
103
+ });
104
+
82
105
  it('call.join() shares an in-flight join flow', async () => {
83
106
  const joinTask = promiseWithResolvers<void>();
84
107
  vi.spyOn(call, 'setup').mockResolvedValue(undefined);