@webex/plugin-meetings 3.9.0-next.2 → 3.9.0-next.21

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 (87) hide show
  1. package/dist/breakouts/breakout.js +1 -1
  2. package/dist/breakouts/index.js +1 -1
  3. package/dist/constants.js +2 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/interpretation/index.js +1 -1
  6. package/dist/interpretation/siLanguage.js +1 -1
  7. package/dist/locus-info/index.js +38 -10
  8. package/dist/locus-info/index.js.map +1 -1
  9. package/dist/locus-info/parser.js +4 -1
  10. package/dist/locus-info/parser.js.map +1 -1
  11. package/dist/media/properties.js +53 -5
  12. package/dist/media/properties.js.map +1 -1
  13. package/dist/meeting/in-meeting-actions.js +2 -0
  14. package/dist/meeting/in-meeting-actions.js.map +1 -1
  15. package/dist/meeting/index.js +189 -122
  16. package/dist/meeting/index.js.map +1 -1
  17. package/dist/meeting/muteState.js +2 -5
  18. package/dist/meeting/muteState.js.map +1 -1
  19. package/dist/meeting/request.js +25 -0
  20. package/dist/meeting/request.js.map +1 -1
  21. package/dist/meeting/util.js +30 -11
  22. package/dist/meeting/util.js.map +1 -1
  23. package/dist/meeting-info/meeting-info-v2.js +29 -21
  24. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  25. package/dist/meetings/index.js +31 -25
  26. package/dist/meetings/index.js.map +1 -1
  27. package/dist/member/types.js.map +1 -1
  28. package/dist/members/collection.js +13 -0
  29. package/dist/members/collection.js.map +1 -1
  30. package/dist/members/index.js +42 -20
  31. package/dist/members/index.js.map +1 -1
  32. package/dist/members/util.js +7 -2
  33. package/dist/members/util.js.map +1 -1
  34. package/dist/metrics/constants.js +2 -1
  35. package/dist/metrics/constants.js.map +1 -1
  36. package/dist/reachability/index.js +3 -3
  37. package/dist/reachability/index.js.map +1 -1
  38. package/dist/types/constants.d.ts +2 -0
  39. package/dist/types/locus-info/index.d.ts +54 -1
  40. package/dist/types/media/properties.d.ts +21 -0
  41. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  42. package/dist/types/meeting/index.d.ts +11 -1
  43. package/dist/types/meeting/request.d.ts +9 -0
  44. package/dist/types/meeting/util.d.ts +10 -3
  45. package/dist/types/meeting-info/meeting-info-v2.d.ts +6 -3
  46. package/dist/types/meetings/index.d.ts +3 -1
  47. package/dist/types/member/types.d.ts +1 -0
  48. package/dist/types/members/collection.d.ts +6 -0
  49. package/dist/types/members/index.d.ts +12 -2
  50. package/dist/types/members/util.d.ts +6 -3
  51. package/dist/types/metrics/constants.d.ts +1 -0
  52. package/dist/webinar/index.js +1 -1
  53. package/package.json +17 -17
  54. package/src/constants.ts +2 -0
  55. package/src/locus-info/index.ts +84 -9
  56. package/src/locus-info/parser.ts +5 -1
  57. package/src/media/properties.ts +43 -0
  58. package/src/meeting/in-meeting-actions.ts +4 -0
  59. package/src/meeting/index.ts +91 -4
  60. package/src/meeting/muteState.ts +2 -6
  61. package/src/meeting/request.ts +23 -0
  62. package/src/meeting/util.ts +41 -20
  63. package/src/meeting-info/meeting-info-v2.ts +24 -5
  64. package/src/meetings/index.ts +9 -3
  65. package/src/member/types.ts +1 -0
  66. package/src/members/collection.ts +11 -0
  67. package/src/members/index.ts +38 -5
  68. package/src/members/util.ts +18 -2
  69. package/src/metrics/constants.ts +1 -0
  70. package/src/reachability/index.ts +3 -3
  71. package/test/unit/spec/common/browser-detection.js +0 -24
  72. package/test/unit/spec/locus-info/index.js +30 -15
  73. package/test/unit/spec/locus-info/parser.js +3 -2
  74. package/test/unit/spec/media/properties.ts +137 -0
  75. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  76. package/test/unit/spec/meeting/index.js +255 -27
  77. package/test/unit/spec/meeting/muteState.js +32 -6
  78. package/test/unit/spec/meeting/request.js +21 -0
  79. package/test/unit/spec/meeting/utils.js +45 -16
  80. package/test/unit/spec/meeting-info/meetinginfov2.js +8 -3
  81. package/test/unit/spec/meetings/index.js +10 -5
  82. package/test/unit/spec/members/collection.js +120 -0
  83. package/test/unit/spec/members/index.js +72 -3
  84. package/test/unit/spec/members/request.js +55 -0
  85. package/test/unit/spec/members/utils.js +116 -14
  86. package/test/unit/spec/reachability/index.ts +158 -3
  87. package/test/unit/spec/roap/turnDiscovery.ts +3 -3
@@ -371,6 +371,7 @@ export default class MeetingInfoV2 {
371
371
  * @param {String} conversationUrl conversationUrl to start adhoc meeting on
372
372
  * @param {String} installedOrgID org ID of user's machine
373
373
  * @param {Boolean} enableStaticMeetingLink whether or not to enable static meeting link
374
+ * @param {String} classificationId need it to start adhoc meeting if space support classification
374
375
  * @returns {Promise} returns a meeting info object
375
376
  * @public
376
377
  * @memberof MeetingInfo
@@ -379,7 +380,8 @@ export default class MeetingInfoV2 {
379
380
  conversationUrl: string,
380
381
  installedOrgID?: string,
381
382
  // setting this to true enables static meeting link
382
- enableStaticMeetingLink = false
383
+ enableStaticMeetingLink = false,
384
+ classificationId = undefined
383
385
  ) {
384
386
  const getInvitees = (particpants = []) => {
385
387
  const invitees = [];
@@ -407,6 +409,7 @@ export default class MeetingInfoV2 {
407
409
  invitees: getInvitees(conversation.participants?.items),
408
410
  installedOrgID,
409
411
  schedule: enableStaticMeetingLink,
412
+ classificationId,
410
413
  };
411
414
 
412
415
  if (installedOrgID) {
@@ -429,16 +432,26 @@ export default class MeetingInfoV2 {
429
432
  * Creates adhoc space meetings for a space by fetching the conversation infomation
430
433
  * @param {String} conversationUrl conversationUrl to start adhoc meeting on
431
434
  * @param {String} installedOrgID org ID of user's machine
435
+ * @param {String} classificationId if space is support classification, it needs provide it during start instant meeting
432
436
  * @returns {Promise} returns a meeting info object
433
437
  * @public
434
438
  * @memberof MeetingInfo
435
439
  */
436
- async createAdhocSpaceMeeting(conversationUrl: string, installedOrgID?: string) {
440
+ async createAdhocSpaceMeeting(
441
+ conversationUrl: string,
442
+ installedOrgID?: string,
443
+ classificationId?: string
444
+ ) {
437
445
  if (!this.webex.meetings.preferredWebexSite) {
438
446
  throw Error('No preferred webex site found');
439
447
  }
440
448
 
441
- return this.createAdhocSpaceMeetingOrEnableStaticMeetingLink(conversationUrl, installedOrgID)
449
+ return this.createAdhocSpaceMeetingOrEnableStaticMeetingLink(
450
+ conversationUrl,
451
+ installedOrgID,
452
+ false,
453
+ classificationId
454
+ )
442
455
  .then((requestResult) => {
443
456
  Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.ADHOC_MEETING_SUCCESS);
444
457
 
@@ -618,6 +631,7 @@ export default class MeetingInfoV2 {
618
631
  * @param {Object} options
619
632
  * @param {String} registrationId
620
633
  * @param {String} fullSiteUrl
634
+ * @param {String} classificationId
621
635
  * @returns {Promise} returns a meeting info object
622
636
  * @public
623
637
  * @memberof MeetingInfo
@@ -635,7 +649,8 @@ export default class MeetingInfoV2 {
635
649
  extraParams: object = {},
636
650
  options: {meetingId?: string; sendCAevents?: boolean} = {},
637
651
  registrationId: string = null,
638
- fullSiteUrl: string = null
652
+ fullSiteUrl: string = null,
653
+ classificationId: string = null
639
654
  ) {
640
655
  const {meetingId, sendCAevents} = options;
641
656
 
@@ -650,7 +665,11 @@ export default class MeetingInfoV2 {
650
665
  this.webex.config.meetings.experimental.enableAdhocMeetings &&
651
666
  this.webex.meetings.preferredWebexSite
652
667
  ) {
653
- return this.createAdhocSpaceMeeting(destinationType.destination, installedOrgID);
668
+ return this.createAdhocSpaceMeeting(
669
+ destinationType.destination,
670
+ installedOrgID,
671
+ classificationId
672
+ );
654
673
  }
655
674
 
656
675
  const body = await MeetingInfoUtil.getRequestBody({
@@ -1331,6 +1331,7 @@ export default class Meetings extends WebexPlugin {
1331
1331
  * @param {Object} [meetingInfo] - Pre-fetched complete meeting info
1332
1332
  * @param {String} [meetingLookupUrl] - meeting info prefetch url
1333
1333
  * @param {string} sessionCorrelationId - the optional specified sessionCorrelationId (callStateForMetrics.sessionCorrelationId) can be provided instead
1334
+ * @param {String} classificationId - If space support classification, it will provide it while start instant meeting
1334
1335
  * @returns {Promise<Meeting>} A new Meeting.
1335
1336
  * @public
1336
1337
  * @memberof Meetings
@@ -1345,7 +1346,8 @@ export default class Meetings extends WebexPlugin {
1345
1346
  callStateForMetrics: CallStateForMetrics = undefined,
1346
1347
  meetingInfo = undefined,
1347
1348
  meetingLookupUrl = undefined,
1348
- sessionCorrelationId: string = undefined
1349
+ sessionCorrelationId: string = undefined,
1350
+ classificationId: string = undefined
1349
1351
  ) {
1350
1352
  // Validate meeting information based on the provided destination and
1351
1353
  // type. This must be performed prior to determining if the meeting is
@@ -1415,7 +1417,8 @@ export default class Meetings extends WebexPlugin {
1415
1417
  callStateForMetrics,
1416
1418
  failOnMissingMeetingInfo,
1417
1419
  meetingInfo,
1418
- meetingLookupUrl
1420
+ meetingLookupUrl,
1421
+ classificationId
1419
1422
  ).then((createdMeeting: any) => {
1420
1423
  // If the meeting was successfully created.
1421
1424
  if (createdMeeting && createdMeeting.on) {
@@ -1529,6 +1532,7 @@ export default class Meetings extends WebexPlugin {
1529
1532
  * @param {Boolean} failOnMissingMeetingInfo - whether to throw an error if meeting info fails to fetch (for calls that are not 1:1 or content share)
1530
1533
  * @param {Object} [meetingInfo] - Pre-fetched complete meeting info
1531
1534
  * @param {String} [meetingLookupUrl] - meeting info prefetch url
1535
+ * @param {String} classificationId see create()
1532
1536
  * @returns {Promise} a new meeting instance complete with meeting info and destination
1533
1537
  * @private
1534
1538
  * @memberof Meetings
@@ -1541,7 +1545,8 @@ export default class Meetings extends WebexPlugin {
1541
1545
  callStateForMetrics: CallStateForMetrics = undefined,
1542
1546
  failOnMissingMeetingInfo = false,
1543
1547
  meetingInfo = undefined,
1544
- meetingLookupUrl = undefined
1548
+ meetingLookupUrl = undefined,
1549
+ classificationId = undefined
1545
1550
  ) {
1546
1551
  const meeting = new Meeting(
1547
1552
  {
@@ -1589,6 +1594,7 @@ export default class Meetings extends WebexPlugin {
1589
1594
  // @ts-ignore
1590
1595
  const {enableUnifiedMeetings} = this.config.experimental;
1591
1596
  const meetingInfoOptions = {
1597
+ classificationId,
1592
1598
  extraParams: infoExtraParams,
1593
1599
  sendCAevents: !!callStateForMetrics?.correlationId, // if client sends correlation id as argument of public create(), then it means that this meeting creation is part of a pre-join intent from user
1594
1600
  };
@@ -109,4 +109,5 @@ export interface Participant {
109
109
  status: ParticipantMediaStatus;
110
110
  type: string;
111
111
  url: ParticipantUrl;
112
+ isRemoved: boolean; // JS-SDK internal field to indicate in updates when the participant is removed
112
113
  }
@@ -39,6 +39,17 @@ export default class MembersCollection {
39
39
  return this.members;
40
40
  }
41
41
 
42
+ /**
43
+ * Removes a member from the collection
44
+ * @param {String} id
45
+ * @returns {void}
46
+ */
47
+ remove(id: string) {
48
+ if (this.members[id]) {
49
+ delete this.members[id];
50
+ }
51
+ }
52
+
42
53
  /**
43
54
  * @returns {void}
44
55
  * reset members
@@ -74,7 +74,11 @@ import {Invitee} from '../meeting/type';
74
74
  * @memberof Members
75
75
  */
76
76
 
77
- type UpdatedMembers = {added: Array<Member>; updated: Array<Member>};
77
+ type UpdatedMembers = {
78
+ added: Array<Member>;
79
+ updated: Array<Member>;
80
+ removedIds?: Array<string>; // removed member ids
81
+ };
78
82
  /**
79
83
  * @class Members
80
84
  */
@@ -384,12 +388,18 @@ export default class Members extends StatelessWebexPlugin {
384
388
  * when new participant updates come in, both delta and full participants, update them in members collection
385
389
  * delta object in the event will have {updated, added} and full will be the full membersCollection
386
390
  * @param {Object} payload
387
- * @param {Object} payload.participants
391
+ * @param {Object} payload.participants new/updated participants
392
+ * @param {Boolean} payload.isReplace whether to replace the whole members collection
393
+ * @param {Object} payload.removedParticipantIds ids of the removed participants
388
394
  * @returns {undefined}
389
395
  * @private
390
396
  * @memberof Members
391
397
  */
392
- locusParticipantsUpdate(payload: {participants: object; isReplace?: boolean}) {
398
+ locusParticipantsUpdate(payload: {
399
+ participants: object;
400
+ isReplace?: boolean;
401
+ removedParticipantIds?: Array<string>;
402
+ }) {
393
403
  if (payload) {
394
404
  if (payload.isReplace) {
395
405
  this.clearMembers();
@@ -547,10 +557,22 @@ export default class Members extends StatelessWebexPlugin {
547
557
  private handleMembersUpdate(membersUpdate: UpdatedMembers) {
548
558
  this.constructMembers(membersUpdate.updated, true);
549
559
  this.constructMembers(membersUpdate.added, false);
560
+ this.removeMembers(membersUpdate.removedIds);
550
561
 
551
562
  return this.membersCollection.getAll();
552
563
  }
553
564
 
565
+ /**
566
+ * removes members from the collection
567
+ * @param {Array<string>} removedMembers removed members ids
568
+ * @returns {void}
569
+ */
570
+ private removeMembers(removedMembers: Array<string>) {
571
+ removedMembers.forEach((memberId) => {
572
+ this.membersCollection.remove(memberId);
573
+ });
574
+ }
575
+
554
576
  /**
555
577
  * set members to the member collection from each updated/added lists as passed in
556
578
  * @param {Array} list
@@ -600,6 +622,10 @@ export default class Members extends StatelessWebexPlugin {
600
622
  }
601
623
  const memberUpdate = this.update(payload.participants);
602
624
 
625
+ // this code depends on memberIds being the same as participantIds
626
+ // if MemberUtil.extractId() ever changes, this will need to be updated
627
+ memberUpdate.removedIds = payload.removedParticipantIds || [];
628
+
603
629
  return memberUpdate;
604
630
  }
605
631
 
@@ -1181,11 +1207,17 @@ export default class Members extends StatelessWebexPlugin {
1181
1207
  * @param {string} memberId - id of the participant who is receiving request
1182
1208
  * @param {string} requestingParticipantId - id of the participant who is sending request (optional)
1183
1209
  * @param {string} [alias] - alias name
1210
+ * @param {string} [suffix] - name suffix (optional)
1184
1211
  * @returns {Promise}
1185
1212
  * @public
1186
1213
  * @memberof Members
1187
1214
  */
1188
- public editDisplayName(memberId: string, requestingParticipantId: string, alias: string) {
1215
+ public editDisplayName(
1216
+ memberId: string,
1217
+ requestingParticipantId: string,
1218
+ alias: string,
1219
+ suffix?: string
1220
+ ) {
1189
1221
  if (!this.locusUrl) {
1190
1222
  return Promise.reject(
1191
1223
  new ParameterError(
@@ -1205,7 +1237,8 @@ export default class Members extends StatelessWebexPlugin {
1205
1237
  memberId,
1206
1238
  requestingParticipantId,
1207
1239
  alias,
1208
- locusUrl
1240
+ locusUrl,
1241
+ suffix
1209
1242
  );
1210
1243
 
1211
1244
  return this.membersRequest.editDisplayNameMember(options);
@@ -190,13 +190,21 @@ const MembersUtil = {
190
190
  * @param {String} requestingParticipantId id of the participant who is sending request (optional)
191
191
  * @param {String} alias alias name
192
192
  * @param {String} locusUrl url
193
+ * @param {String} suffix optional suffix
193
194
  * @returns {Object} consists of {memberID: string, requestingParticipantId: string, alias: string, locusUrl: string}
194
195
  */
195
- generateEditDisplayNameMemberOptions: (memberId, requestingParticipantId, alias, locusUrl) => ({
196
+ generateEditDisplayNameMemberOptions: (
196
197
  memberId,
197
198
  requestingParticipantId,
198
199
  alias,
199
200
  locusUrl,
201
+ suffix
202
+ ) => ({
203
+ memberId,
204
+ requestingParticipantId,
205
+ alias,
206
+ locusUrl,
207
+ suffix,
200
208
  }),
201
209
 
202
210
  getMuteMemberRequestParams: (options) => {
@@ -301,10 +309,18 @@ const MembersUtil = {
301
309
  * @returns {Object} request parameters (method, uri, body) needed to make a editDisplayName request
302
310
  */
303
311
  editDisplayNameMemberRequestParams: (options) => {
304
- const body = {
312
+ const body: {
313
+ aliasValue: string;
314
+ requestingParticipantId: string;
315
+ suffixValue?: string;
316
+ } = {
305
317
  aliasValue: options.alias,
306
318
  requestingParticipantId: options.requestingParticipantId,
307
319
  };
320
+
321
+ if (options.suffix !== undefined) {
322
+ body.suffixValue = options.suffix;
323
+ }
308
324
  const uri = `${options.locusUrl}/${PARTICIPANT}/${options.memberId}/${ALIAS}`;
309
325
 
310
326
  return {
@@ -86,6 +86,7 @@ const BEHAVIORAL_METRICS = {
86
86
  VERIFY_REGISTRATION_ID_SUCCESS: 'js_sdk_verify_registrationId_success',
87
87
  VERIFY_REGISTRATION_ID_ERROR: 'js_sdk_verify_registrationId_error',
88
88
  JOIN_FORBIDDEN_ERROR: 'js_sdk_join_forbidden_error',
89
+ MEDIA_ISSUE_DETECTED: 'js_sdk_media_issue_detected',
89
90
  };
90
91
 
91
92
  export {BEHAVIORAL_METRICS as default};
@@ -923,10 +923,10 @@ export default class Reachability extends EventsScope {
923
923
 
924
924
  // update expected results counters to include this cluster
925
925
  this.expectedResultsCount[cluster.isVideoMesh ? 'videoMesh' : 'public'].udp +=
926
- cluster.udp.length;
926
+ cluster.udp.length > 0 ? 1 : 0;
927
927
  if (!cluster.isVideoMesh) {
928
- this.expectedResultsCount.public.tcp += cluster.tcp.length;
929
- this.expectedResultsCount.public.xtls += cluster.xtls.length;
928
+ this.expectedResultsCount.public.tcp += cluster.tcp.length > 0 ? 1 : 0;
929
+ this.expectedResultsCount.public.xtls += cluster.xtls.length > 0 ? 1 : 0;
930
930
  }
931
931
  });
932
932
 
@@ -18,16 +18,6 @@ const USER_AGENT_SAFARI_MAC =
18
18
  const USER_AGENT_FIREFOX_MAC =
19
19
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:87.0) ' + 'Gecko/20100101 Firefox/87.0';
20
20
 
21
- const mockDetectionObject = {
22
- /* eslint-disable global-require */
23
- getOSName: () => require('os').platform(),
24
- getOSVersion: () => require('os').release(),
25
- /* eslint-enable global-require */
26
- getBrowserName: () => '',
27
- getBrowserVersion: () => '',
28
- isBrowser: () => false,
29
- };
30
-
31
21
  describe('common/browser-detection', () => {
32
22
  it('returns the correct browser name.', () => {
33
23
  assert.equal(
@@ -102,18 +92,4 @@ describe('common/browser-detection', () => {
102
92
  'This browser is NOT Firefox'
103
93
  );
104
94
  });
105
-
106
- it('returns the mock object when there is no userAgent', () => {
107
- Object.defineProperty(global.window.navigator, 'userAgent', {
108
- get: () => undefined,
109
- configurable: true,
110
- });
111
-
112
- const {getBrowserName, getBrowserVersion, getOSName, getOSVersion} = BrowserDetection(null);
113
-
114
- assert.equal(getBrowserName(), mockDetectionObject.getBrowserName());
115
- assert.equal(getBrowserVersion(), mockDetectionObject.getBrowserVersion());
116
- assert.equal(getOSName(), mockDetectionObject.getOSName());
117
- assert.equal(getOSVersion(), mockDetectionObject.getOSVersion());
118
- });
119
95
  });
@@ -772,7 +772,7 @@ describe('plugin-meetings', () => {
772
772
  },
773
773
  };
774
774
  locusInfo.emitScoped = sinon.stub();
775
- locusInfo.updateParticipants({});
775
+ locusInfo.updateParticipants({}, []);
776
776
 
777
777
  // if this assertion fails, double-check the attributes used in
778
778
  // the updateParticipants function in locus-info/index.js
@@ -790,6 +790,7 @@ describe('plugin-meetings', () => {
790
790
  selfId: '2',
791
791
  hostId: '3',
792
792
  isReplace: undefined,
793
+ removedParticipantIds: [],
793
794
  }
794
795
  );
795
796
  // note: in a real use case, recordingId, selfId, and hostId would all be the same
@@ -814,7 +815,7 @@ describe('plugin-meetings', () => {
814
815
  };
815
816
 
816
817
  locusInfo.emitScoped = sinon.stub();
817
- locusInfo.updateParticipants({}, true);
818
+ locusInfo.updateParticipants({}, [], true);
818
819
 
819
820
  assert.calledWith(
820
821
  locusInfo.emitScoped,
@@ -830,6 +831,7 @@ describe('plugin-meetings', () => {
830
831
  selfId: '2',
831
832
  hostId: '3',
832
833
  isReplace: true,
834
+ removedParticipantIds: [],
833
835
  }
834
836
  );
835
837
  });
@@ -847,7 +849,7 @@ describe('plugin-meetings', () => {
847
849
  ];
848
850
 
849
851
  locusInfo.emitScoped = sinon.stub();
850
- locusInfo.updateParticipants(failureParticipant);
852
+ locusInfo.updateParticipants(failureParticipant, []);
851
853
  assert.calledWith(
852
854
  locusInfo.emitScoped,
853
855
  {
@@ -2038,6 +2040,18 @@ describe('plugin-meetings', () => {
2038
2040
  });
2039
2041
  });
2040
2042
 
2043
+ describe('#handleLocusAPIResponse', () => {
2044
+ it('calls handleLocusDelta', () => {
2045
+ const fakeLocus = {eventType: LOCUSEVENT.DIFFERENCE};
2046
+
2047
+ sinon.stub(locusInfo, 'handleLocusDelta');
2048
+
2049
+ locusInfo.handleLocusAPIResponse(mockMeeting, {locus: fakeLocus});
2050
+
2051
+ assert.calledWith(locusInfo.handleLocusDelta, fakeLocus, mockMeeting);
2052
+ });
2053
+ });
2054
+
2041
2055
  describe('#LocusDeltaEvents', () => {
2042
2056
  const fakeMeeting = 'fakeMeeting';
2043
2057
  let sandbox = null;
@@ -2050,7 +2064,7 @@ describe('plugin-meetings', () => {
2050
2064
 
2051
2065
  fakeLocus = {
2052
2066
  meeting: true,
2053
- participants: true,
2067
+ participants: [],
2054
2068
  url: 'newLocusUrl',
2055
2069
  syncUrl: 'newSyncUrl',
2056
2070
  };
@@ -2369,23 +2383,23 @@ describe('plugin-meetings', () => {
2369
2383
 
2370
2384
  it('applyLocusDeltaData handles LOCUS_URL_CHANGED action correctly', () => {
2371
2385
  const {LOCUS_URL_CHANGED} = LocusDeltaParser.loci;
2372
- const fakeDeltaLocus = {id: 'fake delta locus'};
2386
+ const fakeFullLocus = {
2387
+ url: 'new full loci url',
2388
+ };
2373
2389
  const meeting = {
2374
2390
  meetingRequest: {
2375
- getLocusDTO: sandbox.stub().resolves({body: fakeDeltaLocus}),
2391
+ getLocusDTO: sandbox.stub().resolves({body: fakeFullLocus}),
2376
2392
  },
2377
2393
  locusInfo: {
2378
2394
  handleLocusDelta: sandbox.stub(),
2379
2395
  },
2380
- locusUrl: 'current locus url',
2396
+ locusUrl: 'current BO session locus url',
2381
2397
  };
2382
2398
 
2383
- locusInfo.locusParser.workingCopy = {
2384
- syncUrl: 'current sync url',
2385
- };
2399
+ locusInfo.locusParser.workingCopy = null;
2386
2400
 
2387
2401
  locusInfo.applyLocusDeltaData(LOCUS_URL_CHANGED, fakeLocus, meeting);
2388
- assert.calledOnceWithExactly(meeting.meetingRequest.getLocusDTO, {url: 'current sync url'});
2402
+ assert.calledOnceWithExactly(meeting.meetingRequest.getLocusDTO, {url: fakeLocus.url});
2389
2403
  });
2390
2404
 
2391
2405
  describe('edge cases for sync failing', () => {
@@ -2525,7 +2539,7 @@ describe('plugin-meetings', () => {
2525
2539
  });
2526
2540
 
2527
2541
  it('onDeltaLocus handle delta data', () => {
2528
- fakeLocus.participants = {};
2542
+ fakeLocus.participants = [];
2529
2543
  const fakeBreakout = {
2530
2544
  sessionId: 'sessionId',
2531
2545
  groupId: 'groupId',
@@ -2542,11 +2556,11 @@ describe('plugin-meetings', () => {
2542
2556
  };
2543
2557
  locusInfo.updateParticipants = sinon.stub();
2544
2558
  locusInfo.onDeltaLocus(fakeLocus);
2545
- assert.calledWith(locusInfo.updateParticipants, {}, false);
2559
+ assert.calledWith(locusInfo.updateParticipants, [], undefined, false);
2546
2560
 
2547
2561
  fakeLocus.controls.breakout.sessionId = 'sessionId2';
2548
2562
  locusInfo.onDeltaLocus(fakeLocus);
2549
- assert.calledWith(locusInfo.updateParticipants, {}, true);
2563
+ assert.calledWith(locusInfo.updateParticipants, [], undefined, true);
2550
2564
  });
2551
2565
 
2552
2566
  it('onDeltaLocus merges delta participants with existing participants', () => {
@@ -2563,7 +2577,7 @@ describe('plugin-meetings', () => {
2563
2577
  existingParticipants,
2564
2578
  FAKE_DELTA_PARTICIPANTS
2565
2579
  );
2566
- assert.calledWith(locusInfo.updateParticipants, FAKE_DELTA_PARTICIPANTS, false);
2580
+ assert.calledWith(locusInfo.updateParticipants, FAKE_DELTA_PARTICIPANTS, undefined, false);
2567
2581
  });
2568
2582
 
2569
2583
  [true, false].forEach((isDelta) =>
@@ -3074,6 +3088,7 @@ describe('plugin-meetings', () => {
3074
3088
  id: 'test person id',
3075
3089
  },
3076
3090
  },
3091
+ participants: [],
3077
3092
  });
3078
3093
 
3079
3094
  updateLocusInfoStub.resetHistory();
@@ -253,7 +253,8 @@ describe('locus-info/parser', () => {
253
253
  });
254
254
 
255
255
  it('replaces current loci when the locus URL changes and incoming sequence is later, even when baseSequence doesn\'t match', () => {
256
- const {USE_INCOMING} = LocusDeltaParser.loci;
256
+ const {LOCUS_URL_CHANGED} = LocusDeltaParser.loci;
257
+ sandbox.stub(LocusDeltaParser, 'compare').returns(LOCUS_URL_CHANGED);
257
258
 
258
259
  parser.queue.dequeue = sandbox.stub().returns(NEW_LOCI);
259
260
  parser.onDeltaAction = sandbox.stub();
@@ -262,7 +263,7 @@ describe('locus-info/parser', () => {
262
263
 
263
264
  parser.processDeltaEvent();
264
265
 
265
- assert.equal(parser.workingCopy, NEW_LOCI);
266
+ assert.equal(parser.workingCopy, null);
266
267
  });
267
268
 
268
269
  it('does not replace current loci when the locus URL changes but incoming sequence is not later', () => {
@@ -6,6 +6,8 @@ import * as tsSdpModule from '@webex/ts-sdp';
6
6
  import MediaProperties from '@webex/plugin-meetings/src/media/properties';
7
7
  import {Defer} from '@webex/common';
8
8
  import MediaConnectionAwaiter from '../../../../src/media/MediaConnectionAwaiter';
9
+ import Metrics from '../../../../src/metrics';
10
+ import BEHAVIORAL_METRICS from '../../../../src/metrics/constants';
9
11
 
10
12
  describe('MediaProperties', () => {
11
13
  let mediaProperties;
@@ -389,4 +391,139 @@ describe('MediaProperties', () => {
389
391
  });
390
392
  });
391
393
  });
394
+
395
+ // issue types and subtypes used in these tests are just examples
396
+ // they don't reflect real issue types/subtypes used in production
397
+ describe('sendMediaIssueMetric', () => {
398
+ let sendBehavioralMetricStub;
399
+ let clock;
400
+
401
+ beforeEach(() => {
402
+ clock = sinon.useFakeTimers();
403
+ sendBehavioralMetricStub = sinon.stub(Metrics, 'sendBehavioralMetric');
404
+ });
405
+
406
+ afterEach(() => {
407
+ clock.restore();
408
+ });
409
+
410
+ it('should send a behavioral metric with correct parameters', () => {
411
+ const issueType = 'audio';
412
+ const issueSubType = 'packet-loss';
413
+ const correlationId = 'test-correlation-id-123';
414
+
415
+ mediaProperties.sendMediaIssueMetric(issueType, issueSubType, correlationId);
416
+
417
+ assert.calledOnce(sendBehavioralMetricStub);
418
+ assert.calledWith(sendBehavioralMetricStub, BEHAVIORAL_METRICS.MEDIA_ISSUE_DETECTED, {
419
+ correlationId,
420
+ 'audio_packet-loss': 1,
421
+ });
422
+ });
423
+
424
+ it('should increment count while being throttled and reset it once metric goes out', () => {
425
+ const issueType = 'video';
426
+ const issueSubType = 'freeze';
427
+ const correlationId = 'test-correlation-id';
428
+
429
+ // Call multiple times with same issue type/subtype
430
+ mediaProperties.sendMediaIssueMetric(issueType, issueSubType, correlationId);
431
+ mediaProperties.sendMediaIssueMetric(issueType, issueSubType, correlationId);
432
+ mediaProperties.sendMediaIssueMetric(issueType, issueSubType, correlationId);
433
+
434
+ // First call should go through immediately, subsequent calls are throttled
435
+ assert.calledOnce(sendBehavioralMetricStub);
436
+ assert.calledWith(sendBehavioralMetricStub, BEHAVIORAL_METRICS.MEDIA_ISSUE_DETECTED, {
437
+ correlationId,
438
+ video_freeze: 1, // Only the first call goes through due to throttling
439
+ });
440
+ sendBehavioralMetricStub.resetHistory();
441
+
442
+ assert.equal(mediaProperties.mediaIssueCounters['video_freeze'], 2); // counter should be reset after the first metric goes out, hence only 2 not 3 here
443
+
444
+ clock.tick(5 * 60 * 1000); // Advance time by 5 minutes to expire throttle
445
+
446
+ assert.calledOnceWithExactly(
447
+ sendBehavioralMetricStub,
448
+ BEHAVIORAL_METRICS.MEDIA_ISSUE_DETECTED,
449
+ {
450
+ correlationId,
451
+ video_freeze: 2,
452
+ }
453
+ );
454
+ });
455
+
456
+ it('should track different issue types separately in counters', () => {
457
+ const correlationId = 'test-correlation-id';
458
+
459
+ // Send different issue types
460
+ mediaProperties.sendMediaIssueMetric('audio', 'packet-loss', correlationId);
461
+ mediaProperties.sendMediaIssueMetric('video', 'freeze', correlationId);
462
+ mediaProperties.sendMediaIssueMetric('audio', 'packet-loss', correlationId);
463
+ mediaProperties.sendMediaIssueMetric('audio', 'packet-loss', correlationId);
464
+ mediaProperties.sendMediaIssueMetric('audio', 'packet-loss', correlationId);
465
+ mediaProperties.sendMediaIssueMetric('video', 'freeze', correlationId);
466
+
467
+ // First call should go through immediately, subsequent calls are throttled
468
+ assert.calledOnceWithExactly(
469
+ sendBehavioralMetricStub,
470
+ BEHAVIORAL_METRICS.MEDIA_ISSUE_DETECTED,
471
+ {
472
+ correlationId,
473
+ 'audio_packet-loss': 1,
474
+ }
475
+ );
476
+
477
+ // But the counters should be tracked separately
478
+ assert.equal(mediaProperties.mediaIssueCounters['audio_packet-loss'], 3);
479
+ assert.equal(mediaProperties.mediaIssueCounters['video_freeze'], 2);
480
+
481
+ sendBehavioralMetricStub.resetHistory();
482
+
483
+ clock.tick(5 * 60 * 1000); // Advance time by 5 minutes to expire throttle
484
+
485
+ assert.calledOnceWithExactly(
486
+ sendBehavioralMetricStub,
487
+ BEHAVIORAL_METRICS.MEDIA_ISSUE_DETECTED,
488
+ {
489
+ correlationId,
490
+ video_freeze: 2,
491
+ 'audio_packet-loss': 3,
492
+ }
493
+ );
494
+ });
495
+
496
+ it('should flush throttled metrics when unsetPeerConnection is called', () => {
497
+ const issueType = 'share';
498
+ const issueSubType = 'connection-lost';
499
+ const correlationId = 'test-correlation-id';
500
+
501
+ // Send metrics multiple times
502
+ mediaProperties.sendMediaIssueMetric(issueType, issueSubType, correlationId);
503
+ mediaProperties.sendMediaIssueMetric(issueType, issueSubType, correlationId);
504
+
505
+ // First call should go through immediately
506
+ assert.calledOnceWithExactly(
507
+ sendBehavioralMetricStub,
508
+ BEHAVIORAL_METRICS.MEDIA_ISSUE_DETECTED,
509
+ {
510
+ correlationId,
511
+ 'share_connection-lost': 1,
512
+ }
513
+ );
514
+ sendBehavioralMetricStub.resetHistory();
515
+
516
+ // Call unsetPeerConnection which should flush throttled metrics
517
+ mediaProperties.unsetPeerConnection();
518
+
519
+ assert.calledOnceWithExactly(
520
+ sendBehavioralMetricStub,
521
+ BEHAVIORAL_METRICS.MEDIA_ISSUE_DETECTED,
522
+ {
523
+ correlationId,
524
+ 'share_connection-lost': 1,
525
+ }
526
+ );
527
+ });
528
+ });
392
529
  });