@webex/plugin-meetings 3.9.0-webinar5k.1 → 3.10.0-multi-llms.1

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 (147) hide show
  1. package/dist/breakouts/breakout.js +1 -1
  2. package/dist/breakouts/index.js +1 -1
  3. package/dist/common/errors/webex-errors.js +21 -1
  4. package/dist/common/errors/webex-errors.js.map +1 -1
  5. package/dist/constants.js +25 -0
  6. package/dist/constants.js.map +1 -1
  7. package/dist/controls-options-manager/index.js +22 -5
  8. package/dist/controls-options-manager/index.js.map +1 -1
  9. package/dist/index.js +9 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/interceptors/index.js +7 -0
  12. package/dist/interceptors/index.js.map +1 -1
  13. package/dist/interceptors/locusRouteToken.js +116 -0
  14. package/dist/interceptors/locusRouteToken.js.map +1 -0
  15. package/dist/interpretation/index.js +1 -1
  16. package/dist/interpretation/siLanguage.js +1 -1
  17. package/dist/locus-info/controlsUtils.js +11 -2
  18. package/dist/locus-info/controlsUtils.js.map +1 -1
  19. package/dist/locus-info/index.js +76 -322
  20. package/dist/locus-info/index.js.map +1 -1
  21. package/dist/locus-info/parser.js +4 -1
  22. package/dist/locus-info/parser.js.map +1 -1
  23. package/dist/media/index.js +5 -0
  24. package/dist/media/index.js.map +1 -1
  25. package/dist/media/properties.js +53 -5
  26. package/dist/media/properties.js.map +1 -1
  27. package/dist/meeting/in-meeting-actions.js +14 -0
  28. package/dist/meeting/in-meeting-actions.js.map +1 -1
  29. package/dist/meeting/index.js +468 -278
  30. package/dist/meeting/index.js.map +1 -1
  31. package/dist/meeting/request.js +177 -14
  32. package/dist/meeting/request.js.map +1 -1
  33. package/dist/meeting/type.js +7 -0
  34. package/dist/meeting/type.js.map +1 -0
  35. package/dist/meeting/util.js +100 -3
  36. package/dist/meeting/util.js.map +1 -1
  37. package/dist/meeting-info/meeting-info-v2.js +29 -21
  38. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  39. package/dist/meetings/index.js +20 -16
  40. package/dist/meetings/index.js.map +1 -1
  41. package/dist/member/index.js +9 -0
  42. package/dist/member/index.js.map +1 -1
  43. package/dist/member/util.js +10 -0
  44. package/dist/member/util.js.map +1 -1
  45. package/dist/members/index.js +10 -7
  46. package/dist/members/index.js.map +1 -1
  47. package/dist/members/util.js +7 -2
  48. package/dist/members/util.js.map +1 -1
  49. package/dist/metrics/constants.js +2 -1
  50. package/dist/metrics/constants.js.map +1 -1
  51. package/dist/multistream/mediaRequestManager.js +1 -1
  52. package/dist/multistream/mediaRequestManager.js.map +1 -1
  53. package/dist/multistream/remoteMedia.js +34 -5
  54. package/dist/multistream/remoteMedia.js.map +1 -1
  55. package/dist/multistream/remoteMediaGroup.js +42 -2
  56. package/dist/multistream/remoteMediaGroup.js.map +1 -1
  57. package/dist/reachability/index.js +3 -3
  58. package/dist/reachability/index.js.map +1 -1
  59. package/dist/types/common/errors/webex-errors.d.ts +12 -0
  60. package/dist/types/constants.d.ts +23 -0
  61. package/dist/types/controls-options-manager/index.d.ts +9 -1
  62. package/dist/types/index.d.ts +2 -1
  63. package/dist/types/interceptors/index.d.ts +2 -1
  64. package/dist/types/interceptors/locusRouteToken.d.ts +38 -0
  65. package/dist/types/locus-info/index.d.ts +9 -54
  66. package/dist/types/media/properties.d.ts +21 -0
  67. package/dist/types/meeting/in-meeting-actions.d.ts +14 -0
  68. package/dist/types/meeting/index.d.ts +64 -29
  69. package/dist/types/meeting/request.d.ts +42 -0
  70. package/dist/types/meeting/type.d.ts +9 -0
  71. package/dist/types/meeting/util.d.ts +13 -0
  72. package/dist/types/meeting-info/meeting-info-v2.d.ts +6 -3
  73. package/dist/types/meetings/index.d.ts +3 -1
  74. package/dist/types/member/index.d.ts +1 -0
  75. package/dist/types/member/util.d.ts +5 -0
  76. package/dist/types/members/index.d.ts +12 -11
  77. package/dist/types/members/util.d.ts +8 -4
  78. package/dist/types/metrics/constants.d.ts +1 -0
  79. package/dist/types/multistream/remoteMedia.d.ts +20 -1
  80. package/dist/types/multistream/remoteMediaGroup.d.ts +11 -0
  81. package/dist/webinar/index.js +1 -1
  82. package/package.json +26 -28
  83. package/src/common/errors/webex-errors.ts +19 -0
  84. package/src/constants.ts +26 -2
  85. package/src/controls-options-manager/index.ts +26 -5
  86. package/src/index.ts +4 -1
  87. package/src/interceptors/index.ts +2 -1
  88. package/src/interceptors/locusRouteToken.ts +80 -0
  89. package/src/locus-info/controlsUtils.ts +18 -0
  90. package/src/locus-info/index.ts +69 -357
  91. package/src/locus-info/parser.ts +5 -1
  92. package/src/media/index.ts +6 -0
  93. package/src/media/properties.ts +43 -0
  94. package/src/meeting/in-meeting-actions.ts +29 -0
  95. package/src/meeting/index.ts +299 -88
  96. package/src/meeting/request.ts +141 -0
  97. package/src/meeting/type.ts +9 -0
  98. package/src/meeting/util.ts +107 -3
  99. package/src/meeting-info/meeting-info-v2.ts +24 -5
  100. package/src/meetings/index.ts +15 -22
  101. package/src/member/index.ts +10 -0
  102. package/src/member/util.ts +14 -0
  103. package/src/members/index.ts +20 -10
  104. package/src/members/util.ts +20 -3
  105. package/src/metrics/constants.ts +1 -0
  106. package/src/multistream/mediaRequestManager.ts +7 -7
  107. package/src/multistream/remoteMedia.ts +34 -4
  108. package/src/multistream/remoteMediaGroup.ts +37 -2
  109. package/src/reachability/index.ts +3 -3
  110. package/test/unit/spec/common/browser-detection.js +0 -24
  111. package/test/unit/spec/controls-options-manager/index.js +47 -0
  112. package/test/unit/spec/fixture/locus.js +1 -0
  113. package/test/unit/spec/interceptors/locusRouteToken.ts +87 -0
  114. package/test/unit/spec/locus-info/index.js +80 -361
  115. package/test/unit/spec/locus-info/parser.js +3 -2
  116. package/test/unit/spec/media/index.ts +140 -9
  117. package/test/unit/spec/media/properties.ts +137 -0
  118. package/test/unit/spec/meeting/in-meeting-actions.ts +14 -0
  119. package/test/unit/spec/meeting/index.js +679 -82
  120. package/test/unit/spec/meeting/muteState.js +32 -6
  121. package/test/unit/spec/meeting/request.js +21 -0
  122. package/test/unit/spec/meeting/utils.js +170 -17
  123. package/test/unit/spec/meeting-info/meetinginfov2.js +8 -3
  124. package/test/unit/spec/meetings/index.js +12 -7
  125. package/test/unit/spec/member/util.js +24 -0
  126. package/test/unit/spec/members/collection.js +120 -0
  127. package/test/unit/spec/members/index.js +107 -2
  128. package/test/unit/spec/members/request.js +55 -0
  129. package/test/unit/spec/members/utils.js +116 -14
  130. package/test/unit/spec/multistream/mediaRequestManager.ts +19 -6
  131. package/test/unit/spec/multistream/remoteMedia.ts +66 -2
  132. package/test/unit/spec/reachability/index.ts +158 -3
  133. package/test/unit/spec/roap/turnDiscovery.ts +3 -3
  134. package/dist/hashTree/constants.js +0 -23
  135. package/dist/hashTree/constants.js.map +0 -1
  136. package/dist/hashTree/hashTree.js +0 -516
  137. package/dist/hashTree/hashTree.js.map +0 -1
  138. package/dist/hashTree/hashTreeParser.js +0 -521
  139. package/dist/hashTree/hashTreeParser.js.map +0 -1
  140. package/dist/types/hashTree/constants.d.ts +0 -8
  141. package/dist/types/hashTree/hashTree.d.ts +0 -128
  142. package/dist/types/hashTree/hashTreeParser.d.ts +0 -152
  143. package/src/hashTree/constants.ts +0 -12
  144. package/src/hashTree/hashTree.ts +0 -460
  145. package/src/hashTree/hashTreeParser.ts +0 -556
  146. package/test/unit/spec/hashTree/hashTree.ts +0 -394
  147. package/test/unit/spec/hashTree/hashTreeParser.ts +0 -156
@@ -39,6 +39,7 @@ import {
39
39
  ConnectionState,
40
40
  MediaConnectionEventNames,
41
41
  StatsAnalyzerEventNames,
42
+ StatsMonitorEventNames,
42
43
  Errors,
43
44
  ErrorType,
44
45
  RemoteTrackType,
@@ -56,6 +57,7 @@ import * as MeetingRequestImport from '@webex/plugin-meetings/src/meeting/reques
56
57
  import LocusInfo from '@webex/plugin-meetings/src/locus-info';
57
58
  import MediaProperties from '@webex/plugin-meetings/src/media/properties';
58
59
  import MeetingUtil from '@webex/plugin-meetings/src/meeting/util';
60
+ import MembersUtil from '@webex/plugin-meetings/src/members/util';
59
61
  import MeetingsUtil from '@webex/plugin-meetings/src/meetings/util';
60
62
  import Media from '@webex/plugin-meetings/src/media/index';
61
63
  import ReconnectionManager from '@webex/plugin-meetings/src/reconnection-manager';
@@ -95,6 +97,7 @@ import PermissionError from '../../../../src/common/errors/permission';
95
97
  import JoinWebinarError from '../../../../src/common/errors/join-webinar-error';
96
98
  import IntentToJoinError from '../../../../src/common/errors/intent-to-join';
97
99
  import MultistreamNotSupportedError from '../../../../src/common/errors/multistream-not-supported-error';
100
+ import {SdpResponseTimeoutError} from '@webex/plugin-meetings/src/common/errors/webex-errors';
98
101
  import testUtils from '../../../utils/testUtils';
99
102
  import {
100
103
  MeetingInfoV2CaptchaError,
@@ -244,6 +247,7 @@ describe('plugin-meetings', () => {
244
247
  });
245
248
 
246
249
  webex.internal.newMetrics.callDiagnosticMetrics.clearErrorCache = sinon.stub();
250
+ webex.internal.newMetrics.callDiagnosticMetrics.clearEventLimitsForCorrelationId = sinon.stub();
247
251
  webex.internal.support.submitLogs = sinon.stub().returns(Promise.resolve());
248
252
  webex.internal.services = {get: sinon.stub().returns('locus-url')};
249
253
  webex.credentials.getOrgId = sinon.stub().returns('fake-org-id');
@@ -368,6 +372,35 @@ describe('plugin-meetings', () => {
368
372
  assert.instanceOf(meeting.simultaneousInterpretation, SimultaneousInterpretation);
369
373
  assert.instanceOf(meeting.webinar, Webinar);
370
374
  });
375
+
376
+ it('should call the callback with the meeting that has id already set', () => {
377
+ let meetingIdFromCallback;
378
+ // check that the meeting id is already set correctly at the time when the callback is called
379
+ const meetingCreationCallback = sinon.stub().callsFake((meeting) => {
380
+ meetingIdFromCallback = meeting.id;
381
+ });
382
+
383
+ meeting = new Meeting(
384
+ {
385
+ userId: uuid1,
386
+ resource: uuid2,
387
+ deviceUrl: uuid3,
388
+ locus: {url: url1},
389
+ destination: testDestination,
390
+ destinationType: DESTINATION_TYPE.MEETING_ID,
391
+ correlationId,
392
+ selfId: uuid1,
393
+ },
394
+ {
395
+ parent: webex,
396
+ },
397
+ meetingCreationCallback
398
+ );
399
+ assert.exists(meeting.id);
400
+ assert.calledOnceWithExactly(meetingCreationCallback, meeting);
401
+ assert.equal(meeting.id, meetingIdFromCallback);
402
+ });
403
+
371
404
  it('creates MediaRequestManager instances', () => {
372
405
  assert.instanceOf(meeting.mediaRequestManagers.audio, MediaRequestManager);
373
406
  assert.instanceOf(meeting.mediaRequestManagers.video, MediaRequestManager);
@@ -454,6 +487,18 @@ describe('plugin-meetings', () => {
454
487
  });
455
488
  });
456
489
 
490
+ it('pstnCorrelationId getter/setter should work correctly', () => {
491
+ const testPstnCorrelationId = uuid.v4();
492
+
493
+ meeting.pstnCorrelationId = testPstnCorrelationId;
494
+ assert.equal(meeting.pstnCorrelationId, testPstnCorrelationId);
495
+ assert.equal(meeting.callStateForMetrics.pstnCorrelationId, testPstnCorrelationId);
496
+
497
+ meeting.pstnCorrelationId = undefined;
498
+ assert.equal(meeting.pstnCorrelationId, undefined);
499
+ assert.equal(meeting.callStateForMetrics.pstnCorrelationId, undefined);
500
+ });
501
+
457
502
  describe('creates ReceiveSlot manager instance', () => {
458
503
  let mockReceiveSlotManagerCtor;
459
504
  let providedCreateSlotCallback;
@@ -581,7 +626,6 @@ describe('plugin-meetings', () => {
581
626
  assert.isFalse(meeting.isLocusCall());
582
627
  });
583
628
  });
584
-
585
629
  describe('#invite', () => {
586
630
  it('should have #invite', () => {
587
631
  assert.exists(meeting.invite);
@@ -592,8 +636,6 @@ describe('plugin-meetings', () => {
592
636
  it('should proxy members #addMember and return a promise', async () => {
593
637
  const invite = meeting.invite(uuid1, false);
594
638
 
595
- assert.exists(invite.then);
596
- await invite;
597
639
  assert.calledOnce(meeting.members.addMember);
598
640
  assert.calledWith(meeting.members.addMember, uuid1, false);
599
641
  });
@@ -1949,23 +1991,24 @@ describe('plugin-meetings', () => {
1949
1991
  });
1950
1992
  });
1951
1993
 
1952
- it('should post error event if failed', async () => {
1994
+ it('should handle join failure', async () => {
1953
1995
  MeetingUtil.isPinOrGuest = sinon.stub().returns(false);
1996
+ webex.internal.newMetrics.submitClientEvent = sinon.stub();
1997
+
1954
1998
  await meeting.join().catch(() => {
1955
- assert.deepEqual(
1956
- webex.internal.newMetrics.submitClientEvent.getCall(1).args[0].name,
1957
- 'client.locus.join.response'
1958
- );
1959
- assert.match(
1960
- webex.internal.newMetrics.submitClientEvent.getCall(1).args[0].options.rawError,
1961
- {
1962
- code: 2,
1963
- error: null,
1964
- joinOptions: {},
1965
- sdkMessage:
1966
- 'There was an issue joining the meeting, meeting could be in a bad state.',
1967
- }
1968
- );
1999
+ assert.calledOnce(MeetingUtil.joinMeeting);
2000
+
2001
+ // Assert that client.locus.join.response error event is not sent from this function, it is now emitted from MeetingUtil.joinMeeting
2002
+ assert.calledOnce(webex.internal.newMetrics.submitClientEvent);
2003
+ assert.calledWithMatch(webex.internal.newMetrics.submitClientEvent, {
2004
+ name: 'client.call.initiated',
2005
+ payload: {
2006
+ trigger: 'user-interaction',
2007
+ isRoapCallEnabled: true,
2008
+ pstnAudioType: undefined,
2009
+ },
2010
+ options: {meetingId: meeting.id},
2011
+ });
1969
2012
  });
1970
2013
  });
1971
2014
  it('should fail if password is required', async () => {
@@ -2172,6 +2215,7 @@ describe('plugin-meetings', () => {
2172
2215
  });
2173
2216
  meeting.audio = muteStateStub;
2174
2217
  meeting.video = muteStateStub;
2218
+ sinon.stub(MeetingUtil, 'getIpVersion').returns(IP_VERSION.ipv4_and_ipv6);
2175
2219
  sinon.stub(Media, 'createMediaConnection').returns(fakeMediaConnection);
2176
2220
  sinon.stub(meeting, 'setupMediaConnectionListeners');
2177
2221
  sinon.stub(meeting, 'setMercuryListener');
@@ -2243,13 +2287,24 @@ describe('plugin-meetings', () => {
2243
2287
  close: sinon.stub(),
2244
2288
  forceRtcMetricsSend,
2245
2289
  });
2246
- // set a statsAnalyzer on the meeting so that we can check that it gets reset to null
2290
+
2291
+ const mockStatsMonitor = {removeAllListeners: sinon.stub()};
2292
+ const mockNetworkQualityMonitor = {removeAllListeners: sinon.stub()};
2293
+
2294
+ // set a statsAnalyzer and statsMonitor on the meeting so that we can check that they get reset to null
2247
2295
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
2296
+ meeting.statsMonitor = mockStatsMonitor;
2297
+ meeting.networkQualityMonitor = mockNetworkQualityMonitor;
2248
2298
  const error = await assert.isRejected(meeting.addMedia());
2249
2299
 
2250
2300
  assert.calledOnce(forceRtcMetricsSend);
2301
+ assert.calledOnce(mockStatsMonitor.removeAllListeners);
2302
+ assert.calledOnce(mockNetworkQualityMonitor.removeAllListeners);
2251
2303
 
2252
2304
  assert.isNull(meeting.statsAnalyzer);
2305
+ assert.isNull(meeting.statsMonitor);
2306
+ assert.isNull(meeting.networkQualityMonitor);
2307
+
2253
2308
  assert(webex.internal.newMetrics.submitInternalEvent.calledTwice);
2254
2309
  assert.calledWith(webex.internal.newMetrics.submitInternalEvent.firstCall, {
2255
2310
  name: 'internal.client.add-media.turn-discovery.start',
@@ -2293,6 +2348,7 @@ describe('plugin-meetings', () => {
2293
2348
  selected_subnet: null,
2294
2349
  numTransports: 1,
2295
2350
  iceCandidatesCount: 0,
2351
+ ipver: 1,
2296
2352
  }
2297
2353
  );
2298
2354
  });
@@ -2340,6 +2396,7 @@ describe('plugin-meetings', () => {
2340
2396
  subnet_reachable: null,
2341
2397
  selected_cluster: null,
2342
2398
  selected_subnet: null,
2399
+ ipver: 1,
2343
2400
  })
2344
2401
  );
2345
2402
 
@@ -2359,12 +2416,23 @@ describe('plugin-meetings', () => {
2359
2416
 
2360
2417
  meeting.waitForRemoteSDPAnswer = sinon.stub().rejects();
2361
2418
 
2362
- // set a statsAnalyzer on the meeting so that we can check that it gets reset to null
2419
+ const mockStatsMonitor = {removeAllListeners: sinon.stub()};
2420
+ const mockNetworkQualityMonitor = {removeAllListeners: sinon.stub()};
2421
+
2422
+ // set a statsAnalyzer and statsMonitor on the meeting so that we can check that they get reset to null
2363
2423
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
2424
+ meeting.statsMonitor = mockStatsMonitor;
2425
+ meeting.networkQualityMonitor = mockNetworkQualityMonitor;
2364
2426
 
2365
2427
  const error = await assert.isRejected(meeting.addMedia());
2366
2428
 
2367
2429
  assert.isNull(meeting.statsAnalyzer);
2430
+ assert.isNull(meeting.statsMonitor);
2431
+ assert.isNull(meeting.networkQualityMonitor);
2432
+
2433
+ assert.calledOnce(mockStatsMonitor.removeAllListeners);
2434
+ assert.calledOnce(mockNetworkQualityMonitor.removeAllListeners);
2435
+
2368
2436
  assert(webex.internal.newMetrics.submitInternalEvent.calledTwice);
2369
2437
  assert.calledWith(webex.internal.newMetrics.submitInternalEvent.firstCall, {
2370
2438
  name: 'internal.client.add-media.turn-discovery.start',
@@ -2408,6 +2476,7 @@ describe('plugin-meetings', () => {
2408
2476
  subnet_reachable: null,
2409
2477
  selected_cluster: null,
2410
2478
  selected_subnet: null,
2479
+ ipver: 1,
2411
2480
  }
2412
2481
  );
2413
2482
  });
@@ -2428,8 +2497,9 @@ describe('plugin-meetings', () => {
2428
2497
  },
2429
2498
  },
2430
2499
  });
2431
- // set a statsAnalyzer on the meeting so that we can check that it gets reset to null
2500
+ // set a statsAnalyzer and statsMonitor on the meeting so that we can check that they get reset to null
2432
2501
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
2502
+ meeting.statsMonitor = {removeAllListeners: sinon.stub()};
2433
2503
  const error = await assert.isRejected(meeting.addMedia());
2434
2504
 
2435
2505
  assert(webex.internal.newMetrics.submitInternalEvent.calledTwice);
@@ -2468,10 +2538,12 @@ describe('plugin-meetings', () => {
2468
2538
  subnet_reachable: null,
2469
2539
  selected_cluster: null,
2470
2540
  selected_subnet: null,
2541
+ ipver: 1,
2471
2542
  })
2472
2543
  );
2473
2544
 
2474
2545
  assert.isNull(meeting.statsAnalyzer);
2546
+ assert.isNull(meeting.statsMonitor);
2475
2547
  });
2476
2548
 
2477
2549
  it('should include the peer connection properties correctly for transcoded', async () => {
@@ -2488,8 +2560,14 @@ describe('plugin-meetings', () => {
2488
2560
  },
2489
2561
  },
2490
2562
  });
2491
- // set a statsAnalyzer on the meeting so that we can check that it gets reset to null
2563
+
2564
+ const mockStatsMonitor = {removeAllListeners: sinon.stub()};
2565
+ const mockNetworkQualityMonitor = {removeAllListeners: sinon.stub()};
2566
+
2567
+ // set a statsAnalyzer and statsMonitor on the meeting so that we can check that they get reset to null
2492
2568
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
2569
+ meeting.statsMonitor = mockStatsMonitor;
2570
+ meeting.networkQualityMonitor = mockNetworkQualityMonitor;
2493
2571
  const error = await assert.isRejected(meeting.addMedia());
2494
2572
 
2495
2573
  assert(webex.internal.newMetrics.submitInternalEvent.calledTwice);
@@ -2528,10 +2606,15 @@ describe('plugin-meetings', () => {
2528
2606
  subnet_reachable: null,
2529
2607
  selected_cluster: null,
2530
2608
  selected_subnet: null,
2609
+ ipver: 1,
2531
2610
  })
2532
2611
  );
2533
2612
 
2534
2613
  assert.isNull(meeting.statsAnalyzer);
2614
+ assert.isNull(meeting.statsMonitor);
2615
+ assert.isNull(meeting.networkQualityMonitor);
2616
+ assert.calledOnce(mockStatsMonitor.removeAllListeners);
2617
+ assert.calledOnce(mockNetworkQualityMonitor.removeAllListeners);
2535
2618
  });
2536
2619
 
2537
2620
  it('should work the second time addMedia is called in case the first time fails', async () => {
@@ -2594,7 +2677,11 @@ describe('plugin-meetings', () => {
2594
2677
  // simulate timeout waiting for the SDP answer that never comes
2595
2678
  await clock.tickAsync(ROAP_OFFER_ANSWER_EXCHANGE_TIMEOUT);
2596
2679
 
2597
- await assert.isRejected(result);
2680
+ await assert.isRejected(
2681
+ result,
2682
+ SdpResponseTimeoutError,
2683
+ 'Timed out waiting for REMOTE SDP ANSWER'
2684
+ );
2598
2685
 
2599
2686
  assert.calledOnceWithExactly(getErrorPayloadForClientErrorCodeStub, {
2600
2687
  clientErrorCode: 2007,
@@ -3052,6 +3139,7 @@ describe('plugin-meetings', () => {
3052
3139
  subnet_reachable: null,
3053
3140
  selected_cluster: null,
3054
3141
  selected_subnet: null,
3142
+ ipver: 1,
3055
3143
  },
3056
3144
  ]);
3057
3145
 
@@ -3253,6 +3341,7 @@ describe('plugin-meetings', () => {
3253
3341
  connectionType: 'udp',
3254
3342
  selectedCandidatePairChanges: 2,
3255
3343
  ipVersion: 'IPv6',
3344
+ ipver: 1,
3256
3345
  numTransports: 1,
3257
3346
  isMultistream: false,
3258
3347
  retriedWithTurnServer: true,
@@ -3399,6 +3488,7 @@ describe('plugin-meetings', () => {
3399
3488
  meeting.iceCandidatesCount = 3;
3400
3489
  meeting.iceCandidateErrors.set('701_error', 3);
3401
3490
  meeting.iceCandidateErrors.set('701_turn_host_lookup_received_error', 1);
3491
+ MeetingUtil.getIpVersion.returns(IP_VERSION.only_ipv6);
3402
3492
 
3403
3493
  await meeting.addMedia({
3404
3494
  mediaSettings: {},
@@ -3414,6 +3504,7 @@ describe('plugin-meetings', () => {
3414
3504
  connectionType: 'udp',
3415
3505
  selectedCandidatePairChanges: 2,
3416
3506
  ipVersion: 'IPv6',
3507
+ ipver: 6,
3417
3508
  numTransports: 1,
3418
3509
  isMultistream: false,
3419
3510
  retriedWithTurnServer: false,
@@ -3492,6 +3583,7 @@ describe('plugin-meetings', () => {
3492
3583
  selected_cluster: null,
3493
3584
  selected_subnet: null,
3494
3585
  iceCandidatesCount: 0,
3586
+ ipver: 1,
3495
3587
  }
3496
3588
  );
3497
3589
 
@@ -3556,6 +3648,7 @@ describe('plugin-meetings', () => {
3556
3648
  selected_cluster: null,
3557
3649
  selected_subnet: null,
3558
3650
  iceCandidatesCount: 0,
3651
+ ipver: 1,
3559
3652
  }
3560
3653
  );
3561
3654
 
@@ -3602,6 +3695,7 @@ describe('plugin-meetings', () => {
3602
3695
  locus_id: meeting.locusUrl.split('/').pop(),
3603
3696
  connectionType: 'udp',
3604
3697
  ipVersion: 'IPv6',
3698
+ ipver: 1,
3605
3699
  selectedCandidatePairChanges: 2,
3606
3700
  numTransports: 1,
3607
3701
  isMultistream: false,
@@ -3682,6 +3776,7 @@ describe('plugin-meetings', () => {
3682
3776
  selected_cluster: 'some.cluster',
3683
3777
  selected_subnet: '1.X.X.X',
3684
3778
  iceCandidatesCount: 0,
3779
+ ipver: 1,
3685
3780
  }
3686
3781
  );
3687
3782
 
@@ -3987,12 +4082,13 @@ describe('plugin-meetings', () => {
3987
4082
  });
3988
4083
  });
3989
4084
 
3990
- it('counts the number of members that are in the meeting for MEDIA_QUALITY event', async () => {
4085
+ it('counts the number of members that are in the meeting or lobby for MEDIA_QUALITY event', async () => {
3991
4086
  let fakeMembersCollection = {
3992
4087
  members: {
3993
- member1: {isInMeeting: true},
3994
- member2: {isInMeeting: true},
3995
- member3: {isInMeeting: false},
4088
+ member1: {isInMeeting: true, isInLobby: false},
4089
+ member2: {isInMeeting: false, isInLobby: true},
4090
+ member3: {isInMeeting: false, isInLobby: false},
4091
+ member4: {isInMeeting: true, isInLobby: false},
3996
4092
  },
3997
4093
  };
3998
4094
  sinon.stub(meeting, 'getMembers').returns({membersCollection: fakeMembersCollection});
@@ -4011,11 +4107,12 @@ describe('plugin-meetings', () => {
4011
4107
  },
4012
4108
  payload: {
4013
4109
  intervals: [
4014
- sinon.match.has('intervalMetadata', sinon.match.has('meetingUserCount', 2)),
4110
+ sinon.match.has('intervalMetadata', sinon.match.has('meetingUserCount', 3)),
4015
4111
  ],
4016
4112
  },
4017
4113
  });
4018
- fakeMembersCollection.members.member2.isInMeeting = false;
4114
+ // Move member2 from lobby to neither in meeting nor lobby
4115
+ fakeMembersCollection.members.member2.isInLobby = false;
4019
4116
 
4020
4117
  statsAnalyzerStub.emit(
4021
4118
  {file: 'test', function: 'test'},
@@ -4030,7 +4127,7 @@ describe('plugin-meetings', () => {
4030
4127
  },
4031
4128
  payload: {
4032
4129
  intervals: [
4033
- sinon.match.has('intervalMetadata', sinon.match.has('meetingUserCount', 1)),
4130
+ sinon.match.has('intervalMetadata', sinon.match.has('meetingUserCount', 2)),
4034
4131
  ],
4035
4132
  },
4036
4133
  });
@@ -4057,6 +4154,132 @@ describe('plugin-meetings', () => {
4057
4154
  });
4058
4155
  });
4059
4156
 
4157
+ describe('handles StatsMonitor events', () => {
4158
+ let statsMonitorStub;
4159
+ let prevConfigValue;
4160
+ let listeners;
4161
+
4162
+ beforeEach(async () => {
4163
+ meeting.meetingState = 'ACTIVE';
4164
+ prevConfigValue = meeting.config.stats.enableStatsAnalyzer;
4165
+
4166
+ meeting.config.stats.enableStatsAnalyzer = true;
4167
+
4168
+ listeners = {};
4169
+
4170
+ statsMonitorStub = {
4171
+ on: sinon.stub().callsFake((event, callback) => {
4172
+ listeners[event] = callback;
4173
+ }),
4174
+ removeAllListeners: sinon.stub(),
4175
+ };
4176
+
4177
+ sinon.stub(meeting.mediaProperties, 'sendMediaIssueMetric');
4178
+
4179
+ // mock the StatsMonitor constructor
4180
+ sinon.stub(InternalMediaCoreModule, 'StatsMonitor').returns(statsMonitorStub);
4181
+
4182
+ await meeting.addMedia({
4183
+ mediaSettings: {},
4184
+ });
4185
+ });
4186
+
4187
+ afterEach(() => {
4188
+ meeting.config.stats.enableStatsAnalyzer = prevConfigValue;
4189
+ sinon.restore();
4190
+ });
4191
+
4192
+ describe('INBOUND_AUDIO_ISSUE event', () => {
4193
+ it('should not trigger event when no unmuted members exist', () => {
4194
+ const fakeEventData = {issueSubType: 'DECODE_RESULTS_IN_ZERO_AUDIO_LEVEL'};
4195
+
4196
+ // Setup members that are either self or muted
4197
+ const mutedMember = {
4198
+ isSelf: false,
4199
+ isPairedWithSelf: false,
4200
+ isAudioMuted: true,
4201
+ };
4202
+ const selfMember = {
4203
+ isSelf: true,
4204
+ isPairedWithSelf: false,
4205
+ isAudioMuted: false,
4206
+ };
4207
+ const pairedMember = {
4208
+ isSelf: false,
4209
+ isPairedWithSelf: true,
4210
+ isAudioMuted: false,
4211
+ };
4212
+ meeting.members.membersCollection.getAll = sinon.stub().returns({
4213
+ member1: mutedMember,
4214
+ member2: selfMember,
4215
+ member3: pairedMember,
4216
+ });
4217
+
4218
+ // Reset the stub to clear any previous calls
4219
+ TriggerProxy.trigger.resetHistory();
4220
+
4221
+ // Emit the event from statsMonitor
4222
+ listeners[StatsMonitorEventNames.INBOUND_AUDIO_ISSUE](fakeEventData);
4223
+
4224
+ assert.neverCalledWith(
4225
+ TriggerProxy.trigger,
4226
+ meeting,
4227
+ sinon.match.object,
4228
+ EVENT_TRIGGERS.MEDIA_INBOUND_AUDIO_ISSUE_DETECTED,
4229
+ fakeEventData
4230
+ );
4231
+ assert.notCalled(meeting.mediaProperties.sendMediaIssueMetric);
4232
+ });
4233
+
4234
+ it('should trigger event and metric when there are multiple members and at least one is unmuted', () => {
4235
+ const fakeEventData = {issueSubType: 'DECODE_RESULTS_IN_ZERO_AUDIO_LEVEL'};
4236
+
4237
+ // Setup mixed members - some muted, one unmuted
4238
+ const mutedMember = {
4239
+ isSelf: false,
4240
+ isPairedWithSelf: false,
4241
+ isAudioMuted: true,
4242
+ };
4243
+ const unmutedMember = {
4244
+ isSelf: false,
4245
+ isPairedWithSelf: false,
4246
+ isAudioMuted: false,
4247
+ };
4248
+ const selfMember = {
4249
+ isSelf: true,
4250
+ isPairedWithSelf: false,
4251
+ isAudioMuted: false,
4252
+ };
4253
+ meeting.members.membersCollection.getAll = sinon.stub().returns({
4254
+ member1: mutedMember,
4255
+ member2: unmutedMember,
4256
+ member3: selfMember,
4257
+ });
4258
+
4259
+ // Reset the stub to clear any previous calls
4260
+ TriggerProxy.trigger.resetHistory();
4261
+
4262
+ // Emit the event from statsMonitor
4263
+ listeners[StatsMonitorEventNames.INBOUND_AUDIO_ISSUE](fakeEventData);
4264
+
4265
+ assert.calledWith(
4266
+ TriggerProxy.trigger,
4267
+ meeting,
4268
+ sinon.match.object,
4269
+ EVENT_TRIGGERS.MEDIA_INBOUND_AUDIO_ISSUE_DETECTED,
4270
+ fakeEventData
4271
+ );
4272
+
4273
+ assert.calledOnceWithExactly(
4274
+ meeting.mediaProperties.sendMediaIssueMetric,
4275
+ 'inbound_audio',
4276
+ fakeEventData.issueSubType,
4277
+ meeting.correlationId
4278
+ );
4279
+ });
4280
+ });
4281
+ });
4282
+
4060
4283
  describe('bundlePolicy', () => {
4061
4284
  const FAKE_TURN_URL = 'turns:webex.com:3478';
4062
4285
  const FAKE_TURN_USER = 'some-turn-username';
@@ -5523,6 +5746,7 @@ describe('plugin-meetings', () => {
5523
5746
  let multistreamEventListeners;
5524
5747
  let transcodedEventListeners;
5525
5748
  let mockStatsAnalyzerCtor;
5749
+ let statsMonitorStub;
5526
5750
 
5527
5751
  const setupFakeRoapMediaConnection = (fakeRoapMediaConnection, eventListeners) => {
5528
5752
  fakeRoapMediaConnection.on.callsFake((eventName, cb) => {
@@ -5554,6 +5778,14 @@ describe('plugin-meetings', () => {
5554
5778
  return {on: sinon.stub(), stopAnalyzer: sinon.stub()};
5555
5779
  });
5556
5780
 
5781
+ statsMonitorStub = {
5782
+ on: sinon.stub(),
5783
+ removeAllListeners: sinon.stub(),
5784
+ };
5785
+
5786
+ // mock the StatsMonitor constructor
5787
+ sinon.stub(InternalMediaCoreModule, 'StatsMonitor').returns(statsMonitorStub);
5788
+
5557
5789
  webex.internal.newMetrics.callDiagnosticMetrics.getErrorPayloadForClientErrorCode =
5558
5790
  sinon.stub();
5559
5791
 
@@ -5616,6 +5848,7 @@ describe('plugin-meetings', () => {
5616
5848
  mockStatsAnalyzerCtor,
5617
5849
  sinon.match({
5618
5850
  isMultistream: true,
5851
+ statsMonitor: statsMonitorStub,
5619
5852
  })
5620
5853
  );
5621
5854
  const initialStatsAnalyzer = mockStatsAnalyzerCtor.returnValues[0];
@@ -6498,25 +6731,36 @@ describe('plugin-meetings', () => {
6498
6731
  const DIAL_IN_URL = meeting.dialInUrl;
6499
6732
 
6500
6733
  assert.calledWith(meeting.meetingRequest.dialIn, {
6501
- correlationId: meeting.correlationId,
6734
+ correlationId: meeting.pstnCorrelationId,
6502
6735
  dialInUrl: DIAL_IN_URL,
6503
6736
  locusUrl: meeting.locusUrl,
6504
6737
  clientUrl: meeting.deviceUrl,
6505
6738
  });
6506
6739
  assert.notCalled(meeting.meetingRequest.dialOut);
6507
6740
 
6741
+ // Verify pstnCorrelationId was set
6742
+ assert.exists(meeting.pstnCorrelationId);
6743
+ assert.notEqual(meeting.pstnCorrelationId, meeting.correlationId);
6744
+ const firstPstnCorrelationId = meeting.pstnCorrelationId;
6745
+
6508
6746
  meeting.meetingRequest.dialIn.resetHistory();
6509
6747
 
6510
6748
  // try again. the dial in urls should match
6511
6749
  await meeting.usePhoneAudio();
6512
6750
 
6513
6751
  assert.calledWith(meeting.meetingRequest.dialIn, {
6514
- correlationId: meeting.correlationId,
6752
+ correlationId: meeting.pstnCorrelationId,
6515
6753
  dialInUrl: DIAL_IN_URL,
6516
6754
  locusUrl: meeting.locusUrl,
6517
6755
  clientUrl: meeting.deviceUrl,
6518
6756
  });
6519
6757
  assert.notCalled(meeting.meetingRequest.dialOut);
6758
+ // A new PSTN correlationId should be generated for the second attempt
6759
+ assert.notEqual(
6760
+ meeting.pstnCorrelationId,
6761
+ firstPstnCorrelationId,
6762
+ 'pstnCorrelationId should be regenerated on each dial-in attempt'
6763
+ );
6520
6764
  });
6521
6765
 
6522
6766
  it('given a phone number, triggers dial-out, delegating request to meetingRequest correctly', async () => {
@@ -6526,7 +6770,7 @@ describe('plugin-meetings', () => {
6526
6770
  const DIAL_OUT_URL = meeting.dialOutUrl;
6527
6771
 
6528
6772
  assert.calledWith(meeting.meetingRequest.dialOut, {
6529
- correlationId: meeting.correlationId,
6773
+ correlationId: meeting.pstnCorrelationId,
6530
6774
  dialOutUrl: DIAL_OUT_URL,
6531
6775
  locusUrl: meeting.locusUrl,
6532
6776
  clientUrl: meeting.deviceUrl,
@@ -6534,49 +6778,134 @@ describe('plugin-meetings', () => {
6534
6778
  });
6535
6779
  assert.notCalled(meeting.meetingRequest.dialIn);
6536
6780
 
6781
+ // Verify pstnCorrelationId was set
6782
+ assert.exists(meeting.pstnCorrelationId);
6783
+ assert.notEqual(meeting.pstnCorrelationId, meeting.correlationId);
6784
+ const firstPstnCorrelationId = meeting.pstnCorrelationId;
6785
+
6537
6786
  meeting.meetingRequest.dialOut.resetHistory();
6538
6787
 
6539
6788
  // try again. the dial out urls should match
6540
6789
  await meeting.usePhoneAudio(phoneNumber);
6541
6790
 
6542
6791
  assert.calledWith(meeting.meetingRequest.dialOut, {
6543
- correlationId: meeting.correlationId,
6792
+ correlationId: meeting.pstnCorrelationId,
6544
6793
  dialOutUrl: DIAL_OUT_URL,
6545
6794
  locusUrl: meeting.locusUrl,
6546
6795
  clientUrl: meeting.deviceUrl,
6547
6796
  phoneNumber,
6548
6797
  });
6549
6798
  assert.notCalled(meeting.meetingRequest.dialIn);
6799
+ // A new PSTN correlationId should be generated for the second attempt
6800
+ assert.notEqual(
6801
+ meeting.pstnCorrelationId,
6802
+ firstPstnCorrelationId,
6803
+ 'pstnCorrelationId should be regenerated on each dial-out attempt'
6804
+ );
6550
6805
  });
6551
6806
 
6552
- it('rejects if the request failed (dial in)', () => {
6553
- const error = 'something bad happened';
6807
+ it('rejects if the request failed (dial in)', async () => {
6808
+ const error = {error: {message: 'dial in failed'}, stack: 'error stack'};
6554
6809
 
6555
6810
  meeting.meetingRequest.dialIn = sinon.stub().returns(Promise.reject(error));
6556
6811
 
6557
- return meeting
6558
- .usePhoneAudio()
6559
- .then(() => Promise.reject(new Error('Promise resolved when it should have rejected')))
6560
- .catch((e) => {
6561
- assert.equal(e, error);
6812
+ try {
6813
+ await meeting.usePhoneAudio();
6814
+ throw new Error('Promise resolved when it should have rejected');
6815
+ } catch (e) {
6816
+ assert.equal(e, error);
6562
6817
 
6563
- return Promise.resolve();
6564
- });
6818
+ // Verify behavioral metric was sent with dial_in_correlation_id
6819
+ assert.calledWith(
6820
+ Metrics.sendBehavioralMetric,
6821
+ BEHAVIORAL_METRICS.ADD_DIAL_IN_FAILURE,
6822
+ {
6823
+ correlation_id: meeting.correlationId,
6824
+ dial_in_url: meeting.dialInUrl,
6825
+ dial_in_correlation_id: sinon.match.string,
6826
+ locus_id: meeting.locusUrl.split('/').pop(),
6827
+ client_url: meeting.deviceUrl,
6828
+ reason: error.error.message,
6829
+ stack: error.stack,
6830
+ }
6831
+ );
6832
+
6833
+ // Verify pstnCorrelationId was cleared after error
6834
+ assert.equal(meeting.pstnCorrelationId, undefined);
6835
+ }
6565
6836
  });
6566
6837
 
6567
6838
  it('rejects if the request failed (dial out)', async () => {
6568
- const error = 'something bad happened';
6839
+ const error = {error: {message: 'dial out failed'}, stack: 'error stack'};
6569
6840
 
6570
6841
  meeting.meetingRequest.dialOut = sinon.stub().returns(Promise.reject(error));
6571
6842
 
6572
- return meeting
6573
- .usePhoneAudio('+441234567890')
6574
- .then(() => Promise.reject(new Error('Promise resolved when it should have rejected')))
6575
- .catch((e) => {
6576
- assert.equal(e, error);
6843
+ try {
6844
+ await meeting.usePhoneAudio('+441234567890');
6845
+ throw new Error('Promise resolved when it should have rejected');
6846
+ } catch (e) {
6847
+ assert.equal(e, error);
6577
6848
 
6578
- return Promise.resolve();
6579
- });
6849
+ // Verify behavioral metric was sent with dial_out_correlation_id
6850
+ assert.calledWith(
6851
+ Metrics.sendBehavioralMetric,
6852
+ BEHAVIORAL_METRICS.ADD_DIAL_OUT_FAILURE,
6853
+ {
6854
+ correlation_id: meeting.correlationId,
6855
+ dial_out_url: meeting.dialOutUrl,
6856
+ dial_out_correlation_id: sinon.match.string,
6857
+ locus_id: meeting.locusUrl.split('/').pop(),
6858
+ client_url: meeting.deviceUrl,
6859
+ reason: error.error.message,
6860
+ stack: error.stack,
6861
+ }
6862
+ );
6863
+
6864
+ // Verify pstnCorrelationId was cleared after error
6865
+ assert.equal(meeting.pstnCorrelationId, undefined);
6866
+ }
6867
+ });
6868
+ });
6869
+
6870
+ describe('#disconnectPhoneAudio', () => {
6871
+ beforeEach(() => {
6872
+ // Mock the MeetingUtil.disconnectPhoneAudio method
6873
+ sinon.stub(MeetingUtil, 'disconnectPhoneAudio').resolves();
6874
+ meeting.dialInUrl = 'dialin:///test-dial-in-url';
6875
+ meeting.dialOutUrl = 'dialout:///test-dial-out-url';
6876
+ meeting.dialInDeviceStatus = 'JOINED';
6877
+ meeting.dialOutDeviceStatus = 'JOINED';
6878
+ });
6879
+
6880
+ afterEach(() => {
6881
+ MeetingUtil.disconnectPhoneAudio.restore();
6882
+ });
6883
+
6884
+ it('should disconnect phone audio and clear pstnCorrelationId', async () => {
6885
+ meeting.pstnCorrelationId = 'test-pstn-correlation-id';
6886
+
6887
+ await meeting.disconnectPhoneAudio();
6888
+
6889
+ // Verify that pstnCorrelationId is cleared
6890
+ assert.equal(meeting.pstnCorrelationId, undefined);
6891
+
6892
+ // Verify that MeetingUtil.disconnectPhoneAudio was called for both dial-in and dial-out
6893
+ assert.calledTwice(MeetingUtil.disconnectPhoneAudio);
6894
+ assert.calledWith(MeetingUtil.disconnectPhoneAudio, meeting, meeting.dialInUrl);
6895
+ assert.calledWith(MeetingUtil.disconnectPhoneAudio, meeting, meeting.dialOutUrl);
6896
+ });
6897
+
6898
+ it('should handle case when no PSTN connection is active', async () => {
6899
+ meeting.dialInDeviceStatus = 'IDLE';
6900
+ meeting.dialOutDeviceStatus = 'IDLE';
6901
+ meeting.pstnCorrelationId = 'test-pstn-correlation-id';
6902
+
6903
+ await meeting.disconnectPhoneAudio();
6904
+
6905
+ // Verify that pstnCorrelationId is still cleared even when no phone connection is active
6906
+ assert.equal(meeting.pstnCorrelationId, undefined);
6907
+ // And verify no disconnect was attempted
6908
+ assert.notCalled(MeetingUtil.disconnectPhoneAudio);
6580
6909
  });
6581
6910
  });
6582
6911
 
@@ -7330,6 +7659,8 @@ describe('plugin-meetings', () => {
7330
7659
  'locus-id',
7331
7660
  {extraParam1: 'value1', permissionToken: FAKE_PERMISSION_TOKEN},
7332
7661
  {meetingId: meeting.id, sendCAevents: true},
7662
+ null,
7663
+ null,
7333
7664
  null
7334
7665
  );
7335
7666
  assert.deepEqual(meeting.meetingInfo, {
@@ -7376,6 +7707,8 @@ describe('plugin-meetings', () => {
7376
7707
  'locus-id',
7377
7708
  {extraParam1: 'value1', permissionToken: FAKE_PERMISSION_TOKEN},
7378
7709
  {meetingId: meeting.id, sendCAevents: true},
7710
+ null,
7711
+ null,
7379
7712
  null
7380
7713
  );
7381
7714
  assert.deepEqual(meeting.meetingInfo, {
@@ -7431,6 +7764,8 @@ describe('plugin-meetings', () => {
7431
7764
  permissionToken: FAKE_PERMISSION_TOKEN,
7432
7765
  },
7433
7766
  {meetingId: meeting.id, sendCAevents: true},
7767
+ null,
7768
+ null,
7434
7769
  null
7435
7770
  );
7436
7771
  assert.deepEqual(meeting.meetingInfo, {
@@ -8093,6 +8428,7 @@ describe('plugin-meetings', () => {
8093
8428
 
8094
8429
  meeting.requestScreenShareFloor = sinon.stub().resolves({});
8095
8430
  meeting.releaseScreenShareFloor = sinon.stub().resolves({});
8431
+ webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub();
8096
8432
  meeting.mediaProperties.mediaDirection = {
8097
8433
  sendAudio: 'fake value', // using non-boolean here so that we can check that these values are untouched in tests
8098
8434
  sendVideo: 'fake value',
@@ -8174,6 +8510,12 @@ describe('plugin-meetings', () => {
8174
8510
  payload: {mediaType: 'share', shareInstanceId: meeting.localShareInstanceId},
8175
8511
  options: {meetingId: meeting.id},
8176
8512
  });
8513
+
8514
+ // ensure the share start timestamp is saved
8515
+ assert.calledWith(webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp, {
8516
+ key: 'internal.client.share.initiated',
8517
+ });
8518
+
8177
8519
  assert.equal(meeting.mediaProperties.mediaDirection.sendShare, true);
8178
8520
 
8179
8521
  assert.equal(meeting.shareCAEventSentStatus.transmitStart, false);
@@ -8192,6 +8534,11 @@ describe('plugin-meetings', () => {
8192
8534
  options: {meetingId: meeting.id},
8193
8535
  });
8194
8536
 
8537
+ // ensure the share start timestamp is saved
8538
+ assert.calledWith(webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp, {
8539
+ key: 'internal.client.share.initiated',
8540
+ });
8541
+
8195
8542
  assert.calledWith(
8196
8543
  meeting.sendSlotManager.getSlot(MediaType.AudioSlides).publishStream,
8197
8544
  stream
@@ -8860,11 +9207,16 @@ describe('plugin-meetings', () => {
8860
9207
  meeting.hasMediaConnectionConnectedAtLeastOnce = false;
8861
9208
  meeting.setupMediaConnectionListeners();
8862
9209
 
9210
+ sinon.stub(MeetingUtil, 'getCaEventLabelsForIpVersion').returns(['fake labels']);
9211
+
8863
9212
  simulateConnectionStateChange(ConnectionState.Connecting);
8864
9213
 
8865
9214
  assert.calledOnce(webex.internal.newMetrics.submitClientEvent);
8866
9215
  assert.calledWithMatch(webex.internal.newMetrics.submitClientEvent, {
8867
9216
  name: 'client.ice.start',
9217
+ payload: {
9218
+ labels: ['fake labels'],
9219
+ },
8868
9220
  options: {
8869
9221
  meetingId: meeting.id,
8870
9222
  },
@@ -10129,6 +10481,24 @@ describe('plugin-meetings', () => {
10129
10481
  );
10130
10482
  });
10131
10483
 
10484
+ it('listens to CONTROLS_AUTO_END_MEETING_WARNING_CHANGED', async () => {
10485
+ const state = {example: 'value'};
10486
+
10487
+ await meeting.locusInfo.emitScoped(
10488
+ {function: 'test', file: 'test'},
10489
+ LOCUSINFO.EVENTS.CONTROLS_AUTO_END_MEETING_WARNING_CHANGED,
10490
+ {state}
10491
+ );
10492
+
10493
+ assert.calledWith(
10494
+ TriggerProxy.trigger,
10495
+ meeting,
10496
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
10497
+ EVENT_TRIGGERS.MEETING_CONTROLS_AUTO_END_MEETING_WARNING_UPDATED,
10498
+ {state}
10499
+ );
10500
+ });
10501
+
10132
10502
  it('listens to CONTROLS_REMOTE_DESKTOP_CONTROL_CHANGED', async () => {
10133
10503
  const state = {example: 'value'};
10134
10504
 
@@ -10208,6 +10578,7 @@ describe('plugin-meetings', () => {
10208
10578
  describe('#setUpLocusUrlListener', () => {
10209
10579
  it('listens to the locus url update event', (done) => {
10210
10580
  const newLocusUrl = 'newLocusUrl/12345';
10581
+ const payload = {url: newLocusUrl};
10211
10582
 
10212
10583
  meeting.members = {locusUrlUpdate: sinon.stub().returns(Promise.resolve(test1))};
10213
10584
  meeting.recordingController = {setLocusUrl: sinon.stub().returns(undefined)};
@@ -10221,14 +10592,14 @@ describe('plugin-meetings', () => {
10221
10592
  meeting.locusInfo.emit(
10222
10593
  {function: 'test', file: 'test'},
10223
10594
  'LOCUS_INFO_UPDATE_URL',
10224
- newLocusUrl
10595
+ payload
10225
10596
  );
10226
10597
  assert.calledWith(meeting.members.locusUrlUpdate, newLocusUrl);
10227
10598
  assert.calledOnceWithExactly(meeting.breakouts.locusUrlUpdate, newLocusUrl);
10228
10599
  assert.calledOnceWithExactly(meeting.annotation.locusUrlUpdate, newLocusUrl);
10229
10600
  assert.calledWith(meeting.members.locusUrlUpdate, newLocusUrl);
10230
10601
  assert.calledWith(meeting.recordingController.setLocusUrl, newLocusUrl);
10231
- assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl);
10602
+ assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, false);
10232
10603
  assert.calledWith(meeting.simultaneousInterpretation.locusUrlUpdate, newLocusUrl);
10233
10604
  assert.calledWith(meeting.webinar.locusUrlUpdate, newLocusUrl);
10234
10605
  assert.equal(meeting.locusUrl, newLocusUrl);
@@ -10246,6 +10617,22 @@ describe('plugin-meetings', () => {
10246
10617
  {locusUrl: 'newLocusUrl/12345'}
10247
10618
  );
10248
10619
 
10620
+ done();
10621
+ });
10622
+ it('update mainLocusUrl for controlsOptionManager if payload.isMainLocus as true', (done) => {
10623
+ const newLocusUrl = 'newLocusUrl/12345';
10624
+ const payload = {url: newLocusUrl, isMainLocus: true};
10625
+
10626
+ meeting.controlsOptionsManager = {setLocusUrl: sinon.stub().returns(undefined)};
10627
+
10628
+ meeting.locusInfo.emit(
10629
+ {function: 'test', file: 'test'},
10630
+ 'LOCUS_INFO_UPDATE_URL',
10631
+ payload
10632
+ );
10633
+
10634
+ assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, true);
10635
+
10249
10636
  done();
10250
10637
  });
10251
10638
  });
@@ -10465,6 +10852,10 @@ describe('plugin-meetings', () => {
10465
10852
  meeting.mediaProperties = {mediaDirection: {sendShare: true}};
10466
10853
  meeting.meetingRequest.changeMeetingFloor = sinon.stub().returns(Promise.resolve());
10467
10854
  (meeting.deviceUrl = 'deviceUrl.com'), (meeting.localShareInstanceId = '1234-5678');
10855
+ webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub();
10856
+ webex.internal.newMetrics.callDiagnosticLatencies.getShareDuration = sinon
10857
+ .stub()
10858
+ .returns(1000);
10468
10859
  });
10469
10860
  it('should call changeMeetingFloor()', async () => {
10470
10861
  meeting.screenShareFloorState = 'GRANTED';
@@ -10482,6 +10873,22 @@ describe('plugin-meetings', () => {
10482
10873
  assert.exists(share.then);
10483
10874
  await share;
10484
10875
  assert.calledOnce(meeting.meetingRequest.changeMeetingFloor);
10876
+
10877
+ // ensure the share stop timestamp is saved
10878
+ assert.calledWith(webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp, {
10879
+ key: 'internal.client.share.stopped',
10880
+ });
10881
+
10882
+ // ensure the CA share stopped metric is submitted with duration
10883
+ assert.calledWith(webex.internal.newMetrics.submitClientEvent, {
10884
+ name: 'client.share.stopped',
10885
+ payload: {
10886
+ mediaType: 'share',
10887
+ shareInstanceId: meeting.localShareInstanceId,
10888
+ shareDuration: 1000,
10889
+ },
10890
+ options: {meetingId: meeting.id},
10891
+ });
10485
10892
  });
10486
10893
  it('should not call changeMeetingFloor() if someone else already has the floor', async () => {
10487
10894
  // change selfId so that it doesn't match the beneficiary id from meeting.locusInfo.mediaShares
@@ -11065,6 +11472,8 @@ describe('plugin-meetings', () => {
11065
11472
  let canUserRenameOthersSpy;
11066
11473
  let canShareWhiteBoardSpy;
11067
11474
  let canMoveToLobbySpy;
11475
+ let isSpokenLanguageAutoDetectionEnabledSpy;
11476
+ let showAutoEndMeetingWarningSpy;
11068
11477
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11069
11478
 
11070
11479
  beforeEach(() => {
@@ -11096,11 +11505,17 @@ describe('plugin-meetings', () => {
11096
11505
  canUserRenameOthersSpy = sinon.spy(MeetingUtil, 'canUserRenameOthers');
11097
11506
  canShareWhiteBoardSpy = sinon.spy(MeetingUtil, 'canShareWhiteBoard');
11098
11507
  canMoveToLobbySpy = sinon.spy(MeetingUtil, 'canMoveToLobby');
11508
+ showAutoEndMeetingWarningSpy = sinon.spy(MeetingUtil, 'showAutoEndMeetingWarning');
11509
+ isSpokenLanguageAutoDetectionEnabledSpy = sinon.spy(
11510
+ MeetingUtil,
11511
+ 'isSpokenLanguageAutoDetectionEnabled'
11512
+ );
11099
11513
  });
11100
11514
 
11101
11515
  afterEach(() => {
11102
11516
  inMeetingActionsSetSpy.restore();
11103
11517
  waitingForOthersToJoinSpy.restore();
11518
+ showAutoEndMeetingWarningSpy.restore();
11104
11519
  });
11105
11520
 
11106
11521
  forEach(
@@ -11648,6 +12063,8 @@ describe('plugin-meetings', () => {
11648
12063
  assert.calledWith(canUserRenameOthersSpy, userDisplayHints);
11649
12064
  assert.calledWith(canShareWhiteBoardSpy, userDisplayHints, selfUserPolicies);
11650
12065
  assert.calledWith(canMoveToLobbySpy, userDisplayHints);
12066
+ assert.calledWith(showAutoEndMeetingWarningSpy, userDisplayHints);
12067
+ assert.calledWith(isSpokenLanguageAutoDetectionEnabledSpy, userDisplayHints);
11651
12068
 
11652
12069
  assert.calledWith(ControlsOptionsUtil.hasHints, {
11653
12070
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -12054,6 +12471,7 @@ describe('plugin-meetings', () => {
12054
12471
  meeting.locusInfo.self = {url: url1};
12055
12472
  meeting.meetingRequest.changeMeetingFloor = sinon.stub().returns(Promise.resolve());
12056
12473
  meeting.deviceUrl = 'deviceUrl.com';
12474
+ webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub();
12057
12475
  });
12058
12476
  it('should have #startWhiteboardShare', () => {
12059
12477
  assert.exists(meeting.startWhiteboardShare);
@@ -12081,6 +12499,11 @@ describe('plugin-meetings', () => {
12081
12499
  payload: {mediaType: 'whiteboard'},
12082
12500
  options: {meetingId: meeting.id},
12083
12501
  });
12502
+
12503
+ // ensure the share start timestamp is saved
12504
+ assert.calledWith(webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp, {
12505
+ key: 'internal.client.share.initiated',
12506
+ });
12084
12507
  });
12085
12508
  });
12086
12509
  describe('#stopWhiteboardShare', () => {
@@ -12092,6 +12515,11 @@ describe('plugin-meetings', () => {
12092
12515
  meeting.locusInfo.self = {url: url1};
12093
12516
  meeting.meetingRequest.changeMeetingFloor = sinon.stub().returns(Promise.resolve());
12094
12517
  meeting.deviceUrl = 'deviceUrl.com';
12518
+ webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub();
12519
+ webex.internal.newMetrics.callDiagnosticLatencies.getShareDuration = sinon
12520
+ .stub()
12521
+ .returns(1000);
12522
+ webex.internal.newMetrics.submitClientEvent = sinon.stub();
12095
12523
  });
12096
12524
  it('should stop the whiteboard share', async () => {
12097
12525
  const whiteboardShare = meeting.stopWhiteboardShare();
@@ -12106,6 +12534,21 @@ describe('plugin-meetings', () => {
12106
12534
  uri: url1,
12107
12535
  });
12108
12536
  assert.calledOnce(meeting.meetingRequest.changeMeetingFloor);
12537
+
12538
+ // ensure the share stop timestamp is saved
12539
+ assert.calledWith(webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp, {
12540
+ key: 'internal.client.share.stopped',
12541
+ });
12542
+
12543
+ // ensure the CA share stopped metric is submitted with duration
12544
+ assert.calledWith(webex.internal.newMetrics.submitClientEvent, {
12545
+ name: 'client.share.stopped',
12546
+ payload: {
12547
+ mediaType: 'whiteboard',
12548
+ shareDuration: 1000,
12549
+ },
12550
+ options: {meetingId: meeting.id},
12551
+ });
12109
12552
  });
12110
12553
  });
12111
12554
  });
@@ -12178,6 +12621,11 @@ describe('plugin-meetings', () => {
12178
12621
  meeting.selfId = '9528d952-e4de-46cf-8157-fd4823b98377';
12179
12622
  meeting.deviceUrl = 'my-web-url';
12180
12623
  meeting.locusInfo.info = {isWebinar: false};
12624
+ webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub();
12625
+ webex.internal.newMetrics.callDiagnosticLatencies.getShareDuration = sinon
12626
+ .stub()
12627
+ .returns(1500);
12628
+ webex.internal.newMetrics.submitClientEvent = sinon.stub();
12181
12629
  });
12182
12630
 
12183
12631
  const USER_IDS = {
@@ -12404,12 +12852,12 @@ describe('plugin-meetings', () => {
12404
12852
  activeSharingId.whiteboard = beneficiaryId;
12405
12853
 
12406
12854
  eventTrigger.share.push(
12407
- meeting.webinar.selfIsAttendee
12855
+ meeting.webinar.selfIsAttendee || meeting.guest
12408
12856
  ? {
12409
12857
  eventName: EVENT_TRIGGERS.MEETING_STARTED_SHARING_REMOTE,
12410
12858
  functionName: 'remoteShare',
12411
12859
  eventPayload: {
12412
- memberId: null,
12860
+ memberId: meeting.webinar.selfIsAttendee ? beneficiaryId : null,
12413
12861
  url,
12414
12862
  shareInstanceId,
12415
12863
  annotationInfo: undefined,
@@ -12423,9 +12871,10 @@ describe('plugin-meetings', () => {
12423
12871
  }
12424
12872
  );
12425
12873
 
12426
- shareStatus = meeting.webinar.selfIsAttendee
12427
- ? SHARE_STATUS.REMOTE_SHARE_ACTIVE
12428
- : SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE;
12874
+ shareStatus =
12875
+ meeting.webinar.selfIsAttendee || meeting.guest
12876
+ ? SHARE_STATUS.REMOTE_SHARE_ACTIVE
12877
+ : SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE;
12429
12878
  }
12430
12879
 
12431
12880
  if (eventTrigger.member) {
@@ -12463,7 +12912,7 @@ describe('plugin-meetings', () => {
12463
12912
  eventName: EVENT_TRIGGERS.MEETING_STARTED_SHARING_REMOTE,
12464
12913
  functionName: 'remoteShare',
12465
12914
  eventPayload: {
12466
- memberId: null,
12915
+ memberId: beneficiaryId,
12467
12916
  url,
12468
12917
  shareInstanceId,
12469
12918
  annotationInfo: undefined,
@@ -12641,6 +13090,36 @@ describe('plugin-meetings', () => {
12641
13090
  });
12642
13091
  });
12643
13092
 
13093
+ describe('Whiteboard Share - User is guest', () => {
13094
+ it('User receives a remote share instead of whiteboard share', () => {
13095
+ // Set the guest flag
13096
+ meeting.guest = true;
13097
+
13098
+ // Step 1: Start sharing whiteboard A
13099
+ const data1 = generateData(
13100
+ blankPayload, // Initial payload
13101
+ true, // isGranting: Granting share
13102
+ false, // isContent: Whiteboard (not content)
13103
+ USER_IDS.REMOTE_A, // Beneficiary ID: Remote user A
13104
+ RESOURCE_URLS.WHITEBOARD_A // Resource URL: Whiteboard A
13105
+ );
13106
+
13107
+ // Step 2: Stop sharing whiteboard A
13108
+ const data2 = generateData(
13109
+ data1.payload, // Updated payload from Step 1
13110
+ false, // isGranting: Stopping share
13111
+ false, // isContent: Whiteboard
13112
+ USER_IDS.REMOTE_A // Beneficiary ID: Remote user A
13113
+ );
13114
+
13115
+ // Validate the payload changes and status updates
13116
+ payloadTestHelper([data1]);
13117
+
13118
+ // Specific assertions for guest
13119
+ assert.equal(meeting.shareStatus, SHARE_STATUS.REMOTE_SHARE_ACTIVE);
13120
+ });
13121
+ });
13122
+
12644
13123
  describe('Whiteboard A --> Whiteboard B', () => {
12645
13124
  it('Scenario #1: you share both whiteboards', () => {
12646
13125
  const data1 = generateData(
@@ -13292,30 +13771,78 @@ describe('plugin-meetings', () => {
13292
13771
  payloadTestHelper([data1, data2, data3]);
13293
13772
  });
13294
13773
  });
13295
- });
13296
13774
 
13297
- describe('handleShareVideoStreamMuteStateChange', () => {
13298
- it('should emit MEETING_SHARE_VIDEO_MUTE_STATE_CHANGE event with correct fields', () => {
13299
- meeting.isMultistream = true;
13300
- meeting.statsAnalyzer = {shareVideoEncoderImplementation: 'OpenH264'};
13301
- meeting.mediaProperties.shareVideoStream = {
13302
- getSettings: sinon.stub().returns({displaySurface: 'monitor', frameRate: 30}),
13303
- };
13775
+ it('should send share stopped metric when whiteboard sharing stops', () => {
13776
+ // Start whiteboard sharing (this won't trigger metrics)
13777
+ const data1 = generateData(
13778
+ blankPayload,
13779
+ true, // isGranting: true
13780
+ false, // isContent: false (whiteboard)
13781
+ USER_IDS.ME,
13782
+ RESOURCE_URLS.WHITEBOARD_A
13783
+ );
13304
13784
 
13305
- meeting.handleShareVideoStreamMuteStateChange(true);
13785
+ // Stop whiteboard sharing (this should trigger metrics)
13786
+ const data2 = generateData(
13787
+ data1.payload,
13788
+ false, // isGranting: false (stopping share)
13789
+ false, // isContent: false (whiteboard)
13790
+ USER_IDS.ME
13791
+ );
13306
13792
 
13307
- assert.calledOnceWithExactly(
13308
- Metrics.sendBehavioralMetric,
13309
- BEHAVIORAL_METRICS.MEETING_SHARE_VIDEO_MUTE_STATE_CHANGE,
13310
- {
13311
- correlationId: meeting.correlationId,
13312
- muted: true,
13313
- encoderImplementation: 'OpenH264',
13314
- displaySurface: 'monitor',
13315
- isMultistream: true,
13316
- frameRate: 30,
13317
- }
13793
+ // Trigger the events
13794
+ meeting.locusInfo.emit(
13795
+ {function: 'test', file: 'test'},
13796
+ EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES,
13797
+ data1.payload
13798
+ );
13799
+
13800
+ meeting.locusInfo.emit(
13801
+ {function: 'test', file: 'test'},
13802
+ EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES,
13803
+ data2.payload
13318
13804
  );
13805
+
13806
+ // Verify metrics were called when whiteboard sharing stopped
13807
+ assert.calledWith(webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp, {
13808
+ key: 'internal.client.share.stopped',
13809
+ });
13810
+
13811
+ assert.calledWith(webex.internal.newMetrics.submitClientEvent, {
13812
+ name: 'client.share.stopped',
13813
+ payload: {
13814
+ mediaType: 'whiteboard',
13815
+ shareDuration: 1500, // mocked return value
13816
+ },
13817
+ options: {
13818
+ meetingId: meeting.id,
13819
+ },
13820
+ });
13821
+ });
13822
+
13823
+ describe('handleShareVideoStreamMuteStateChange', () => {
13824
+ it('should emit MEETING_SHARE_VIDEO_MUTE_STATE_CHANGE event with correct fields', () => {
13825
+ meeting.isMultistream = true;
13826
+ meeting.statsAnalyzer = {shareVideoEncoderImplementation: 'OpenH264'};
13827
+ meeting.mediaProperties.shareVideoStream = {
13828
+ getSettings: sinon.stub().returns({displaySurface: 'monitor', frameRate: 30}),
13829
+ };
13830
+
13831
+ meeting.handleShareVideoStreamMuteStateChange(true);
13832
+
13833
+ assert.calledOnceWithExactly(
13834
+ Metrics.sendBehavioralMetric,
13835
+ BEHAVIORAL_METRICS.MEETING_SHARE_VIDEO_MUTE_STATE_CHANGE,
13836
+ {
13837
+ correlationId: meeting.correlationId,
13838
+ muted: true,
13839
+ encoderImplementation: 'OpenH264',
13840
+ displaySurface: 'monitor',
13841
+ isMultistream: true,
13842
+ frameRate: 30,
13843
+ }
13844
+ );
13845
+ });
13319
13846
  });
13320
13847
  });
13321
13848
  });
@@ -14517,11 +15044,81 @@ describe('plugin-meetings', () => {
14517
15044
  assert.exists(unsetStagePromise.then);
14518
15045
  await unsetStagePromise;
14519
15046
 
15047
+ assert.calledOnceWithExactly(meeting.meetingRequest.synchronizeStage, locusUrl, {
15048
+ overrideDefault: false,
15049
+ });
15050
+ });
15051
+ });
15052
+
15053
+ describe('#notifyHost', () => {
15054
+ beforeEach(() => {
15055
+ meeting.meetingRequest.notifyHost = sinon.stub().returns(Promise.resolve());
15056
+ });
15057
+
15058
+ it('sends the expected request', async () => {
15059
+ meeting.meetingInfo.siteFullUrl = `convergedats.webex.com`;
15060
+ const meetingUuid = 'meeting-uuid';
15061
+ const displayName = ['Test', 'User'];
15062
+ meeting.locusId = 'locusId';
15063
+
15064
+ const notifyHostPromise = meeting.notifyHost(meetingUuid, displayName);
15065
+
15066
+ assert.exists(notifyHostPromise.then);
15067
+ await notifyHostPromise;
15068
+
14520
15069
  assert.calledOnceWithExactly(
14521
- meeting.meetingRequest.synchronizeStage,
14522
- locusUrl,
14523
- {overrideDefault: false}
15070
+ meeting.meetingRequest.notifyHost,
15071
+ meeting.meetingInfo.siteFullUrl,
15072
+ meeting.locusId,
15073
+ meetingUuid,
15074
+ displayName
15075
+ );
15076
+ });
15077
+ });
15078
+
15079
+ describe('#sipCallOut', () => {
15080
+ beforeEach(() => {
15081
+ meeting.meetingRequest.sipCallOut = sinon.stub().returns(Promise.resolve({body: {}}));
15082
+ });
15083
+
15084
+ it('sends the expected request', async () => {
15085
+ const address = 'sip:user@example.com';
15086
+ const displayName = 'John Doe';
15087
+ const meetingId = 'a643beaa47f04eedac08f1310ca12366';
15088
+
15089
+ meeting.meetingInfo = {
15090
+ meetingId,
15091
+ };
15092
+
15093
+ const sipCallOutPromise = meeting.sipCallOut(address, displayName);
15094
+
15095
+ assert.exists(sipCallOutPromise.then);
15096
+ await sipCallOutPromise;
15097
+
15098
+ assert.calledOnceWithExactly(
15099
+ meeting.meetingRequest.sipCallOut,
15100
+ meetingId,
15101
+ meetingId,
15102
+ address,
15103
+ displayName
14524
15104
  );
14525
15105
  });
14526
15106
  });
15107
+
15108
+ describe('#cancelSipCallOut', () => {
15109
+ beforeEach(() => {
15110
+ meeting.meetingRequest.cancelSipCallOut = sinon.stub().returns(Promise.resolve({body: {}}));
15111
+ });
15112
+
15113
+ it('sends the expected request', async () => {
15114
+ const participantId = '12345-abcde';
15115
+
15116
+ const cancelSipCallOutPromise = meeting.cancelSipCallOut(participantId);
15117
+
15118
+ assert.exists(cancelSipCallOutPromise.then);
15119
+ await cancelSipCallOutPromise;
15120
+
15121
+ assert.calledOnceWithExactly(meeting.meetingRequest.cancelSipCallOut, participantId);
15122
+ });
15123
+ });
14527
15124
  });