@stream-io/video-client 1.58.0 → 2.0.0-beta.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 +22 -0
  2. package/dist/index.browser.es.js +367 -76
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +378 -74
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.es.js +367 -76
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +15 -4
  10. package/dist/src/StreamVideoClient.d.ts +9 -1
  11. package/dist/src/coordinator/connection/utils.d.ts +10 -0
  12. package/dist/src/devices/CameraManager.d.ts +2 -1
  13. package/dist/src/devices/DeviceManager.d.ts +1 -1
  14. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  15. package/dist/src/gen/coordinator/index.d.ts +26 -0
  16. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  17. package/dist/src/helpers/concurrency.d.ts +8 -0
  18. package/dist/src/helpers/loopback.d.ts +55 -0
  19. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  20. package/dist/src/rtc/Subscriber.d.ts +5 -0
  21. package/dist/src/sorting/participants.d.ts +13 -0
  22. package/index.ts +2 -0
  23. package/package.json +1 -1
  24. package/src/Call.ts +156 -134
  25. package/src/StreamVideoClient.ts +35 -8
  26. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  27. package/src/__tests__/Call.publishing.test.ts +42 -1
  28. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  29. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  30. package/src/__tests__/StreamVideoClient.ringing.test.ts +43 -24
  31. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  32. package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
  33. package/src/coordinator/connection/client.ts +3 -2
  34. package/src/coordinator/connection/utils.ts +28 -0
  35. package/src/devices/CameraManager.ts +16 -4
  36. package/src/devices/DeviceManager.ts +7 -4
  37. package/src/devices/MicrophoneManager.ts +11 -3
  38. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  39. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  40. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  41. package/src/events/call.ts +6 -3
  42. package/src/gen/coordinator/index.ts +26 -0
  43. package/src/helpers/MediaStreamRecorder.ts +104 -0
  44. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  45. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  46. package/src/helpers/__tests__/loopback.test.ts +367 -0
  47. package/src/helpers/concurrency.ts +24 -0
  48. package/src/helpers/loopback.ts +200 -0
  49. package/src/reporting/ClientEventReporter.ts +15 -28
  50. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  51. package/src/rtc/Subscriber.ts +8 -0
  52. package/src/rtc/__tests__/Subscriber.test.ts +101 -0
  53. package/src/sorting/__tests__/presets.test.ts +188 -3
  54. package/src/sorting/__tests__/sorting.test.ts +22 -1
  55. package/src/sorting/participants.ts +20 -0
  56. package/src/sorting/presets.ts +4 -0
  57. package/src/store/__tests__/CallState.test.ts +2 -2
package/src/Call.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  BlockUserResponse,
40
40
  CallRingEvent,
41
41
  CallSettingsResponse,
42
+ CallStateResponseFields,
42
43
  CollectUserFeedbackRequest,
43
44
  CollectUserFeedbackResponse,
44
45
  Credentials,
@@ -178,7 +179,11 @@ import {
178
179
  SpeakerManager,
179
180
  } from './devices';
180
181
  import { normalize } from './devices/devicePersistence';
181
- import { hasPending, withoutConcurrency } from './helpers/concurrency';
182
+ import {
183
+ hasPending,
184
+ singleFlight,
185
+ withoutConcurrency,
186
+ } from './helpers/concurrency';
182
187
  import { ensureExhausted } from './helpers/ensureExhausted';
183
188
  import { pushToIfMissing } from './helpers/array';
184
189
  import {
@@ -753,7 +758,10 @@ export class Call {
753
758
  }
754
759
  }
755
760
  }
756
- globalThis.streamRNVideoSDK?.callingX?.endCall(this);
761
+ globalThis.streamRNVideoSDK?.callingX?.endCall(
762
+ this,
763
+ reason === 'ended' ? 'remote' : undefined,
764
+ );
757
765
 
758
766
  this.statsReporter?.stop();
759
767
  this.statsReporter = undefined;
@@ -936,8 +944,19 @@ export class Call {
936
944
  // const calls = useCalls().filter((c) => c.ringing);
937
945
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
938
946
  this.clientStore.setCalls([this, ...calls]);
939
- const skipSpeakerApply = isReactNative();
940
- await this.applyDeviceConfig(settings, false, skipSpeakerApply);
947
+ await this.applyDeviceConfig(settings, { publish: false });
948
+ };
949
+
950
+ /**
951
+ * Applies a backend-provided call state snapshot
952
+ * (call, members, own capabilities) to this instance.
953
+ *
954
+ * @internal an internal method and should not be used outside the SDK.
955
+ */
956
+ updateFromCallStateResponse = (response: CallStateResponseFields) => {
957
+ this.state.updateFromCallResponse(response.call);
958
+ this.state.setMembers(response.members);
959
+ this.state.setOwnCapabilities(response.own_capabilities);
941
960
  };
942
961
 
943
962
  /**
@@ -961,9 +980,7 @@ export class Call {
961
980
  params,
962
981
  );
963
982
 
964
- this.state.updateFromCallResponse(response.call);
965
- this.state.setMembers(response.members);
966
- this.state.setOwnCapabilities(response.own_capabilities);
983
+ this.updateFromCallStateResponse(response);
967
984
 
968
985
  if (params?.ring) {
969
986
  this.ringingSubject.next(true);
@@ -973,13 +990,7 @@ export class Call {
973
990
  this.watching = true;
974
991
  this.clientStore.registerOrUpdateCall(this);
975
992
  }
976
- // Skip speaker setup on RN if ringing was requested or the call is already ringing
977
- const skipSpeakerApply = isReactNative();
978
- await this.applyDeviceConfig(
979
- response.call.settings,
980
- false,
981
- skipSpeakerApply,
982
- );
993
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
983
994
 
984
995
  return response;
985
996
  };
@@ -997,9 +1008,7 @@ export class Call {
997
1008
  GetOrCreateCallRequest
998
1009
  >(this.streamClientBasePath, data);
999
1010
 
1000
- this.state.updateFromCallResponse(response.call);
1001
- this.state.setMembers(response.members);
1002
- this.state.setOwnCapabilities(response.own_capabilities);
1011
+ this.updateFromCallStateResponse(response);
1003
1012
 
1004
1013
  if (data?.ring) {
1005
1014
  this.ringingSubject.next(true);
@@ -1010,13 +1019,7 @@ export class Call {
1010
1019
  this.clientStore.registerOrUpdateCall(this);
1011
1020
  }
1012
1021
 
1013
- // Skip speaker setup on RN if ringing was requested or the call is already ringing
1014
- const skipSpeakerApply = isReactNative();
1015
- await this.applyDeviceConfig(
1016
- response.call.settings,
1017
- false,
1018
- skipSpeakerApply,
1019
- );
1022
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
1020
1023
 
1021
1024
  return response;
1022
1025
  };
@@ -1103,112 +1106,113 @@ export class Call {
1103
1106
  *
1104
1107
  * @returns a promise which resolves once the call join-flow has finished.
1105
1108
  */
1106
- join = async ({
1107
- maxJoinRetries = 3,
1108
- joinResponseTimeout,
1109
- rpcRequestTimeout,
1110
- allowOwnTracksLoopback = false,
1111
- ...data
1112
- }: JoinCallData & {
1113
- maxJoinRetries?: number;
1114
- joinResponseTimeout?: number;
1115
- rpcRequestTimeout?: number;
1116
- allowOwnTracksLoopback?: boolean;
1117
- } = {}): Promise<void> => {
1118
- const callingState = this.state.callingState;
1109
+ join = singleFlight(
1110
+ async ({
1111
+ maxJoinRetries = 3,
1112
+ joinResponseTimeout,
1113
+ rpcRequestTimeout,
1114
+ allowOwnTracksLoopback = false,
1115
+ ...data
1116
+ }: JoinCallData & {
1117
+ maxJoinRetries?: number;
1118
+ joinResponseTimeout?: number;
1119
+ rpcRequestTimeout?: number;
1120
+ allowOwnTracksLoopback?: boolean;
1121
+ } = {}): Promise<void> => {
1122
+ const callingState = this.state.callingState;
1119
1123
 
1120
- if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
1121
- throw new Error(`Illegal State: call.join() shall be called only once`);
1122
- }
1124
+ if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
1125
+ throw new Error(`Illegal State: call.join() shall be called only once`);
1126
+ }
1123
1127
 
1124
- // we need this to be set before the callingx.joinCall() is
1125
- // called to avoid registering the test call in the CallKit/Telecom
1126
- this.allowOwnTracksLoopback = allowOwnTracksLoopback;
1128
+ // we need this to be set before the callingx.joinCall() is
1129
+ // called to avoid registering the test call in the CallKit/Telecom
1130
+ this.allowOwnTracksLoopback = allowOwnTracksLoopback;
1127
1131
 
1128
- if (data?.ring) {
1129
- this.ringingSubject.next(true);
1130
- }
1132
+ if (data?.ring) {
1133
+ this.ringingSubject.next(true);
1134
+ }
1131
1135
 
1132
- const callingX = globalThis.streamRNVideoSDK?.callingX;
1133
- if (callingX) {
1134
- // for Android/iOS, we need to start the call in the callingx library as soon as possible
1135
- await callingX.joinCall(this, this.clientStore.calls);
1136
- }
1136
+ const callingX = globalThis.streamRNVideoSDK?.callingX;
1137
+ if (callingX) {
1138
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
1139
+ await callingX.joinCall(this, this.clientStore.calls);
1140
+ }
1137
1141
 
1138
- await this.setup();
1142
+ await this.setup();
1139
1143
 
1140
- this.clientEventReporter.registerCall(this.cid, {
1141
- callType: this.type,
1142
- callId: this.id,
1143
- getCallSessionId: () => this.state.session?.id ?? '',
1144
- getSfuId: () => this.credentials?.server.edge_name ?? '',
1145
- getUserSessionId: () => this.sfuClient?.sessionId ?? '',
1146
- });
1144
+ this.clientEventReporter.registerCall(this.cid, {
1145
+ callType: this.type,
1146
+ callId: this.id,
1147
+ getCallSessionId: () => this.state.session?.id ?? '',
1148
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
1149
+ });
1147
1150
 
1148
- this.joinResponseTimeout = joinResponseTimeout;
1149
- this.rpcRequestTimeout = rpcRequestTimeout;
1150
- // we will count the number of join failures per SFU.
1151
- // once the number of failures reaches 2, we will piggyback on the `migrating_from`
1152
- // field to force the coordinator to provide us another SFU
1153
- const sfuJoinFailures = new Map<string, number>();
1154
- const joinData: JoinCallData = data;
1155
- maxJoinRetries = Math.max(maxJoinRetries, 1);
1156
- try {
1157
- await this.clientEventReporter.withJoinLifecycle(
1158
- this.cid,
1159
- 'first-attempt',
1160
- async () => {
1161
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
1162
- try {
1163
- this.logger.trace(`Joining call (${attempt})`, this.cid);
1164
- await this.doJoin(data);
1165
- delete joinData.migrating_from;
1166
- delete joinData.migrating_from_list;
1167
- return;
1168
- } catch (err) {
1169
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
1170
- if (
1171
- (err instanceof ErrorFromResponse && err.unrecoverable) ||
1172
- (err instanceof SfuJoinError && err.unrecoverable)
1173
- ) {
1174
- throw err;
1175
- }
1151
+ this.joinResponseTimeout = joinResponseTimeout;
1152
+ this.rpcRequestTimeout = rpcRequestTimeout;
1153
+ // we will count the number of join failures per SFU.
1154
+ // once the number of failures reaches 2, we will piggyback on the `migrating_from`
1155
+ // field to force the coordinator to provide us another SFU
1156
+ const sfuJoinFailures = new Map<string, number>();
1157
+ const joinData: JoinCallData = data;
1158
+ maxJoinRetries = Math.max(maxJoinRetries, 1);
1159
+ try {
1160
+ await this.clientEventReporter.withJoinLifecycle(
1161
+ this.cid,
1162
+ 'first-attempt',
1163
+ async () => {
1164
+ for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
1165
+ try {
1166
+ this.logger.trace(`Joining call (${attempt})`, this.cid);
1167
+ await this.doJoin(data);
1168
+ delete joinData.migrating_from;
1169
+ delete joinData.migrating_from_list;
1170
+ return;
1171
+ } catch (err) {
1172
+ this.logger.warn(`Failed to join call (${attempt})`, this.cid);
1173
+ if (
1174
+ (err instanceof ErrorFromResponse && err.unrecoverable) ||
1175
+ (err instanceof SfuJoinError && err.unrecoverable)
1176
+ ) {
1177
+ throw err;
1178
+ }
1176
1179
 
1177
- const switchSfu =
1178
- err instanceof SfuJoinError &&
1179
- SfuJoinError.isJoinErrorCode(err.errorEvent);
1180
-
1181
- const sfuId = this.credentials?.server.edge_name;
1182
- if (sfuId) {
1183
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
1184
- sfuJoinFailures.set(sfuId, failures);
1185
- if (switchSfu || failures >= 2) {
1186
- joinData.migrating_from = sfuId;
1187
- joinData.migrating_from_list = Array.from(
1188
- sfuJoinFailures.keys(),
1189
- );
1190
- if (attempt < maxJoinRetries - 1) {
1191
- this.clientEventReporter.startCorrelation(
1192
- this.cid,
1193
- 'first-attempt',
1180
+ const switchSfu =
1181
+ err instanceof SfuJoinError &&
1182
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
1183
+
1184
+ const sfuId = this.credentials?.server.edge_name;
1185
+ if (sfuId) {
1186
+ const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
1187
+ sfuJoinFailures.set(sfuId, failures);
1188
+ if (switchSfu || failures >= 2) {
1189
+ joinData.migrating_from = sfuId;
1190
+ joinData.migrating_from_list = Array.from(
1191
+ sfuJoinFailures.keys(),
1194
1192
  );
1193
+ if (attempt < maxJoinRetries - 1) {
1194
+ this.clientEventReporter.startCorrelation(
1195
+ this.cid,
1196
+ 'first-attempt',
1197
+ );
1198
+ }
1195
1199
  }
1196
1200
  }
1197
- }
1198
1201
 
1199
- if (attempt === maxJoinRetries - 1) {
1200
- throw err;
1202
+ if (attempt === maxJoinRetries - 1) {
1203
+ throw err;
1204
+ }
1201
1205
  }
1206
+ await sleep(retryInterval(attempt));
1202
1207
  }
1203
- await sleep(retryInterval(attempt));
1204
- }
1205
- },
1206
- );
1207
- } catch (error) {
1208
- callingX?.endCall(this, 'error');
1209
- throw error;
1210
- }
1211
- };
1208
+ },
1209
+ );
1210
+ } catch (error) {
1211
+ callingX?.endCall(this, 'error');
1212
+ throw error;
1213
+ }
1214
+ },
1215
+ );
1212
1216
 
1213
1217
  /**
1214
1218
  * Will make a single attempt to watch for call related WebSocket events
@@ -1431,7 +1435,10 @@ export class Call {
1431
1435
  this.state.settings &&
1432
1436
  !supersededByLeave()
1433
1437
  ) {
1434
- await this.applyDeviceConfig(this.state.settings, true, false);
1438
+ await this.applyDeviceConfig(this.state.settings, {
1439
+ publish: true,
1440
+ skipSpeakerApply: false,
1441
+ });
1435
1442
  this.deviceSettingsAppliedOnce = true;
1436
1443
  }
1437
1444
 
@@ -2411,12 +2418,18 @@ export class Call {
2411
2418
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
2412
2419
  if (!streamStateProp) continue;
2413
2420
 
2414
- this.state.updateParticipant(sessionId, (p) => ({
2415
- publishedTracks: mediaStream
2416
- ? pushToIfMissing([...p.publishedTracks], trackType)
2417
- : p.publishedTracks.filter((t) => t !== trackType),
2418
- [streamStateProp]: mediaStream,
2419
- }));
2421
+ this.state.updateParticipant(sessionId, (p) => {
2422
+ const currentStream = p[streamStateProp];
2423
+ const isEchoedStream =
2424
+ this.subscriber?.isSelfSubscribedStream(currentStream);
2425
+
2426
+ return {
2427
+ publishedTracks: mediaStream
2428
+ ? pushToIfMissing([...p.publishedTracks], trackType)
2429
+ : p.publishedTracks.filter((t) => t !== trackType),
2430
+ [streamStateProp]: isEchoedStream ? currentStream : mediaStream,
2431
+ };
2432
+ });
2420
2433
  }
2421
2434
  };
2422
2435
 
@@ -3367,20 +3380,29 @@ export class Call {
3367
3380
  */
3368
3381
  applyDeviceConfig = async (
3369
3382
  settings: CallSettingsResponse,
3370
- publish: boolean,
3371
- skipSpeakerApply: boolean,
3383
+ opts: {
3384
+ publish: boolean;
3385
+ skipSpeakerApply?: boolean;
3386
+ withDisabledDevices?: boolean;
3387
+ },
3372
3388
  ) => {
3389
+ const {
3390
+ publish,
3391
+ // Skip speaker setup on RN until joining (explicitly passed as false during join())
3392
+ skipSpeakerApply = isReactNative(),
3393
+ withDisabledDevices,
3394
+ } = opts;
3373
3395
  if (!skipSpeakerApply) {
3374
- await this.speaker.apply(settings).catch((err) => {
3375
- this.logger.warn('Speaker init failed', err);
3376
- });
3396
+ await this.speaker
3397
+ .apply(settings)
3398
+ .catch((err) => this.logger.warn('Speaker init failed', err));
3377
3399
  }
3378
- await this.camera.apply(settings.video, publish).catch((err) => {
3379
- this.logger.warn('Camera init failed', err);
3380
- });
3381
- await this.microphone.apply(settings.audio, publish).catch((err) => {
3382
- this.logger.warn('Mic init failed', err);
3383
- });
3400
+ await this.camera
3401
+ .apply(settings.video, publish, withDisabledDevices)
3402
+ .catch((err) => this.logger.warn('Camera init failed', err));
3403
+ await this.microphone
3404
+ .apply(settings.audio, publish, withDisabledDevices)
3405
+ .catch((err) => this.logger.warn('Mic init failed', err));
3384
3406
  };
3385
3407
 
3386
3408
  /**
@@ -39,7 +39,6 @@ import {
39
39
  getInstanceKey,
40
40
  } from './helpers/clientUtils';
41
41
  import { logToConsole, ScopedLogger, videoLoggerSystem } from './logger';
42
- import { isReactNative } from './helpers/platforms';
43
42
  import { withoutConcurrency } from './helpers/concurrency';
44
43
  import { enableTimerWorker } from './timers';
45
44
  import { ClientEventReporter } from './reporting';
@@ -211,6 +210,7 @@ export class StreamVideoClient {
211
210
  await call.reject('busy');
212
211
  } else {
213
212
  await call.updateFromRingingEvent(e as CallRingEvent);
213
+ await call.get();
214
214
  }
215
215
  } else {
216
216
  call.state.updateFromCallResponse(e.call);
@@ -248,6 +248,17 @@ export class StreamVideoClient {
248
248
  }
249
249
  };
250
250
 
251
+ /**
252
+ * Queries the API for calls matching the given filters.
253
+ * @param data the query data.
254
+ */
255
+ private doQueryCalls = (data: QueryCallsRequest) => {
256
+ return this.streamClient.post<QueryCallsResponse, QueryCallsRequest>(
257
+ '/calls',
258
+ data,
259
+ );
260
+ };
261
+
251
262
  /**
252
263
  * Rewatches the given calls with retry logic.
253
264
  * @param callsToReWatch array of call IDs to rewatch
@@ -261,11 +272,22 @@ export class StreamVideoClient {
261
272
  `Rewatching calls ${callsToReWatch.join(', ')} attempt ${attempt + 1}`,
262
273
  );
263
274
 
264
- await this.queryCalls({
275
+ const response = await this.doQueryCalls({
265
276
  watch: true,
266
277
  filter_conditions: { cid: { $in: callsToReWatch } },
267
278
  });
268
279
 
280
+ for (const c of response.calls) {
281
+ const call = this.writeableStateStore.findCall(
282
+ c.call.type,
283
+ c.call.id,
284
+ );
285
+
286
+ if (call) {
287
+ call.updateFromCallStateResponse(c);
288
+ }
289
+ }
290
+
269
291
  return;
270
292
  } catch (err) {
271
293
  if (err instanceof ErrorFromResponse && err.unrecoverable) {
@@ -452,12 +474,14 @@ export class StreamVideoClient {
452
474
  * Will query the API for calls matching the given filters.
453
475
  *
454
476
  * @param data the query data.
477
+ * @param opts additional options, for tweaking the API behavior.
455
478
  */
456
- queryCalls = async (data: QueryCallsRequest = {}) => {
457
- const response = await this.streamClient.post<
458
- QueryCallsResponse,
459
- QueryCallsRequest
460
- >('/calls', data);
479
+ queryCalls = async (
480
+ data: QueryCallsRequest = {},
481
+ opts: { withDisabledDevices?: boolean } = {},
482
+ ) => {
483
+ const { withDisabledDevices = true } = opts;
484
+ const response = await this.doQueryCalls(data);
461
485
  const calls = [];
462
486
  for (const c of response.calls) {
463
487
  const call = new Call({
@@ -471,7 +495,10 @@ export class StreamVideoClient {
471
495
  clientStore: this.writeableStateStore,
472
496
  });
473
497
  call.state.updateFromCallResponse(c.call);
474
- await call.applyDeviceConfig(c.call.settings, false, isReactNative());
498
+ await call.applyDeviceConfig(c.call.settings, {
499
+ publish: false,
500
+ withDisabledDevices,
501
+ });
475
502
  if (data.watch) {
476
503
  await call.setup();
477
504
  this.writeableStateStore.registerCall(call);
@@ -4,27 +4,38 @@
4
4
 
5
5
  import '../rtc/__tests__/mocks/webrtc.mocks';
6
6
 
7
- import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
8
  import { Call } from '../Call';
9
9
  import { StreamClient } from '../coordinator/connection/client';
10
10
  import { ClientEventReporter } from '../reporting';
11
11
  import { generateUUIDv4 } from '../coordinator/connection/utils';
12
12
  import { StreamVideoWriteableStateStore } from '../store';
13
+ import { promiseWithResolvers } from '../helpers/promise';
13
14
 
14
15
  describe('Call lifecycle wiring', () => {
15
16
  let call: Call;
16
17
 
17
18
  beforeEach(() => {
19
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
20
+ headers: { get: () => 'AMS1-P2' },
21
+ } as Response);
18
22
  const streamClient = new StreamClient('abc');
19
23
  call = new Call({
20
24
  type: 'test',
21
25
  id: generateUUIDv4(),
22
26
  streamClient,
23
- clientEventReporter: new ClientEventReporter({ streamClient }),
27
+ clientEventReporter: new ClientEventReporter({
28
+ streamClient,
29
+ enabled: false,
30
+ }),
24
31
  clientStore: new StreamVideoWriteableStateStore(),
25
32
  });
26
33
  });
27
34
 
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
28
39
  // Regression guard for the Call-owned helper teardown chain. Each of
29
40
  // these helpers holds a resource (timer, listener, AudioContext) that
30
41
  // leaks across calls if teardown is dropped during a refactor.
@@ -67,4 +78,24 @@ describe('Call lifecycle wiring', () => {
67
78
  expect(trackSubOrder).toBeLessThan(audioBindingsOrder);
68
79
  expect(audioBindingsOrder).toBeLessThan(dynascaleOrder);
69
80
  });
81
+
82
+ it('call.join() shares an in-flight join flow', async () => {
83
+ const joinTask = promiseWithResolvers<void>();
84
+ vi.spyOn(call, 'setup').mockResolvedValue(undefined);
85
+ const doJoin = vi
86
+ .spyOn(call as unknown as { doJoin: Call['join'] }, 'doJoin')
87
+ .mockReturnValue(joinTask.promise);
88
+
89
+ const firstJoin = call.join();
90
+ const secondJoin = call.join();
91
+
92
+ await Promise.resolve();
93
+ expect(doJoin).toHaveBeenCalledTimes(1);
94
+
95
+ joinTask.resolve();
96
+ await expect(Promise.all([firstJoin, secondJoin])).resolves.toEqual([
97
+ undefined,
98
+ undefined,
99
+ ]);
100
+ });
70
101
  });
@@ -2,7 +2,7 @@ import '../rtc/__tests__/mocks/webrtc.mocks';
2
2
 
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { Call } from '../Call';
5
- import { Publisher } from '../rtc';
5
+ import { Publisher, Subscriber } from '../rtc';
6
6
  import { StreamClient } from '../coordinator/connection/client';
7
7
  import { ClientEventReporter } from '../reporting';
8
8
  import { generateUUIDv4 } from '../coordinator/connection/utils';
@@ -183,6 +183,47 @@ describe('Publishing and Unpublishing tracks', () => {
183
183
  expect(participant!.audioStream).toEqual(mediaStream);
184
184
  });
185
185
 
186
+ it('keeps a stream the SFU already echoed back instead of the capture one', async () => {
187
+ // `allowOwnTracksLoopback`: the echo can land on the participant before
188
+ // publishing gets to write the capture stream to the same field.
189
+ const echoedStream = new MediaStream();
190
+ call.subscriber = {
191
+ isSelfSubscribedStream: (stream: MediaStream | undefined) =>
192
+ stream === echoedStream,
193
+ } as unknown as Subscriber;
194
+ call.state.updateParticipant(sessionId, { audioStream: echoedStream });
195
+
196
+ const track = new MediaStreamTrack();
197
+ const captureStream = new MediaStream();
198
+ vi.spyOn(captureStream, 'getAudioTracks').mockReturnValue([track]);
199
+
200
+ await call.publish(captureStream, TrackType.AUDIO);
201
+
202
+ const participant = call.state.findParticipantBySessionId(sessionId);
203
+ expect(participant!.publishedTracks).toEqual([TrackType.AUDIO]);
204
+ expect(participant!.audioStream).toBe(echoedStream);
205
+ });
206
+
207
+ it('keeps an echoed stream when the track is unpublished', async () => {
208
+ // a device switch unpublishes and republishes; the echoed track goes
209
+ // briefly silent but is never replaced, so the reference has to survive
210
+ const echoedStream = new MediaStream();
211
+ call.subscriber = {
212
+ isSelfSubscribedStream: (stream: MediaStream | undefined) =>
213
+ stream === echoedStream,
214
+ } as unknown as Subscriber;
215
+ call.state.updateParticipant(sessionId, {
216
+ audioStream: echoedStream,
217
+ publishedTracks: [TrackType.AUDIO],
218
+ });
219
+
220
+ await call.stopPublish(TrackType.AUDIO);
221
+
222
+ const participant = call.state.findParticipantBySessionId(sessionId);
223
+ expect(participant!.publishedTracks).toEqual([]);
224
+ expect(participant!.audioStream).toBe(echoedStream);
225
+ });
226
+
186
227
  it('publish screen share stream', async () => {
187
228
  const track = new MediaStreamTrack();
188
229
  const mediaStream = new MediaStream();
@@ -9,6 +9,8 @@ import {
9
9
  } from 'vitest';
10
10
  import { StreamVideoClient } from '../StreamVideoClient';
11
11
  import { Call } from '../Call';
12
+ import { CameraManager } from '../devices/CameraManager';
13
+ import { MicrophoneManager } from '../devices/MicrophoneManager';
12
14
  import { CallCreatedPayload } from './data';
13
15
  import { generateUUIDv4 } from '../coordinator/connection/utils';
14
16
  import type { StreamClient } from '../coordinator/connection/client';
@@ -73,6 +75,37 @@ describe('StreamVideoClient - coordinator API', () => {
73
75
  expect(call.cid).toBe(CallCreatedPayload.call.cid);
74
76
  });
75
77
 
78
+ it('query calls keeps devices disabled by default', async () => {
79
+ const response: QueryCallsResponse = {
80
+ duration: '1ms',
81
+ calls: [
82
+ {
83
+ call: CallCreatedPayload.call,
84
+ members: CallCreatedPayload.members,
85
+ own_capabilities: [],
86
+ },
87
+ ],
88
+ };
89
+ post.mockResolvedValue(response);
90
+ const applyCamera = vi
91
+ .spyOn(CameraManager.prototype, 'apply')
92
+ .mockResolvedValue();
93
+ const applyMicrophone = vi
94
+ .spyOn(MicrophoneManager.prototype, 'apply')
95
+ .mockResolvedValue();
96
+
97
+ await client.queryCalls();
98
+ await client.queryCalls({}, {});
99
+ await client.queryCalls({}, { withDisabledDevices: false });
100
+
101
+ expect(
102
+ applyCamera.mock.calls.map(([, , forceDisabled]) => forceDisabled),
103
+ ).toEqual([true, true, false]);
104
+ expect(
105
+ applyMicrophone.mock.calls.map(([, , forceDisabled]) => forceDisabled),
106
+ ).toEqual([true, true, false]);
107
+ });
108
+
76
109
  it('query calls - ongoing', async () => {
77
110
  post.mockResolvedValue({ duration: '1ms', calls: [] });
78
111