@stream-io/video-client 1.54.0 → 1.55.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 (64) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +9641 -8767
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +9638 -8764
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +9639 -8765
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +13 -1
  9. package/dist/src/StreamSfuClient.d.ts +11 -3
  10. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  11. package/dist/src/gen/google/protobuf/struct.d.ts +3 -1
  12. package/dist/src/gen/google/protobuf/timestamp.d.ts +3 -1
  13. package/dist/src/gen/video/sfu/event/events.d.ts +22 -1
  14. package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
  15. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +23 -2
  16. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  17. package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
  18. package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
  19. package/dist/src/rtc/Publisher.d.ts +5 -2
  20. package/dist/src/rtc/Subscriber.d.ts +8 -0
  21. package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
  22. package/dist/src/rtc/types.d.ts +2 -0
  23. package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
  25. package/dist/src/stats/rtc/Tracer.d.ts +9 -2
  26. package/dist/src/stats/rtc/types.d.ts +10 -4
  27. package/package.json +5 -3
  28. package/src/Call.ts +83 -35
  29. package/src/StreamSfuClient.ts +36 -21
  30. package/src/__tests__/StreamSfuClient.test.ts +159 -1
  31. package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
  32. package/src/coordinator/connection/__tests__/connection.test.ts +22 -0
  33. package/src/coordinator/connection/connection.ts +8 -5
  34. package/src/gen/google/protobuf/struct.ts +7 -12
  35. package/src/gen/google/protobuf/timestamp.ts +6 -7
  36. package/src/gen/video/sfu/event/events.ts +22 -25
  37. package/src/gen/video/sfu/models/models.ts +10 -1
  38. package/src/gen/video/sfu/signal_rpc/signal.client.ts +24 -29
  39. package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
  40. package/src/helpers/__tests__/browsers.test.ts +12 -12
  41. package/src/helpers/browsers.ts +5 -5
  42. package/src/reporting/ClientEventReporter.ts +17 -12
  43. package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
  44. package/src/rtc/BasePeerConnection.ts +15 -34
  45. package/src/rtc/IceTrickleBuffer.ts +105 -12
  46. package/src/rtc/Publisher.ts +23 -19
  47. package/src/rtc/Subscriber.ts +97 -36
  48. package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
  49. package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
  50. package/src/rtc/__tests__/Publisher.test.ts +2 -31
  51. package/src/rtc/__tests__/Subscriber.test.ts +271 -20
  52. package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
  53. package/src/rtc/helpers/degradationPreference.ts +1 -0
  54. package/src/rtc/helpers/iceCandiates.ts +35 -0
  55. package/src/rtc/helpers/sdp.ts +3 -2
  56. package/src/rtc/helpers/tracks.ts +2 -0
  57. package/src/rtc/types.ts +2 -0
  58. package/src/stats/SfuStatsReporter.ts +149 -49
  59. package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
  60. package/src/stats/rtc/StatsTracer.ts +90 -32
  61. package/src/stats/rtc/Tracer.ts +23 -2
  62. package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
  63. package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
  64. package/src/stats/rtc/types.ts +11 -4
@@ -1,7 +1,11 @@
1
1
  import { BasePeerConnection } from './BasePeerConnection';
2
- import { BasePeerConnectionOpts } from './types';
2
+ import { BasePeerConnectionOpts, ReconnectReason } from './types';
3
3
  import { NegotiationError } from './NegotiationError';
4
- import { PeerType, TrackType } from '../gen/video/sfu/models/models';
4
+ import {
5
+ PeerType,
6
+ TrackType,
7
+ WebsocketReconnectStrategy,
8
+ } from '../gen/video/sfu/models/models';
5
9
  import { SubscriberOffer } from '../gen/video/sfu/event/events';
6
10
  import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks';
7
11
  import { pushToIfMissing, removeFromIfPresent } from '../helpers/array';
@@ -14,6 +18,15 @@ import { enableStereo, removeCodecsExcept } from './helpers/sdp';
14
18
  * @internal
15
19
  */
16
20
  export class Subscriber extends BasePeerConnection {
21
+ /**
22
+ * Remote streams received from the SFU. For a self-sub case
23
+ * we need to be able to distinguish between the local capture stream.
24
+ * The map will never contain local streams so we can safely use it to
25
+ * check if the stream is remote and dispose it when needed.
26
+ */
27
+ private trackedStreams?: WeakSet<MediaStream>;
28
+ private negotiationFailures = 0;
29
+
17
30
  /**
18
31
  * Constructs a new `Subscriber` instance.
19
32
  */
@@ -22,9 +35,25 @@ export class Subscriber extends BasePeerConnection {
22
35
  this.pc.addEventListener('track', this.handleOnTrack);
23
36
 
24
37
  this.on('subscriberOffer', async (subscriberOffer) => {
25
- return this.negotiate(subscriberOffer).catch((err) => {
26
- this.logger.error(`Negotiation failed.`, err);
27
- });
38
+ try {
39
+ const result = await this.negotiate(subscriberOffer);
40
+ this.negotiationFailures = 0;
41
+ return result;
42
+ } catch (err: any) {
43
+ const message = 'subscriber.negotiationFailed';
44
+ this.tracer?.trace(message, err.message);
45
+ this.logger.warn(message, err);
46
+
47
+ const failures = ++this.negotiationFailures;
48
+ if (failures < 3) return this.tryRestartIce();
49
+
50
+ this.logger.error(`negotiation failed ${failures} times, rejoining`);
51
+ this.onReconnectionNeeded?.(
52
+ WebsocketReconnectStrategy.REJOIN,
53
+ ReconnectReason.SUBSCRIBER_NEGOTIATION_FAILED,
54
+ this.peerType,
55
+ );
56
+ }
28
57
  });
29
58
  }
30
59
 
@@ -44,13 +73,11 @@ export class Subscriber extends BasePeerConnection {
44
73
  restartIce = async () => {
45
74
  this.logger.debug('Restarting ICE connection');
46
75
  if (this.pc.signalingState === 'have-remote-offer') {
47
- this.logger.debug('ICE restart is already in progress');
76
+ this.logger.debug('ICE negotiation is already in progress');
48
77
  return;
49
78
  }
50
79
  if (this.pc.connectionState === 'new') {
51
- this.logger.debug(
52
- `ICE connection is not yet established, skipping restart.`,
53
- );
80
+ this.logger.debug(`ICE connection not yet established, skipping restart`);
54
81
  return;
55
82
  }
56
83
  const previousIsIceRestarting = this.isIceRestarting;
@@ -75,6 +102,7 @@ export class Subscriber extends BasePeerConnection {
75
102
  const participantToUpdate = this.state.participants.find(
76
103
  (p) => p.trackLookupPrefix === trackId,
77
104
  );
105
+ const isSelfSub = !!participantToUpdate?.isLocalParticipant;
78
106
  this.logger.debug(
79
107
  `[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
80
108
  track.id,
@@ -108,6 +136,11 @@ export class Subscriber extends BasePeerConnection {
108
136
 
109
137
  this.trackIdToTrackType.set(track.id, trackType);
110
138
 
139
+ if (isSelfSub) {
140
+ this.trackedStreams ??= new WeakSet<MediaStream>();
141
+ this.trackedStreams.add(primaryStream);
142
+ }
143
+
111
144
  if (!participantToUpdate) {
112
145
  this.logger.warn(
113
146
  `[onTrack]: Received track for unknown participant: ${trackId}`,
@@ -128,6 +161,13 @@ export class Subscriber extends BasePeerConnection {
128
161
  return;
129
162
  }
130
163
 
164
+ // Self-sub loopback audio routes to the speaker by default, which
165
+ // would echo the local user's voice. Default-mute here; consumers
166
+ // (the loopback recording hook) re-enable explicitly when needed.
167
+ if (isSelfSub && e.track.kind === 'audio') {
168
+ e.track.enabled = false;
169
+ }
170
+
131
171
  // get the previous stream to dispose it later
132
172
  // usually this happens during migration, when the stream is replaced
133
173
  // with a new one but the old one is still in the state
@@ -138,8 +178,15 @@ export class Subscriber extends BasePeerConnection {
138
178
  [streamKindProp]: primaryStream,
139
179
  });
140
180
 
141
- // now, dispose the previous stream if it exists
142
181
  if (previousStream) {
182
+ if (isSelfSub && !this.trackedStreams?.has(previousStream)) {
183
+ // this is the local capture stream, we don't want to dispose it
184
+ this.logger.debug(
185
+ `[onTrack]: Skipping cleanup of previous ${e.track.kind} stream for userId: ${participantToUpdate.userId} because it is not tracked`,
186
+ );
187
+ return;
188
+ }
189
+
143
190
  this.logger.info(
144
191
  `[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`,
145
192
  );
@@ -172,34 +219,48 @@ export class Subscriber extends BasePeerConnection {
172
219
  };
173
220
 
174
221
  private negotiate = async (subscriberOffer: SubscriberOffer) => {
175
- await this.pc.setRemoteDescription({
176
- type: 'offer',
177
- sdp: subscriberOffer.sdp,
178
- });
222
+ // The generation currently committed on the peer connection. If this
223
+ // negotiation fails and rolls back, the buffer is restored to it.
224
+ const previousSdp = this.pc.currentRemoteDescription?.sdp;
225
+ try {
226
+ await this.pc.setRemoteDescription({
227
+ type: 'offer',
228
+ sdp: subscriberOffer.sdp,
229
+ });
179
230
 
180
- this.addTrickledIceCandidates();
181
-
182
- const answer = await this.pc.createAnswer();
183
- if (answer.sdp) {
184
- answer.sdp = enableStereo(subscriberOffer.sdp, answer.sdp);
185
- const { dangerouslyForceCodec, subscriberFmtpLine } =
186
- this.clientPublishOptions || {};
187
- if (dangerouslyForceCodec) {
188
- answer.sdp = removeCodecsExcept(
189
- answer.sdp,
190
- dangerouslyForceCodec,
191
- subscriberFmtpLine,
192
- );
193
- }
194
- }
195
- await this.pc.setLocalDescription(answer);
231
+ this.addTrickledIceCandidates();
196
232
 
197
- await this.sfuClient.sendAnswer({
198
- peerType: PeerType.SUBSCRIBER,
199
- sdp: answer.sdp || '',
200
- negotiationId: subscriberOffer.negotiationId,
201
- });
233
+ const answer = await this.pc.createAnswer();
234
+ if (answer.sdp) {
235
+ answer.sdp = enableStereo(subscriberOffer.sdp, answer.sdp);
236
+ const { dangerouslyForceCodec, subscriberFmtpLine } =
237
+ this.clientPublishOptions || {};
238
+ if (dangerouslyForceCodec) {
239
+ answer.sdp = removeCodecsExcept(
240
+ answer.sdp,
241
+ dangerouslyForceCodec,
242
+ subscriberFmtpLine,
243
+ );
244
+ }
245
+ }
246
+ await this.pc.setLocalDescription(answer);
202
247
 
203
- this.isIceRestarting = false;
248
+ await this.sfuClient.sendAnswer({
249
+ peerType: PeerType.SUBSCRIBER,
250
+ sdp: answer.sdp || '',
251
+ negotiationId: subscriberOffer.negotiationId,
252
+ });
253
+ } catch (err) {
254
+ if (this.pc.signalingState === 'have-remote-offer') {
255
+ await this.pc.setRemoteDescription({ type: 'rollback' }).catch((e) => {
256
+ this.logger.warn('Failed to rollback after negotiation error', e);
257
+ });
258
+ const { iceTrickleBuffer } = this.sfuClient;
259
+ iceTrickleBuffer.updateActiveGeneration(this.peerType, previousSdp);
260
+ }
261
+ throw err;
262
+ } finally {
263
+ this.isIceRestarting = false;
264
+ }
204
265
  };
205
266
  }
@@ -818,43 +818,6 @@ describe('Call reconnect wiring (PC event → leave)', () => {
818
818
  expect(publisher.restartIce).toHaveBeenCalled();
819
819
  });
820
820
 
821
- /**
822
- * Scenario 4 (manual smoke equivalent: drop only the signal WS while the
823
- * publisher PC stays `connected`): the FAST path should NOT call
824
- * `publisher.restartIce()` because the PC is stable.
825
- */
826
- it('FAST path skips publisher.restartIce when publisher PC is stable', async () => {
827
- const publisher = makePublisherWiredToCall();
828
- // @ts-expect-error private field
829
- publisher['pc'].iceConnectionState = 'connected';
830
- publisher['onIceConnectionStateChange']();
831
- // @ts-expect-error private field
832
- publisher['pc'].connectionState = 'connected';
833
-
834
- // pretend the publisher has tracks so isPublishing() would return true
835
- vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
836
- const restartIceSpy = vi.spyOn(publisher, 'restartIce').mockResolvedValue();
837
- const setSfuSpy = vi.spyOn(publisher, 'setSfuClient');
838
- call['publisher'] = publisher;
839
-
840
- // mimic the FAST branch in doJoin: restoreICE is the gateway
841
- const publisherIsStable = call['publisher']?.isStable() ?? true;
842
- const includePublisher =
843
- !!call['publisher']?.isPublishing() && !publisherIsStable;
844
- await call['restoreICE'](sfuClient, {
845
- includeSubscriber: false,
846
- includePublisher,
847
- });
848
-
849
- expect(includePublisher).toBe(false);
850
- expect(setSfuSpy).toHaveBeenCalledWith(sfuClient); // wire still updated
851
- expect(restartIceSpy).not.toHaveBeenCalled(); // but NO ICE restart
852
- });
853
-
854
- /**
855
- * Counterpart to the above: when the publisher PC is NOT stable (e.g.,
856
- * `disconnected`), the FAST path SHOULD still issue an ICE restart.
857
- */
858
821
  it('FAST path DOES call publisher.restartIce when publisher PC is unstable', async () => {
859
822
  const publisher = makePublisherWiredToCall();
860
823
  // @ts-expect-error private field
@@ -869,15 +832,8 @@ describe('Call reconnect wiring (PC event → leave)', () => {
869
832
  const restartIceSpy = vi.spyOn(publisher, 'restartIce').mockResolvedValue();
870
833
  call['publisher'] = publisher;
871
834
 
872
- const publisherIsStable = call['publisher']?.isStable() ?? true;
873
- const includePublisher =
874
- !!call['publisher']?.isPublishing() && !publisherIsStable;
875
- await call['restoreICE'](sfuClient, {
876
- includeSubscriber: false,
877
- includePublisher,
878
- });
835
+ await call['restoreICE'](sfuClient, { includeSubscriber: false });
879
836
 
880
- expect(includePublisher).toBe(true);
881
837
  expect(restartIceSpy).toHaveBeenCalled();
882
838
  });
883
839
 
@@ -998,6 +954,50 @@ describe('Call reconnect wiring (PC event → leave)', () => {
998
954
  });
999
955
  });
1000
956
 
957
+ /**
958
+ * `handleSfuSignalClose` is the bridge from a dead signal WS to the reconnect
959
+ * loop. A reconnect swaps in a fresh SFU client, but the old socket can still
960
+ * fire a (delayed) `close` later. Such stragglers must be ignored: only the
961
+ * currently-active client may drive a reconnect.
962
+ */
963
+ describe('Call.handleSfuSignalClose superseded-client guard', () => {
964
+ let call: Call;
965
+
966
+ beforeEach(() => {
967
+ call = makeCall();
968
+ vi.spyOn(call, 'leave').mockResolvedValue(undefined);
969
+ });
970
+
971
+ afterEach(() => {
972
+ vi.clearAllMocks();
973
+ });
974
+
975
+ it('ignores a signal close from a superseded SFU client', () => {
976
+ call.state.setCallingState(CallingState.JOINED);
977
+ const reconnectSpy = vi
978
+ .spyOn(call as unknown as { reconnect: () => Promise<void> }, 'reconnect')
979
+ .mockResolvedValue(undefined);
980
+
981
+ const currentClient = { isLeaving: false, isClosingClean: false };
982
+ const supersededClient = { isLeaving: false, isClosingClean: false };
983
+ (call as unknown as { sfuClient: unknown }).sfuClient = currentClient;
984
+
985
+ // a close from a client that is no longer active must not reconnect
986
+ call['handleSfuSignalClose'](
987
+ supersededClient as unknown as StreamSfuClient,
988
+ '1006 ',
989
+ );
990
+ expect(reconnectSpy).not.toHaveBeenCalled();
991
+
992
+ // the active client's close still drives a reconnect
993
+ call['handleSfuSignalClose'](
994
+ currentClient as unknown as StreamSfuClient,
995
+ '1006 ',
996
+ );
997
+ expect(reconnectSpy).toHaveBeenCalledTimes(1);
998
+ });
999
+ });
1000
+
1001
1001
  /**
1002
1002
  * `leave()` runs after both the success path (end of `joinFlow`) and the
1003
1003
  * giveUpAndLeave path. Only the success path resets `reconnectStrategy` /
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { IceTrickleBuffer } from '../IceTrickleBuffer';
3
+ import { PeerType } from '../../gen/video/sfu/models/models';
4
+
5
+ // The generation is carried via `usernameFragment` (the key getCandidateUfrag
6
+ // reads); the candidate-string `ufrag` token path has its own helper tests.
7
+ const trickle = (
8
+ ufrag: string | undefined,
9
+ candidate: string,
10
+ peerType = PeerType.SUBSCRIBER,
11
+ ) => ({
12
+ peerType,
13
+ iceCandidate: JSON.stringify(
14
+ ufrag ? { usernameFragment: ufrag, candidate } : { candidate },
15
+ ),
16
+ });
17
+
18
+ const sdp = (ufrag: string) =>
19
+ `v=0\r\na=ice-ufrag:${ufrag}\r\na=ice-pwd:pwd\r\n`;
20
+
21
+ const collect = (
22
+ observable: IceTrickleBuffer['subscriber']['candidates'],
23
+ ): RTCIceCandidateInit[] => {
24
+ const seen: RTCIceCandidateInit[] = [];
25
+ observable.subscribe((c) => seen.push(c)).unsubscribe();
26
+ return seen;
27
+ };
28
+
29
+ describe('IceTrickleBuffer', () => {
30
+ it('emits buffered candidates of the active generation to a new subscriber', () => {
31
+ const buffer = new IceTrickleBuffer();
32
+ buffer.push(trickle('u1', 'a'));
33
+ buffer.push(trickle('u1', 'b'));
34
+ buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
35
+
36
+ expect(collect(buffer.subscriber.candidates)).toEqual([
37
+ { usernameFragment: 'u1', candidate: 'a' },
38
+ { usernameFragment: 'u1', candidate: 'b' },
39
+ ]);
40
+ });
41
+
42
+ it('emits live candidates of the active generation', () => {
43
+ const buffer = new IceTrickleBuffer();
44
+ buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
45
+ const seen: RTCIceCandidateInit[] = [];
46
+ buffer.subscriber.candidates.subscribe((c) => seen.push(c));
47
+
48
+ buffer.push(trickle('u1', 'a'));
49
+
50
+ expect(seen).toEqual([{ usernameFragment: 'u1', candidate: 'a' }]);
51
+ });
52
+
53
+ it('drops superseded-generation candidates once the generation advances', () => {
54
+ const buffer = new IceTrickleBuffer();
55
+ buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u0'));
56
+ buffer.push(trickle('u0', 'old'));
57
+
58
+ // ICE restart -> new generation
59
+ buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
60
+ buffer.push(trickle('u1', 'new'));
61
+
62
+ expect(collect(buffer.subscriber.candidates)).toEqual([
63
+ { usernameFragment: 'u1', candidate: 'new' },
64
+ ]);
65
+ });
66
+
67
+ it('holds future-generation candidates until their generation becomes active', () => {
68
+ const buffer = new IceTrickleBuffer();
69
+ buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
70
+ // a candidate for a not-yet-applied generation arrives early (trickle race)
71
+ buffer.push(trickle('u2', 'future'));
72
+
73
+ expect(collect(buffer.subscriber.candidates)).toEqual([]);
74
+
75
+ buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u2'));
76
+
77
+ expect(collect(buffer.subscriber.candidates)).toEqual([
78
+ { usernameFragment: 'u2', candidate: 'future' },
79
+ ]);
80
+ });
81
+
82
+ it('emits candidates without a generation marker (fail-open)', () => {
83
+ const buffer = new IceTrickleBuffer();
84
+ buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
85
+ buffer.push(trickle(undefined, 'no-generation'));
86
+
87
+ expect(collect(buffer.subscriber.candidates)).toEqual([
88
+ { candidate: 'no-generation' },
89
+ ]);
90
+ });
91
+
92
+ it('emits all candidates when no active generation is set (fail-open)', () => {
93
+ const buffer = new IceTrickleBuffer();
94
+ buffer.push(trickle('u1', 'a'));
95
+ buffer.push(trickle('u2', 'b'));
96
+
97
+ expect(collect(buffer.subscriber.candidates)).toEqual([
98
+ { usernameFragment: 'u1', candidate: 'a' },
99
+ { usernameFragment: 'u2', candidate: 'b' },
100
+ ]);
101
+ });
102
+
103
+ it('keeps subscriber and publisher generations independent', () => {
104
+ const buffer = new IceTrickleBuffer();
105
+ buffer.push(trickle('u1', 'sub', PeerType.SUBSCRIBER));
106
+ buffer.push(trickle('p1', 'pub', PeerType.PUBLISHER_UNSPECIFIED));
107
+ buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
108
+ buffer.updateActiveGeneration(PeerType.PUBLISHER_UNSPECIFIED, sdp('p1'));
109
+
110
+ expect(collect(buffer.subscriber.candidates)).toEqual([
111
+ { usernameFragment: 'u1', candidate: 'sub' },
112
+ ]);
113
+ expect(collect(buffer.publisher.candidates)).toEqual([
114
+ { usernameFragment: 'p1', candidate: 'pub' },
115
+ ]);
116
+ });
117
+
118
+ it('dispose clears retained candidates', () => {
119
+ const buffer = new IceTrickleBuffer();
120
+ buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
121
+ buffer.push(trickle('u1', 'a'));
122
+
123
+ buffer.dispose();
124
+
125
+ expect(collect(buffer.subscriber.candidates)).toEqual([]);
126
+ });
127
+ });
@@ -525,37 +525,6 @@ describe('Publisher', () => {
525
525
  expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
526
526
  });
527
527
 
528
- it(`isStable() returns false when ICE is 'new'`, () => {
529
- // @ts-expect-error private api
530
- publisher['pc'].iceConnectionState = 'new';
531
- // default connectionState in mock is 'connected'
532
- expect(publisher.isStable()).toBe(false);
533
- });
534
-
535
- it(`isStable() returns true when ICE is 'connected' and connectionState is 'connected'`, () => {
536
- // @ts-expect-error private api
537
- publisher['pc'].iceConnectionState = 'connected';
538
- // @ts-expect-error private api
539
- publisher['pc'].connectionState = 'connected';
540
- expect(publisher.isStable()).toBe(true);
541
- });
542
-
543
- it(`isStable() returns true when ICE is 'completed' and connectionState is 'connected'`, () => {
544
- // @ts-expect-error private api
545
- publisher['pc'].iceConnectionState = 'completed';
546
- // @ts-expect-error private api
547
- publisher['pc'].connectionState = 'connected';
548
- expect(publisher.isStable()).toBe(true);
549
- });
550
-
551
- it(`isStable() returns false when ICE is 'disconnected'`, () => {
552
- // @ts-expect-error private api
553
- publisher['pc'].iceConnectionState = 'disconnected';
554
- // @ts-expect-error private api
555
- publisher['pc'].connectionState = 'connected';
556
- expect(publisher.isStable()).toBe(false);
557
- });
558
-
559
528
  it(`after connected→disconnected→connected cycle, subsequent 'failed' DOES trigger ICE restart (flag stays true)`, () => {
560
529
  // @ts-expect-error private api
561
530
  publisher['pc'].iceConnectionState = 'connected';
@@ -1634,6 +1603,7 @@ describe('Publisher', () => {
1634
1603
  {
1635
1604
  publishOptionId: publishOption.id,
1636
1605
  trackType: TrackType.VIDEO,
1606
+ degradationPreference: DegradationPreference.UNSPECIFIED,
1637
1607
  layers: [
1638
1608
  {
1639
1609
  name: 'q',
@@ -1728,6 +1698,7 @@ describe('Publisher', () => {
1728
1698
  {
1729
1699
  publishOptionId: publishOption.id,
1730
1700
  trackType: TrackType.VIDEO,
1701
+ degradationPreference: DegradationPreference.UNSPECIFIED,
1731
1702
  layers: [
1732
1703
  {
1733
1704
  name: 'q',