@webex/plugin-meetings 3.12.0-next.91 → 3.12.0-next.93

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 (38) hide show
  1. package/dist/aiEnableRequest/index.js +1 -1
  2. package/dist/breakouts/breakout.js +1 -1
  3. package/dist/breakouts/index.js +1 -1
  4. package/dist/common/errors/webex-errors.js +47 -5
  5. package/dist/common/errors/webex-errors.js.map +1 -1
  6. package/dist/interpretation/index.js +1 -1
  7. package/dist/interpretation/siLanguage.js +1 -1
  8. package/dist/locus-info/index.js +14 -5
  9. package/dist/locus-info/index.js.map +1 -1
  10. package/dist/media/MediaConnectionAwaiter.js +15 -2
  11. package/dist/media/MediaConnectionAwaiter.js.map +1 -1
  12. package/dist/media/index.js +5 -1
  13. package/dist/media/index.js.map +1 -1
  14. package/dist/meeting/index.js +458 -365
  15. package/dist/meeting/index.js.map +1 -1
  16. package/dist/reachability/clusterReachability.js.map +1 -1
  17. package/dist/reachability/index.js +111 -70
  18. package/dist/reachability/index.js.map +1 -1
  19. package/dist/types/common/errors/webex-errors.d.ts +31 -2
  20. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  21. package/dist/types/meeting/index.d.ts +18 -3
  22. package/dist/types/reachability/clusterReachability.d.ts +3 -3
  23. package/dist/types/reachability/index.d.ts +9 -2
  24. package/dist/webinar/index.js +1 -1
  25. package/package.json +3 -3
  26. package/src/common/errors/webex-errors.ts +47 -3
  27. package/src/locus-info/index.ts +15 -5
  28. package/src/media/MediaConnectionAwaiter.ts +21 -4
  29. package/src/media/index.ts +7 -1
  30. package/src/meeting/index.ts +100 -27
  31. package/src/reachability/clusterReachability.ts +3 -2
  32. package/src/reachability/index.ts +28 -1
  33. package/test/unit/spec/common/errors/webex-errors.ts +62 -0
  34. package/test/unit/spec/locus-info/index.js +44 -0
  35. package/test/unit/spec/media/MediaConnectionAwaiter.ts +55 -0
  36. package/test/unit/spec/media/index.ts +61 -0
  37. package/test/unit/spec/meeting/index.js +290 -8
  38. package/test/unit/spec/reachability/index.ts +69 -0
@@ -2846,6 +2846,10 @@ describe('plugin-meetings', () => {
2846
2846
 
2847
2847
  let expectedMeeting;
2848
2848
 
2849
+ // simulate that updateSelf has been called previously (as happens in production)
2850
+ // so that parsedLocus.self reflects the joined state
2851
+ locusInfo.parsedLocus.self = {state: 'JOINED'};
2852
+
2849
2853
  /*
2850
2854
  When the event is triggered, it is required that the meeting has already
2851
2855
  been updated. This is why the meeting is being checked within the stubbed event emitter
@@ -2997,6 +3001,46 @@ describe('plugin-meetings', () => {
2997
3001
  // since self is not passed to updateMeetingInfo, MEETING_INFO_UPDATED should be triggered with isIntializing: true
2998
3002
  checkMeetingInfoUpdatedCalledForRoles(true, {isInitializing: true});
2999
3003
  });
3004
+
3005
+ // joined-section hints (like ROSTER_IN_MEETING) are filtered out while not joined, so they
3006
+ // are a good proxy for verifying that userDisplayHints get recomputed on a join transition
3007
+ [
3008
+ {
3009
+ name: 'the JOINED delta carries the info section',
3010
+ getSecondInfo: (info) => info,
3011
+ },
3012
+ {
3013
+ name: 'the JOINED delta omits the info section (falls back to stored info)',
3014
+ getSecondInfo: () => undefined,
3015
+ },
3016
+ ].forEach(({name, getSecondInfo}) => {
3017
+ it(`recomputes userDisplayHints when self transitions to JOINED with unchanged roles and ${name}`, () => {
3018
+ const info = cloneDeep(meetingInfo); // joined: ['ROSTER_IN_MEETING', 'LOCK_STATUS_UNLOCKED']
3019
+
3020
+ const notJoinedSelf = cloneDeep(self);
3021
+ notJoinedSelf.state = 'IDLE';
3022
+ notJoinedSelf.controls.role.roles = [];
3023
+
3024
+ const joinedSelf = cloneDeep(self);
3025
+ joinedSelf.state = 'JOINED';
3026
+ joinedSelf.controls.role.roles = [];
3027
+
3028
+ sinon.stub(locusInfo, 'emitScoped');
3029
+
3030
+ // first update while not joined: joined-section hints are filtered out
3031
+ locusInfo.updateMeetingInfo(info, notJoinedSelf);
3032
+ assert.notInclude(locusInfo.parsedLocus.info.userDisplayHints, 'ROSTER_IN_MEETING');
3033
+ assert.notInclude(locusInfo.parsedLocus.info.userDisplayHints, 'LOCK_STATUS_UNLOCKED');
3034
+
3035
+ // self transitions to JOINED - info and roles are unchanged
3036
+ locusInfo.updateMeetingInfo(getSecondInfo(info), joinedSelf);
3037
+
3038
+ // the hints must be recomputed with the new joined state
3039
+ assert.include(locusInfo.parsedLocus.info.userDisplayHints, 'ROSTER_IN_MEETING');
3040
+ assert.include(locusInfo.parsedLocus.info.userDisplayHints, 'LOCK_STATUS_UNLOCKED');
3041
+ checkMeetingInfoUpdatedCalled(true, {isInitializing: false});
3042
+ });
3043
+ });
3000
3044
  });
3001
3045
 
3002
3046
  describe('#updateMediaShares', () => {
@@ -204,6 +204,61 @@ describe('MediaConnectionAwaiter', () => {
204
204
  assert.calledThrice(mockMC.off);
205
205
  });
206
206
 
207
+ it('sets iceConnected=true when ice connection state reaches "completed"', async () => {
208
+ mockMC.getConnectionState.returns(ConnectionState.Connecting);
209
+ mockMC.getIceGatheringState.returns('complete');
210
+
211
+ let promiseRejected = false;
212
+ let rejectedIceConnected;
213
+
214
+ mediaConnectionAwaiter
215
+ .waitForMediaConnectionConnected()
216
+ .then(() => {})
217
+ .catch((error) => {
218
+ promiseRejected = true;
219
+ rejectedIceConnected = error.iceConnected;
220
+ });
221
+
222
+ await testUtils.flushPromises();
223
+
224
+ const iceConnectionListener = mockMC.on.getCall(1).args[1];
225
+
226
+ mockMC.getIceConnectionState.returns('completed');
227
+ iceConnectionListener();
228
+
229
+ await clock.tickAsync(ICE_AND_DTLS_CONNECTION_TIMEOUT);
230
+ await testUtils.flushPromises();
231
+
232
+ assert.equal(promiseRejected, true);
233
+ assert.equal(rejectedIceConnected, true);
234
+ });
235
+
236
+ ['connected', 'completed'].forEach((iceState) => {
237
+ it(`initializes iceConnected=true from current ICE state "${iceState}" when starting the await`, async () => {
238
+ mockMC.getConnectionState.returns(ConnectionState.Connecting);
239
+ mockMC.getIceGatheringState.returns('complete');
240
+ mockMC.getIceConnectionState.returns(iceState);
241
+
242
+ let promiseRejected = false;
243
+ let rejectedIceConnected;
244
+
245
+ mediaConnectionAwaiter
246
+ .waitForMediaConnectionConnected()
247
+ .then(() => {})
248
+ .catch((error) => {
249
+ promiseRejected = true;
250
+ rejectedIceConnected = error.iceConnected;
251
+ });
252
+
253
+ // no ICE connection state events fired - the flag should be set from the initial state
254
+ await clock.tickAsync(ICE_AND_DTLS_CONNECTION_TIMEOUT);
255
+ await testUtils.flushPromises();
256
+
257
+ assert.equal(promiseRejected, true);
258
+ assert.equal(rejectedIceConnected, true);
259
+ });
260
+ });
261
+
207
262
  it('resolves after timeout if connection state reach connected/completed', async () => {
208
263
  mockMC.getConnectionState.returns(ConnectionState.Connecting);
209
264
  mockMC.getIceGatheringState.returns('gathering');
@@ -509,6 +509,67 @@ describe('createMediaConnection', () => {
509
509
  );
510
510
  });
511
511
 
512
+ it('passes iceTransportPolicy to MultistreamRoapMediaConnection when provided', () => {
513
+ const multistreamRoapMediaConnectionConstructorStub = sinon
514
+ .stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection')
515
+ .returns(fakeRoapMediaConnection);
516
+
517
+ Media.createMediaConnection(true, 'debug string', 'meeting id', {
518
+ mediaProperties: {
519
+ mediaDirection: {
520
+ sendAudio: true,
521
+ sendVideo: true,
522
+ sendShare: false,
523
+ receiveAudio: true,
524
+ receiveVideo: true,
525
+ receiveShare: true,
526
+ },
527
+ },
528
+ iceTransportPolicy: 'relay',
529
+ });
530
+ assert.calledOnce(multistreamRoapMediaConnectionConstructorStub);
531
+ assert.calledWith(
532
+ multistreamRoapMediaConnectionConstructorStub,
533
+ {
534
+ iceServers: [],
535
+ iceTransportPolicy: 'relay',
536
+ disableAudioTwcc: true,
537
+ enableAV1SlidesSupport: false,
538
+ },
539
+ 'meeting id'
540
+ );
541
+ });
542
+
543
+ it('does not pass iceTransportPolicy to MultistreamRoapMediaConnection when undefined', () => {
544
+ const multistreamRoapMediaConnectionConstructorStub = sinon
545
+ .stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection')
546
+ .returns(fakeRoapMediaConnection);
547
+
548
+ Media.createMediaConnection(true, 'debug string', 'meeting id', {
549
+ mediaProperties: {
550
+ mediaDirection: {
551
+ sendAudio: true,
552
+ sendVideo: true,
553
+ sendShare: false,
554
+ receiveAudio: true,
555
+ receiveVideo: true,
556
+ receiveShare: true,
557
+ },
558
+ },
559
+ iceTransportPolicy: undefined,
560
+ });
561
+ assert.calledOnce(multistreamRoapMediaConnectionConstructorStub);
562
+ assert.calledWith(
563
+ multistreamRoapMediaConnectionConstructorStub,
564
+ {
565
+ iceServers: [],
566
+ disableAudioTwcc: true,
567
+ enableAV1SlidesSupport: false,
568
+ },
569
+ 'meeting id'
570
+ );
571
+ });
572
+
512
573
  it('MultistreamRoapMediaConnection disable audio twcc by default', () => {
513
574
  const multistreamRoapMediaConnectionConstructorStub = sinon
514
575
  .stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection')
@@ -94,6 +94,7 @@ import {
94
94
  MeetingNotActiveError,
95
95
  UserInLobbyError,
96
96
  AddMediaFailed,
97
+ MediaConnectionTimedOutError,
97
98
  } from '../../../../src/common/errors/webex-errors';
98
99
  import WebExMeetingsErrors from '../../../../src/common/errors/webex-meetings-error';
99
100
  import ParameterError from '../../../../src/common/errors/parameter';
@@ -264,6 +265,7 @@ describe('plugin-meetings', () => {
264
265
  webex.meetings.uploadLogs = sinon.stub().returns(Promise.resolve());
265
266
  webex.meetings.reachability = {
266
267
  isAnyPublicClusterReachable: sinon.stub().resolves(true),
268
+ isAnyClusterReachableViaProtocol: sinon.stub().resolves(false),
267
269
  getReachabilityResults: sinon.stub().resolves(undefined),
268
270
  getReachabilityMetrics: sinon.stub().resolves({}),
269
271
  stopReachability: sinon.stub(),
@@ -513,6 +515,7 @@ describe('plugin-meetings', () => {
513
515
  localWebex.meetings.uploadLogs = sinon.stub().returns(Promise.resolve());
514
516
  localWebex.meetings.reachability = {
515
517
  isAnyPublicClusterReachable: sinon.stub().resolves(true),
518
+ isAnyClusterReachableViaProtocol: sinon.stub().resolves(false),
516
519
  getReachabilityResults: sinon.stub().resolves(undefined),
517
520
  getReachabilityMetrics: sinon.stub().resolves({}),
518
521
  stopReachability: sinon.stub(),
@@ -1015,8 +1018,9 @@ describe('plugin-meetings', () => {
1015
1018
  assert.calledOnceWithExactly(
1016
1019
  meeting.addMediaInternal,
1017
1020
  sinon.match.any,
1018
- fakeTurnServerInfo,
1019
1021
  false,
1022
+ fakeTurnServerInfo,
1023
+ undefined,
1020
1024
  mediaOptions
1021
1025
  );
1022
1026
 
@@ -1048,8 +1052,9 @@ describe('plugin-meetings', () => {
1048
1052
  assert.calledOnceWithExactly(
1049
1053
  meeting.addMediaInternal,
1050
1054
  sinon.match.any,
1051
- undefined,
1052
1055
  false,
1056
+ undefined,
1057
+ undefined,
1053
1058
  mediaOptions
1054
1059
  );
1055
1060
 
@@ -1083,8 +1088,9 @@ describe('plugin-meetings', () => {
1083
1088
  assert.calledOnceWithExactly(
1084
1089
  meeting.addMediaInternal,
1085
1090
  sinon.match.any,
1086
- undefined,
1087
1091
  false,
1092
+ undefined,
1093
+ undefined,
1088
1094
  mediaOptions
1089
1095
  );
1090
1096
 
@@ -1638,8 +1644,9 @@ describe('plugin-meetings', () => {
1638
1644
  assert.calledWith(
1639
1645
  meeting.addMediaInternal.firstCall,
1640
1646
  sinon.match.any,
1641
- fakeTurnServerInfo,
1642
1647
  false,
1648
+ fakeTurnServerInfo,
1649
+ undefined,
1643
1650
  mediaOptions
1644
1651
  );
1645
1652
 
@@ -1647,8 +1654,9 @@ describe('plugin-meetings', () => {
1647
1654
  assert.calledWith(
1648
1655
  meeting.addMediaInternal.secondCall,
1649
1656
  sinon.match.any,
1650
- undefined,
1651
1657
  true,
1658
+ undefined,
1659
+ undefined,
1652
1660
  mediaOptions
1653
1661
  );
1654
1662
 
@@ -1666,7 +1674,7 @@ describe('plugin-meetings', () => {
1666
1674
 
1667
1675
  meeting.addMediaInternal = sinon
1668
1676
  .stub()
1669
- .callsFake((icePhaseCallback, _turnServerInfo, _forceTurnDiscovery) => {
1677
+ .callsFake((icePhaseCallback, _forceTurnDiscovery, _turnServerInfo) => {
1670
1678
  const defer = new Defer();
1671
1679
 
1672
1680
  icePhaseCallbacks.push(icePhaseCallback);
@@ -1880,6 +1888,7 @@ describe('plugin-meetings', () => {
1880
1888
  sinon.match.any,
1881
1889
  sinon.match.any,
1882
1890
  sinon.match.any,
1891
+ sinon.match.any,
1883
1892
  sinon.match.has('videoEnabled', false).and(sinon.match.has('allowMediaInLobby', true))
1884
1893
  );
1885
1894
  });
@@ -1900,6 +1909,7 @@ describe('plugin-meetings', () => {
1900
1909
  sinon.match.any,
1901
1910
  sinon.match.any,
1902
1911
  sinon.match.any,
1912
+ sinon.match.any,
1903
1913
  sinon.match.has('audioEnabled', false).and(sinon.match.has('allowMediaInLobby', true))
1904
1914
  );
1905
1915
  });
@@ -1921,6 +1931,7 @@ describe('plugin-meetings', () => {
1921
1931
  sinon.match.any,
1922
1932
  sinon.match.any,
1923
1933
  sinon.match.any,
1934
+ sinon.match.any,
1924
1935
  sinon.match({
1925
1936
  sendVideo: true,
1926
1937
  receiveVideo: false,
@@ -1930,6 +1941,97 @@ describe('plugin-meetings', () => {
1930
1941
  );
1931
1942
  });
1932
1943
 
1944
+ describe('shouldRetryMediaWithOnlyTurnTLS', () => {
1945
+ it('should pass iceTransportPolicy=relay for multistream when previous error is DTLS failure over UDP and TLS is reachable', async () => {
1946
+ meeting.isMultistream = true;
1947
+ const dtlsError = new AddMediaFailed({iceConnected: true, connectionType: 'UDP'});
1948
+
1949
+ webex.meetings.reachability.isAnyClusterReachableViaProtocol = sinon.stub().resolves(true);
1950
+
1951
+ addMediaInternalStub.onFirstCall().rejects(dtlsError);
1952
+ addMediaInternalStub.onSecondCall().resolves(test4);
1953
+
1954
+ await meeting.joinWithMedia({joinOptions, mediaOptions});
1955
+
1956
+ assert.calledWith(
1957
+ meeting.addMediaInternal.secondCall,
1958
+ sinon.match.any,
1959
+ sinon.match.any,
1960
+ sinon.match.any,
1961
+ 'relay',
1962
+ mediaOptions
1963
+ );
1964
+ });
1965
+
1966
+ [
1967
+ {
1968
+ title: 'TLS is not reachable',
1969
+ prevError: new AddMediaFailed({iceConnected: true, connectionType: 'UDP'}),
1970
+ tlsReachable: false,
1971
+ isMultistream: true,
1972
+ },
1973
+ {
1974
+ title: 'error is not AddMediaFailed',
1975
+ prevError: new Error('generic error'),
1976
+ tlsReachable: true,
1977
+ isMultistream: true,
1978
+ },
1979
+ {
1980
+ title: 'error is AddMediaFailed but not DTLS failure',
1981
+ prevError: new AddMediaFailed({iceConnected: false, connectionType: 'UDP'}),
1982
+ tlsReachable: true,
1983
+ isMultistream: true,
1984
+ },
1985
+ {
1986
+ title: 'error is DTLS failure but connectionType is not UDP',
1987
+ prevError: new AddMediaFailed({iceConnected: true, connectionType: 'TURN-TLS'}),
1988
+ tlsReachable: true,
1989
+ isMultistream: true,
1990
+ },
1991
+ {
1992
+ title: 'isMultistream is false',
1993
+ prevError: new AddMediaFailed({iceConnected: true, connectionType: 'UDP'}),
1994
+ tlsReachable: true,
1995
+ isMultistream: false,
1996
+ },
1997
+ ].forEach(({title, prevError, tlsReachable, isMultistream}) => {
1998
+ it(`should pass iceTransportPolicy=undefined when ${title}`, async () => {
1999
+ meeting.join = sinon.stub().callsFake(() => {
2000
+ meeting.isMultistream = isMultistream;
2001
+ return Promise.resolve(fakeJoinResult);
2002
+ });
2003
+ webex.meetings.reachability.isAnyClusterReachableViaProtocol = sinon.stub().resolves(tlsReachable);
2004
+
2005
+ addMediaInternalStub.onFirstCall().rejects(prevError);
2006
+ addMediaInternalStub.onSecondCall().resolves(test4);
2007
+
2008
+ await meeting.joinWithMedia({joinOptions, mediaOptions});
2009
+
2010
+ assert.calledWith(
2011
+ meeting.addMediaInternal.secondCall,
2012
+ sinon.match.any,
2013
+ sinon.match.any,
2014
+ sinon.match.any,
2015
+ undefined,
2016
+ mediaOptions
2017
+ );
2018
+ });
2019
+ });
2020
+
2021
+ it('should pass iceTransportPolicy=undefined on first attempt (no prevError)', async () => {
2022
+ await meeting.joinWithMedia({joinOptions, mediaOptions});
2023
+
2024
+ assert.calledWith(
2025
+ meeting.addMediaInternal.firstCall,
2026
+ sinon.match.any,
2027
+ sinon.match.any,
2028
+ sinon.match.any,
2029
+ undefined,
2030
+ mediaOptions
2031
+ );
2032
+ });
2033
+ });
2034
+
1933
2035
  it('should throw immediately if RTCPeerConnection is not available', async () => {
1934
2036
  supportsRTCPeerConnectionStub.returns(CapabilityState.NOT_CAPABLE);
1935
2037
 
@@ -3270,9 +3372,11 @@ describe('plugin-meetings', () => {
3270
3372
  retriedWithTurnServer: false,
3271
3373
  isMultistream: false,
3272
3374
  isJoinWithMediaRetry: false,
3375
+ iceTransportPolicy: 'all',
3273
3376
  signalingState: 'unknown',
3274
3377
  connectionState: 'unknown',
3275
3378
  iceConnectionState: 'unknown',
3379
+ connectionType: 'udp',
3276
3380
  someReachabilityMetric1: 'some value1',
3277
3381
  someReachabilityMetric2: 'some value2',
3278
3382
  selectedCandidatePairChanges: 2,
@@ -3398,9 +3502,11 @@ describe('plugin-meetings', () => {
3398
3502
  retriedWithTurnServer: false,
3399
3503
  isMultistream: false,
3400
3504
  isJoinWithMediaRetry: false,
3505
+ iceTransportPolicy: 'all',
3401
3506
  signalingState: 'unknown',
3402
3507
  connectionState: 'unknown',
3403
3508
  iceConnectionState: 'unknown',
3509
+ connectionType: 'udp',
3404
3510
  someReachabilityMetric1: 'some value1',
3405
3511
  someReachabilityMetric2: 'some value2',
3406
3512
  selectedCandidatePairChanges: 2,
@@ -4168,9 +4274,11 @@ describe('plugin-meetings', () => {
4168
4274
  retriedWithTurnServer: true,
4169
4275
  isMultistream: false,
4170
4276
  isJoinWithMediaRetry: false,
4277
+ iceTransportPolicy: 'all',
4171
4278
  signalingState: 'unknown',
4172
4279
  connectionState: 'unknown',
4173
4280
  iceConnectionState: 'unknown',
4281
+ connectionType: 'udp',
4174
4282
  selectedCandidatePairChanges: 2,
4175
4283
  numTransports: 1,
4176
4284
  iceCandidatesCount: 0,
@@ -4195,6 +4303,161 @@ describe('plugin-meetings', () => {
4195
4303
  assert.isOk(errorThrown);
4196
4304
  });
4197
4305
 
4306
+ [
4307
+ {iceConnected: true, title: 'iceConnected=true'},
4308
+ {iceConnected: false, title: 'iceConnected=false'},
4309
+ ].forEach(({iceConnected, title}) => {
4310
+ it(`should propagate ${title} from waitForMediaConnectionConnected rejection to AddMediaFailed`, async () => {
4311
+ webex.meetings.reachability = {
4312
+ isWebexMediaBackendUnreachable: sinon.stub().resolves(false),
4313
+ getReachabilityMetrics: sinon.stub().resolves({}),
4314
+ stopReachability: sinon.stub(),
4315
+ isSubnetReachable: sinon.stub().returns(true),
4316
+ };
4317
+ webex.internal.newMetrics.callDiagnosticMetrics.getErrorPayloadForClientErrorCode =
4318
+ sinon.stub().returns({fatal: true});
4319
+ sinon.stub(CallDiagnosticUtils, 'generateClientErrorCodeForIceFailure').returns(2004);
4320
+
4321
+ meeting.meetingState = 'ACTIVE';
4322
+ meeting.mediaProperties.waitForMediaConnectionConnected = sinon.stub().rejects({iceConnected});
4323
+
4324
+ meeting.roap.doTurnDiscovery = sinon.stub().returns({
4325
+ turnServerInfo: {urls: ['turns:fake:443'], username: 'u', password: 'p'},
4326
+ turnDiscoverySkippedReason: undefined,
4327
+ });
4328
+
4329
+ const forceRtcMetricsSend = sinon.stub().resolves();
4330
+ Media.createMediaConnection = sinon.stub().returns({
4331
+ close: sinon.stub(),
4332
+ forceRtcMetricsSend,
4333
+ getConnectionState: sinon.stub().returns(ConnectionState.Connected),
4334
+ initiateOffer: sinon.stub().resolves({}),
4335
+ on: sinon.stub(),
4336
+ });
4337
+
4338
+ let thrownError;
4339
+ await meeting.addMedia({mediaSettings: {}}).catch((err) => {
4340
+ thrownError = err;
4341
+ });
4342
+
4343
+ assert.instanceOf(thrownError, AddMediaFailed);
4344
+ assert.equal(thrownError.iceConnected, iceConnected);
4345
+ });
4346
+ });
4347
+
4348
+ describe('iceTransportPolicy=relay handling', () => {
4349
+ let createMediaConnectionStub;
4350
+
4351
+ beforeEach(() => {
4352
+ webex.meetings.reachability = {
4353
+ isWebexMediaBackendUnreachable: sinon.stub().resolves(false),
4354
+ isAnyClusterReachableViaProtocol: sinon.stub().resolves(true),
4355
+ getReachabilityMetrics: sinon.stub().resolves({}),
4356
+ stopReachability: sinon.stub(),
4357
+ isSubnetReachable: sinon.stub().returns(true),
4358
+ };
4359
+ webex.internal.newMetrics.callDiagnosticMetrics.getErrorPayloadForClientErrorCode =
4360
+ sinon.stub().returns({fatal: true});
4361
+ sinon.stub(CallDiagnosticUtils, 'generateClientErrorCodeForIceFailure').returns(2004);
4362
+
4363
+ meeting.meetingState = 'ACTIVE';
4364
+ meeting.mediaProperties.waitForMediaConnectionConnected = sinon.stub().resolves();
4365
+
4366
+ createMediaConnectionStub = sinon.stub().returns({
4367
+ close: sinon.stub(),
4368
+ forceRtcMetricsSend: sinon.stub().resolves(),
4369
+ getConnectionState: sinon.stub().returns(ConnectionState.Connected),
4370
+ initiateOffer: sinon.stub().resolves({}),
4371
+ on: sinon.stub(),
4372
+ });
4373
+ Media.createMediaConnection = createMediaConnectionStub;
4374
+ });
4375
+
4376
+ [
4377
+ {
4378
+ title: 'should drop iceTransportPolicy=relay when TURN discovery returns no turn server info',
4379
+ turnServerInfo: undefined,
4380
+ turnDiscoverySkippedReason: 'reachability',
4381
+ },
4382
+ {
4383
+ title: 'should drop iceTransportPolicy=relay when TURN discovery returns turnServerInfo with empty urls',
4384
+ turnServerInfo: {urls: [], username: 'u', password: 'p'},
4385
+ turnDiscoverySkippedReason: undefined,
4386
+ },
4387
+ ].forEach(({title, turnServerInfo, turnDiscoverySkippedReason}) => {
4388
+ it(title, async () => {
4389
+ meeting.roap.doTurnDiscovery = sinon.stub().returns({
4390
+ turnServerInfo,
4391
+ turnDiscoverySkippedReason,
4392
+ });
4393
+
4394
+ await meeting.addMediaInternal(
4395
+ () => 'JOIN_MEETING_FINAL',
4396
+ false,
4397
+ undefined,
4398
+ 'relay',
4399
+ {mediaSettings: {}}
4400
+ );
4401
+
4402
+ assert.calledOnce(createMediaConnectionStub);
4403
+ const config = createMediaConnectionStub.firstCall.args[3];
4404
+ assert.isUndefined(config.iceTransportPolicy);
4405
+
4406
+ const successCall = Metrics.sendBehavioralMetric.getCalls().find(
4407
+ (call) => call.args[0] === BEHAVIORAL_METRICS.ADD_MEDIA_SUCCESS
4408
+ );
4409
+ assert.isDefined(successCall);
4410
+ assert.equal(successCall.args[1].iceTransportPolicy, 'all');
4411
+ });
4412
+ });
4413
+
4414
+ it('should send ADD_MEDIA_SUCCESS metric with iceTransportPolicy=relay when relay is used', async () => {
4415
+ meeting.roap.doTurnDiscovery = sinon.stub().returns({
4416
+ turnServerInfo: {urls: ['turns:turn-server:443?transport=tcp'], username: 'u', password: 'p'},
4417
+ turnDiscoverySkippedReason: undefined,
4418
+ });
4419
+
4420
+ await meeting.addMediaInternal(
4421
+ () => 'JOIN_MEETING_FINAL',
4422
+ false,
4423
+ undefined,
4424
+ 'relay',
4425
+ {mediaSettings: {}}
4426
+ );
4427
+
4428
+ const successCall = Metrics.sendBehavioralMetric.getCalls().find(
4429
+ (call) => call.args[0] === BEHAVIORAL_METRICS.ADD_MEDIA_SUCCESS
4430
+ );
4431
+ assert.isDefined(successCall);
4432
+ assert.equal(successCall.args[1].iceTransportPolicy, 'relay');
4433
+ });
4434
+
4435
+ it('should send ADD_MEDIA_FAILURE metric with iceTransportPolicy=relay when relay attempt fails', async () => {
4436
+ meeting.roap.doTurnDiscovery = sinon.stub().returns({
4437
+ turnServerInfo: {urls: ['turns:turn-server:443?transport=tcp'], username: 'u', password: 'p'},
4438
+ turnDiscoverySkippedReason: undefined,
4439
+ });
4440
+
4441
+ meeting.mediaProperties.waitForMediaConnectionConnected = sinon.stub().rejects(
4442
+ new MediaConnectionTimedOutError('timed out', true)
4443
+ );
4444
+
4445
+ await assert.isRejected(meeting.addMediaInternal(
4446
+ () => 'JOIN_MEETING_FINAL',
4447
+ false,
4448
+ undefined,
4449
+ 'relay',
4450
+ {mediaSettings: {}}
4451
+ ));
4452
+
4453
+ const failureCall = Metrics.sendBehavioralMetric.getCalls().find(
4454
+ (call) => call.args[0] === BEHAVIORAL_METRICS.ADD_MEDIA_FAILURE
4455
+ );
4456
+ assert.isDefined(failureCall);
4457
+ assert.equal(failureCall.args[1].iceTransportPolicy, 'relay');
4458
+ });
4459
+ });
4460
+
4198
4461
  it('should resolve if waitForMediaConnectionConnected() rejects the first time but resolves the second time', async () => {
4199
4462
  const FAKE_ERROR = {fatal: true};
4200
4463
  webex.meetings.reachability = {
@@ -4384,6 +4647,7 @@ describe('plugin-meetings', () => {
4384
4647
  isMultistream: false,
4385
4648
  retriedWithTurnServer: true,
4386
4649
  isJoinWithMediaRetry: false,
4650
+ iceTransportPolicy: 'all',
4387
4651
  iceCandidatesCount: 0,
4388
4652
  subnet_reachable: null,
4389
4653
  selected_cluster: null,
@@ -4547,6 +4811,7 @@ describe('plugin-meetings', () => {
4547
4811
  isMultistream: false,
4548
4812
  retriedWithTurnServer: false,
4549
4813
  isJoinWithMediaRetry: false,
4814
+ iceTransportPolicy: 'all',
4550
4815
  someReachabilityMetric1: 'some value1',
4551
4816
  someReachabilityMetric2: 'some value2',
4552
4817
  iceCandidatesCount: 3,
@@ -4612,9 +4877,11 @@ describe('plugin-meetings', () => {
4612
4877
  retriedWithTurnServer: false,
4613
4878
  isMultistream: false,
4614
4879
  isJoinWithMediaRetry: false,
4880
+ iceTransportPolicy: 'all',
4615
4881
  signalingState: 'unknown',
4616
4882
  connectionState: 'unknown',
4617
4883
  iceConnectionState: 'unknown',
4884
+ connectionType: 'udp',
4618
4885
  selectedCandidatePairChanges: 2,
4619
4886
  numTransports: 1,
4620
4887
  subnet_reachable: null,
@@ -4675,9 +4942,11 @@ describe('plugin-meetings', () => {
4675
4942
  retriedWithTurnServer: false,
4676
4943
  isMultistream: false,
4677
4944
  isJoinWithMediaRetry: false,
4945
+ iceTransportPolicy: 'all',
4678
4946
  signalingState: 'unknown',
4679
4947
  connectionState: 'unknown',
4680
4948
  iceConnectionState: 'unknown',
4949
+ connectionType: 'udp',
4681
4950
  selectedCandidatePairChanges: 2,
4682
4951
  numTransports: 1,
4683
4952
  '701_error': 2,
@@ -4739,6 +5008,7 @@ describe('plugin-meetings', () => {
4739
5008
  isMultistream: false,
4740
5009
  retriedWithTurnServer: false,
4741
5010
  isJoinWithMediaRetry: false,
5011
+ iceTransportPolicy: 'all',
4742
5012
  iceCandidatesCount: 0,
4743
5013
  reachability_public_udp_success: 5,
4744
5014
  subnet_reachable: false,
@@ -4804,9 +5074,11 @@ describe('plugin-meetings', () => {
4804
5074
  retriedWithTurnServer: false,
4805
5075
  isMultistream: false,
4806
5076
  isJoinWithMediaRetry: false,
5077
+ iceTransportPolicy: 'all',
4807
5078
  signalingState: 'unknown',
4808
5079
  connectionState: 'unknown',
4809
5080
  iceConnectionState: 'unknown',
5081
+ connectionType: 'udp',
4810
5082
  selectedCandidatePairChanges: 2,
4811
5083
  numTransports: 1,
4812
5084
  reachability_public_udp_success: 5,
@@ -7139,13 +7411,14 @@ describe('plugin-meetings', () => {
7139
7411
  sinon.stub(meeting.locusMediaRequest, 'downgradeFromMultistreamToTranscoded');
7140
7412
  });
7141
7413
 
7142
- const runCheck = async (turnServerInfo, forceTurnDiscovery) => {
7414
+ const runCheck = async (turnServerInfo, forceTurnDiscovery, iceTransportPolicy) => {
7143
7415
  // we're calling addMediaInternal() with mic stream,
7144
7416
  // so that we also verify that audioMute, videoMute info is correctly sent to backend
7145
7417
  const addMediaPromise = meeting.addMediaInternal(
7146
7418
  () => '',
7147
- turnServerInfo,
7148
7419
  forceTurnDiscovery,
7420
+ turnServerInfo,
7421
+ iceTransportPolicy,
7149
7422
  {
7150
7423
  localStreams: {microphone: fakeMicrophoneStream},
7151
7424
  }
@@ -7199,6 +7472,10 @@ describe('plugin-meetings', () => {
7199
7472
  // at this point the meeting should have been downgraded to transcoded
7200
7473
  assert.equal(meeting.isMultistream, false);
7201
7474
 
7475
+ // iceTransportPolicy must be reset to undefined on downgrade to transcoded,
7476
+ // otherwise the transcoded retry could gather direct candidates while metrics report relay
7477
+ assert.isUndefined(meeting.addMediaData.iceTransportPolicy);
7478
+
7202
7479
  // old stats analyzer stopped and new one created
7203
7480
  assert.calledOnce(initialStatsAnalyzer.stopAnalyzer);
7204
7481
  assert.calledOnceWithExactly(
@@ -7289,6 +7566,11 @@ describe('plugin-meetings', () => {
7289
7566
  true
7290
7567
  );
7291
7568
  });
7569
+
7570
+ it('resets iceTransportPolicy to undefined when falling back from multistream (relay) to transcoded', async () => {
7571
+ // simulate a relay-only retry falling back to transcoded and verify iceTransportPolicy is reset
7572
+ await runCheck(undefined, false, 'relay');
7573
+ });
7292
7574
  });
7293
7575
  }
7294
7576
  })