@stream-io/video-client 1.49.0 → 1.51.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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +1404 -682
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1404 -682
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1404 -682
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +43 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/CameraManager.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +23 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  16. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  17. package/dist/src/devices/devicePersistence.d.ts +1 -1
  18. package/dist/src/devices/index.d.ts +1 -0
  19. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  20. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  21. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  22. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  23. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  24. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  25. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  26. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  27. package/dist/src/helpers/browsers.d.ts +13 -0
  28. package/dist/src/helpers/concurrency.d.ts +6 -4
  29. package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
  30. package/dist/src/rtc/Publisher.d.ts +38 -3
  31. package/dist/src/rtc/Subscriber.d.ts +1 -0
  32. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  33. package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
  34. package/dist/src/rtc/types.d.ts +2 -0
  35. package/dist/src/stats/rtc/types.d.ts +1 -1
  36. package/dist/src/store/rxUtils.d.ts +9 -0
  37. package/dist/src/types.d.ts +18 -0
  38. package/package.json +2 -2
  39. package/src/Call.ts +111 -33
  40. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  41. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  42. package/src/coordinator/connection/client.ts +1 -1
  43. package/src/coordinator/connection/connection.ts +149 -96
  44. package/src/coordinator/connection/types.ts +15 -0
  45. package/src/coordinator/connection/utils.ts +15 -0
  46. package/src/devices/CameraManager.ts +9 -2
  47. package/src/devices/DeviceManager.ts +239 -39
  48. package/src/devices/DeviceManagerState.ts +4 -2
  49. package/src/devices/VirtualDevice.ts +69 -0
  50. package/src/devices/__tests__/CameraManager.test.ts +19 -0
  51. package/src/devices/__tests__/DeviceManager.test.ts +404 -1
  52. package/src/devices/__tests__/mocks.ts +2 -0
  53. package/src/devices/devicePersistence.ts +2 -1
  54. package/src/devices/index.ts +1 -0
  55. package/src/gen/video/sfu/event/events.ts +15 -0
  56. package/src/gen/video/sfu/models/models.ts +44 -0
  57. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  58. package/src/helpers/BlockedAudioTracker.ts +74 -0
  59. package/src/helpers/DynascaleManager.ts +46 -337
  60. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  61. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  62. package/src/helpers/ViewportTracker.ts +74 -19
  63. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  64. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  65. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rtc/BasePeerConnection.ts +15 -3
  72. package/src/rtc/Publisher.ts +185 -40
  73. package/src/rtc/Subscriber.ts +42 -14
  74. package/src/rtc/TransceiverCache.ts +10 -3
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  76. package/src/rtc/__tests__/Publisher.test.ts +747 -88
  77. package/src/rtc/__tests__/Subscriber.test.ts +148 -3
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
  80. package/src/rtc/helpers/degradationPreference.ts +40 -0
  81. package/src/rtc/types.ts +2 -0
  82. package/src/stats/rtc/types.ts +1 -0
  83. package/src/store/__tests__/rxUtils.test.ts +276 -0
  84. package/src/store/rxUtils.ts +19 -0
  85. package/src/types.ts +19 -0
package/src/Call.ts CHANGED
@@ -145,7 +145,11 @@ import {
145
145
  StatsReporter,
146
146
  Tracer,
147
147
  } from './stats';
148
+ import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
149
+ import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
150
+ import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
148
151
  import { DynascaleManager } from './helpers/DynascaleManager';
152
+ import { ViewportTracker } from './helpers/ViewportTracker';
149
153
  import { PermissionsContext } from './permissions';
150
154
  import { CallTypes } from './CallType';
151
155
  import { StreamClient } from './coordinator/connection/client';
@@ -230,7 +234,32 @@ export class Call {
230
234
  /**
231
235
  * The DynascaleManager instance.
232
236
  */
233
- readonly dynascaleManager: DynascaleManager;
237
+ readonly dynascaleManager: DynascaleManager | undefined;
238
+
239
+ /**
240
+ * Tracks viewport visibility for participant video elements.
241
+ * Available only in DOM environments.
242
+ */
243
+ readonly viewportTracker: ViewportTracker | undefined;
244
+
245
+ /**
246
+ * Owns the SFU-side video-subscription state (per-session and global overrides).
247
+ */
248
+ readonly trackSubscriptionManager: TrackSubscriptionManager;
249
+
250
+ /**
251
+ * Warns periodically when a remote participant is publishing audio, but no
252
+ * `<audio>` element has been bound for them.
253
+ */
254
+ readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
255
+
256
+ /**
257
+ * Tracks audio elements blocked by the browser's autoplay policy.
258
+ * Subscribe to `blockedAudioTracker.autoplayBlocked$` to react to the
259
+ * blocked state, and call {@link Call.resumeAudio} inside a user gesture
260
+ * to retry playback.
261
+ */
262
+ readonly blockedAudioTracker: BlockedAudioTracker;
234
263
 
235
264
  subscriber?: Subscriber;
236
265
  publisher?: Publisher;
@@ -258,6 +287,7 @@ export class Call {
258
287
  private statsReportingIntervalInMs: number = 2000;
259
288
  private statsReporter?: StatsReporter;
260
289
  private sfuStatsReporter?: SfuStatsReporter;
290
+ private lastStatsOptions?: StatsOptions;
261
291
  private dropTimeout: ReturnType<typeof setTimeout> | undefined;
262
292
 
263
293
  private readonly clientStore: StreamVideoWriteableStateStore;
@@ -362,11 +392,26 @@ export class Call {
362
392
  this.microphone = new MicrophoneManager(this, preferences);
363
393
  this.speaker = new SpeakerManager(this, preferences);
364
394
  this.screenShare = new ScreenShareManager(this);
365
- this.dynascaleManager = new DynascaleManager(
395
+ this.trackSubscriptionManager = new TrackSubscriptionManager(
366
396
  this.state,
367
- this.speaker,
368
397
  this.tracer,
369
398
  );
399
+ this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
400
+
401
+ if (typeof document !== 'undefined') {
402
+ this.audioBindingsWatchdog = new AudioBindingsWatchdog(
403
+ this.state,
404
+ this.tracer,
405
+ );
406
+ this.viewportTracker = new ViewportTracker(this.state);
407
+ this.dynascaleManager = new DynascaleManager(
408
+ this.state,
409
+ this.speaker,
410
+ this.tracer,
411
+ this.trackSubscriptionManager,
412
+ this.blockedAudioTracker,
413
+ );
414
+ }
370
415
  }
371
416
 
372
417
  /**
@@ -692,17 +737,20 @@ export class Call {
692
737
  this.sfuStatsReporter?.flush();
693
738
  this.sfuStatsReporter?.stop();
694
739
  this.sfuStatsReporter = undefined;
740
+ this.lastStatsOptions = undefined;
695
741
 
696
- this.subscriber?.dispose();
742
+ await this.subscriber?.dispose();
697
743
  this.subscriber = undefined;
698
744
 
699
- this.publisher?.dispose();
745
+ await this.publisher?.dispose();
700
746
  this.publisher = undefined;
701
747
 
702
748
  await this.sfuClient?.leaveAndClose(leaveReason);
703
749
  this.sfuClient = undefined;
704
- this.dynascaleManager.setSfuClient(undefined);
705
- await this.dynascaleManager.dispose();
750
+ this.trackSubscriptionManager.setSfuClient(undefined);
751
+ this.trackSubscriptionManager.dispose();
752
+ this.audioBindingsWatchdog?.dispose();
753
+ await this.dynascaleManager?.dispose();
706
754
 
707
755
  this.state.setCallingState(CallingState.LEFT);
708
756
  this.state.setParticipants([]);
@@ -1079,17 +1127,19 @@ export class Call {
1079
1127
  const performingFastReconnect =
1080
1128
  this.reconnectStrategy === WebsocketReconnectStrategy.FAST;
1081
1129
 
1082
- let statsOptions = this.sfuStatsReporter?.options;
1130
+ let statsOptions = this.lastStatsOptions;
1083
1131
  if (
1084
1132
  !this.credentials ||
1085
1133
  !statsOptions ||
1086
1134
  performingRejoin ||
1087
- performingMigration
1135
+ performingMigration ||
1136
+ data?.migrating_from
1088
1137
  ) {
1089
1138
  try {
1090
1139
  const joinResponse = await this.doJoinRequest(data);
1091
1140
  this.credentials = joinResponse.credentials;
1092
1141
  statsOptions = joinResponse.stats_options;
1142
+ this.lastStatsOptions = statsOptions;
1093
1143
  } catch (error) {
1094
1144
  // prevent triggering reconnect flow if the state is OFFLINE
1095
1145
  const avoidRestoreState =
@@ -1126,7 +1176,7 @@ export class Call {
1126
1176
  : previousSfuClient;
1127
1177
  this.sfuClient = sfuClient;
1128
1178
  this.unifiedSessionId ??= sfuClient.sessionId;
1129
- this.dynascaleManager.setSfuClient(sfuClient);
1179
+ this.trackSubscriptionManager.setSfuClient(sfuClient);
1130
1180
 
1131
1181
  const clientDetails = await getClientDetails();
1132
1182
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
@@ -1216,7 +1266,7 @@ export class Call {
1216
1266
  });
1217
1267
  } else {
1218
1268
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
1219
- this.initPublisherAndSubscriber({
1269
+ await this.initPublisherAndSubscriber({
1220
1270
  sfuClient,
1221
1271
  connectionConfig,
1222
1272
  clientDetails,
@@ -1293,7 +1343,7 @@ export class Call {
1293
1343
  return {
1294
1344
  strategy,
1295
1345
  announcedTracks,
1296
- subscriptions: this.dynascaleManager.trackSubscriptions,
1346
+ subscriptions: this.trackSubscriptionManager.subscriptions,
1297
1347
  reconnectAttempt: this.reconnectAttempts,
1298
1348
  fromSfuId: migratingFromSfuId || '',
1299
1349
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
@@ -1390,7 +1440,7 @@ export class Call {
1390
1440
  * Initializes the Publisher and Subscriber Peer Connections.
1391
1441
  * @internal
1392
1442
  */
1393
- private initPublisherAndSubscriber = (opts: {
1443
+ private initPublisherAndSubscriber = async (opts: {
1394
1444
  sfuClient: StreamSfuClient;
1395
1445
  connectionConfig: RTCConfiguration;
1396
1446
  statsOptions: StatsOptions;
@@ -1410,7 +1460,7 @@ export class Call {
1410
1460
  } = opts;
1411
1461
  const { enable_rtc_stats: enableTracing } = statsOptions;
1412
1462
  if (closePreviousInstances && this.subscriber) {
1413
- this.subscriber.dispose();
1463
+ await this.subscriber.dispose();
1414
1464
  }
1415
1465
  const basePeerConnectionOptions: BasePeerConnectionOpts = {
1416
1466
  sfuClient,
@@ -1441,7 +1491,7 @@ export class Call {
1441
1491
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
1442
1492
  if (!isAnonymous) {
1443
1493
  if (closePreviousInstances && this.publisher) {
1444
- this.publisher.dispose();
1494
+ await this.publisher.dispose();
1445
1495
  }
1446
1496
  this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
1447
1497
  }
@@ -1567,12 +1617,19 @@ export class Call {
1567
1617
  reason: ReconnectReason,
1568
1618
  ): Promise<void> => {
1569
1619
  if (
1620
+ this.state.callingState === CallingState.JOINING ||
1570
1621
  this.state.callingState === CallingState.RECONNECTING ||
1571
1622
  this.state.callingState === CallingState.MIGRATING ||
1572
1623
  this.state.callingState === CallingState.RECONNECTING_FAILED
1573
1624
  )
1574
1625
  return;
1575
1626
 
1627
+ // Drop redundant reconnect calls. If a reconnect is already queued or
1628
+ // running for this Call, that entry will resolve whatever broke;
1629
+ // queueing more entries just replays the full REJOIN cycle (one extra
1630
+ // `POST /join` per entry) once the call is already healthy again.
1631
+ if (hasPending(this.reconnectConcurrencyTag)) return;
1632
+
1576
1633
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
1577
1634
  const reconnectStartTime = Date.now();
1578
1635
  this.reconnectStrategy = strategy;
@@ -1835,8 +1892,8 @@ export class Call {
1835
1892
  // the `migrationTask`
1836
1893
  this.state.setCallingState(CallingState.JOINED);
1837
1894
  } finally {
1838
- currentSubscriber?.dispose();
1839
- currentPublisher?.dispose();
1895
+ await currentSubscriber?.dispose();
1896
+ await currentPublisher?.dispose();
1840
1897
 
1841
1898
  // and close the previous SFU client, without specifying close code
1842
1899
  currentSfuClient.close(StreamSfuClient.NORMAL_CLOSURE, 'Migrating away');
@@ -1976,7 +2033,7 @@ export class Call {
1976
2033
  private restoreSubscribedTracks = () => {
1977
2034
  const { remoteParticipants } = this.state;
1978
2035
  if (remoteParticipants.length <= 0) return;
1979
- this.dynascaleManager.applyTrackSubscriptions(undefined);
2036
+ this.trackSubscriptionManager.apply(undefined);
1980
2037
  };
1981
2038
 
1982
2039
  /**
@@ -2063,7 +2120,7 @@ export class Call {
2063
2120
  */
2064
2121
  stopPublish = async (...trackTypes: TrackType[]) => {
2065
2122
  if (!this.sfuClient || !this.publisher) return;
2066
- this.publisher.stopTracks(...trackTypes);
2123
+ await this.publisher.stopTracks(...trackTypes);
2067
2124
  await this.updateLocalStreamState(undefined, ...trackTypes);
2068
2125
  };
2069
2126
 
@@ -2097,6 +2154,20 @@ export class Call {
2097
2154
  }
2098
2155
  };
2099
2156
 
2157
+ /**
2158
+ * Re-arms the encoder for a currently published track type. Useful for
2159
+ * working around WebKit's stalled sender bug after an iOS audio session
2160
+ * interruption (Siri, PSTN call).
2161
+ *
2162
+ * @internal
2163
+ *
2164
+ * @param trackType the track type to refresh.
2165
+ */
2166
+ refreshPublishedTrack = async (trackType: TrackType) => {
2167
+ if (!this.publisher) return;
2168
+ await this.publisher.refreshTrack(trackType);
2169
+ };
2170
+
2100
2171
  /**
2101
2172
  * Updates the preferred publishing options
2102
2173
  *
@@ -3010,7 +3081,7 @@ export class Call {
3010
3081
  sessionId: string,
3011
3082
  trackType: VideoTrackType,
3012
3083
  ) => {
3013
- return this.dynascaleManager.trackElementVisibility(
3084
+ return this.viewportTracker?.trackElementVisibility(
3014
3085
  element,
3015
3086
  sessionId,
3016
3087
  trackType,
@@ -3023,7 +3094,7 @@ export class Call {
3023
3094
  * @param element the viewport element.
3024
3095
  */
3025
3096
  setViewport = <T extends HTMLElement>(element: T) => {
3026
- return this.dynascaleManager.setViewport(element);
3097
+ return this.viewportTracker?.setViewport(element);
3027
3098
  };
3028
3099
 
3029
3100
  /**
@@ -3046,7 +3117,7 @@ export class Call {
3046
3117
  sessionId: string,
3047
3118
  trackType: VideoTrackType,
3048
3119
  ) => {
3049
- const unbind = this.dynascaleManager.bindVideoElement(
3120
+ const unbind = this.dynascaleManager?.bindVideoElement(
3050
3121
  videoElement,
3051
3122
  sessionId,
3052
3123
  trackType,
@@ -3075,26 +3146,33 @@ export class Call {
3075
3146
  sessionId: string,
3076
3147
  trackType: AudioTrackType = 'audioTrack',
3077
3148
  ) => {
3078
- const unbind = this.dynascaleManager.bindAudioElement(
3149
+ const unbind = this.dynascaleManager?.bindAudioElement(
3079
3150
  audioElement,
3080
3151
  sessionId,
3081
3152
  trackType,
3082
3153
  );
3083
3154
 
3084
3155
  if (!unbind) return;
3085
- this.leaveCallHooks.add(unbind);
3086
- return () => {
3087
- this.leaveCallHooks.delete(unbind);
3156
+ this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
3157
+ const cleanup = () => {
3088
3158
  unbind();
3159
+ this.audioBindingsWatchdog?.unregister(sessionId, trackType);
3160
+ };
3161
+ this.leaveCallHooks.add(cleanup);
3162
+ return () => {
3163
+ this.leaveCallHooks.delete(cleanup);
3164
+ cleanup();
3089
3165
  };
3090
3166
  };
3091
3167
 
3092
3168
  /**
3093
3169
  * Plays all audio elements blocked by the browser's autoplay policy.
3170
+ * Must be called from within a user gesture (e.g., click handler).
3171
+ *
3172
+ * Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
3173
+ * gesture is required.
3094
3174
  */
3095
- resumeAudio = () => {
3096
- return this.dynascaleManager.resumeAudio();
3097
- };
3175
+ resumeAudio = () => this.blockedAudioTracker.resumeAudio();
3098
3176
 
3099
3177
  /**
3100
3178
  * Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
@@ -3148,7 +3226,7 @@ export class Call {
3148
3226
  resolution: VideoDimension | undefined,
3149
3227
  sessionIds?: string[],
3150
3228
  ) => {
3151
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(
3229
+ this.trackSubscriptionManager.setOverrides(
3152
3230
  resolution
3153
3231
  ? {
3154
3232
  enabled: true,
@@ -3157,7 +3235,7 @@ export class Call {
3157
3235
  : undefined,
3158
3236
  sessionIds,
3159
3237
  );
3160
- this.dynascaleManager.applyTrackSubscriptions();
3238
+ this.trackSubscriptionManager.apply();
3161
3239
  };
3162
3240
 
3163
3241
  /**
@@ -3165,10 +3243,10 @@ export class Call {
3165
3243
  * and removes any preference for preferred resolution.
3166
3244
  */
3167
3245
  setIncomingVideoEnabled = (enabled: boolean) => {
3168
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(
3246
+ this.trackSubscriptionManager.setOverrides(
3169
3247
  enabled ? undefined : { enabled: false },
3170
3248
  );
3171
- this.dynascaleManager.applyTrackSubscriptions();
3249
+ this.trackSubscriptionManager.apply();
3172
3250
  };
3173
3251
 
3174
3252
  /**
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+
5
+ import '../rtc/__tests__/mocks/webrtc.mocks';
6
+
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { Call } from '../Call';
9
+ import { StreamClient } from '../coordinator/connection/client';
10
+ import { generateUUIDv4 } from '../coordinator/connection/utils';
11
+ import { StreamVideoWriteableStateStore } from '../store';
12
+
13
+ describe('Call lifecycle wiring', () => {
14
+ let call: Call;
15
+
16
+ beforeEach(() => {
17
+ call = new Call({
18
+ type: 'test',
19
+ id: generateUUIDv4(),
20
+ streamClient: new StreamClient('abc'),
21
+ clientStore: new StreamVideoWriteableStateStore(),
22
+ });
23
+ });
24
+
25
+ // Regression guard for the Call-owned helper teardown chain. Each of
26
+ // these helpers holds a resource (timer, listener, AudioContext) that
27
+ // leaks across calls if teardown is dropped during a refactor.
28
+ // Covers trackSubscriptionManager, audioBindingsWatchdog, and
29
+ // dynascaleManager. SFU-lifecycle disposables (publisher/subscriber/
30
+ // sfuStatsReporter) require a real join and are out of scope.
31
+ it('call.leave() tears down all Call-owned helpers exactly once', async () => {
32
+ const trackSubDispose = vi.spyOn(call.trackSubscriptionManager, 'dispose');
33
+ const audioBindingsDispose = vi.spyOn(
34
+ call.audioBindingsWatchdog!,
35
+ 'dispose',
36
+ );
37
+ const dynascaleDispose = vi.spyOn(call.dynascaleManager!, 'dispose');
38
+
39
+ await call.leave();
40
+
41
+ expect(trackSubDispose).toHaveBeenCalledTimes(1);
42
+ expect(audioBindingsDispose).toHaveBeenCalledTimes(1);
43
+ expect(dynascaleDispose).toHaveBeenCalledTimes(1);
44
+ });
45
+
46
+ // Order matters: the SFU subscription pump must finish tearing down
47
+ // before DynascaleManager closes its AudioContext, otherwise helpers
48
+ // can run on a closed context (logged as warnings or thrown by
49
+ // happy-dom). This is the contract the leave() teardown chain encodes.
50
+ it('call.leave() tears down helpers in the documented order', async () => {
51
+ const trackSubDispose = vi.spyOn(call.trackSubscriptionManager, 'dispose');
52
+ const audioBindingsDispose = vi.spyOn(
53
+ call.audioBindingsWatchdog!,
54
+ 'dispose',
55
+ );
56
+ const dynascaleDispose = vi.spyOn(call.dynascaleManager!, 'dispose');
57
+
58
+ await call.leave();
59
+
60
+ const trackSubOrder = trackSubDispose.mock.invocationCallOrder[0];
61
+ const audioBindingsOrder = audioBindingsDispose.mock.invocationCallOrder[0];
62
+ const dynascaleOrder = dynascaleDispose.mock.invocationCallOrder[0];
63
+
64
+ expect(trackSubOrder).toBeLessThan(audioBindingsOrder);
65
+ expect(audioBindingsOrder).toBeLessThan(dynascaleOrder);
66
+ });
67
+ });