@stream-io/video-client 0.1.3 → 0.1.4

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.
@@ -28,14 +28,19 @@ import {
28
28
  } from '../helpers/sdp-munging';
29
29
  import { Logger } from '../coordinator/connection/types';
30
30
  import { getLogger } from '../logger';
31
+ import { Dispatcher } from './Dispatcher';
32
+
33
+ const logger: Logger = getLogger(['Publisher']);
31
34
 
32
35
  export type PublisherOpts = {
33
36
  sfuClient: StreamSfuClient;
34
37
  state: CallState;
38
+ dispatcher: Dispatcher;
35
39
  connectionConfig?: RTCConfiguration;
36
40
  isDtxEnabled: boolean;
37
41
  isRedEnabled: boolean;
38
42
  preferredVideoCodec?: string;
43
+ iceRestartDelay?: number;
39
44
  };
40
45
 
41
46
  /**
@@ -45,6 +50,7 @@ export type PublisherOpts = {
45
50
  export class Publisher {
46
51
  private pc: RTCPeerConnection;
47
52
  private readonly state: CallState;
53
+ private readonly dispatcher: Dispatcher;
48
54
 
49
55
  private readonly transceiverRegistry: {
50
56
  [key in TrackType]: RTCRtpTransceiver | undefined;
@@ -87,7 +93,11 @@ export class Publisher {
87
93
  private readonly isDtxEnabled: boolean;
88
94
  private readonly isRedEnabled: boolean;
89
95
  private readonly preferredVideoCodec?: string;
90
- private logger: Logger = getLogger(['Publisher']);
96
+
97
+ private readonly unsubscribeOnIceRestart: () => void;
98
+
99
+ private readonly iceRestartDelay: number;
100
+ private isIceRestarting = false;
91
101
 
92
102
  /**
93
103
  * The SFU client instance to use for publishing and signaling.
@@ -100,24 +110,40 @@ export class Publisher {
100
110
  * @param connectionConfig the connection configuration to use.
101
111
  * @param sfuClient the SFU client to use.
102
112
  * @param state the call state to use.
113
+ * @param dispatcher the dispatcher to use.
103
114
  * @param isDtxEnabled whether DTX is enabled.
104
115
  * @param isRedEnabled whether RED is enabled.
105
116
  * @param preferredVideoCodec the preferred video codec.
117
+ * @param iceRestartDelay the delay in milliseconds to wait before restarting ICE once connection goes to `disconnected` state.
106
118
  */
107
119
  constructor({
108
120
  connectionConfig,
109
121
  sfuClient,
122
+ dispatcher,
110
123
  state,
111
124
  isDtxEnabled,
112
125
  isRedEnabled,
113
126
  preferredVideoCodec,
127
+ iceRestartDelay = 2500,
114
128
  }: PublisherOpts) {
115
129
  this.pc = this.createPeerConnection(connectionConfig);
116
130
  this.sfuClient = sfuClient;
117
131
  this.state = state;
132
+ this.dispatcher = dispatcher;
118
133
  this.isDtxEnabled = isDtxEnabled;
119
134
  this.isRedEnabled = isRedEnabled;
120
135
  this.preferredVideoCodec = preferredVideoCodec;
136
+ this.iceRestartDelay = iceRestartDelay;
137
+
138
+ this.unsubscribeOnIceRestart = dispatcher.on(
139
+ 'iceRestart',
140
+ async (message) => {
141
+ if (message.eventPayload.oneofKind !== 'iceRestart') return;
142
+ const { iceRestart } = message.eventPayload;
143
+ if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED) return;
144
+ await this.restartIce();
145
+ },
146
+ );
121
147
  }
122
148
 
123
149
  private createPeerConnection = (connectionConfig?: RTCConfiguration) => {
@@ -154,6 +180,7 @@ export class Publisher {
154
180
  });
155
181
  }
156
182
 
183
+ this.unsubscribeOnIceRestart();
157
184
  this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
158
185
  this.pc.close();
159
186
  };
@@ -192,7 +219,7 @@ export class Publisher {
192
219
  * Once the track has ended, it will notify the SFU and update the state.
193
220
  */
194
221
  const handleTrackEnded = async () => {
195
- this.logger(
222
+ logger(
196
223
  'info',
197
224
  `Track ${TrackType[trackType]} has ended, notifying the SFU`,
198
225
  );
@@ -233,12 +260,12 @@ export class Publisher {
233
260
  sendEncodings: videoEncodings,
234
261
  });
235
262
 
236
- this.logger('debug', `Added ${TrackType[trackType]} transceiver`);
263
+ logger('debug', `Added ${TrackType[trackType]} transceiver`);
237
264
  this.transceiverInitOrder.push(trackType);
238
265
  this.transceiverRegistry[trackType] = transceiver;
239
266
 
240
267
  if ('setCodecPreferences' in transceiver && codecPreferences) {
241
- this.logger(
268
+ logger(
242
269
  'info',
243
270
  `Setting ${TrackType[trackType]} codec preferences`,
244
271
  codecPreferences,
@@ -336,7 +363,7 @@ export class Publisher {
336
363
  * Stops publishing all tracks and stop all tracks.
337
364
  */
338
365
  stopPublishing = () => {
339
- this.logger('debug', 'Stopping publishing all tracks');
366
+ logger('debug', 'Stopping publishing all tracks');
340
367
  this.pc.getSenders().forEach((s) => {
341
368
  s.track?.stop();
342
369
  if (this.pc.signalingState !== 'closed') {
@@ -346,7 +373,7 @@ export class Publisher {
346
373
  };
347
374
 
348
375
  updateVideoPublishQuality = async (enabledRids: string[]) => {
349
- this.logger(
376
+ logger(
350
377
  'info',
351
378
  'Update publish quality, requested rids by SFU:',
352
379
  enabledRids,
@@ -354,13 +381,13 @@ export class Publisher {
354
381
 
355
382
  const videoSender = this.transceiverRegistry[TrackType.VIDEO]?.sender;
356
383
  if (!videoSender) {
357
- this.logger('warn', 'Update publish quality, no video sender found.');
384
+ logger('warn', 'Update publish quality, no video sender found.');
358
385
  return;
359
386
  }
360
387
 
361
388
  const params = videoSender.getParameters();
362
389
  if (params.encodings.length === 0) {
363
- this.logger(
390
+ logger(
364
391
  'warn',
365
392
  'Update publish quality, No suitable video encoding quality found',
366
393
  );
@@ -383,12 +410,9 @@ export class Publisher {
383
410
  .join(', ');
384
411
  if (changed) {
385
412
  await videoSender.setParameters(params);
386
- this.logger(
387
- 'info',
388
- `Update publish quality, enabled rids: ${activeRids}`,
389
- );
413
+ logger('info', `Update publish quality, enabled rids: ${activeRids}`);
390
414
  } else {
391
- this.logger('info', `Update publish quality, no change: ${activeRids}`);
415
+ logger('info', `Update publish quality, no change: ${activeRids}`);
392
416
  }
393
417
  };
394
418
 
@@ -422,7 +446,7 @@ export class Publisher {
422
446
  private onIceCandidate = async (e: RTCPeerConnectionIceEvent) => {
423
447
  const { candidate } = e;
424
448
  if (!candidate) {
425
- this.logger('debug', 'null ice candidate');
449
+ logger('debug', 'null ice candidate');
426
450
  return;
427
451
  }
428
452
  await this.sfuClient.iceTrickle({
@@ -457,7 +481,12 @@ export class Publisher {
457
481
  * Restarts the ICE connection and renegotiates with the SFU.
458
482
  */
459
483
  restartIce = async () => {
460
- this.logger('debug', 'Restarting ICE connection');
484
+ logger('debug', 'Restarting ICE connection');
485
+ const signalingState = this.pc.signalingState;
486
+ if (this.isIceRestarting || signalingState === 'have-local-offer') {
487
+ logger('debug', 'ICE restart is already in progress');
488
+ return;
489
+ }
461
490
  await this.negotiate({ iceRestart: true });
462
491
  };
463
492
 
@@ -471,6 +500,8 @@ export class Publisher {
471
500
  * @param options the optional offer options to use.
472
501
  */
473
502
  private negotiate = async (options?: RTCOfferOptions) => {
503
+ this.isIceRestarting = options?.iceRestart ?? false;
504
+
474
505
  const offer = await this.pc.createOffer(options);
475
506
  offer.sdp = this.mungeCodecs(offer.sdp);
476
507
 
@@ -494,22 +525,21 @@ export class Publisher {
494
525
  sdp: response.sdp,
495
526
  });
496
527
  } catch (e) {
497
- this.logger('error', `setRemoteDescription error`, {
528
+ logger('error', `setRemoteDescription error`, {
498
529
  sdp: response.sdp,
499
530
  error: e,
500
531
  });
501
532
  }
502
533
 
534
+ this.isIceRestarting = false;
535
+
503
536
  this.sfuClient.iceTrickleBuffer.publisherCandidates.subscribe(
504
537
  async (candidate) => {
505
538
  try {
506
539
  const iceCandidate = JSON.parse(candidate.iceCandidate);
507
540
  await this.pc.addIceCandidate(iceCandidate);
508
541
  } catch (e) {
509
- this.logger('error', `ICE candidate error`, {
510
- error: e,
511
- candidate,
512
- });
542
+ logger('warn', `ICE candidate error`, [e, candidate]);
513
543
  }
514
544
  },
515
545
  );
@@ -544,11 +574,11 @@ export class Publisher {
544
574
  ): string => {
545
575
  if (defaultMid) return defaultMid;
546
576
  if (!sdp) {
547
- this.logger('warn', 'No SDP found. Returning empty mid');
577
+ logger('warn', 'No SDP found. Returning empty mid');
548
578
  return '';
549
579
  }
550
580
 
551
- this.logger(
581
+ logger(
552
582
  'debug',
553
583
  `No 'mid' found for track. Trying to find it from the Offer SDP`,
554
584
  );
@@ -562,7 +592,7 @@ export class Publisher {
562
592
  );
563
593
  });
564
594
  if (typeof media?.mid === 'undefined') {
565
- this.logger(
595
+ logger(
566
596
  'debug',
567
597
  `No mid found in SDP for track type ${track.kind} and id ${track.id}. Attempting to find a heuristic mid`,
568
598
  );
@@ -572,7 +602,7 @@ export class Publisher {
572
602
  return String(heuristicMid);
573
603
  }
574
604
 
575
- this.logger('debug', 'No heuristic mid found. Returning empty mid');
605
+ logger('debug', 'No heuristic mid found. Returning empty mid');
576
606
  return '';
577
607
  }
578
608
  return String(media.mid);
@@ -603,7 +633,7 @@ export class Publisher {
603
633
  } else {
604
634
  // we report the last known optimal layers for ended tracks
605
635
  optimalLayers = this.trackLayersCache[trackType] || [];
606
- this.logger(
636
+ logger(
607
637
  'debug',
608
638
  `Track ${TrackType[trackType]} is ended. Announcing last known optimal layers`,
609
639
  optimalLayers,
@@ -639,22 +669,22 @@ export class Publisher {
639
669
  const errorMessage =
640
670
  e instanceof RTCPeerConnectionIceErrorEvent &&
641
671
  `${e.errorCode}: ${e.errorText}`;
642
- this.logger('error', `ICE Candidate error`, errorMessage);
672
+ logger('error', `ICE Candidate error`, errorMessage);
643
673
  };
644
674
 
645
675
  private onIceConnectionStateChange = () => {
646
676
  const state = this.pc.iceConnectionState;
647
- this.logger('debug', `ICE Connection state changed to`, state);
677
+ logger('debug', `ICE Connection state changed to`, state);
648
678
 
649
679
  if (state === 'failed') {
650
- this.logger('warn', `Attempting to restart ICE`);
680
+ logger('warn', `Attempting to restart ICE`);
651
681
  this.restartIce().catch((e) => {
652
- this.logger('error', `ICE restart error`, e);
682
+ logger('error', `ICE restart error`, e);
653
683
  });
654
684
  } else if (state === 'disconnected') {
655
685
  // when in `disconnected` state, the browser may recover automatically,
656
686
  // hence, we delay the ICE restart
657
- this.logger('warn', `Scheduling ICE restart in 5 seconds`);
687
+ logger('warn', `Scheduling ICE restart in ${this.iceRestartDelay} ms.`);
658
688
  setTimeout(() => {
659
689
  // check if the state is still `disconnected` or `failed`
660
690
  // as the connection may have recovered (or failed) in the meantime
@@ -663,19 +693,24 @@ export class Publisher {
663
693
  this.pc.iceConnectionState === 'failed'
664
694
  ) {
665
695
  this.restartIce().catch((e) => {
666
- this.logger('error', `ICE restart error`, e);
696
+ logger('error', `ICE restart error`, e);
667
697
  });
698
+ } else {
699
+ logger(
700
+ 'debug',
701
+ `Scheduled ICE restart: connection recovered, canceled.`,
702
+ );
668
703
  }
669
- }, 5000);
704
+ }, this.iceRestartDelay);
670
705
  }
671
706
  };
672
707
 
673
708
  private onIceGatheringStateChange = () => {
674
- this.logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
709
+ logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
675
710
  };
676
711
 
677
712
  private onSignalingStateChange = () => {
678
- this.logger('debug', `Signaling state changed`, this.pc.signalingState);
713
+ logger('debug', `Signaling state changed`, this.pc.signalingState);
679
714
  };
680
715
 
681
716
  private ridToVideoQuality = (rid: string): VideoQuality => {
@@ -11,6 +11,7 @@ export type SubscriberOpts = {
11
11
  dispatcher: Dispatcher;
12
12
  state: CallState;
13
13
  connectionConfig?: RTCConfiguration;
14
+ iceRestartDelay?: number;
14
15
  };
15
16
 
16
17
  const logger = getLogger(['Subscriber']);
@@ -21,11 +22,16 @@ const logger = getLogger(['Subscriber']);
21
22
  */
22
23
  export class Subscriber {
23
24
  private pc: RTCPeerConnection;
24
- private readonly unregisterOnSubscriberOffer: () => void;
25
25
  private sfuClient: StreamSfuClient;
26
26
  private dispatcher: Dispatcher;
27
27
  private state: CallState;
28
28
 
29
+ private readonly unregisterOnSubscriberOffer: () => void;
30
+ private readonly unregisterOnIceRestart: () => void;
31
+
32
+ private readonly iceRestartDelay: number;
33
+ private isIceRestarting = false;
34
+
29
35
  /**
30
36
  * Constructs a new `Subscriber` instance.
31
37
  *
@@ -33,16 +39,19 @@ export class Subscriber {
33
39
  * @param dispatcher the dispatcher to use.
34
40
  * @param state the state of the call.
35
41
  * @param connectionConfig the connection configuration to use.
42
+ * @param iceRestartDelay the delay in milliseconds to wait before restarting ICE when connection goes to `disconnected` state.
36
43
  */
37
44
  constructor({
38
45
  sfuClient,
39
46
  dispatcher,
40
47
  state,
41
48
  connectionConfig,
49
+ iceRestartDelay = 2500,
42
50
  }: SubscriberOpts) {
43
51
  this.sfuClient = sfuClient;
44
52
  this.dispatcher = dispatcher;
45
53
  this.state = state;
54
+ this.iceRestartDelay = iceRestartDelay;
46
55
 
47
56
  this.pc = this.createPeerConnection(connectionConfig);
48
57
 
@@ -54,6 +63,16 @@ export class Subscriber {
54
63
  await this.negotiate(subscriberOffer);
55
64
  },
56
65
  );
66
+
67
+ this.unregisterOnIceRestart = dispatcher.on(
68
+ 'iceRestart',
69
+ async (message) => {
70
+ if (message.eventPayload.oneofKind !== 'iceRestart') return;
71
+ const { iceRestart } = message.eventPayload;
72
+ if (iceRestart.peerType !== PeerType.SUBSCRIBER) return;
73
+ await this.restartIce();
74
+ },
75
+ );
57
76
  }
58
77
 
59
78
  /**
@@ -84,6 +103,7 @@ export class Subscriber {
84
103
  */
85
104
  close = () => {
86
105
  this.unregisterOnSubscriberOffer();
106
+ this.unregisterOnIceRestart();
87
107
  this.pc.close();
88
108
  };
89
109
 
@@ -177,9 +197,23 @@ export class Subscriber {
177
197
  /**
178
198
  * Restarts the ICE connection and renegotiates with the SFU.
179
199
  */
180
- restartIce = () => {
200
+ restartIce = async () => {
181
201
  logger('debug', 'Restarting ICE connection');
182
- this.pc.restartIce();
202
+ if (this.pc.signalingState === 'have-remote-offer') {
203
+ logger('debug', 'ICE restart is already in progress');
204
+ return;
205
+ }
206
+ const previousIsIceRestarting = this.isIceRestarting;
207
+ try {
208
+ this.isIceRestarting = true;
209
+ await this.sfuClient.iceRestart({
210
+ peerType: PeerType.SUBSCRIBER,
211
+ });
212
+ } catch (e) {
213
+ // restore the previous state, as our intent for restarting ICE failed
214
+ this.isIceRestarting = previousIsIceRestarting;
215
+ throw e;
216
+ }
183
217
  };
184
218
 
185
219
  private handleOnTrack = (e: RTCTrackEvent) => {
@@ -280,12 +314,11 @@ export class Subscriber {
280
314
  const iceCandidate = JSON.parse(candidate.iceCandidate);
281
315
  await this.pc.addIceCandidate(iceCandidate);
282
316
  } catch (e) {
283
- logger('error', `ICE candidate error`, [e, candidate]);
317
+ logger('warn', `ICE candidate error`, [e, candidate]);
284
318
  }
285
319
  },
286
320
  );
287
321
 
288
- // apply ice candidates
289
322
  const answer = await this.pc.createAnswer();
290
323
  await this.pc.setLocalDescription(answer);
291
324
 
@@ -293,14 +326,48 @@ export class Subscriber {
293
326
  peerType: PeerType.SUBSCRIBER,
294
327
  sdp: answer.sdp || '',
295
328
  });
329
+
330
+ this.isIceRestarting = false;
296
331
  };
297
332
 
298
333
  private onIceConnectionStateChange = () => {
299
- logger('info', `ICE connection state changed`, this.pc.iceConnectionState);
334
+ const state = this.pc.iceConnectionState;
335
+ logger('debug', `ICE connection state changed`, state);
336
+
337
+ // do nothing when ICE is restarting
338
+ if (this.isIceRestarting) return;
339
+
340
+ if (state === 'failed') {
341
+ logger('warn', `Attempting to restart ICE`);
342
+ this.restartIce().catch((e) => {
343
+ logger('error', `ICE restart failed`, e);
344
+ });
345
+ } else if (state === 'disconnected') {
346
+ // when in `disconnected` state, the browser may recover automatically,
347
+ // hence, we delay the ICE restart
348
+ logger('warn', `Scheduling ICE restart in ${this.iceRestartDelay} ms.`);
349
+ setTimeout(() => {
350
+ // check if the state is still `disconnected` or `failed`
351
+ // as the connection may have recovered (or failed) in the meantime
352
+ if (
353
+ this.pc.iceConnectionState === 'disconnected' ||
354
+ this.pc.iceConnectionState === 'failed'
355
+ ) {
356
+ this.restartIce().catch((e) => {
357
+ logger('error', `ICE restart failed`, e);
358
+ });
359
+ } else {
360
+ logger(
361
+ 'debug',
362
+ `Scheduled ICE restart: connection recovered, canceled.`,
363
+ );
364
+ }
365
+ }, 5000);
366
+ }
300
367
  };
301
368
 
302
369
  private onIceGatheringStateChange = () => {
303
- logger('info', `ICE gathering state changed`, this.pc.iceGatheringState);
370
+ logger('debug', `ICE gathering state changed`, this.pc.iceGatheringState);
304
371
  };
305
372
 
306
373
  private onIceCandidateError = (e: Event) => {
@@ -5,8 +5,9 @@ import { Publisher } from '../Publisher';
5
5
  import { CallState } from '../../store';
6
6
  import { StreamSfuClient } from '../../StreamSfuClient';
7
7
  import { Dispatcher } from '../Dispatcher';
8
- import { TrackType } from '../../gen/video/sfu/models/models';
8
+ import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
9
9
  import { IceTrickleBuffer } from '../IceTrickleBuffer';
10
+ import { SfuEvent } from '../../gen/video/sfu/event/events';
10
11
 
11
12
  vi.mock('../../StreamSfuClient', () => {
12
13
  console.log('MOCKING StreamSfuClient');
@@ -33,9 +34,10 @@ describe('Publisher', () => {
33
34
  let publisher: Publisher;
34
35
  let sfuClient: StreamSfuClient;
35
36
  let state: CallState;
37
+ let dispatcher: Dispatcher;
36
38
 
37
39
  beforeEach(() => {
38
- const dispatcher = new Dispatcher();
40
+ dispatcher = new Dispatcher();
39
41
  sfuClient = new StreamSfuClient({
40
42
  dispatcher,
41
43
  sfuServer: {
@@ -52,15 +54,18 @@ describe('Publisher', () => {
52
54
  state = new CallState();
53
55
  publisher = new Publisher({
54
56
  sfuClient,
57
+ dispatcher,
55
58
  state,
56
59
  isDtxEnabled: true,
57
60
  isRedEnabled: true,
61
+ iceRestartDelay: 100,
58
62
  });
59
63
  });
60
64
 
61
65
  afterEach(() => {
62
66
  vi.clearAllMocks();
63
67
  vi.resetModules();
68
+ dispatcher.offAll();
64
69
  });
65
70
 
66
71
  it('can publish, re-publish and un-publish a stream', async () => {
@@ -209,4 +214,79 @@ describe('Publisher', () => {
209
214
  expect(sfuClient.setPublisher).toHaveBeenCalled();
210
215
  });
211
216
  });
217
+
218
+ describe('Publisher ICE Restart', () => {
219
+ it('should perform ICE restart when iceRestart event is received', () => {
220
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
221
+ dispatcher.dispatch(
222
+ SfuEvent.create({
223
+ eventPayload: {
224
+ oneofKind: 'iceRestart',
225
+ iceRestart: {
226
+ peerType: PeerType.PUBLISHER_UNSPECIFIED,
227
+ },
228
+ },
229
+ }),
230
+ );
231
+ expect(publisher.restartIce).toHaveBeenCalled();
232
+ });
233
+
234
+ it('should not perform ICE restart when iceRestart event is received for a different peer type', () => {
235
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
236
+ dispatcher.dispatch(
237
+ SfuEvent.create({
238
+ eventPayload: {
239
+ oneofKind: 'iceRestart',
240
+ iceRestart: {
241
+ peerType: PeerType.SUBSCRIBER,
242
+ },
243
+ },
244
+ }),
245
+ );
246
+ expect(publisher.restartIce).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it(`should drop consequent ICE restart requests`, async () => {
250
+ // @ts-ignore
251
+ publisher['pc'].signalingState = 'have-local-offer';
252
+ // @ts-ignore
253
+ vi.spyOn(publisher, 'negotiate').mockResolvedValue();
254
+
255
+ await publisher.restartIce();
256
+ expect(publisher['negotiate']).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it(`should perform ICE restart when connection state changes to 'failed'`, () => {
260
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
261
+ // @ts-ignore
262
+ publisher['pc'].iceConnectionState = 'failed';
263
+ publisher['onIceConnectionStateChange']();
264
+ expect(publisher.restartIce).toHaveBeenCalled();
265
+ });
266
+
267
+ it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
268
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
269
+ vi.useFakeTimers();
270
+
271
+ // @ts-ignore
272
+ publisher['pc'].iceConnectionState = 'disconnected';
273
+ publisher['onIceConnectionStateChange']();
274
+ vi.runAllTimers();
275
+ expect(publisher.restartIce).toHaveBeenCalled();
276
+ });
277
+
278
+ it(`should bail-out from ICE restart once connection recovers before timeout`, () => {
279
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
280
+ vi.useFakeTimers();
281
+
282
+ // @ts-ignore
283
+ publisher['pc'].iceConnectionState = 'disconnected';
284
+ publisher['onIceConnectionStateChange']();
285
+ // @ts-ignore
286
+ publisher['pc'].iceConnectionState = 'connected';
287
+
288
+ vi.runAllTimers();
289
+ expect(publisher.restartIce).not.toHaveBeenCalled();
290
+ });
291
+ });
212
292
  });