@stream-io/video-client 1.55.2 → 1.57.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 (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +216 -119
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +216 -119
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +216 -119
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/helpers/BlockedAudioTracker.d.ts +16 -1
  9. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
  10. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  11. package/dist/src/rtc/Publisher.d.ts +1 -0
  12. package/dist/src/rtc/types.d.ts +9 -2
  13. package/dist/src/store/rxUtils.d.ts +5 -0
  14. package/package.json +11 -11
  15. package/src/Call.ts +1 -0
  16. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -2
  17. package/src/devices/devicePersistence.ts +1 -2
  18. package/src/helpers/BlockedAudioTracker.ts +71 -13
  19. package/src/helpers/DynascaleManager.ts +14 -3
  20. package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
  21. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +54 -6
  22. package/src/helpers/__tests__/DynascaleManager.test.ts +45 -0
  23. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
  24. package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
  25. package/src/reporting/ClientEventReporter.ts +39 -39
  26. package/src/reporting/__tests__/ClientEventReporter.test.ts +159 -44
  27. package/src/rtc/BasePeerConnection.ts +1 -0
  28. package/src/rtc/Publisher.ts +78 -58
  29. package/src/rtc/__tests__/Publisher.test.ts +297 -0
  30. package/src/rtc/types.ts +14 -13
  31. package/src/stats/rtc/types.ts +8 -2
  32. package/src/store/CallState.ts +6 -19
  33. package/src/store/rxUtils.ts +15 -0
  34. package/src/types.ts +1 -4
@@ -28,20 +28,33 @@ const createMediaElement = (
28
28
  state: FakeMediaState = {},
29
29
  ) => {
30
30
  const el = document.createElement(kind) as HTMLMediaElement;
31
- Object.defineProperty(el, 'srcObject', { writable: true });
31
+ Object.defineProperty(el, 'srcObject', {
32
+ value: 'srcObject' in state ? state.srcObject : new MediaStream(),
33
+ writable: true,
34
+ });
32
35
  Object.defineProperty(el, 'paused', { writable: true, configurable: true });
33
36
  Object.defineProperty(el, 'readyState', {
37
+ value: state.readyState ?? 4,
38
+ writable: true,
39
+ configurable: true,
40
+ });
41
+ Object.defineProperty(el, 'ended', {
42
+ value: state.ended ?? false,
34
43
  writable: true,
35
44
  configurable: true,
36
45
  });
37
- Object.defineProperty(el, 'ended', { writable: true, configurable: true });
38
- el.srcObject = 'srcObject' in state ? state.srcObject : new MediaStream();
39
- el.paused = state.paused ?? true;
40
- el.readyState = state.readyState ?? 4;
41
- el.ended = state.ended ?? false;
46
+ setPaused(el, state.paused ?? true);
42
47
  return el;
43
48
  };
44
49
 
50
+ const setPaused = (el: HTMLMediaElement, paused: boolean) => {
51
+ Object.defineProperty(el, 'paused', {
52
+ value: paused,
53
+ writable: true,
54
+ configurable: true,
55
+ });
56
+ };
57
+
45
58
  type SetupOpts = {
46
59
  kind?: 'audio' | 'video';
47
60
  state?: FakeMediaState;
@@ -111,15 +124,131 @@ describe('MediaPlaybackWatchdog', () => {
111
124
  await vi.advanceTimersByTimeAsync(6000);
112
125
  expect(play).toHaveBeenCalledTimes(2);
113
126
 
114
- // Simulate the element actually starting to play.
127
+ // Simulate the element actually starting to play, and staying up long
128
+ // enough for the recovery to count as settled.
129
+ setPaused(el, false);
115
130
  el.dispatchEvent(new Event('playing'));
131
+ await vi.advanceTimersByTimeAsync(1000);
116
132
 
117
- // A subsequent pause should still trigger a fresh recovery attempt.
133
+ // A subsequent pause should trigger a fresh, immediate recovery attempt.
134
+ setPaused(el, true);
118
135
  el.dispatchEvent(new Event('pause'));
119
136
  await vi.advanceTimersByTimeAsync(0);
120
137
  expect(play).toHaveBeenCalledTimes(3);
121
138
  });
122
139
 
140
+ it('backs off instead of retrying immediately when playback has not settled', async () => {
141
+ el.dispatchEvent(new Event('pause'));
142
+ await vi.advanceTimersByTimeAsync(0);
143
+ expect(play).toHaveBeenCalledTimes(1);
144
+
145
+ // resumed, but re-paused before the settle delay elapsed
146
+ setPaused(el, false);
147
+ el.dispatchEvent(new Event('playing'));
148
+ expect(tracer.trace).not.toHaveBeenCalledWith(
149
+ 'mediaPlayback.recover.success',
150
+ {
151
+ kind: 'audio',
152
+ attempts: 1,
153
+ },
154
+ );
155
+ await vi.advanceTimersByTimeAsync(500);
156
+ setPaused(el, true);
157
+ el.dispatchEvent(new Event('pause'));
158
+
159
+ // the next attempt is subject to backoff, not scheduled on the next tick
160
+ await vi.advanceTimersByTimeAsync(0);
161
+ expect(play).toHaveBeenCalledTimes(1);
162
+
163
+ await vi.advanceTimersByTimeAsync(6000);
164
+ expect(play).toHaveBeenCalledTimes(2);
165
+ });
166
+
167
+ it('stops fighting an element that re-pauses after every successful play', async () => {
168
+ setup({ kind: 'video' });
169
+ // Safari re-pauses the element right after play() resolves. This used to
170
+ // reset the attempt counter, so the loop spun with a zero delay forever.
171
+ play.mockImplementation(async () => {
172
+ setPaused(el, false);
173
+ el.dispatchEvent(new Event('playing'));
174
+ setPaused(el, true);
175
+ el.dispatchEvent(new Event('pause'));
176
+ });
177
+
178
+ el.dispatchEvent(new Event('pause'));
179
+ for (let i = 0; i < 20; i++) {
180
+ await vi.advanceTimersByTimeAsync(6000);
181
+ }
182
+
183
+ expect(play).toHaveBeenCalledTimes(10);
184
+ expect(tracer.trace).toHaveBeenCalledWith('mediaPlayback.recover.giveUp', {
185
+ kind: 'video',
186
+ attempts: 10,
187
+ });
188
+
189
+ // External playback can still emit `playing` after the watchdog gave up
190
+ // (for example from a user gesture or another playback helper). If it
191
+ // re-pauses before settling, it must not revive the watchdog or trace a
192
+ // false recovery success.
193
+ setPaused(el, false);
194
+ el.dispatchEvent(new Event('playing'));
195
+ setPaused(el, true);
196
+ el.dispatchEvent(new Event('pause'));
197
+ await vi.advanceTimersByTimeAsync(1000);
198
+
199
+ expect(tracer.trace).not.toHaveBeenCalledWith(
200
+ 'mediaPlayback.recover.success',
201
+ {
202
+ kind: 'video',
203
+ attempts: 10,
204
+ },
205
+ );
206
+
207
+ // and it stays stopped, without tracing the pause storm
208
+ const traceMock = tracer.trace as unknown as MockInstance;
209
+ const tracesBefore = traceMock.mock.calls.length;
210
+ for (let i = 0; i < 100; i++) {
211
+ el.dispatchEvent(new Event('pause'));
212
+ }
213
+ await vi.advanceTimersByTimeAsync(6000);
214
+
215
+ expect(play).toHaveBeenCalledTimes(10);
216
+ expect(traceMock.mock.calls.length).toBe(tracesBefore);
217
+ });
218
+
219
+ it('revives once playback genuinely holds for the settle window', async () => {
220
+ setup({ kind: 'video' });
221
+ play.mockImplementation(async () => {
222
+ setPaused(el, false);
223
+ el.dispatchEvent(new Event('playing'));
224
+ setPaused(el, true);
225
+ el.dispatchEvent(new Event('pause'));
226
+ });
227
+
228
+ el.dispatchEvent(new Event('pause'));
229
+ for (let i = 0; i < 20; i++) {
230
+ await vi.advanceTimersByTimeAsync(6000);
231
+ }
232
+ expect(play).toHaveBeenCalledTimes(10); // gave up
233
+
234
+ play.mockResolvedValue(undefined);
235
+ setPaused(el, false);
236
+ el.dispatchEvent(new Event('playing'));
237
+ await vi.advanceTimersByTimeAsync(1500);
238
+
239
+ expect(tracer.trace).toHaveBeenCalledWith('mediaPlayback.recover.success', {
240
+ kind: 'video',
241
+ attempts: 10,
242
+ });
243
+
244
+ // recovery is live again: the attempt counter was reset, so a fresh pause
245
+ // gets an immediate attempt rather than being ignored
246
+ setPaused(el, true);
247
+ el.dispatchEvent(new Event('pause'));
248
+ await vi.advanceTimersByTimeAsync(0);
249
+ expect(play).toHaveBeenCalledTimes(11);
250
+ });
251
+
123
252
  it('does not attempt recovery when srcObject is null', async () => {
124
253
  setup({ state: { srcObject: null } });
125
254
 
@@ -38,8 +38,7 @@ describe('ViewportTracker', () => {
38
38
 
39
39
  it('updates participant viewportVisibilityState as visibility changes', () => {
40
40
  let visibilityHandler:
41
- | ((entry: IntersectionObserverEntry) => void)
42
- | undefined;
41
+ ((entry: IntersectionObserverEntry) => void) | undefined;
43
42
  vi.spyOn(viewportTracker, 'observe').mockImplementation((_el, handler) => {
44
43
  visibilityHandler = handler;
45
44
  return vi.fn();
@@ -33,16 +33,12 @@ export type ClientEventStage =
33
33
  | 'FirstAudioFrame';
34
34
 
35
35
  export type MediaPermissionState =
36
- | 'INITIATED'
37
- | 'FAILED'
38
- | 'GRANTED'
39
- | 'NOT_INITIATED';
36
+ 'INITIATED' | 'FAILED' | 'GRANTED' | 'NOT_INITIATED';
37
+
38
+ export type ReportedIceState = 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED';
40
39
 
41
40
  export type JoinReason =
42
- | 'first-attempt'
43
- | 'network-available'
44
- | 'migration'
45
- | 'full-rejoin';
41
+ 'first-attempt' | 'network-available' | 'migration' | 'full-rejoin';
46
42
 
47
43
  export type ClientEventStandardCode =
48
44
  | 'CLIENT_ABORTED'
@@ -86,6 +82,7 @@ type PeerConnectionPairState = StagePairState & {
86
82
  sfuId: string;
87
83
  userSessionId: string;
88
84
  wasPreviouslyConnected: boolean;
85
+ lastIceState?: RTCIceConnectionState;
89
86
  };
90
87
 
91
88
  const pcKey = (cid: string, role: ClientEventPeerConnection): string =>
@@ -356,20 +353,8 @@ export class ClientEventReporter {
356
353
  this.failCoordinator(cid);
357
354
  this.failWs(cid);
358
355
 
359
- this.emitPeerConnectionFailure(
360
- cid,
361
- 'publish',
362
- code,
363
- reason,
364
- 'NOT_CONNECTED',
365
- );
366
- this.emitPeerConnectionFailure(
367
- cid,
368
- 'subscribe',
369
- code,
370
- reason,
371
- 'NOT_CONNECTED',
372
- );
356
+ this.emitPeerConnectionFailure(cid, 'publish', code, reason);
357
+ this.emitPeerConnectionFailure(cid, 'subscribe', code, reason);
373
358
  } catch (err) {
374
359
  this.logger.warn('Failed to report abort', err);
375
360
  }
@@ -384,7 +369,6 @@ export class ClientEventReporter {
384
369
  role,
385
370
  'CLIENT_ABORTED',
386
371
  'superseded by a new join attempt',
387
- 'NOT_CONNECTED',
388
372
  );
389
373
  }
390
374
  };
@@ -573,34 +557,35 @@ export class ClientEventReporter {
573
557
  const role: ClientEventPeerConnection =
574
558
  event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish';
575
559
 
576
- if (event.stateType === 'ice' && event.state === 'failed') {
577
- this.emitPeerConnectionFailure(
578
- cid,
579
- role,
580
- 'ICE_CONNECTIVITY_FAILED',
581
- 'ICE connectivity checks failed',
582
- 'FAILED',
583
- );
560
+ const pair = this.peerConnectionPairs.get(pcKey(cid, role));
561
+ if (pair) pair.lastIceState = event.iceConnectionState;
562
+
563
+ if (event.stateType === 'ice') {
564
+ if (event.state === 'failed') {
565
+ this.emitPeerConnectionFailure(
566
+ cid,
567
+ role,
568
+ 'ICE_CONNECTIVITY_FAILED',
569
+ 'ICE connectivity checks failed',
570
+ );
571
+ }
584
572
  return;
585
573
  }
586
574
 
587
- if (event.stateType === 'peerConnection' && event.state === 'failed') {
575
+ if (event.state === 'failed') {
588
576
  this.emitPeerConnectionFailure(
589
577
  cid,
590
578
  role,
591
579
  'DTLS_CONNECTIVITY_FAILED',
592
580
  'DTLS connectivity checks failed',
593
- 'CONNECTED',
594
581
  );
595
582
  return;
596
583
  }
597
584
 
598
- if (event.stateType !== 'peerConnection') return;
599
-
600
585
  switch (event.state) {
601
586
  case 'connecting':
602
- if (this.peerConnectionPairs.has(pcKey(cid, role))) return;
603
- this.openPeerConnectionPair(cid, role);
587
+ if (pair) return;
588
+ this.openPeerConnectionPair(cid, role, event.iceConnectionState);
604
589
  break;
605
590
  case 'connected':
606
591
  this.emitPeerConnectionSuccess(cid, role);
@@ -614,6 +599,7 @@ export class ClientEventReporter {
614
599
  private openPeerConnectionPair = (
615
600
  cid: string,
616
601
  role: ClientEventPeerConnection,
602
+ iceConnectionState: RTCIceConnectionState,
617
603
  ) => {
618
604
  const key = pcKey(cid, role);
619
605
  const pair: PeerConnectionPairState = {
@@ -624,6 +610,7 @@ export class ClientEventReporter {
624
610
  sfuId: this.getSfuId(cid),
625
611
  userSessionId: this.getUserSessionId(cid),
626
612
  wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
613
+ lastIceState: iceConnectionState,
627
614
  };
628
615
  this.peerConnectionPairs.set(key, pair);
629
616
 
@@ -670,7 +657,6 @@ export class ClientEventReporter {
670
657
  role: ClientEventPeerConnection,
671
658
  code: ClientEventStandardCode,
672
659
  reason: string,
673
- iceState: 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED',
674
660
  ) => {
675
661
  const key = pcKey(cid, role);
676
662
  const pair = this.peerConnectionPairs.get(key);
@@ -689,7 +675,7 @@ export class ClientEventReporter {
689
675
  outcome: 'failure',
690
676
  retry_count_attempt: 0,
691
677
  elapsed_time: Date.now() - pair.startedAt,
692
- ice_state: iceState,
678
+ ice_state: toReportedIceState(pair.lastIceState),
693
679
  retry_failure_reason: reason,
694
680
  retry_failure_code: code,
695
681
  });
@@ -782,6 +768,20 @@ export class ClientEventReporter {
782
768
  };
783
769
  }
784
770
 
771
+ const toReportedIceState = (
772
+ state: RTCIceConnectionState | undefined,
773
+ ): ReportedIceState => {
774
+ switch (state) {
775
+ case 'connected':
776
+ case 'completed':
777
+ return 'CONNECTED';
778
+ case 'failed':
779
+ return 'FAILED';
780
+ default:
781
+ return 'NOT_CONNECTED';
782
+ }
783
+ };
784
+
785
785
  const readPermissionStatus = (
786
786
  permission: BrowserPermission,
787
787
  ): MediaPermissionState => {
@@ -7,6 +7,7 @@ import { ErrorFromResponse } from '../../coordinator/connection/types';
7
7
  import { SfuTimeoutError } from '../../errors';
8
8
  import type { AxiosResponse } from 'axios';
9
9
  import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
10
+ import type { PeerConnectionStateChangeEvent } from '../../rtc';
10
11
 
11
12
  vi.mock('../../devices', () => ({
12
13
  getAudioBrowserPermission: () => ({ asStateObservable: () => of('granted') }),
@@ -15,6 +16,27 @@ vi.mock('../../devices', () => ({
15
16
 
16
17
  const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
17
18
 
19
+ const peerConnectionEvent = (
20
+ peerType: PeerType,
21
+ state: RTCPeerConnectionState,
22
+ iceConnectionState: RTCIceConnectionState,
23
+ ): PeerConnectionStateChangeEvent => ({
24
+ peerType,
25
+ stateType: 'peerConnection',
26
+ state,
27
+ iceConnectionState,
28
+ });
29
+
30
+ const iceEvent = (
31
+ peerType: PeerType,
32
+ state: RTCIceConnectionState,
33
+ ): PeerConnectionStateChangeEvent => ({
34
+ peerType,
35
+ stateType: 'ice',
36
+ state,
37
+ iceConnectionState: state,
38
+ });
39
+
18
40
  describe('ClientEventReporter', () => {
19
41
  const cid = 'default:call-1';
20
42
  let doAxiosRequest: ReturnType<typeof vi.fn>;
@@ -24,6 +46,21 @@ describe('ClientEventReporter', () => {
24
46
  const postedEvents = (): Array<Record<string, any>> =>
25
47
  doAxiosRequest.mock.calls.map((call) => call[2].events[0]);
26
48
 
49
+ const reportPeerConnectionState = (
50
+ peerType: PeerType,
51
+ state: RTCPeerConnectionState,
52
+ iceConnectionState: RTCIceConnectionState,
53
+ ) => {
54
+ reporter.onPeerConnectionStateChange(
55
+ cid,
56
+ peerConnectionEvent(peerType, state, iceConnectionState),
57
+ );
58
+ };
59
+
60
+ const reportIceState = (peerType: PeerType, state: RTCIceConnectionState) => {
61
+ reporter.onPeerConnectionStateChange(cid, iceEvent(peerType, state));
62
+ };
63
+
27
64
  beforeEach(() => {
28
65
  doAxiosRequest = vi.fn().mockResolvedValue({});
29
66
  const streamClient = fromPartial<StreamClient>({
@@ -465,16 +502,16 @@ describe('ClientEventReporter', () => {
465
502
 
466
503
  it('tracks a publisher peer connection connect', async () => {
467
504
  reporter.startCorrelation(cid, 'first-attempt');
468
- reporter.onPeerConnectionStateChange(cid, {
469
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
470
- stateType: 'peerConnection',
471
- state: 'connecting',
472
- });
473
- reporter.onPeerConnectionStateChange(cid, {
474
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
475
- stateType: 'peerConnection',
476
- state: 'connected',
477
- });
505
+ reportPeerConnectionState(
506
+ PeerType.PUBLISHER_UNSPECIFIED,
507
+ 'connecting',
508
+ 'checking',
509
+ );
510
+ reportPeerConnectionState(
511
+ PeerType.PUBLISHER_UNSPECIFIED,
512
+ 'connected',
513
+ 'connected',
514
+ );
478
515
  await flush();
479
516
 
480
517
  const events = postedEvents().filter(
@@ -497,16 +534,8 @@ describe('ClientEventReporter', () => {
497
534
 
498
535
  it('reports an ICE failure on the peer connection', async () => {
499
536
  reporter.startCorrelation(cid, 'first-attempt');
500
- reporter.onPeerConnectionStateChange(cid, {
501
- peerType: PeerType.SUBSCRIBER,
502
- stateType: 'peerConnection',
503
- state: 'connecting',
504
- });
505
- reporter.onPeerConnectionStateChange(cid, {
506
- peerType: PeerType.SUBSCRIBER,
507
- stateType: 'ice',
508
- state: 'failed',
509
- });
537
+ reportPeerConnectionState(PeerType.SUBSCRIBER, 'connecting', 'checking');
538
+ reportIceState(PeerType.SUBSCRIBER, 'failed');
510
539
  await flush();
511
540
 
512
541
  const completed = postedEvents().filter(
@@ -522,25 +551,111 @@ describe('ClientEventReporter', () => {
522
551
  });
523
552
  });
524
553
 
525
- it('opens a fresh peer connection pair after a new correlation', async () => {
554
+ it('reports a DTLS failure with the real ICE state (connected)', async () => {
526
555
  reporter.startCorrelation(cid, 'first-attempt');
527
- reporter.onPeerConnectionStateChange(cid, {
528
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
529
- stateType: 'peerConnection',
530
- state: 'connecting',
556
+ reportPeerConnectionState(
557
+ PeerType.PUBLISHER_UNSPECIFIED,
558
+ 'connecting',
559
+ 'checking',
560
+ );
561
+ reportIceState(PeerType.PUBLISHER_UNSPECIFIED, 'connected');
562
+ // Aggregate connection fails while ICE stays connected -> genuine DTLS.
563
+ reportPeerConnectionState(
564
+ PeerType.PUBLISHER_UNSPECIFIED,
565
+ 'failed',
566
+ 'connected',
567
+ );
568
+ await flush();
569
+
570
+ const completed = postedEvents().filter(
571
+ (e) =>
572
+ e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
573
+ );
574
+ expect(completed).toHaveLength(1);
575
+ expect(completed[0]).toMatchObject({
576
+ outcome: 'failure',
577
+ peer_connection: 'publish',
578
+ retry_failure_code: 'DTLS_CONNECTIVITY_FAILED',
579
+ ice_state: 'CONNECTED',
531
580
  });
581
+ });
532
582
 
533
- reporter.startCorrelation(cid, 'full-rejoin');
534
- reporter.onPeerConnectionStateChange(cid, {
535
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
536
- stateType: 'peerConnection',
537
- state: 'connecting',
583
+ it('reports a DTLS failure with the real ICE state (never connected)', async () => {
584
+ reporter.startCorrelation(cid, 'first-attempt');
585
+ reportPeerConnectionState(
586
+ PeerType.PUBLISHER_UNSPECIFIED,
587
+ 'connecting',
588
+ 'checking',
589
+ );
590
+ // ICE is still `checking` when the aggregate peer-connection state fails:
591
+ // the reported ice_state must be NOT_CONNECTED, not an assumed CONNECTED.
592
+ reportPeerConnectionState(
593
+ PeerType.PUBLISHER_UNSPECIFIED,
594
+ 'failed',
595
+ 'checking',
596
+ );
597
+ await flush();
598
+
599
+ const completed = postedEvents().filter(
600
+ (e) =>
601
+ e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
602
+ );
603
+ expect(completed).toHaveLength(1);
604
+ expect(completed[0]).toMatchObject({
605
+ outcome: 'failure',
606
+ peer_connection: 'publish',
607
+ retry_failure_code: 'DTLS_CONNECTIVITY_FAILED',
608
+ ice_state: 'NOT_CONNECTED',
538
609
  });
539
- reporter.onPeerConnectionStateChange(cid, {
540
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
541
- stateType: 'peerConnection',
542
- state: 'connected',
610
+ });
611
+
612
+ it('reports the ICE state captured when the pair opened, with no ICE event', async () => {
613
+ reporter.startCorrelation(cid, 'first-attempt');
614
+ // The pair opens with ICE already connected (carried on the connecting
615
+ // event); no separate ICE event is ever delivered.
616
+ reportPeerConnectionState(
617
+ PeerType.PUBLISHER_UNSPECIFIED,
618
+ 'connecting',
619
+ 'connected',
620
+ );
621
+ reportPeerConnectionState(
622
+ PeerType.PUBLISHER_UNSPECIFIED,
623
+ 'failed',
624
+ 'connected',
625
+ );
626
+ await flush();
627
+
628
+ const completed = postedEvents().filter(
629
+ (e) =>
630
+ e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
631
+ );
632
+ expect(completed).toHaveLength(1);
633
+ expect(completed[0]).toMatchObject({
634
+ outcome: 'failure',
635
+ retry_failure_code: 'DTLS_CONNECTIVITY_FAILED',
636
+ ice_state: 'CONNECTED',
543
637
  });
638
+ });
639
+
640
+ it('opens a fresh peer connection pair after a new correlation', async () => {
641
+ reporter.startCorrelation(cid, 'first-attempt');
642
+ reportPeerConnectionState(
643
+ PeerType.PUBLISHER_UNSPECIFIED,
644
+ 'connecting',
645
+ 'checking',
646
+ );
647
+
648
+ reporter.startCorrelation(cid, 'full-rejoin');
649
+ reportPeerConnectionState(
650
+ PeerType.PUBLISHER_UNSPECIFIED,
651
+ 'connecting',
652
+ 'checking',
653
+ );
654
+ reportPeerConnectionState(
655
+ PeerType.PUBLISHER_UNSPECIFIED,
656
+ 'connected',
657
+ 'connected',
658
+ );
544
659
  await flush();
545
660
 
546
661
  const pc = postedEvents().filter(
@@ -566,16 +681,16 @@ describe('ClientEventReporter', () => {
566
681
  it('marks a peer connection as previously connected on reconnect', async () => {
567
682
  reporter.startCorrelation(cid, 'first-attempt');
568
683
  const connect = () => {
569
- reporter.onPeerConnectionStateChange(cid, {
570
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
571
- stateType: 'peerConnection',
572
- state: 'connecting',
573
- });
574
- reporter.onPeerConnectionStateChange(cid, {
575
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
576
- stateType: 'peerConnection',
577
- state: 'connected',
578
- });
684
+ reportPeerConnectionState(
685
+ PeerType.PUBLISHER_UNSPECIFIED,
686
+ 'connecting',
687
+ 'checking',
688
+ );
689
+ reportPeerConnectionState(
690
+ PeerType.PUBLISHER_UNSPECIFIED,
691
+ 'connected',
692
+ 'connected',
693
+ );
579
694
  };
580
695
  connect();
581
696
  connect();
@@ -362,6 +362,7 @@ export abstract class BasePeerConnection {
362
362
  try {
363
363
  this.onPeerConnectionStateChange?.({
364
364
  peerType: this.peerType,
365
+ iceConnectionState: this.pc.iceConnectionState,
365
366
  ...event,
366
367
  });
367
368
  } catch (err) {