@webex/plugin-meetings 3.12.0-next.9 → 3.12.0-next.90

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 (213) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +15 -2
  3. package/dist/aiEnableRequest/index.js.map +1 -1
  4. package/dist/breakouts/breakout.js +8 -3
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +26 -2
  7. package/dist/breakouts/index.js.map +1 -1
  8. package/dist/config.js +2 -2
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +35 -9
  11. package/dist/constants.js.map +1 -1
  12. package/dist/controls-options-manager/constants.js +11 -1
  13. package/dist/controls-options-manager/constants.js.map +1 -1
  14. package/dist/controls-options-manager/index.js +67 -29
  15. package/dist/controls-options-manager/index.js.map +1 -1
  16. package/dist/controls-options-manager/util.js +91 -0
  17. package/dist/controls-options-manager/util.js.map +1 -1
  18. package/dist/hashTree/constants.js +13 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +880 -382
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +42 -0
  23. package/dist/hashTree/utils.js.map +1 -1
  24. package/dist/index.js +7 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/interceptors/dataChannelAuthToken.js +75 -15
  27. package/dist/interceptors/dataChannelAuthToken.js.map +1 -1
  28. package/dist/interceptors/locusRetry.js +23 -8
  29. package/dist/interceptors/locusRetry.js.map +1 -1
  30. package/dist/interpretation/index.js +10 -1
  31. package/dist/interpretation/index.js.map +1 -1
  32. package/dist/interpretation/interpretation.types.js +7 -0
  33. package/dist/interpretation/interpretation.types.js.map +1 -0
  34. package/dist/interpretation/siLanguage.js +1 -1
  35. package/dist/locus-info/controlsUtils.js +4 -1
  36. package/dist/locus-info/controlsUtils.js.map +1 -1
  37. package/dist/locus-info/index.js +303 -88
  38. package/dist/locus-info/index.js.map +1 -1
  39. package/dist/locus-info/infoUtils.js +36 -5
  40. package/dist/locus-info/infoUtils.js.map +1 -1
  41. package/dist/locus-info/types.js +19 -0
  42. package/dist/locus-info/types.js.map +1 -1
  43. package/dist/media/index.js +3 -1
  44. package/dist/media/index.js.map +1 -1
  45. package/dist/media/properties.js +1 -0
  46. package/dist/media/properties.js.map +1 -1
  47. package/dist/meeting/in-meeting-actions.js +5 -1
  48. package/dist/meeting/in-meeting-actions.js.map +1 -1
  49. package/dist/meeting/index.js +1233 -793
  50. package/dist/meeting/index.js.map +1 -1
  51. package/dist/meeting/locusMediaRequest.js +229 -82
  52. package/dist/meeting/locusMediaRequest.js.map +1 -1
  53. package/dist/meeting/muteState.js +10 -1
  54. package/dist/meeting/muteState.js.map +1 -1
  55. package/dist/meeting/request.js +5 -2
  56. package/dist/meeting/request.js.map +1 -1
  57. package/dist/meeting/util.js +35 -2
  58. package/dist/meeting/util.js.map +1 -1
  59. package/dist/meeting-info/meeting-info-v2.js +2 -2
  60. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  61. package/dist/meetings/index.js +231 -118
  62. package/dist/meetings/index.js.map +1 -1
  63. package/dist/meetings/meetings.types.js +6 -1
  64. package/dist/meetings/meetings.types.js.map +1 -1
  65. package/dist/meetings/request.js +39 -0
  66. package/dist/meetings/request.js.map +1 -1
  67. package/dist/meetings/util.js +79 -5
  68. package/dist/meetings/util.js.map +1 -1
  69. package/dist/member/index.js +10 -0
  70. package/dist/member/index.js.map +1 -1
  71. package/dist/member/types.js.map +1 -1
  72. package/dist/member/util.js +30 -0
  73. package/dist/member/util.js.map +1 -1
  74. package/dist/members/index.js +101 -43
  75. package/dist/members/index.js.map +1 -1
  76. package/dist/members/util.js +8 -1
  77. package/dist/members/util.js.map +1 -1
  78. package/dist/metrics/constants.js +5 -1
  79. package/dist/metrics/constants.js.map +1 -1
  80. package/dist/multistream/codec/constants.js +63 -0
  81. package/dist/multistream/codec/constants.js.map +1 -0
  82. package/dist/multistream/mediaRequestManager.js +62 -15
  83. package/dist/multistream/mediaRequestManager.js.map +1 -1
  84. package/dist/multistream/receiveSlot.js +9 -0
  85. package/dist/multistream/receiveSlot.js.map +1 -1
  86. package/dist/reachability/index.js +10 -13
  87. package/dist/reachability/index.js.map +1 -1
  88. package/dist/reachability/reachability.types.js +2 -0
  89. package/dist/reachability/reachability.types.js.map +1 -1
  90. package/dist/reachability/util.js +45 -0
  91. package/dist/reachability/util.js.map +1 -1
  92. package/dist/reactions/reactions.type.js.map +1 -1
  93. package/dist/recording-controller/index.js +1 -3
  94. package/dist/recording-controller/index.js.map +1 -1
  95. package/dist/types/config.d.ts +2 -2
  96. package/dist/types/constants.d.ts +12 -1
  97. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  98. package/dist/types/controls-options-manager/index.d.ts +16 -0
  99. package/dist/types/hashTree/constants.d.ts +2 -0
  100. package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
  101. package/dist/types/hashTree/utils.d.ts +18 -0
  102. package/dist/types/index.d.ts +3 -0
  103. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  104. package/dist/types/interpretation/interpretation.types.d.ts +10 -0
  105. package/dist/types/locus-info/index.d.ts +50 -6
  106. package/dist/types/locus-info/infoUtils.d.ts +8 -0
  107. package/dist/types/locus-info/types.d.ts +21 -1
  108. package/dist/types/media/properties.d.ts +1 -0
  109. package/dist/types/meeting/in-meeting-actions.d.ts +4 -0
  110. package/dist/types/meeting/index.d.ts +94 -7
  111. package/dist/types/meeting/locusMediaRequest.d.ts +12 -0
  112. package/dist/types/meeting/request.d.ts +1 -0
  113. package/dist/types/meeting/util.d.ts +9 -0
  114. package/dist/types/meetings/index.d.ts +30 -18
  115. package/dist/types/meetings/meetings.types.d.ts +15 -0
  116. package/dist/types/meetings/request.d.ts +14 -0
  117. package/dist/types/member/index.d.ts +1 -0
  118. package/dist/types/member/types.d.ts +1 -0
  119. package/dist/types/member/util.d.ts +9 -0
  120. package/dist/types/members/index.d.ts +15 -2
  121. package/dist/types/members/util.d.ts +1 -5
  122. package/dist/types/metrics/constants.d.ts +4 -0
  123. package/dist/types/multistream/codec/constants.d.ts +7 -0
  124. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  125. package/dist/types/reachability/index.d.ts +1 -1
  126. package/dist/types/reachability/reachability.types.d.ts +7 -0
  127. package/dist/types/reachability/util.d.ts +17 -0
  128. package/dist/types/reactions/reactions.type.d.ts +3 -0
  129. package/dist/webinar/index.js +305 -159
  130. package/dist/webinar/index.js.map +1 -1
  131. package/package.json +22 -22
  132. package/src/aiEnableRequest/index.ts +16 -0
  133. package/src/breakouts/breakout.ts +3 -1
  134. package/src/breakouts/index.ts +31 -0
  135. package/src/config.ts +2 -2
  136. package/src/constants.ts +20 -3
  137. package/src/controls-options-manager/constants.ts +14 -1
  138. package/src/controls-options-manager/index.ts +87 -28
  139. package/src/controls-options-manager/util.ts +81 -1
  140. package/src/hashTree/constants.ts +16 -0
  141. package/src/hashTree/hashTreeParser.ts +580 -196
  142. package/src/hashTree/utils.ts +36 -0
  143. package/src/index.ts +6 -0
  144. package/src/interceptors/dataChannelAuthToken.ts +88 -12
  145. package/src/interceptors/locusRetry.ts +25 -4
  146. package/src/interpretation/index.ts +27 -9
  147. package/src/interpretation/interpretation.types.ts +11 -0
  148. package/src/locus-info/controlsUtils.ts +3 -1
  149. package/src/locus-info/index.ts +304 -100
  150. package/src/locus-info/infoUtils.ts +41 -6
  151. package/src/locus-info/types.ts +25 -1
  152. package/src/media/index.ts +3 -0
  153. package/src/media/properties.ts +1 -0
  154. package/src/meeting/in-meeting-actions.ts +8 -0
  155. package/src/meeting/index.ts +494 -67
  156. package/src/meeting/locusMediaRequest.ts +106 -6
  157. package/src/meeting/muteState.ts +10 -1
  158. package/src/meeting/request.ts +11 -0
  159. package/src/meeting/util.ts +47 -5
  160. package/src/meeting-info/meeting-info-v2.ts +4 -2
  161. package/src/meetings/index.ts +133 -78
  162. package/src/meetings/meetings.types.ts +19 -0
  163. package/src/meetings/request.ts +43 -0
  164. package/src/meetings/util.ts +97 -1
  165. package/src/member/index.ts +10 -0
  166. package/src/member/types.ts +1 -0
  167. package/src/member/util.ts +29 -0
  168. package/src/members/index.ts +70 -7
  169. package/src/members/util.ts +12 -4
  170. package/src/metrics/constants.ts +4 -0
  171. package/src/multistream/codec/constants.ts +58 -0
  172. package/src/multistream/mediaRequestManager.ts +119 -28
  173. package/src/multistream/receiveSlot.ts +18 -0
  174. package/src/reachability/index.ts +19 -15
  175. package/src/reachability/reachability.types.ts +11 -0
  176. package/src/reachability/util.ts +36 -0
  177. package/src/reactions/reactions.type.ts +3 -0
  178. package/src/recording-controller/index.ts +1 -2
  179. package/src/webinar/index.ts +214 -36
  180. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  181. package/test/unit/spec/breakouts/breakout.ts +9 -3
  182. package/test/unit/spec/breakouts/index.ts +49 -0
  183. package/test/unit/spec/controls-options-manager/index.js +244 -29
  184. package/test/unit/spec/controls-options-manager/util.js +165 -0
  185. package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
  186. package/test/unit/spec/hashTree/utils.ts +125 -1
  187. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
  188. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  189. package/test/unit/spec/interpretation/index.ts +26 -4
  190. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  191. package/test/unit/spec/locus-info/index.js +499 -81
  192. package/test/unit/spec/locus-info/infoUtils.js +184 -1
  193. package/test/unit/spec/media/index.ts +31 -0
  194. package/test/unit/spec/meeting/in-meeting-actions.ts +4 -0
  195. package/test/unit/spec/meeting/index.js +1615 -42
  196. package/test/unit/spec/meeting/locusMediaRequest.ts +302 -4
  197. package/test/unit/spec/meeting/muteState.js +81 -0
  198. package/test/unit/spec/meeting/request.js +12 -0
  199. package/test/unit/spec/meeting/utils.js +157 -1
  200. package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
  201. package/test/unit/spec/meetings/index.js +363 -36
  202. package/test/unit/spec/meetings/request.js +141 -0
  203. package/test/unit/spec/meetings/utils.js +189 -0
  204. package/test/unit/spec/member/index.js +7 -0
  205. package/test/unit/spec/member/util.js +89 -0
  206. package/test/unit/spec/members/index.js +216 -0
  207. package/test/unit/spec/members/request.js +37 -1
  208. package/test/unit/spec/members/utils.js +63 -0
  209. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  210. package/test/unit/spec/reachability/index.ts +59 -46
  211. package/test/unit/spec/reachability/util.ts +31 -0
  212. package/test/unit/spec/recording-controller/index.js +9 -8
  213. package/test/unit/spec/webinar/index.ts +329 -28
@@ -13,6 +13,7 @@ import MediaSharesUtils from '@webex/plugin-meetings/src/locus-info//mediaShares
13
13
  import LocusDeltaParser from '@webex/plugin-meetings/src/locus-info/parser';
14
14
  import Metrics from '@webex/plugin-meetings/src/metrics';
15
15
  import * as HashTreeParserModule from '@webex/plugin-meetings/src/hashTree/hashTreeParser';
16
+ import MeetingsUtil from '@webex/plugin-meetings/src/meetings/util';
16
17
 
17
18
  import {
18
19
  LOCUSINFO,
@@ -220,6 +221,47 @@ describe('plugin-meetings', () => {
220
221
  assert.isTrue(locusInfo.emitChange);
221
222
  });
222
223
 
224
+ it('calls onLocusSynced callback passed as second argument with full locus from join response', async () => {
225
+ const syncedLocus = {url: 'http://locus-url.com', participants: []};
226
+ const onLocusSynced = sinon.stub();
227
+
228
+ await locusInfo.initialSetup(
229
+ {
230
+ trigger: 'join-response',
231
+ locus: syncedLocus,
232
+ },
233
+ onLocusSynced
234
+ );
235
+
236
+ assert.calledOnceWithExactly(onLocusSynced, syncedLocus);
237
+ });
238
+
239
+ it('swallows onLocusSynced callback errors and logs warn', async () => {
240
+ const syncedLocus = {url: 'http://locus-url.com', participants: []};
241
+ const callbackError = new Error('onLocusSynced failed');
242
+ const onLocusSynced = sinon.stub().throws(callbackError);
243
+ const loggerWarnStub = LoggerProxy.logger.warn?.isSinonProxy
244
+ ? LoggerProxy.logger.warn
245
+ : sinon.stub(LoggerProxy.logger, 'warn');
246
+
247
+ loggerWarnStub.resetHistory();
248
+
249
+ await locusInfo.initialSetup(
250
+ {
251
+ trigger: 'join-response',
252
+ locus: syncedLocus,
253
+ },
254
+ onLocusSynced
255
+ );
256
+
257
+ assert.calledOnceWithExactly(onLocusSynced, syncedLocus);
258
+ assert.calledOnce(loggerWarnStub);
259
+ assert.match(
260
+ loggerWarnStub.firstCall.args[0],
261
+ /Locus-info:index#initialSetup --> onLocusSynced callback failed/
262
+ );
263
+ });
264
+
223
265
  it('should initialize the hash tree parser correctly when triggered from a get loci response containing visible datasets', async () => {
224
266
  const visibleDataSets = ['dataset1', 'dataset2'];
225
267
  const locus = createLocusWithVisibleDataSets(visibleDataSets);
@@ -290,6 +332,7 @@ describe('plugin-meetings', () => {
290
332
  describe('should setup correct locusInfoUpdateCallback when creating HashTreeParser', () => {
291
333
  const OBJECTS_UPDATED = HashTreeParserModule.LocusInfoUpdateType.OBJECTS_UPDATED;
292
334
  const MEETING_ENDED = HashTreeParserModule.LocusInfoUpdateType.MEETING_ENDED;
335
+ const LOCUS_NOT_FOUND = HashTreeParserModule.LocusInfoUpdateType.LOCUS_NOT_FOUND;
293
336
 
294
337
  let locusInfoUpdateCallback;
295
338
  let onDeltaLocusStub;
@@ -413,7 +456,7 @@ describe('plugin-meetings', () => {
413
456
  };
414
457
 
415
458
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
416
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
459
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
417
460
  updatedObjects: [{htMeta: {elementId: {type: 'self'}}, data: newSelf}],
418
461
  });
419
462
 
@@ -440,7 +483,7 @@ describe('plugin-meetings', () => {
440
483
  locusInfo.info.isWebinar = true;
441
484
 
442
485
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
443
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
486
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
444
487
  updatedObjects: [{htMeta: {elementId: {type: 'self'}}, data: newSelf}],
445
488
  });
446
489
 
@@ -473,7 +516,7 @@ describe('plugin-meetings', () => {
473
516
  locusInfo.info.isWebinar = true;
474
517
 
475
518
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
476
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
519
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
477
520
  updatedObjects: [{htMeta: {elementId: {type: 'self'}}, data: newSelf}],
478
521
  });
479
522
 
@@ -501,7 +544,7 @@ describe('plugin-meetings', () => {
501
544
  };
502
545
 
503
546
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
504
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
547
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
505
548
  updatedObjects: [{htMeta: {elementId: {type: 'fullState'}}, data: newFullState}],
506
549
  });
507
550
 
@@ -519,7 +562,7 @@ describe('plugin-meetings', () => {
519
562
  };
520
563
 
521
564
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
522
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
565
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
523
566
  updatedObjects: [{htMeta: {elementId: {type: 'info'}}, data: newInfo}],
524
567
  });
525
568
 
@@ -537,7 +580,7 @@ describe('plugin-meetings', () => {
537
580
  };
538
581
 
539
582
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
540
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
583
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
541
584
  updatedObjects: [{htMeta: {elementId: {type: 'links'}}, data: newLinks}],
542
585
  });
543
586
 
@@ -557,7 +600,7 @@ describe('plugin-meetings', () => {
557
600
  };
558
601
 
559
602
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
560
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
603
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
561
604
  updatedObjects: [{htMeta: newLocusHtMeta, data: newLocus}],
562
605
  });
563
606
 
@@ -590,7 +633,7 @@ describe('plugin-meetings', () => {
590
633
  };
591
634
 
592
635
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
593
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
636
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
594
637
  updatedObjects: [
595
638
  {
596
639
  htMeta: newLocusHtMeta,
@@ -637,7 +680,7 @@ describe('plugin-meetings', () => {
637
680
  };
638
681
 
639
682
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
640
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
683
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
641
684
  updatedObjects: [
642
685
  // first, a removal of LOCUS object
643
686
  {htMeta: {elementId: {type: 'locus'}}, data: null},
@@ -671,7 +714,7 @@ describe('plugin-meetings', () => {
671
714
  const newLocusHtMeta = {elementId: {type: 'locus', version: 99}};
672
715
 
673
716
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
674
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
717
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
675
718
  updatedObjects: [
676
719
  // first, an update
677
720
  {htMeta: newLocusHtMeta, data: newLocus},
@@ -700,7 +743,7 @@ describe('plugin-meetings', () => {
700
743
  };
701
744
 
702
745
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
703
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
746
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
704
747
  updatedObjects: [
705
748
  // first, an update
706
749
  {htMeta: {elementId: {type: 'locus'}}, data: newLocus1},
@@ -730,7 +773,7 @@ describe('plugin-meetings', () => {
730
773
  };
731
774
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
732
775
  // with 1 participant added, 1 updated, and 1 removed
733
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
776
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
734
777
  updatedObjects: [
735
778
  {htMeta: {elementId: {type: 'participant', id: 'fake-ht-participant-1'}}, data: null},
736
779
  {
@@ -774,7 +817,7 @@ describe('plugin-meetings', () => {
774
817
  };
775
818
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
776
819
  // with 1 participant added, 1 updated, and 1 removed
777
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
820
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
778
821
  updatedObjects: [
779
822
  {htMeta: {elementId: {type: 'mediashare', id: 'fake-ht-mediaShare-1'}}, data: null},
780
823
  {
@@ -807,7 +850,7 @@ describe('plugin-meetings', () => {
807
850
  };
808
851
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
809
852
  // with 1 embedded app added, 1 updated, and 1 removed
810
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
853
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
811
854
  updatedObjects: [
812
855
  {htMeta: {elementId: {type: 'embeddedapp', id: 'fake-ht-embeddedApp-1'}}, data: null},
813
856
  {
@@ -844,7 +887,7 @@ describe('plugin-meetings', () => {
844
887
  };
845
888
 
846
889
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
847
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
890
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
848
891
  updatedObjects: [
849
892
  {
850
893
  htMeta: {elementId: {type: 'mediashare', id: 'fake-ht-mediaShare-2'}},
@@ -883,7 +926,7 @@ describe('plugin-meetings', () => {
883
926
  };
884
927
 
885
928
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
886
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
929
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
887
930
  updatedObjects: [
888
931
  {
889
932
  htMeta: {elementId: {type: 'controlentry', id: 'control-1'}},
@@ -911,7 +954,7 @@ describe('plugin-meetings', () => {
911
954
 
912
955
  it('should process locus update correctly when CONTROL object is received with no data', () => {
913
956
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
914
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
957
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
915
958
  updatedObjects: [
916
959
  {
917
960
  htMeta: {elementId: {type: 'controlentry', id: 'some-control-id'}},
@@ -935,7 +978,7 @@ describe('plugin-meetings', () => {
935
978
  const destroyStub = sinon.stub(locusInfo.webex.meetings, 'destroy');
936
979
 
937
980
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
938
- locusInfoUpdateCallback(MEETING_ENDED);
981
+ locusInfoUpdateCallback({updateType: MEETING_ENDED});
939
982
 
940
983
  assert.calledOnceWithExactly(collectionGetStub, locusInfo.meetingId);
941
984
  assert.calledOnceWithExactly(
@@ -953,17 +996,48 @@ describe('plugin-meetings', () => {
953
996
  const destroyStub = sinon.stub(locusInfo.webex.meetings, 'destroy');
954
997
 
955
998
  // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
956
- locusInfoUpdateCallback(MEETING_ENDED);
999
+ locusInfoUpdateCallback({updateType: MEETING_ENDED});
957
1000
 
958
1001
  assert.calledOnceWithExactly(collectionGetStub, locusInfo.meetingId);
959
1002
  assert.notCalled(destroyStub);
960
1003
  });
961
1004
 
1005
+ it('should handle LOCUS_NOT_FOUND by calling syncMeetings with skipHashTreeSync', () => {
1006
+ const syncMeetingsStub = sinon.stub(locusInfo.webex.meetings, 'syncMeetings').resolves();
1007
+
1008
+ locusInfoUpdateCallback({updateType: LOCUS_NOT_FOUND});
1009
+
1010
+ assert.calledOnceWithExactly(syncMeetingsStub, {keepOnlyLocusMeetings: false, skipHashTreeSync: true});
1011
+ });
1012
+
1013
+ it('should handle LOCUS_NOT_FOUND and log error if syncMeetings fails', async () => {
1014
+ const syncError = new Error('sync failed');
1015
+ const syncMeetingsStub = sinon.stub(locusInfo.webex.meetings, 'syncMeetings').rejects(syncError);
1016
+ const logErrorStub = LoggerProxy.logger.error?.isSinonProxy
1017
+ ? LoggerProxy.logger.error
1018
+ : sinon.stub(LoggerProxy.logger, 'error');
1019
+
1020
+ logErrorStub.resetHistory();
1021
+
1022
+ locusInfoUpdateCallback({updateType: LOCUS_NOT_FOUND});
1023
+
1024
+ assert.calledOnceWithExactly(syncMeetingsStub, {keepOnlyLocusMeetings: false, skipHashTreeSync: true});
1025
+
1026
+ // wait for the promise rejection to be handled
1027
+ await testUtils.flushPromises();
1028
+
1029
+ assert.calledOnce(logErrorStub);
1030
+ assert.match(
1031
+ logErrorStub.firstCall.args[0],
1032
+ /syncMeetings failed after LOCUS_NOT_FOUND/
1033
+ );
1034
+ });
1035
+
962
1036
  it('should set forceReplaceMembers to true on the first update for a locusUrl (initializedFromHashTree is false)', () => {
963
1037
  const createdHashTreeParser = locusInfo.hashTreeParsers.get('fake-locus-url');
964
1038
  createdHashTreeParser.initializedFromHashTree = false;
965
1039
 
966
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
1040
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
967
1041
  updatedObjects: [
968
1042
  {
969
1043
  htMeta: {elementId: {type: 'self'}},
@@ -978,7 +1052,7 @@ describe('plugin-meetings', () => {
978
1052
  });
979
1053
 
980
1054
  it('should set forceReplaceMembers to false on subsequent updates (initializedFromHashTree is true)', () => {
981
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
1055
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
982
1056
  updatedObjects: [
983
1057
  {
984
1058
  htMeta: {elementId: {type: 'self'}},
@@ -994,7 +1068,7 @@ describe('plugin-meetings', () => {
994
1068
  it('should copy participant data to self when participant matches self identity and state is LEFT with reason MOVED', () => {
995
1069
  locusInfo.self = {id: 'fake-self', identity: 'user-123'};
996
1070
 
997
- locusInfoUpdateCallback(OBJECTS_UPDATED, {
1071
+ locusInfoUpdateCallback({updateType: OBJECTS_UPDATED,
998
1072
  updatedObjects: [
999
1073
  {
1000
1074
  htMeta: {elementId: {type: 'participant', id: 99}},
@@ -1289,6 +1363,8 @@ describe('plugin-meetings', () => {
1289
1363
  state: RECORDING_STATE.IDLE,
1290
1364
  modifiedBy: 'George Kittle',
1291
1365
  lastModified: 'TODAY',
1366
+ modifiedByServiceAppName: undefined,
1367
+ modifiedByServiceAppId: undefined,
1292
1368
  }
1293
1369
  );
1294
1370
  });
@@ -1323,6 +1399,8 @@ describe('plugin-meetings', () => {
1323
1399
  state: RECORDING_STATE.RECORDING,
1324
1400
  modifiedBy: 'George Kittle',
1325
1401
  lastModified: 'TODAY',
1402
+ modifiedByServiceAppName: undefined,
1403
+ modifiedByServiceAppId: undefined,
1326
1404
  }
1327
1405
  );
1328
1406
  });
@@ -1358,6 +1436,8 @@ describe('plugin-meetings', () => {
1358
1436
  state: RECORDING_STATE.PAUSED,
1359
1437
  modifiedBy: 'George Kittle',
1360
1438
  lastModified: 'TODAY',
1439
+ modifiedByServiceAppName: undefined,
1440
+ modifiedByServiceAppId: undefined,
1361
1441
  }
1362
1442
  );
1363
1443
  });
@@ -1394,6 +1474,8 @@ describe('plugin-meetings', () => {
1394
1474
  state: RECORDING_STATE.RESUMED,
1395
1475
  modifiedBy: 'George Kittle',
1396
1476
  lastModified: 'TODAY',
1477
+ modifiedByServiceAppName: undefined,
1478
+ modifiedByServiceAppId: undefined,
1397
1479
  }
1398
1480
  );
1399
1481
  });
@@ -1429,6 +1511,44 @@ describe('plugin-meetings', () => {
1429
1511
  state: RECORDING_STATE.IDLE,
1430
1512
  modifiedBy: 'George Kittle',
1431
1513
  lastModified: 'TODAY',
1514
+ modifiedByServiceAppName: undefined,
1515
+ modifiedByServiceAppId: undefined,
1516
+ }
1517
+ );
1518
+ });
1519
+
1520
+ it('should include service app fields in the recording event when present', () => {
1521
+ locusInfo.controls = {
1522
+ record: {
1523
+ recording: false,
1524
+ paused: false,
1525
+ meta: {
1526
+ lastModified: 'TODAY',
1527
+ modifiedBy: 'George Kittle',
1528
+ },
1529
+ },
1530
+ shareControl: {},
1531
+ transcribe: {},
1532
+ };
1533
+ newControls.record.recording = true;
1534
+ newControls.record.meta.modifiedByServiceAppName = 'My Bot';
1535
+ newControls.record.meta.modifiedByServiceAppId = 'app-id-123';
1536
+ locusInfo.emitScoped = sinon.stub();
1537
+ locusInfo.updateControls(newControls);
1538
+
1539
+ assert.calledWith(
1540
+ locusInfo.emitScoped,
1541
+ {
1542
+ file: 'locus-info',
1543
+ function: 'updateControls',
1544
+ },
1545
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
1546
+ {
1547
+ state: RECORDING_STATE.RECORDING,
1548
+ modifiedBy: 'George Kittle',
1549
+ lastModified: 'TODAY',
1550
+ modifiedByServiceAppName: 'My Bot',
1551
+ modifiedByServiceAppId: 'app-id-123',
1432
1552
  }
1433
1553
  );
1434
1554
  });
@@ -2024,7 +2144,7 @@ describe('plugin-meetings', () => {
2024
2144
  function: 'updateSelf',
2025
2145
  },
2026
2146
  LOCUSINFO.EVENTS.SELF_REMOTE_MUTE_STATUS_UPDATED,
2027
- {muted: true, unmuteAllowed: true}
2147
+ {muted: true, unmuteAllowed: true, modifiedBy: null}
2028
2148
  );
2029
2149
 
2030
2150
  // but sometimes "previous self" is defined, but without controls.audio.muted, so we test this here:
@@ -2039,7 +2159,7 @@ describe('plugin-meetings', () => {
2039
2159
  function: 'updateSelf',
2040
2160
  },
2041
2161
  LOCUSINFO.EVENTS.SELF_REMOTE_MUTE_STATUS_UPDATED,
2042
- {muted: true, unmuteAllowed: true}
2162
+ {muted: true, unmuteAllowed: true, modifiedBy: null}
2043
2163
  );
2044
2164
  });
2045
2165
 
@@ -2098,7 +2218,7 @@ describe('plugin-meetings', () => {
2098
2218
  function: 'updateSelf',
2099
2219
  },
2100
2220
  LOCUSINFO.EVENTS.SELF_REMOTE_MUTE_STATUS_UPDATED,
2101
- {muted: true, unmuteAllowed: true}
2221
+ {muted: true, unmuteAllowed: true, modifiedBy: null}
2102
2222
  );
2103
2223
  });
2104
2224
 
@@ -2237,7 +2357,7 @@ describe('plugin-meetings', () => {
2237
2357
  function: 'updateSelf',
2238
2358
  },
2239
2359
  LOCUSINFO.EVENTS.SELF_REMOTE_MUTE_STATUS_UPDATED,
2240
- {muted: true, unmuteAllowed: false}
2360
+ {muted: true, unmuteAllowed: false, modifiedBy: null}
2241
2361
  );
2242
2362
 
2243
2363
  // now change only disallowUnmute
@@ -2255,7 +2375,28 @@ describe('plugin-meetings', () => {
2255
2375
  function: 'updateSelf',
2256
2376
  },
2257
2377
  LOCUSINFO.EVENTS.SELF_REMOTE_MUTE_STATUS_UPDATED,
2258
- {muted: true, unmuteAllowed: true}
2378
+ {muted: true, unmuteAllowed: true, modifiedBy: null}
2379
+ );
2380
+ });
2381
+
2382
+ it('should include modifiedBy in payload when muted by host', () => {
2383
+ locusInfo.webex.internal.device.url = self.deviceUrl;
2384
+ locusInfo.updateSelf(self);
2385
+ const newSelf = cloneDeep(self);
2386
+ newSelf.controls.audio.muted = true;
2387
+ newSelf.controls.audio.meta = {modifiedBy: 'host-uuid-123'};
2388
+
2389
+ locusInfo.emitScoped = sinon.stub();
2390
+ locusInfo.updateSelf(newSelf);
2391
+
2392
+ assert.calledWith(
2393
+ locusInfo.emitScoped,
2394
+ {
2395
+ file: 'locus-info',
2396
+ function: 'updateSelf',
2397
+ },
2398
+ LOCUSINFO.EVENTS.SELF_REMOTE_MUTE_STATUS_UPDATED,
2399
+ {muted: true, unmuteAllowed: true, modifiedBy: 'host-uuid-123'}
2259
2400
  );
2260
2401
  });
2261
2402
 
@@ -2715,6 +2856,7 @@ describe('plugin-meetings', () => {
2715
2856
 
2716
2857
  // set the info initially as locusInfo.info starts as undefined
2717
2858
  expectedMeeting = {
2859
+ attendee: {},
2718
2860
  coHost: {
2719
2861
  LOWER_SOMEONE_ELSES_HAND: true,
2720
2862
  },
@@ -2723,10 +2865,12 @@ describe('plugin-meetings', () => {
2723
2865
  moderator: {
2724
2866
  LOWER_SOMEONE_ELSES_HAND: true,
2725
2867
  },
2868
+ panelist: {},
2726
2869
  policy: {
2727
2870
  LOCK_STATUS_UNLOCKED: true,
2728
2871
  ROSTER_IN_MEETING: true,
2729
2872
  },
2873
+ presenter: {},
2730
2874
  userDisplayHints: ['ROSTER_IN_MEETING', 'LOCK_STATUS_UNLOCKED'],
2731
2875
  };
2732
2876
  locusInfo.updateMeetingInfo(initialInfo, self);
@@ -2741,6 +2885,7 @@ describe('plugin-meetings', () => {
2741
2885
 
2742
2886
  // Updating with different info should trigger the event
2743
2887
  expectedMeeting = {
2888
+ attendee: {},
2744
2889
  coHost: {
2745
2890
  LOWER_SOMEONE_ELSES_HAND: true,
2746
2891
  LOCK_CONTROL_LOCK: true,
@@ -2750,10 +2895,12 @@ describe('plugin-meetings', () => {
2750
2895
  moderator: {
2751
2896
  LOWER_SOMEONE_ELSES_HAND: true,
2752
2897
  },
2898
+ panelist: {},
2753
2899
  policy: {
2754
2900
  LOCK_STATUS_UNLOCKED: true,
2755
2901
  ROSTER_IN_MEETING: true,
2756
2902
  },
2903
+ presenter: {},
2757
2904
  userDisplayHints: ['ROSTER_IN_MEETING', 'LOCK_STATUS_UNLOCKED'],
2758
2905
  };
2759
2906
  locusInfo.updateMeetingInfo(newInfo, self);
@@ -2762,6 +2909,7 @@ describe('plugin-meetings', () => {
2762
2909
 
2763
2910
  // update it with the same info
2764
2911
  expectedMeeting = {
2912
+ attendee: {},
2765
2913
  coHost: {
2766
2914
  LOWER_SOMEONE_ELSES_HAND: true,
2767
2915
  LOCK_CONTROL_LOCK: true,
@@ -2771,10 +2919,12 @@ describe('plugin-meetings', () => {
2771
2919
  moderator: {
2772
2920
  LOWER_SOMEONE_ELSES_HAND: true,
2773
2921
  },
2922
+ panelist: {},
2774
2923
  policy: {
2775
2924
  LOCK_STATUS_UNLOCKED: true,
2776
2925
  ROSTER_IN_MEETING: true,
2777
2926
  },
2927
+ presenter: {},
2778
2928
  userDisplayHints: ['ROSTER_IN_MEETING', 'LOCK_STATUS_UNLOCKED'],
2779
2929
  };
2780
2930
  locusInfo.updateMeetingInfo(newInfo, self);
@@ -2789,6 +2939,7 @@ describe('plugin-meetings', () => {
2789
2939
  hasRole: true,
2790
2940
  });
2791
2941
  expectedMeeting = {
2942
+ attendee: {},
2792
2943
  coHost: {
2793
2944
  LOWER_SOMEONE_ELSES_HAND: true,
2794
2945
  LOCK_CONTROL_LOCK: true,
@@ -2798,10 +2949,12 @@ describe('plugin-meetings', () => {
2798
2949
  moderator: {
2799
2950
  LOWER_SOMEONE_ELSES_HAND: true,
2800
2951
  },
2952
+ panelist: {},
2801
2953
  policy: {
2802
2954
  LOCK_STATUS_UNLOCKED: true,
2803
2955
  ROSTER_IN_MEETING: true,
2804
2956
  },
2957
+ presenter: {},
2805
2958
  userDisplayHints: [
2806
2959
  'ROSTER_IN_MEETING',
2807
2960
  'LOCK_STATUS_UNLOCKED',
@@ -3154,13 +3307,14 @@ describe('plugin-meetings', () => {
3154
3307
  const createMockParser = (state = 'active') => ({
3155
3308
  state,
3156
3309
  stop: sinon.stub(),
3157
- resume: sinon.stub(),
3310
+ resumeFromMessage: sinon.stub(),
3158
3311
  handleMessage: sinon.stub(),
3159
3312
  });
3160
3313
 
3161
3314
  const createSelfElementWithReplaces = (replacedLocusUrl, replacedAt) => ({
3162
3315
  htMeta: {elementId: {type: 'Self'}},
3163
3316
  data: {
3317
+ deviceUrl,
3164
3318
  devices: [{url: deviceUrl, replaces: [{locusUrl: replacedLocusUrl, replacedAt}]}],
3165
3319
  },
3166
3320
  });
@@ -3236,7 +3390,7 @@ describe('plugin-meetings', () => {
3236
3390
  stateElementsMessage: message,
3237
3391
  });
3238
3392
 
3239
- assert.calledOnce(parserA.resume);
3393
+ assert.calledOnce(parserA.resumeFromMessage);
3240
3394
  assert.calledOnce(parserB.stop);
3241
3395
  });
3242
3396
 
@@ -3259,7 +3413,7 @@ describe('plugin-meetings', () => {
3259
3413
  stateElementsMessage: message,
3260
3414
  });
3261
3415
 
3262
- assert.notCalled(parserA.resume);
3416
+ assert.notCalled(parserA.resumeFromMessage);
3263
3417
  assert.notCalled(parserB.stop);
3264
3418
  });
3265
3419
 
@@ -3278,7 +3432,7 @@ describe('plugin-meetings', () => {
3278
3432
  stateElementsMessage: message,
3279
3433
  });
3280
3434
 
3281
- assert.notCalled(parserA.resume);
3435
+ assert.notCalled(parserA.resumeFromMessage);
3282
3436
  assert.notCalled(parserA.handleMessage);
3283
3437
  });
3284
3438
 
@@ -3362,6 +3516,51 @@ describe('plugin-meetings', () => {
3362
3516
 
3363
3517
  assert.calledOnceWithExactly(parserA.handleMessage, message);
3364
3518
  });
3519
+
3520
+ it('should send mismatch metric when eventType is not HASH_TREE_DATA_UPDATED', () => {
3521
+ const locusUrlA = 'http://locus-url-A.com';
3522
+ const parserA = {state: 'active', handleMessage: sinon.stub()};
3523
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, initializedFromHashTree: true});
3524
+
3525
+ locusInfo.parse(mockMeeting, {
3526
+ eventType: LOCUSEVENT.SELF_CHANGED,
3527
+ stateElementsMessage: {locusUrl: locusUrlA, locusStateElements: [], dataSets: []},
3528
+ });
3529
+
3530
+ assert.calledOnceWithExactly(
3531
+ sendBehavioralMetricStub,
3532
+ 'js_sdk_locus_classic_vs_hash_tree_mismatch',
3533
+ {
3534
+ correlationId: mockMeeting.correlationId,
3535
+ message: `got ${LOCUSEVENT.SELF_CHANGED}, expected ${LOCUSEVENT.HASH_TREE_DATA_UPDATED}`,
3536
+ }
3537
+ );
3538
+ assert.notCalled(parserA.handleMessage);
3539
+ });
3540
+ });
3541
+
3542
+ describe('#sendClassicVsHashTreeMismatchMetric', () => {
3543
+ it('should send the metric when called for the first time', () => {
3544
+ locusInfo.sendClassicVsHashTreeMismatchMetric(mockMeeting, 'some mismatch');
3545
+
3546
+ assert.calledOnceWithExactly(
3547
+ sendBehavioralMetricStub,
3548
+ 'js_sdk_locus_classic_vs_hash_tree_mismatch',
3549
+ {
3550
+ correlationId: mockMeeting.correlationId,
3551
+ message: 'some mismatch',
3552
+ }
3553
+ );
3554
+ });
3555
+
3556
+ it('should send the metric up to 5 times and stop after that', () => {
3557
+ for (let i = 0; i < 7; i += 1) {
3558
+ locusInfo.sendClassicVsHashTreeMismatchMetric(mockMeeting, `mismatch ${i}`);
3559
+ }
3560
+
3561
+ assert.callCount(sendBehavioralMetricStub, 5);
3562
+ assert.equal(locusInfo.classicVsHashTreeMismatchMetricCounter, 5);
3563
+ });
3365
3564
  });
3366
3565
 
3367
3566
  describe('#handleLocusAPIResponse', () => {
@@ -3417,19 +3616,186 @@ describe('plugin-meetings', () => {
3417
3616
  assert.calledOnceWithExactly(locusInfo.handleLocusDelta, fakeLocus, mockMeeting);
3418
3617
  });
3419
3618
 
3420
- it('should send mismatch metric when hash tree parser exists but dataSets are missing in wrapped response', () => {
3619
+ it('should send mismatch metric in classic mode when wrapped response has dataSets', () => {
3421
3620
  const fakeLocus = {url: 'http://locus-url.com'};
3422
- const mockHashTreeParser = {handleLocusUpdate: sinon.stub()};
3423
- locusInfo.hashTreeParsers.set(fakeLocus.url, {
3424
- parser: mockHashTreeParser,
3425
- initializedFromHashTree: true,
3621
+ sinon.stub(locusInfo, 'handleLocusDelta');
3622
+
3623
+ locusInfo.handleLocusAPIResponse(mockMeeting, {
3624
+ locus: fakeLocus,
3625
+ dataSets: [{name: 'dataset1', url: 'test-url'}],
3426
3626
  });
3427
- sinon.stub(locusInfo, 'sendClassicVsHashTreeMismatchMetric');
3428
3627
 
3429
- locusInfo.handleLocusAPIResponse(mockMeeting, {locus: fakeLocus});
3628
+ assert.calledOnceWithExactly(
3629
+ sendBehavioralMetricStub,
3630
+ 'js_sdk_locus_classic_vs_hash_tree_mismatch',
3631
+ {
3632
+ correlationId: mockMeeting.correlationId,
3633
+ message: 'unexpected hash tree dataSets in API response',
3634
+ }
3635
+ );
3636
+ assert.calledOnce(locusInfo.handleLocusDelta);
3637
+ });
3430
3638
 
3431
- assert.calledOnce(locusInfo.sendClassicVsHashTreeMismatchMetric);
3432
- assert.calledOnce(mockHashTreeParser.handleLocusUpdate);
3639
+ describe('parser switch via API response', () => {
3640
+ const deviceUrl = 'http://device-url.com';
3641
+ const locusUrlA = 'http://locus-url-A.com';
3642
+ const locusUrlB = 'http://locus-url-B.com';
3643
+
3644
+ let HashTreeParserStub;
3645
+
3646
+ const createMockApiParser = (state = 'active') => ({
3647
+ state,
3648
+ stop: sinon.stub(),
3649
+ resumeFromApiResponse: sinon.stub(),
3650
+ handleLocusUpdate: sinon.stub(),
3651
+ initializeFromGetLociResponse: sinon.stub(),
3652
+ });
3653
+
3654
+ const createLocusWithReplaces = (url, replacedLocusUrl, replacedAt) => ({
3655
+ url,
3656
+ self: {
3657
+ devices: [{url: deviceUrl, replaces: [{locusUrl: replacedLocusUrl, replacedAt}]}],
3658
+ },
3659
+ });
3660
+
3661
+ const createLocusWithoutReplaces = (url) => ({
3662
+ url,
3663
+ self: {devices: [{url: deviceUrl}]},
3664
+ });
3665
+
3666
+ beforeEach(() => {
3667
+ locusInfo.webex.internal.device.url = deviceUrl;
3668
+ HashTreeParserStub = sinon
3669
+ .stub(HashTreeParserModule, 'default')
3670
+ .returns(createMockApiParser());
3671
+ });
3672
+
3673
+ it('should create a new parser and initialize it when no entry exists for the locusUrl', () => {
3674
+ // existing parser for a different url so hashTreeParsers.size > 0
3675
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: createMockApiParser(), initializedFromHashTree: true});
3676
+
3677
+ const locus = createLocusWithReplaces(locusUrlB, locusUrlA, '2026-01-01T00:00:00Z');
3678
+ sinon.stub(locusInfo, 'handleLocusDelta');
3679
+
3680
+ locusInfo.handleLocusAPIResponse(mockMeeting, {locus});
3681
+
3682
+ assert.isTrue(locusInfo.hashTreeParsers.has(locusUrlB));
3683
+ const newEntry = locusInfo.hashTreeParsers.get(locusUrlB);
3684
+ assert.isFalse(newEntry.initializedFromHashTree);
3685
+
3686
+ // the stub returns the mock, so initializeFromGetLociResponse should be called on it
3687
+ const createdParser = HashTreeParserStub.returnValues[0];
3688
+ assert.calledOnceWithExactly(createdParser.initializeFromGetLociResponse, locus);
3689
+ assert.notCalled(locusInfo.handleLocusDelta);
3690
+ });
3691
+
3692
+ it('should reactivate a stopped parser when replaces info is newer', () => {
3693
+ const parserA = createMockApiParser('stopped');
3694
+ const parserB = createMockApiParser('active');
3695
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, replacedAt: '2026-01-01T00:00:00Z', initializedFromHashTree: true});
3696
+ locusInfo.hashTreeParsers.set(locusUrlB, {parser: parserB, initializedFromHashTree: true});
3697
+
3698
+ const locus = createLocusWithReplaces(locusUrlA, locusUrlB, '2026-02-01T00:00:00Z');
3699
+
3700
+ locusInfo.handleLocusAPIResponse(mockMeeting, {locus});
3701
+
3702
+ assert.calledOnce(parserA.resumeFromApiResponse);
3703
+ assert.calledWithExactly(parserA.resumeFromApiResponse, locus);
3704
+ assert.calledOnce(parserB.stop);
3705
+ assert.equal(locusInfo.hashTreeParsers.get(locusUrlB).replacedAt, '2026-02-01T00:00:00Z');
3706
+ assert.isFalse(locusInfo.hashTreeParsers.get(locusUrlA).initializedFromHashTree);
3707
+ });
3708
+
3709
+ it('should not reactivate a stopped parser when replaces info is not newer', () => {
3710
+ const parserA = createMockApiParser('stopped');
3711
+ const parserB = createMockApiParser('active');
3712
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, replacedAt: '2026-03-01T00:00:00Z', initializedFromHashTree: true});
3713
+ locusInfo.hashTreeParsers.set(locusUrlB, {parser: parserB, initializedFromHashTree: true});
3714
+
3715
+ const locus = createLocusWithReplaces(locusUrlA, locusUrlB, '2026-01-01T00:00:00Z');
3716
+
3717
+ locusInfo.handleLocusAPIResponse(mockMeeting, {locus});
3718
+
3719
+ assert.notCalled(parserA.resumeFromApiResponse);
3720
+ assert.notCalled(parserB.stop);
3721
+ });
3722
+
3723
+ it('should not reactivate a stopped parser when no replaces info is available', () => {
3724
+ const parserA = createMockApiParser('stopped');
3725
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, initializedFromHashTree: true});
3726
+
3727
+ const locus = createLocusWithoutReplaces(locusUrlA);
3728
+
3729
+ locusInfo.handleLocusAPIResponse(mockMeeting, {locus});
3730
+
3731
+ assert.notCalled(parserA.resumeFromApiResponse);
3732
+ });
3733
+ });
3734
+ });
3735
+
3736
+ describe('#syncAllHashTreeDatasets', () => {
3737
+ it('should call syncAllDatasets on each parser that has an entry', async () => {
3738
+ const parser1 = {syncAllDatasets: sinon.stub().resolves()};
3739
+ const parser2 = {syncAllDatasets: sinon.stub().resolves()};
3740
+ locusInfo.hashTreeParsers.set('url1', {parser: parser1});
3741
+ locusInfo.hashTreeParsers.set('url2', {parser: parser2});
3742
+
3743
+ await locusInfo.syncAllHashTreeDatasets();
3744
+
3745
+ assert.calledOnce(parser1.syncAllDatasets);
3746
+ assert.calledOnce(parser2.syncAllDatasets);
3747
+ });
3748
+
3749
+ it('should skip parser entries without a parser object', async () => {
3750
+ const parser1 = {syncAllDatasets: sinon.stub().resolves()};
3751
+ locusInfo.hashTreeParsers.set('url1', {parser: parser1});
3752
+ locusInfo.hashTreeParsers.set('url2', {parser: undefined});
3753
+
3754
+ await locusInfo.syncAllHashTreeDatasets();
3755
+
3756
+ assert.calledOnce(parser1.syncAllDatasets);
3757
+ });
3758
+
3759
+ it('should forward options to each parser syncAllDatasets', async () => {
3760
+ const parser1 = {syncAllDatasets: sinon.stub().resolves()};
3761
+ const parser2 = {syncAllDatasets: sinon.stub().resolves()};
3762
+ locusInfo.hashTreeParsers.set('url1', {parser: parser1});
3763
+ locusInfo.hashTreeParsers.set('url2', {parser: parser2});
3764
+
3765
+ await locusInfo.syncAllHashTreeDatasets({onlyLLM: true});
3766
+
3767
+ assert.calledOnceWithExactly(parser1.syncAllDatasets, {onlyLLM: true});
3768
+ assert.calledOnceWithExactly(parser2.syncAllDatasets, {onlyLLM: true});
3769
+ });
3770
+
3771
+ it('should await each parsers syncAllDatasets sequentially', async () => {
3772
+ const callOrder = [];
3773
+ const parser1 = {syncAllDatasets: sinon.stub().callsFake(() => {
3774
+ callOrder.push('start1');
3775
+ return new Promise((resolve) => {
3776
+ setTimeout(() => {
3777
+ callOrder.push('end1');
3778
+ resolve();
3779
+ }, 100);
3780
+ });
3781
+ })};
3782
+ const parser2 = {syncAllDatasets: sinon.stub().callsFake(() => {
3783
+ callOrder.push('start2');
3784
+ return Promise.resolve();
3785
+ })};
3786
+ locusInfo.hashTreeParsers.set('url1', {parser: parser1});
3787
+ locusInfo.hashTreeParsers.set('url2', {parser: parser2});
3788
+
3789
+ const clock = sinon.useFakeTimers();
3790
+ const promise = locusInfo.syncAllHashTreeDatasets();
3791
+ // parser1 started but parser2 not yet
3792
+ assert.deepEqual(callOrder, ['start1']);
3793
+
3794
+ await clock.tickAsync(100);
3795
+ await promise;
3796
+ // parser1 finished, then parser2 started and finished
3797
+ assert.deepEqual(callOrder, ['start1', 'end1', 'start2']);
3798
+ clock.restore();
3433
3799
  });
3434
3800
  });
3435
3801
 
@@ -3521,49 +3887,23 @@ describe('plugin-meetings', () => {
3521
3887
  assert.deepEqual(callOrder, ['updateLocusUrl', 'updateMeetingInfo']);
3522
3888
  });
3523
3889
 
3524
- it('#updateLocusInfo ignores breakout LEFT message', () => {
3525
- const newLocus = {
3526
- self: {
3527
- reason: 'MOVED',
3528
- state: 'LEFT',
3529
- },
3530
- };
3890
+ it('#updateLocusInfo ignores locus when isSelfMovedOrBreakoutEnded returns true', () => {
3891
+ const newLocus = {self: {state: 'JOINED'}};
3892
+
3893
+ sinon.stub(MeetingsUtil, 'isSelfMovedOrBreakoutEnded').returns(true);
3531
3894
 
3532
3895
  locusInfo.updateControls = sinon.stub();
3533
- locusInfo.updateConversationUrl = sinon.stub();
3534
- locusInfo.updateCreated = sinon.stub();
3535
3896
  locusInfo.updateFullState = sinon.stub();
3536
- locusInfo.updateHostInfo = sinon.stub();
3537
- locusInfo.updateMeetingInfo = sinon.stub();
3538
- locusInfo.updateMediaShares = sinon.stub();
3539
- locusInfo.updateReplaces = sinon.stub();
3540
3897
  locusInfo.updateSelf = sinon.stub();
3541
- locusInfo.updateLocusUrl = sinon.stub();
3542
- locusInfo.updateAclUrl = sinon.stub();
3543
- locusInfo.updateBasequence = sinon.stub();
3544
- locusInfo.updateSequence = sinon.stub();
3545
- locusInfo.updateEmbeddedApps = sinon.stub();
3546
- locusInfo.updateLinks = sinon.stub();
3547
- locusInfo.compareAndUpdate = sinon.stub();
3548
3898
 
3549
3899
  locusInfo.updateLocusInfo(newLocus);
3550
3900
 
3901
+ assert.calledOnceWithExactly(MeetingsUtil.isSelfMovedOrBreakoutEnded, newLocus);
3551
3902
  assert.notCalled(locusInfo.updateControls);
3552
- assert.notCalled(locusInfo.updateConversationUrl);
3553
- assert.notCalled(locusInfo.updateCreated);
3554
3903
  assert.notCalled(locusInfo.updateFullState);
3555
- assert.notCalled(locusInfo.updateHostInfo);
3556
- assert.notCalled(locusInfo.updateMeetingInfo);
3557
- assert.notCalled(locusInfo.updateMediaShares);
3558
- assert.notCalled(locusInfo.updateReplaces);
3559
3904
  assert.notCalled(locusInfo.updateSelf);
3560
- assert.notCalled(locusInfo.updateLocusUrl);
3561
- assert.notCalled(locusInfo.updateAclUrl);
3562
- assert.notCalled(locusInfo.updateBasequence);
3563
- assert.notCalled(locusInfo.updateSequence);
3564
- assert.notCalled(locusInfo.updateEmbeddedApps);
3565
- assert.notCalled(locusInfo.updateLinks);
3566
- assert.notCalled(locusInfo.compareAndUpdate);
3905
+
3906
+ MeetingsUtil.isSelfMovedOrBreakoutEnded.restore();
3567
3907
  });
3568
3908
 
3569
3909
  it('#updateLocusInfo puts the Locus DTO top level properties at the right place in LocusInfo class', () => {
@@ -4413,6 +4753,31 @@ describe('plugin-meetings', () => {
4413
4753
  });
4414
4754
  });
4415
4755
 
4756
+ describe('#cleanUp', () => {
4757
+ it('calls cleanUp on all hash tree parsers and clears maps', () => {
4758
+ const parser1 = {cleanUp: sinon.stub()};
4759
+ const parser2 = {cleanUp: sinon.stub()};
4760
+
4761
+ locusInfo.hashTreeParsers.set('url1', {parser: parser1, initializedFromHashTree: true});
4762
+ locusInfo.hashTreeParsers.set('url2', {parser: parser2, initializedFromHashTree: true});
4763
+ locusInfo.hashTreeObjectId2ParticipantId.set(1, 'participant1');
4764
+
4765
+ locusInfo.cleanUp();
4766
+
4767
+ assert.calledOnce(parser1.cleanUp);
4768
+ assert.calledOnce(parser2.cleanUp);
4769
+ assert.equal(locusInfo.hashTreeParsers.size, 0);
4770
+ assert.equal(locusInfo.hashTreeObjectId2ParticipantId.size, 0);
4771
+ });
4772
+
4773
+ it('works when there are no hash tree parsers', () => {
4774
+ locusInfo.cleanUp();
4775
+
4776
+ assert.equal(locusInfo.hashTreeParsers.size, 0);
4777
+ assert.equal(locusInfo.hashTreeObjectId2ParticipantId.size, 0);
4778
+ });
4779
+ });
4780
+
4416
4781
  describe('#handleOneonOneEvent', () => {
4417
4782
  beforeEach(() => {
4418
4783
  locusInfo.emitScoped = sinon.stub();
@@ -4455,6 +4820,9 @@ describe('plugin-meetings', () => {
4455
4820
  });
4456
4821
 
4457
4822
  describe('#isMeetingActive', () => {
4823
+ beforeEach(() => {
4824
+ webex.internal.newMetrics.submitClientEvent.resetHistory();
4825
+ });
4458
4826
  forEach([_CALL_, _SIP_BRIDGE_, _SPACE_SHARE_], (type) => {
4459
4827
  describe(`type = ${type}`, () => {
4460
4828
  it('sends client event correctly for state = inactive', () => {
@@ -4521,7 +4889,7 @@ describe('plugin-meetings', () => {
4521
4889
  });
4522
4890
  });
4523
4891
 
4524
- it('sends client event correctly for state = MEETING_INACTIVE_TERMINATING', () => {
4892
+ it('sends client event correctly for state = MEETING_INACTIVE', () => {
4525
4893
  locusInfo.getLocusPartner = sinon.stub().returns({state: MEETING_STATE.STATES.LEFT});
4526
4894
  locusInfo.parsedLocus = {
4527
4895
  fullState: {
@@ -4543,7 +4911,7 @@ describe('plugin-meetings', () => {
4543
4911
  });
4544
4912
  });
4545
4913
 
4546
- it('sends client event correctly for state = FULLSTATE_REMOVED', () => {
4914
+ it('does not send client event when state = INACTIVE and endMeetingReason = BREAKOUT_ENDED', () => {
4547
4915
  locusInfo.getLocusPartner = sinon.stub().returns({state: MEETING_STATE.STATES.LEFT});
4548
4916
  locusInfo.parsedLocus = {
4549
4917
  fullState: {
@@ -4552,17 +4920,41 @@ describe('plugin-meetings', () => {
4552
4920
  };
4553
4921
 
4554
4922
  locusInfo.fullState = {
4555
- removed: true,
4923
+ state: LOCUS.STATE.INACTIVE,
4924
+ endMeetingReason: 'BREAKOUT_ENDED',
4556
4925
  };
4557
4926
 
4558
4927
  locusInfo.isMeetingActive();
4559
4928
 
4560
- assert.calledWith(webex.internal.newMetrics.submitClientEvent, {
4561
- name: 'client.call.remote-ended',
4562
- options: {
4563
- meetingId: locusInfo.meetingId,
4929
+ assert.notCalled(webex.internal.newMetrics.submitClientEvent);
4930
+ });
4931
+
4932
+ it('sends client event correctly for state self removed', () => {
4933
+ locusInfo.emitScoped = sinon.stub();
4934
+ locusInfo.parsedLocus = {
4935
+ fullState: {
4936
+ type: _MEETING_,
4564
4937
  },
4565
- });
4938
+ self: {
4939
+ removed: true,
4940
+ }
4941
+ };
4942
+
4943
+ locusInfo.isMeetingActive();
4944
+
4945
+ assert.notCalled(webex.internal.newMetrics.submitClientEvent);
4946
+ assert.calledOnceWithExactly(
4947
+ locusInfo.emitScoped,
4948
+ {
4949
+ file: 'locus-info',
4950
+ function: 'isMeetingActive',
4951
+ },
4952
+ EVENTS.DESTROY_MEETING,
4953
+ {
4954
+ reason: MEETING_REMOVED_REASON.SELF_REMOVED,
4955
+ shouldLeave: false,
4956
+ }
4957
+ );
4566
4958
  });
4567
4959
  });
4568
4960
 
@@ -4954,6 +5346,31 @@ describe('plugin-meetings', () => {
4954
5346
  );
4955
5347
  assert.notCalled(getTheLocusToUpdateStub);
4956
5348
  });
5349
+
5350
+ it('should call handleLocusAPIResponse for SDK_LOCUS_FROM_SYNC_MEETINGS when hash tree parsers exist', () => {
5351
+ const fakeLocusUrl = 'http://locus-url.com';
5352
+ const fakeLocus = {url: fakeLocusUrl, fullState: {state: 'ACTIVE'}};
5353
+ const mockHashTreeParser = {
5354
+ handleMessage: sinon.stub(),
5355
+ handleLocusUpdate: sinon.stub(),
5356
+ };
5357
+ locusInfo.hashTreeParsers.set(fakeLocusUrl, {
5358
+ parser: mockHashTreeParser,
5359
+ initializedFromHashTree: true,
5360
+ });
5361
+
5362
+ sinon.stub(locusInfo, 'handleLocusDelta');
5363
+
5364
+ locusInfo.parse(mockMeeting, {
5365
+ eventType: LOCUSEVENT.SDK_LOCUS_FROM_SYNC_MEETINGS,
5366
+ locus: fakeLocus,
5367
+ });
5368
+
5369
+ // should route through handleLocusAPIResponse which passes unwrapped LocusDTO to parser
5370
+ assert.calledOnce(mockHashTreeParser.handleLocusUpdate);
5371
+ assert.notCalled(mockHashTreeParser.handleMessage);
5372
+ assert.notCalled(locusInfo.handleLocusDelta);
5373
+ });
4957
5374
  });
4958
5375
  });
4959
5376
 
@@ -5146,6 +5563,7 @@ describe('plugin-meetings', () => {
5146
5563
  return {
5147
5564
  htMeta: {elementId: {type: 'Self'}},
5148
5565
  data: {
5566
+ deviceUrl,
5149
5567
  devices,
5150
5568
  },
5151
5569
  };