@webex/plugin-meetings 3.11.0 → 3.12.0-next.2

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 (170) hide show
  1. package/dist/aiEnableRequest/index.js +184 -0
  2. package/dist/aiEnableRequest/index.js.map +1 -0
  3. package/dist/aiEnableRequest/utils.js +36 -0
  4. package/dist/aiEnableRequest/utils.js.map +1 -0
  5. package/dist/annotation/index.js +14 -5
  6. package/dist/annotation/index.js.map +1 -1
  7. package/dist/breakouts/breakout.js +1 -1
  8. package/dist/breakouts/index.js +1 -1
  9. package/dist/config.js +7 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/constants.js +28 -6
  12. package/dist/constants.js.map +1 -1
  13. package/dist/hashTree/constants.js +3 -1
  14. package/dist/hashTree/constants.js.map +1 -1
  15. package/dist/hashTree/hashTree.js +18 -0
  16. package/dist/hashTree/hashTree.js.map +1 -1
  17. package/dist/hashTree/hashTreeParser.js +850 -410
  18. package/dist/hashTree/hashTreeParser.js.map +1 -1
  19. package/dist/hashTree/types.js +4 -2
  20. package/dist/hashTree/types.js.map +1 -1
  21. package/dist/hashTree/utils.js +10 -0
  22. package/dist/hashTree/utils.js.map +1 -1
  23. package/dist/index.js +11 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/interceptors/constant.js +12 -0
  26. package/dist/interceptors/constant.js.map +1 -0
  27. package/dist/interceptors/dataChannelAuthToken.js +290 -0
  28. package/dist/interceptors/dataChannelAuthToken.js.map +1 -0
  29. package/dist/interceptors/index.js +7 -0
  30. package/dist/interceptors/index.js.map +1 -1
  31. package/dist/interceptors/utils.js +27 -0
  32. package/dist/interceptors/utils.js.map +1 -0
  33. package/dist/interpretation/index.js +2 -2
  34. package/dist/interpretation/index.js.map +1 -1
  35. package/dist/interpretation/siLanguage.js +1 -1
  36. package/dist/locus-info/controlsUtils.js +5 -3
  37. package/dist/locus-info/controlsUtils.js.map +1 -1
  38. package/dist/locus-info/index.js +522 -131
  39. package/dist/locus-info/index.js.map +1 -1
  40. package/dist/locus-info/selfUtils.js +1 -0
  41. package/dist/locus-info/selfUtils.js.map +1 -1
  42. package/dist/locus-info/types.js.map +1 -1
  43. package/dist/media/MediaConnectionAwaiter.js +57 -1
  44. package/dist/media/MediaConnectionAwaiter.js.map +1 -1
  45. package/dist/media/properties.js +4 -2
  46. package/dist/media/properties.js.map +1 -1
  47. package/dist/meeting/in-meeting-actions.js +7 -1
  48. package/dist/meeting/in-meeting-actions.js.map +1 -1
  49. package/dist/meeting/index.js +1173 -877
  50. package/dist/meeting/index.js.map +1 -1
  51. package/dist/meeting/request.js +50 -0
  52. package/dist/meeting/request.js.map +1 -1
  53. package/dist/meeting/request.type.js.map +1 -1
  54. package/dist/meeting/util.js +133 -3
  55. package/dist/meeting/util.js.map +1 -1
  56. package/dist/meetings/index.js +117 -48
  57. package/dist/meetings/index.js.map +1 -1
  58. package/dist/member/index.js +10 -0
  59. package/dist/member/index.js.map +1 -1
  60. package/dist/member/util.js +10 -0
  61. package/dist/member/util.js.map +1 -1
  62. package/dist/metrics/constants.js +2 -1
  63. package/dist/metrics/constants.js.map +1 -1
  64. package/dist/multistream/mediaRequestManager.js +9 -60
  65. package/dist/multistream/mediaRequestManager.js.map +1 -1
  66. package/dist/multistream/remoteMediaManager.js +11 -0
  67. package/dist/multistream/remoteMediaManager.js.map +1 -1
  68. package/dist/reachability/index.js +18 -10
  69. package/dist/reachability/index.js.map +1 -1
  70. package/dist/reactions/reactions.type.js.map +1 -1
  71. package/dist/reconnection-manager/index.js +0 -1
  72. package/dist/reconnection-manager/index.js.map +1 -1
  73. package/dist/types/aiEnableRequest/index.d.ts +5 -0
  74. package/dist/types/aiEnableRequest/utils.d.ts +2 -0
  75. package/dist/types/config.d.ts +4 -0
  76. package/dist/types/constants.d.ts +23 -1
  77. package/dist/types/hashTree/constants.d.ts +1 -0
  78. package/dist/types/hashTree/hashTree.d.ts +7 -0
  79. package/dist/types/hashTree/hashTreeParser.d.ts +122 -14
  80. package/dist/types/hashTree/types.d.ts +3 -0
  81. package/dist/types/hashTree/utils.d.ts +6 -0
  82. package/dist/types/index.d.ts +1 -0
  83. package/dist/types/interceptors/constant.d.ts +5 -0
  84. package/dist/types/interceptors/dataChannelAuthToken.d.ts +43 -0
  85. package/dist/types/interceptors/index.d.ts +2 -1
  86. package/dist/types/interceptors/utils.d.ts +1 -0
  87. package/dist/types/locus-info/index.d.ts +60 -8
  88. package/dist/types/locus-info/types.d.ts +7 -0
  89. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  90. package/dist/types/media/properties.d.ts +2 -1
  91. package/dist/types/meeting/in-meeting-actions.d.ts +6 -0
  92. package/dist/types/meeting/index.d.ts +61 -7
  93. package/dist/types/meeting/request.d.ts +16 -1
  94. package/dist/types/meeting/request.type.d.ts +5 -0
  95. package/dist/types/meeting/util.d.ts +31 -0
  96. package/dist/types/meetings/index.d.ts +4 -2
  97. package/dist/types/member/index.d.ts +1 -0
  98. package/dist/types/member/util.d.ts +5 -0
  99. package/dist/types/metrics/constants.d.ts +1 -0
  100. package/dist/types/multistream/mediaRequestManager.d.ts +0 -23
  101. package/dist/types/reactions/reactions.type.d.ts +1 -0
  102. package/dist/types/webinar/utils.d.ts +6 -0
  103. package/dist/webinar/index.js +291 -91
  104. package/dist/webinar/index.js.map +1 -1
  105. package/dist/webinar/utils.js +25 -0
  106. package/dist/webinar/utils.js.map +1 -0
  107. package/package.json +24 -23
  108. package/src/aiEnableRequest/README.md +84 -0
  109. package/src/aiEnableRequest/index.ts +170 -0
  110. package/src/aiEnableRequest/utils.ts +25 -0
  111. package/src/annotation/index.ts +27 -7
  112. package/src/config.ts +4 -0
  113. package/src/constants.ts +29 -1
  114. package/src/hashTree/constants.ts +1 -0
  115. package/src/hashTree/hashTree.ts +17 -0
  116. package/src/hashTree/hashTreeParser.ts +745 -252
  117. package/src/hashTree/types.ts +4 -0
  118. package/src/hashTree/utils.ts +9 -0
  119. package/src/index.ts +8 -1
  120. package/src/interceptors/constant.ts +6 -0
  121. package/src/interceptors/dataChannelAuthToken.ts +170 -0
  122. package/src/interceptors/index.ts +2 -1
  123. package/src/interceptors/utils.ts +16 -0
  124. package/src/interpretation/index.ts +2 -2
  125. package/src/locus-info/controlsUtils.ts +11 -0
  126. package/src/locus-info/index.ts +579 -113
  127. package/src/locus-info/selfUtils.ts +1 -0
  128. package/src/locus-info/types.ts +8 -0
  129. package/src/media/MediaConnectionAwaiter.ts +41 -1
  130. package/src/media/properties.ts +3 -1
  131. package/src/meeting/in-meeting-actions.ts +12 -0
  132. package/src/meeting/index.ts +291 -76
  133. package/src/meeting/request.ts +42 -0
  134. package/src/meeting/request.type.ts +6 -0
  135. package/src/meeting/util.ts +160 -2
  136. package/src/meetings/index.ts +157 -44
  137. package/src/member/index.ts +10 -0
  138. package/src/member/util.ts +12 -0
  139. package/src/metrics/constants.ts +1 -0
  140. package/src/multistream/mediaRequestManager.ts +4 -54
  141. package/src/multistream/remoteMediaManager.ts +13 -0
  142. package/src/reachability/index.ts +9 -0
  143. package/src/reactions/reactions.type.ts +1 -0
  144. package/src/reconnection-manager/index.ts +0 -1
  145. package/src/webinar/index.ts +191 -6
  146. package/src/webinar/utils.ts +16 -0
  147. package/test/unit/spec/aiEnableRequest/index.ts +981 -0
  148. package/test/unit/spec/aiEnableRequest/utils.ts +130 -0
  149. package/test/unit/spec/annotation/index.ts +69 -7
  150. package/test/unit/spec/hashTree/hashTree.ts +66 -0
  151. package/test/unit/spec/hashTree/hashTreeParser.ts +2225 -189
  152. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +210 -0
  153. package/test/unit/spec/interceptors/utils.ts +75 -0
  154. package/test/unit/spec/locus-info/controlsUtils.js +29 -0
  155. package/test/unit/spec/locus-info/index.js +1134 -55
  156. package/test/unit/spec/media/MediaConnectionAwaiter.ts +41 -1
  157. package/test/unit/spec/media/properties.ts +12 -3
  158. package/test/unit/spec/meeting/in-meeting-actions.ts +8 -2
  159. package/test/unit/spec/meeting/index.js +829 -115
  160. package/test/unit/spec/meeting/request.js +70 -0
  161. package/test/unit/spec/meeting/utils.js +438 -26
  162. package/test/unit/spec/meetings/index.js +653 -32
  163. package/test/unit/spec/member/index.js +28 -4
  164. package/test/unit/spec/member/util.js +65 -27
  165. package/test/unit/spec/multistream/mediaRequestManager.ts +2 -85
  166. package/test/unit/spec/multistream/remoteMediaManager.ts +30 -0
  167. package/test/unit/spec/reachability/index.ts +23 -0
  168. package/test/unit/spec/reconnection-manager/index.js +4 -8
  169. package/test/unit/spec/webinar/index.ts +474 -37
  170. package/test/unit/spec/webinar/utils.ts +39 -0
@@ -5,7 +5,7 @@ import {assert} from '@webex/test-helper-chai';
5
5
  import MockWebex from '@webex/test-helper-mock-webex';
6
6
  import testUtils from '../../../utils/testUtils';
7
7
  import Meetings from '@webex/plugin-meetings';
8
- import LocusInfo from '@webex/plugin-meetings/src/locus-info';
8
+ import LocusInfo, {createLocusFromHashTreeMessage, findMeetingForHashTreeMessage} from '@webex/plugin-meetings/src/locus-info';
9
9
  import SelfUtils from '@webex/plugin-meetings/src/locus-info/selfUtils';
10
10
  import InfoUtils from '@webex/plugin-meetings/src/locus-info/infoUtils';
11
11
  import EmbeddedAppsUtils from '@webex/plugin-meetings/src/locus-info/embeddedAppsUtils';
@@ -29,7 +29,8 @@ import {
29
29
  } from '../../../../src/constants';
30
30
 
31
31
  import {self, selfWithInactivity} from './selfConstant';
32
- import { MEETING_REMOVED_REASON } from '@webex/plugin-meetings/src/constants';
32
+ import {MEETING_REMOVED_REASON} from '@webex/plugin-meetings/src/constants';
33
+ import LoggerProxy from '@webex/plugin-meetings/src/common/logs/logger-proxy';
33
34
 
34
35
  describe('plugin-meetings', () => {
35
36
  describe('LocusInfo index', () => {
@@ -104,9 +105,10 @@ describe('plugin-meetings', () => {
104
105
  });
105
106
 
106
107
  const createHashTreeMessage = (visibleDataSets) => ({
108
+ locusUrl: 'http://locus-url.com',
107
109
  locusStateElements: [
108
110
  {
109
- htMeta: {elementId: {type: 'self'}},
111
+ htMeta: {elementId: {type: 'metadata'}},
110
112
  data: {visibleDataSets},
111
113
  },
112
114
  ],
@@ -114,6 +116,7 @@ describe('plugin-meetings', () => {
114
116
  });
115
117
 
116
118
  const createLocusWithVisibleDataSets = (visibleDataSets) => ({
119
+ url: 'http://locus-url.com',
117
120
  self: {visibleDataSets},
118
121
  participants: [],
119
122
  links: {
@@ -136,8 +139,12 @@ describe('plugin-meetings', () => {
136
139
  HashTreeParserStub,
137
140
  sinon.match({
138
141
  initialLocus: {
139
- locus: {self: {visibleDataSets}},
140
- dataSets: [],
142
+ locus: null,
143
+ dataSets: hashTreeMessage.dataSets,
144
+ },
145
+ metadata: {
146
+ htMeta: hashTreeMessage.locusStateElements[0].htMeta,
147
+ visibleDataSets,
141
148
  },
142
149
  webexRequest: sinon.match.func,
143
150
  locusInfoUpdateCallback: sinon.match.func,
@@ -169,11 +176,16 @@ describe('plugin-meetings', () => {
169
176
  const visibleDataSets = ['dataset1', 'dataset2'];
170
177
  const locus = createLocusWithVisibleDataSets(visibleDataSets);
171
178
  const dataSets = [{name: 'dataset1', url: 'http://dataset-url.com'}];
179
+ const metadata = {
180
+ htMeta: {elementId: {type: 'metadata'}},
181
+ visibleDataSets,
182
+ };
172
183
 
173
184
  await locusInfo.initialSetup({
174
185
  trigger: 'join-response',
175
186
  locus,
176
187
  dataSets,
188
+ metadata,
177
189
  });
178
190
 
179
191
  assert.calledOnceWithExactly(
@@ -183,6 +195,7 @@ describe('plugin-meetings', () => {
183
195
  locus,
184
196
  dataSets,
185
197
  },
198
+ metadata,
186
199
  webexRequest: sinon.match.func,
187
200
  locusInfoUpdateCallback: sinon.match.func,
188
201
  debugId: sinon.match.string,
@@ -220,12 +233,13 @@ describe('plugin-meetings', () => {
220
233
  HashTreeParserStub,
221
234
  sinon.match({
222
235
  initialLocus: {
223
- locus: {self: {visibleDataSets}},
236
+ locus: null,
224
237
  dataSets: [],
225
238
  },
226
239
  webexRequest: sinon.match.func,
227
240
  locusInfoUpdateCallback: sinon.match.func,
228
241
  debugId: sinon.match.string,
242
+ metadata: null,
229
243
  })
230
244
  );
231
245
  assert.calledOnceWithExactly(mockHashTreeParser.initializeFromGetLociResponse, locus);
@@ -249,6 +263,30 @@ describe('plugin-meetings', () => {
249
263
  assert.isTrue(locusInfo.emitChange);
250
264
  });
251
265
 
266
+ it('throws if called with "locus-message" and Metadata object without visibleDataSets', async () => {
267
+ const hashTreeMessage = {
268
+ locusStateElements: [
269
+ {
270
+ htMeta: {elementId: {type: 'Metadata'}},
271
+ data: {},
272
+ },
273
+ ],
274
+ dataSets: [{name: 'dataset1', url: 'test-url'}],
275
+ };
276
+ try {
277
+ await locusInfo.initialSetup({
278
+ trigger: 'locus-message',
279
+ hashTreeMessage,
280
+ });
281
+ assert.fail('should have thrown an error');
282
+ } catch (error) {
283
+ assert.equal(
284
+ error.message,
285
+ 'Metadata object with visibleDataSets is missing in the message'
286
+ );
287
+ }
288
+ });
289
+
252
290
  describe('should setup correct locusInfoUpdateCallback when creating HashTreeParser', () => {
253
291
  const OBJECTS_UPDATED = HashTreeParserModule.LocusInfoUpdateType.OBJECTS_UPDATED;
254
292
  const MEETING_ENDED = HashTreeParserModule.LocusInfoUpdateType.MEETING_ENDED;
@@ -263,10 +301,11 @@ describe('plugin-meetings', () => {
263
301
  await locusInfo.initialSetup({
264
302
  trigger: 'locus-message',
265
303
  hashTreeMessage: {
304
+ locusUrl: 'fake-locus-url',
266
305
  locusStateElements: [
267
306
  {
268
- htMeta: {elementId: {type: 'self'}},
269
- data: {visibleDataSets: ['dataset1']},
307
+ htMeta: {elementId: {type: 'Metadata'}},
308
+ data: {visibleDataSets: [{name: 'dataset1', url: 'test-url'}]},
270
309
  },
271
310
  ],
272
311
  dataSets: [{name: 'dataset1', url: 'test-url'}],
@@ -293,6 +332,16 @@ describe('plugin-meetings', () => {
293
332
  htMeta: {elementId: {type: 'mediashare', id: 'fake-ht-mediaShare-2', version: 1}},
294
333
  },
295
334
  ];
335
+ locusInfo.embeddedApps = [
336
+ {
337
+ id: 'fake-embedded-app-1',
338
+ htMeta: {elementId: {type: 'embeddedapp', id: 'fake-ht-embeddedApp-1', version: 1}},
339
+ },
340
+ {
341
+ id: 'fake-embedded-app-2',
342
+ htMeta: {elementId: {type: 'embeddedapp', id: 'fake-ht-embeddedApp-2', version: 1}},
343
+ },
344
+ ];
296
345
  locusInfo.meetings = {id: 'fake-meetings'};
297
346
  locusInfo.participants = [
298
347
  {id: 'fake-participant-1', name: 'Participant One'},
@@ -311,6 +360,13 @@ describe('plugin-meetings', () => {
311
360
  locusInfo.url = 'fake-locus-url';
312
361
  locusInfo.htMeta = {elementId: {type: 'locus', id: 'fake-ht-locus-id', version: 1}};
313
362
 
363
+ const createdHashTreeParser = locusInfo.hashTreeParsers.get('fake-locus-url');
364
+
365
+ assert.isDefined(createdHashTreeParser);
366
+ // this flag would have been set to true on the first callback triggered by initialSetup() wa called earlier
367
+ // it's not because we're mocking HashTreeParser, so we have to set it manually here
368
+ createdHashTreeParser.initializedFromHashTree = true;
369
+
314
370
  // setup the default expected locus info state that each test builds upon
315
371
  expectedLocusInfo = {
316
372
  controls: {id: 'fake-controls'},
@@ -328,8 +384,18 @@ describe('plugin-meetings', () => {
328
384
  htMeta: {elementId: {type: 'mediashare', id: 'fake-ht-mediaShare-2', version: 1}},
329
385
  },
330
386
  ],
387
+ embeddedApps: [
388
+ {
389
+ id: 'fake-embedded-app-1',
390
+ htMeta: {elementId: {type: 'embeddedapp', id: 'fake-ht-embeddedApp-1', version: 1}},
391
+ },
392
+ {
393
+ id: 'fake-embedded-app-2',
394
+ htMeta: {elementId: {type: 'embeddedapp', id: 'fake-ht-embeddedApp-2', version: 1}},
395
+ },
396
+ ],
331
397
  meetings: {id: 'fake-meetings'},
332
- jsSdkMeta: {removedParticipantIds: []},
398
+ jsSdkMeta: {removedParticipantIds: [], forceReplaceMembers: false},
333
399
  participants: [], // empty means there were no participant updates
334
400
  replaces: {id: 'fake-replaces'},
335
401
  self: {id: 'fake-self'},
@@ -504,11 +570,12 @@ describe('plugin-meetings', () => {
504
570
  self: {id: 'fake-self'},
505
571
  links: {id: 'fake-links'},
506
572
  mediaShares: expectedLocusInfo.mediaShares,
573
+ embeddedApps: expectedLocusInfo.embeddedApps,
507
574
  // and now the new fields
508
575
  ...newLocus,
509
576
  htMeta: newLocusHtMeta,
510
577
  participants: [], // empty means there were no participant updates
511
- jsSdkMeta: {removedParticipantIds: []}, // no participants were removed
578
+ jsSdkMeta: {removedParticipantIds: [], forceReplaceMembers: false}, // no participants were removed
512
579
  });
513
580
  });
514
581
 
@@ -536,6 +603,7 @@ describe('plugin-meetings', () => {
536
603
  self: 'new-self',
537
604
  participants: 'new-participants',
538
605
  mediaShares: 'new-mediaShares',
606
+ embeddedApps: 'new-embeddedApps',
539
607
  },
540
608
  },
541
609
  ],
@@ -551,8 +619,9 @@ describe('plugin-meetings', () => {
551
619
  self: {id: 'fake-self'},
552
620
  links: {id: 'fake-links'},
553
621
  mediaShares: expectedLocusInfo.mediaShares,
622
+ embeddedApps: expectedLocusInfo.embeddedApps,
554
623
  participants: [], // empty means there were no participant updates
555
- jsSdkMeta: {removedParticipantIds: []}, // no participants were removed
624
+ jsSdkMeta: {removedParticipantIds: [], forceReplaceMembers: false}, // no participants were removed
556
625
  ...newLocus,
557
626
  htMeta: newLocusHtMeta,
558
627
  });
@@ -586,11 +655,12 @@ describe('plugin-meetings', () => {
586
655
  self: {id: 'fake-self'},
587
656
  links: {id: 'fake-links'},
588
657
  mediaShares: expectedLocusInfo.mediaShares,
658
+ embeddedApps: expectedLocusInfo.embeddedApps,
589
659
  // and now the new fields
590
660
  ...newLocus,
591
661
  htMeta: newLocusHtMeta,
592
662
  participants: [], // empty means there were no participant updates
593
- jsSdkMeta: {removedParticipantIds: []}, // no participants were removed
663
+ jsSdkMeta: {removedParticipantIds: [], forceReplaceMembers: false}, // no participants were removed
594
664
  });
595
665
  });
596
666
 
@@ -678,7 +748,7 @@ describe('plugin-meetings', () => {
678
748
  assert.calledOnceWithExactly(onDeltaLocusStub, {
679
749
  ...expectedLocusInfo,
680
750
  participants: [newParticipant, updatedParticipant2],
681
- jsSdkMeta: {removedParticipantIds: ['fake-participant-1']},
751
+ jsSdkMeta: {removedParticipantIds: ['fake-participant-1'], forceReplaceMembers: false},
682
752
  });
683
753
  // and that the hashTreeObjectId2ParticipantId map was updated correctly
684
754
  assert.isUndefined(locusInfo.hashTreeObjectId2ParticipantId.get('fake-ht-participant-1'));
@@ -725,6 +795,39 @@ describe('plugin-meetings', () => {
725
795
  });
726
796
  });
727
797
 
798
+ it('should process locus update correctly when called with updated EMBEDDEDAPP objects', () => {
799
+ const newEmbeddedApp = {
800
+ id: 'new-embedded-app-3',
801
+ htMeta: {elementId: {type: 'embeddedapp', id: 'fake-ht-embeddedApp-3', version: 100}},
802
+ };
803
+ const updatedEmbeddedApp2 = {
804
+ id: 'fake-embedded-app-2',
805
+ someNewProp: 'newValue',
806
+ htMeta: {elementId: {type: 'embeddedapp', id: 'fake-ht-embeddedApp-2', version: 100}},
807
+ };
808
+ // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
809
+ // with 1 embedded app added, 1 updated, and 1 removed
810
+ locusInfoUpdateCallback(OBJECTS_UPDATED, {
811
+ updatedObjects: [
812
+ {htMeta: {elementId: {type: 'embeddedapp', id: 'fake-ht-embeddedApp-1'}}, data: null},
813
+ {
814
+ htMeta: {elementId: {type: 'embeddedapp', id: 'fake-ht-embeddedApp-2'}},
815
+ data: updatedEmbeddedApp2,
816
+ },
817
+ {
818
+ htMeta: {elementId: {type: 'embeddedapp', id: 'fake-ht-embeddedApp-3'}},
819
+ data: newEmbeddedApp,
820
+ },
821
+ ],
822
+ });
823
+
824
+ // check onDeltaLocus() was called with correctly updated locus info
825
+ assert.calledOnceWithExactly(onDeltaLocusStub, {
826
+ ...expectedLocusInfo,
827
+ embeddedApps: [updatedEmbeddedApp2, newEmbeddedApp],
828
+ });
829
+ });
830
+
728
831
  it('should process locus update correctly when called with a combination of various updated objects', () => {
729
832
  const newSelf = {
730
833
  id: 'new-self',
@@ -841,6 +944,78 @@ describe('plugin-meetings', () => {
841
944
  MEETING_REMOVED_REASON.SELF_REMOVED
842
945
  );
843
946
  });
947
+
948
+ // this could happen if meeting gets destroyed while we're doing some async hash tree operation like a sync
949
+ it('should handle MEETING_ENDED correctly when meeting is not found in the collection', () => {
950
+ const collectionGetStub = sinon
951
+ .stub(locusInfo.webex.meetings.meetingCollection, 'get')
952
+ .returns(null);
953
+ const destroyStub = sinon.stub(locusInfo.webex.meetings, 'destroy');
954
+
955
+ // simulate an update from the HashTreeParser (normally this would be triggered by incoming locus messages)
956
+ locusInfoUpdateCallback(MEETING_ENDED);
957
+
958
+ assert.calledOnceWithExactly(collectionGetStub, locusInfo.meetingId);
959
+ assert.notCalled(destroyStub);
960
+ });
961
+
962
+ it('should set forceReplaceMembers to true on the first update for a locusUrl (initializedFromHashTree is false)', () => {
963
+ const createdHashTreeParser = locusInfo.hashTreeParsers.get('fake-locus-url');
964
+ createdHashTreeParser.initializedFromHashTree = false;
965
+
966
+ locusInfoUpdateCallback(OBJECTS_UPDATED, {
967
+ updatedObjects: [
968
+ {
969
+ htMeta: {elementId: {type: 'self'}},
970
+ data: {id: 'new-self'},
971
+ },
972
+ ],
973
+ });
974
+
975
+ assert.calledOnce(onDeltaLocusStub);
976
+ assert.equal(onDeltaLocusStub.firstCall.args[0].jsSdkMeta.forceReplaceMembers, true);
977
+ assert.isTrue(createdHashTreeParser.initializedFromHashTree);
978
+ });
979
+
980
+ it('should set forceReplaceMembers to false on subsequent updates (initializedFromHashTree is true)', () => {
981
+ locusInfoUpdateCallback(OBJECTS_UPDATED, {
982
+ updatedObjects: [
983
+ {
984
+ htMeta: {elementId: {type: 'self'}},
985
+ data: {id: 'new-self'},
986
+ },
987
+ ],
988
+ });
989
+
990
+ assert.calledOnce(onDeltaLocusStub);
991
+ assert.equal(onDeltaLocusStub.firstCall.args[0].jsSdkMeta.forceReplaceMembers, false);
992
+ });
993
+
994
+ it('should copy participant data to self when participant matches self identity and state is LEFT with reason MOVED', () => {
995
+ locusInfo.self = {id: 'fake-self', identity: 'user-123'};
996
+
997
+ locusInfoUpdateCallback(OBJECTS_UPDATED, {
998
+ updatedObjects: [
999
+ {
1000
+ htMeta: {elementId: {type: 'participant', id: 99}},
1001
+ data: {
1002
+ id: 'participant-matching-self',
1003
+ identity: 'user-123',
1004
+ state: 'LEFT',
1005
+ reason: 'MOVED',
1006
+ roles: ['MODERATOR'],
1007
+ },
1008
+ },
1009
+ ],
1010
+ });
1011
+
1012
+ assert.calledOnce(onDeltaLocusStub);
1013
+ const passedLocus = onDeltaLocusStub.firstCall.args[0];
1014
+
1015
+ assert.equal(passedLocus.self.identity, 'user-123');
1016
+ assert.equal(passedLocus.self.state, 'LEFT');
1017
+ assert.equal(passedLocus.self.reason, 'MOVED');
1018
+ });
844
1019
  });
845
1020
  });
846
1021
 
@@ -1076,7 +1251,7 @@ describe('plugin-meetings', () => {
1076
1251
  it('should trigger the CONTROLS_POLLING_QA_CHANGED event when necessary', () => {
1077
1252
  locusInfo.controls = {};
1078
1253
  locusInfo.emitScoped = sinon.stub();
1079
- newControls.pollingQAControl = { enabled: true };
1254
+ newControls.pollingQAControl = {enabled: true};
1080
1255
  locusInfo.updateControls(newControls);
1081
1256
 
1082
1257
  assert.calledWith(
@@ -1366,6 +1541,34 @@ describe('plugin-meetings', () => {
1366
1541
  );
1367
1542
  });
1368
1543
 
1544
+ it('should emit CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED when aiSummaryNotification changes', () => {
1545
+ locusInfo.emitScoped = sinon.stub();
1546
+ locusInfo.controls = {
1547
+ transcribe: {
1548
+ transcribing: false,
1549
+ caption: false,
1550
+ aiSummaryNotification: false,
1551
+ },
1552
+ };
1553
+ newControls.transcribe.transcribing = false;
1554
+ newControls.transcribe.caption = false;
1555
+ newControls.transcribe.aiSummaryNotification = true;
1556
+
1557
+ locusInfo.updateControls(newControls);
1558
+
1559
+ assert.calledWith(
1560
+ locusInfo.emitScoped,
1561
+ {
1562
+ file: 'locus-info',
1563
+ function: 'updateControls',
1564
+ },
1565
+ LOCUSINFO.EVENTS.CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED,
1566
+ {
1567
+ aiSummaryNotification: true,
1568
+ }
1569
+ );
1570
+ });
1571
+
1369
1572
  it('should update the transcribe spoken language', () => {
1370
1573
  locusInfo.emitScoped = sinon.stub();
1371
1574
  locusInfo.controls = {
@@ -1631,7 +1834,6 @@ describe('plugin-meetings', () => {
1631
1834
  );
1632
1835
  });
1633
1836
 
1634
-
1635
1837
  it('should call with participant display name', () => {
1636
1838
  const failureParticipant = [
1637
1839
  {
@@ -1656,7 +1858,7 @@ describe('plugin-meetings', () => {
1656
1858
  displayName: 'Test User',
1657
1859
  }
1658
1860
  );
1659
- })
1861
+ });
1660
1862
  });
1661
1863
 
1662
1864
  describe('#updateSelf', () => {
@@ -2457,8 +2659,8 @@ describe('plugin-meetings', () => {
2457
2659
  {
2458
2660
  isInitializing: !self,
2459
2661
  }
2460
- );
2461
- });
2662
+ );
2663
+ });
2462
2664
 
2463
2665
  const checkMeetingInfoUpdatedCalled = (expected, payload) => {
2464
2666
  const expectedArgs = [
@@ -2835,6 +3037,333 @@ describe('plugin-meetings', () => {
2835
3037
  });
2836
3038
  });
2837
3039
 
3040
+ describe('#createHashTreeParser', () => {
3041
+ let HashTreeParserStub;
3042
+
3043
+ beforeEach(() => {
3044
+ HashTreeParserStub = sinon
3045
+ .stub(HashTreeParserModule, 'default')
3046
+ .returns({
3047
+ initializeFromMessage: sinon.stub().resolves(),
3048
+ initializeFromGetLociResponse: sinon.stub().resolves(),
3049
+ state: 'active',
3050
+ stop: sinon.stub(),
3051
+ handleMessage: sinon.stub(),
3052
+ });
3053
+ });
3054
+
3055
+ const setupParserViaInitialSetup = async (locusUrl = 'http://locus-url-A.com') => {
3056
+ await locusInfo.initialSetup({
3057
+ trigger: 'locus-message',
3058
+ hashTreeMessage: {
3059
+ locusUrl,
3060
+ locusStateElements: [
3061
+ {
3062
+ htMeta: {elementId: {type: 'Metadata'}},
3063
+ data: {visibleDataSets: [{name: 'dataset1', url: 'test-url'}]},
3064
+ },
3065
+ ],
3066
+ dataSets: [{name: 'dataset1', url: 'test-url'}],
3067
+ },
3068
+ });
3069
+ };
3070
+
3071
+ it('should stop existing active parsers when creating a new one', async () => {
3072
+ await setupParserViaInitialSetup('http://locus-url-A.com');
3073
+
3074
+ const firstParser = locusInfo.hashTreeParsers.get('http://locus-url-A.com').parser;
3075
+
3076
+ await setupParserViaInitialSetup('http://locus-url-B.com');
3077
+
3078
+ assert.calledOnce(firstParser.stop);
3079
+ });
3080
+
3081
+ it('should set replacedAt on existing entries when replacedAt is provided', async () => {
3082
+ await setupParserViaInitialSetup('http://locus-url-A.com');
3083
+
3084
+ // Call createHashTreeParser with replacedAt via parse -> handleHashTreeParserSwitch
3085
+ // which calls createHashTreeParser with replacedAt from the self element
3086
+ locusInfo.webex.internal.device.url = 'http://device-url.com';
3087
+ const message = {
3088
+ locusUrl: 'http://locus-url-B.com',
3089
+ locusStateElements: [
3090
+ {
3091
+ htMeta: {elementId: {type: 'Metadata'}},
3092
+ data: {visibleDataSets: [{name: 'dataset1', url: 'test-url'}]},
3093
+ },
3094
+ {
3095
+ htMeta: {elementId: {type: 'Self'}},
3096
+ data: {
3097
+ devices: [{url: 'http://device-url.com', replaces: [{locusUrl: 'http://locus-url-A.com', replacedAt: '2026-01-01T00:00:00Z'}]}],
3098
+ },
3099
+ },
3100
+ ],
3101
+ dataSets: [{name: 'dataset1', url: 'test-url'}],
3102
+ };
3103
+
3104
+ locusInfo.parse(mockMeeting, {
3105
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
3106
+ stateElementsMessage: message,
3107
+ });
3108
+
3109
+ assert.equal(locusInfo.hashTreeParsers.get('http://locus-url-A.com').replacedAt, '2026-01-01T00:00:00Z');
3110
+ });
3111
+
3112
+ it('should not set replacedAt on existing entries when replacedAt is not provided', async () => {
3113
+ await setupParserViaInitialSetup('http://locus-url-A.com');
3114
+
3115
+ await setupParserViaInitialSetup('http://locus-url-B.com');
3116
+
3117
+ assert.isUndefined(locusInfo.hashTreeParsers.get('http://locus-url-A.com').replacedAt);
3118
+ });
3119
+
3120
+ it('should store the new parser in hashTreeParsers map with the correct locusUrl key', async () => {
3121
+ await setupParserViaInitialSetup('http://locus-url-A.com');
3122
+
3123
+ assert.isTrue(locusInfo.hashTreeParsers.has('http://locus-url-A.com'));
3124
+ assert.isDefined(locusInfo.hashTreeParsers.get('http://locus-url-A.com').parser);
3125
+ });
3126
+
3127
+ it('should clear hashTreeObjectId2ParticipantId when creating a new parser', async () => {
3128
+ await setupParserViaInitialSetup('http://locus-url-A.com');
3129
+ locusInfo.hashTreeObjectId2ParticipantId.set(1, 'participant-1');
3130
+
3131
+ await setupParserViaInitialSetup('http://locus-url-B.com');
3132
+
3133
+ assert.equal(locusInfo.hashTreeObjectId2ParticipantId.size, 0);
3134
+ });
3135
+
3136
+ it('should not stop already stopped parsers', async () => {
3137
+ await setupParserViaInitialSetup('http://locus-url-A.com');
3138
+ const firstParser = locusInfo.hashTreeParsers.get('http://locus-url-A.com').parser;
3139
+ firstParser.state = 'stopped';
3140
+
3141
+ await setupParserViaInitialSetup('http://locus-url-B.com');
3142
+
3143
+ assert.notCalled(firstParser.stop);
3144
+ });
3145
+ });
3146
+
3147
+ describe('#handleHashTreeParserSwitch', () => {
3148
+ const deviceUrl = 'http://device-url.com';
3149
+ const locusUrlA = 'http://locus-url-A.com';
3150
+ const locusUrlB = 'http://locus-url-B.com';
3151
+
3152
+ let HashTreeParserStub;
3153
+
3154
+ const createMockParser = (state = 'active') => ({
3155
+ state,
3156
+ stop: sinon.stub(),
3157
+ resume: sinon.stub(),
3158
+ handleMessage: sinon.stub(),
3159
+ });
3160
+
3161
+ const createSelfElementWithReplaces = (replacedLocusUrl, replacedAt) => ({
3162
+ htMeta: {elementId: {type: 'Self'}},
3163
+ data: {
3164
+ devices: [{url: deviceUrl, replaces: [{locusUrl: replacedLocusUrl, replacedAt}]}],
3165
+ },
3166
+ });
3167
+
3168
+ const createMetadataElement = () => ({
3169
+ htMeta: {elementId: {type: 'Metadata'}},
3170
+ data: {visibleDataSets: [{name: 'dataset1', url: 'test-url'}]},
3171
+ });
3172
+
3173
+ beforeEach(() => {
3174
+ locusInfo.webex.internal.device.url = deviceUrl;
3175
+ HashTreeParserStub = sinon
3176
+ .stub(HashTreeParserModule, 'default')
3177
+ .returns(createMockParser());
3178
+ });
3179
+
3180
+ it('should create a new parser when no entry exists for locusUrl and metadata has visibleDataSets', () => {
3181
+ // set up an existing parser for a different url
3182
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: createMockParser(), initializedFromHashTree: true});
3183
+
3184
+ const message = {
3185
+ locusUrl: locusUrlB,
3186
+ locusStateElements: [createMetadataElement()],
3187
+ dataSets: [{name: 'dataset1', url: 'test-url'}],
3188
+ };
3189
+
3190
+ locusInfo.parse(mockMeeting, {
3191
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
3192
+ stateElementsMessage: message,
3193
+ });
3194
+
3195
+ assert.isTrue(locusInfo.hashTreeParsers.has(locusUrlB));
3196
+ });
3197
+
3198
+ it('should return true when no entry exists even if no metadata is available', () => {
3199
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: createMockParser(), initializedFromHashTree: true});
3200
+ const parserA = locusInfo.hashTreeParsers.get(locusUrlA).parser;
3201
+
3202
+ const message = {
3203
+ locusUrl: locusUrlB,
3204
+ locusStateElements: [],
3205
+ dataSets: [],
3206
+ };
3207
+
3208
+ locusInfo.parse(mockMeeting, {
3209
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
3210
+ stateElementsMessage: message,
3211
+ });
3212
+
3213
+ // no new parser created since no metadata
3214
+ assert.isFalse(locusInfo.hashTreeParsers.has(locusUrlB));
3215
+ // the existing parser's handleMessage should NOT have been called
3216
+ assert.notCalled(parserA.handleMessage);
3217
+ });
3218
+
3219
+ it('should resume a stopped parser when replaces info is newer', () => {
3220
+ const parserA = createMockParser('stopped');
3221
+ const parserB = createMockParser('active');
3222
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, replacedAt: '2026-01-01T00:00:00Z', initializedFromHashTree: true});
3223
+ locusInfo.hashTreeParsers.set(locusUrlB, {parser: parserB, initializedFromHashTree: true});
3224
+
3225
+ const message = {
3226
+ locusUrl: locusUrlA,
3227
+ locusStateElements: [
3228
+ createSelfElementWithReplaces(locusUrlB, '2026-02-01T00:00:00Z'),
3229
+ createMetadataElement(),
3230
+ ],
3231
+ dataSets: [{name: 'dataset1', url: 'test-url'}],
3232
+ };
3233
+
3234
+ locusInfo.parse(mockMeeting, {
3235
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
3236
+ stateElementsMessage: message,
3237
+ });
3238
+
3239
+ assert.calledOnce(parserA.resume);
3240
+ assert.calledOnce(parserB.stop);
3241
+ });
3242
+
3243
+ it('should not resume a stopped parser when replaces info is not newer', () => {
3244
+ const parserA = createMockParser('stopped');
3245
+ const parserB = createMockParser('active');
3246
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, replacedAt: '2026-03-01T00:00:00Z', initializedFromHashTree: true});
3247
+ locusInfo.hashTreeParsers.set(locusUrlB, {parser: parserB, initializedFromHashTree: true});
3248
+
3249
+ const message = {
3250
+ locusUrl: locusUrlA,
3251
+ locusStateElements: [
3252
+ createSelfElementWithReplaces(locusUrlB, '2026-01-01T00:00:00Z'),
3253
+ ],
3254
+ dataSets: [{name: 'dataset1', url: 'test-url'}],
3255
+ };
3256
+
3257
+ locusInfo.parse(mockMeeting, {
3258
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
3259
+ stateElementsMessage: message,
3260
+ });
3261
+
3262
+ assert.notCalled(parserA.resume);
3263
+ assert.notCalled(parserB.stop);
3264
+ });
3265
+
3266
+ it('should return true for a stopped parser with no replaces info', () => {
3267
+ const parserA = createMockParser('stopped');
3268
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, initializedFromHashTree: true});
3269
+
3270
+ const message = {
3271
+ locusUrl: locusUrlA,
3272
+ locusStateElements: [],
3273
+ dataSets: [],
3274
+ };
3275
+
3276
+ locusInfo.parse(mockMeeting, {
3277
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
3278
+ stateElementsMessage: message,
3279
+ });
3280
+
3281
+ assert.notCalled(parserA.resume);
3282
+ assert.notCalled(parserA.handleMessage);
3283
+ });
3284
+
3285
+ it('should return false when the entry exists and parser is active', () => {
3286
+ const parserA = createMockParser('active');
3287
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, initializedFromHashTree: true});
3288
+
3289
+ const message = {
3290
+ locusUrl: locusUrlA,
3291
+ locusStateElements: [],
3292
+ dataSets: [],
3293
+ };
3294
+
3295
+ locusInfo.parse(mockMeeting, {
3296
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
3297
+ stateElementsMessage: message,
3298
+ });
3299
+
3300
+ assert.calledOnceWithExactly(parserA.handleMessage, message);
3301
+ });
3302
+
3303
+ it('should pass replacedAt from replaces to createHashTreeParser when creating a new parser', () => {
3304
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: createMockParser(), initializedFromHashTree: true});
3305
+
3306
+ const message = {
3307
+ locusUrl: locusUrlB,
3308
+ locusStateElements: [
3309
+ createMetadataElement(),
3310
+ createSelfElementWithReplaces(locusUrlA, '2026-05-01T00:00:00Z'),
3311
+ ],
3312
+ dataSets: [{name: 'dataset1', url: 'test-url'}],
3313
+ };
3314
+
3315
+ locusInfo.parse(mockMeeting, {
3316
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
3317
+ stateElementsMessage: message,
3318
+ });
3319
+
3320
+ assert.isTrue(locusInfo.hashTreeParsers.has(locusUrlB));
3321
+ assert.equal(locusInfo.hashTreeParsers.get(locusUrlA).replacedAt, '2026-05-01T00:00:00Z');
3322
+ });
3323
+ });
3324
+
3325
+ describe('#handleHashTreeMessage', () => {
3326
+ it('should call handleHashTreeParserSwitch and not call handleMessage if parser was switched', () => {
3327
+ const locusUrlA = 'http://locus-url-A.com';
3328
+ const locusUrlB = 'http://locus-url-B.com';
3329
+ const parserA = {state: 'stopped', handleMessage: sinon.stub(), resume: sinon.stub(), stop: sinon.stub()};
3330
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, initializedFromHashTree: true});
3331
+
3332
+ // message for a stopped parser without replaces -> handleHashTreeParserSwitch returns true
3333
+ const message = {
3334
+ locusUrl: locusUrlA,
3335
+ locusStateElements: [],
3336
+ dataSets: [],
3337
+ };
3338
+
3339
+ locusInfo.parse(mockMeeting, {
3340
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
3341
+ stateElementsMessage: message,
3342
+ });
3343
+
3344
+ assert.notCalled(parserA.handleMessage);
3345
+ });
3346
+
3347
+ it('should call handleMessage on the correct parser when no switch occurs', () => {
3348
+ const locusUrlA = 'http://locus-url-A.com';
3349
+ const parserA = {state: 'active', handleMessage: sinon.stub()};
3350
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, initializedFromHashTree: true});
3351
+
3352
+ const message = {
3353
+ locusUrl: locusUrlA,
3354
+ locusStateElements: [],
3355
+ dataSets: [],
3356
+ };
3357
+
3358
+ locusInfo.parse(mockMeeting, {
3359
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
3360
+ stateElementsMessage: message,
3361
+ });
3362
+
3363
+ assert.calledOnceWithExactly(parserA.handleMessage, message);
3364
+ });
3365
+ });
3366
+
2838
3367
  describe('#handleLocusAPIResponse', () => {
2839
3368
  it('calls handleLocusDelta when we are not using hash trees', () => {
2840
3369
  const fakeLocus = {eventType: LOCUSEVENT.DIFFERENCE};
@@ -2846,7 +3375,7 @@ describe('plugin-meetings', () => {
2846
3375
  assert.calledWith(locusInfo.handleLocusDelta, fakeLocus, mockMeeting);
2847
3376
  });
2848
3377
  it('calls hash tree parser when we are using hash trees', () => {
2849
- const fakeLocus = {eventType: LOCUSEVENT.DIFFERENCE};
3378
+ const fakeLocus = {eventType: LOCUSEVENT.DIFFERENCE, url: 'http://locus-url.com'};
2850
3379
  const fakeDataSets = [{name: 'dataset1', url: 'http://test.com'}];
2851
3380
  const responseBody = {locus: fakeLocus, dataSets: fakeDataSets};
2852
3381
 
@@ -2854,7 +3383,10 @@ describe('plugin-meetings', () => {
2854
3383
  const mockHashTreeParser = {
2855
3384
  handleLocusUpdate: sinon.stub(),
2856
3385
  };
2857
- locusInfo.hashTreeParser = mockHashTreeParser;
3386
+ locusInfo.hashTreeParsers.set(fakeLocus.url, {
3387
+ parser: mockHashTreeParser,
3388
+ initializedFromHashTree: true,
3389
+ });
2858
3390
 
2859
3391
  sinon.stub(locusInfo, 'onDeltaLocus');
2860
3392
 
@@ -2862,6 +3394,43 @@ describe('plugin-meetings', () => {
2862
3394
 
2863
3395
  assert.calledOnceWithExactly(mockHashTreeParser.handleLocusUpdate, responseBody);
2864
3396
  });
3397
+
3398
+ it('should handle unwrapped LocusDTO (without locus wrapper) when hash tree parser exists', () => {
3399
+ const fakeLocus = {url: 'http://locus-url.com', fullState: {state: 'ACTIVE'}};
3400
+ const mockHashTreeParser = {handleLocusUpdate: sinon.stub()};
3401
+ locusInfo.hashTreeParsers.set(fakeLocus.url, {
3402
+ parser: mockHashTreeParser,
3403
+ initializedFromHashTree: true,
3404
+ });
3405
+
3406
+ locusInfo.handleLocusAPIResponse(mockMeeting, fakeLocus);
3407
+
3408
+ assert.calledOnceWithExactly(mockHashTreeParser.handleLocusUpdate, {locus: fakeLocus});
3409
+ });
3410
+
3411
+ it('should handle unwrapped LocusDTO in classic mode (no hash tree parser)', () => {
3412
+ const fakeLocus = {url: 'http://locus-url.com', fullState: {state: 'ACTIVE'}};
3413
+ sinon.stub(locusInfo, 'handleLocusDelta');
3414
+
3415
+ locusInfo.handleLocusAPIResponse(mockMeeting, fakeLocus);
3416
+
3417
+ assert.calledOnceWithExactly(locusInfo.handleLocusDelta, fakeLocus, mockMeeting);
3418
+ });
3419
+
3420
+ it('should send mismatch metric when hash tree parser exists but dataSets are missing in wrapped response', () => {
3421
+ 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,
3426
+ });
3427
+ sinon.stub(locusInfo, 'sendClassicVsHashTreeMismatchMetric');
3428
+
3429
+ locusInfo.handleLocusAPIResponse(mockMeeting, {locus: fakeLocus});
3430
+
3431
+ assert.calledOnce(locusInfo.sendClassicVsHashTreeMismatchMetric);
3432
+ assert.calledOnce(mockHashTreeParser.handleLocusUpdate);
3433
+ });
2865
3434
  });
2866
3435
 
2867
3436
  describe('#LocusDeltaEvents', () => {
@@ -2923,28 +3492,28 @@ describe('plugin-meetings', () => {
2923
3492
  assert.isFunction(locusParser.onDeltaAction);
2924
3493
  });
2925
3494
 
2926
- it("#updateLocusInfo invokes updateLocusUrl before updateMeetingInfo", () => {
3495
+ it('#updateLocusInfo invokes updateLocusUrl before updateMeetingInfo', () => {
2927
3496
  const callOrder = [];
2928
- sinon.stub(locusInfo, "updateControls");
2929
- sinon.stub(locusInfo, "updateConversationUrl");
2930
- sinon.stub(locusInfo, "updateCreated");
2931
- sinon.stub(locusInfo, "updateFullState");
2932
- sinon.stub(locusInfo, "updateHostInfo");
2933
- sinon.stub(locusInfo, "updateMeetingInfo").callsFake(() => {
2934
- callOrder.push("updateMeetingInfo");
3497
+ sinon.stub(locusInfo, 'updateControls');
3498
+ sinon.stub(locusInfo, 'updateConversationUrl');
3499
+ sinon.stub(locusInfo, 'updateCreated');
3500
+ sinon.stub(locusInfo, 'updateFullState');
3501
+ sinon.stub(locusInfo, 'updateHostInfo');
3502
+ sinon.stub(locusInfo, 'updateMeetingInfo').callsFake(() => {
3503
+ callOrder.push('updateMeetingInfo');
2935
3504
  });
2936
- sinon.stub(locusInfo, "updateMediaShares");
2937
- sinon.stub(locusInfo, "updateReplaces");
2938
- sinon.stub(locusInfo, "updateSelf");
2939
- sinon.stub(locusInfo, "updateLocusUrl").callsFake(() => {
2940
- callOrder.push("updateLocusUrl");
3505
+ sinon.stub(locusInfo, 'updateMediaShares');
3506
+ sinon.stub(locusInfo, 'updateReplaces');
3507
+ sinon.stub(locusInfo, 'updateSelf');
3508
+ sinon.stub(locusInfo, 'updateLocusUrl').callsFake(() => {
3509
+ callOrder.push('updateLocusUrl');
2941
3510
  });
2942
- sinon.stub(locusInfo, "updateAclUrl");
2943
- sinon.stub(locusInfo, "updateBasequence");
2944
- sinon.stub(locusInfo, "updateSequence");
2945
- sinon.stub(locusInfo, "updateEmbeddedApps");
2946
- sinon.stub(locusInfo, "updateLinks");
2947
- sinon.stub(locusInfo, "compareAndUpdate");
3511
+ sinon.stub(locusInfo, 'updateAclUrl');
3512
+ sinon.stub(locusInfo, 'updateBasequence');
3513
+ sinon.stub(locusInfo, 'updateSequence');
3514
+ sinon.stub(locusInfo, 'updateEmbeddedApps');
3515
+ sinon.stub(locusInfo, 'updateLinks');
3516
+ sinon.stub(locusInfo, 'compareAndUpdate');
2948
3517
 
2949
3518
  locusInfo.updateLocusInfo(locus);
2950
3519
 
@@ -3000,7 +3569,7 @@ describe('plugin-meetings', () => {
3000
3569
  it('#updateLocusInfo puts the Locus DTO top level properties at the right place in LocusInfo class', () => {
3001
3570
  // this test verifies that the top-level properties of Locus DTO are copied
3002
3571
  // into LocusInfo class and set as top level properties too
3003
- // this is important, because the code handling Locus hass trees relies on it, see updateFromHashTree()
3572
+ // this is important, because the code handling Locus hash trees relies on it, see updateFromHashTree()
3004
3573
  const info = {id: 'info id'};
3005
3574
  const fullState = {id: 'fullState id'};
3006
3575
  const links = {services: {id: 'service links'}, resources: {id: 'resource links'}};
@@ -3039,7 +3608,7 @@ describe('plugin-meetings', () => {
3039
3608
  sandbox.stub(locusInfo, 'handleOneOnOneEvent');
3040
3609
  sandbox.stub(locusParser, 'isNewFullLocus').returns(true);
3041
3610
 
3042
- locusInfo.onFullLocus(fakeLocus, eventType);
3611
+ locusInfo.onFullLocus('test', fakeLocus, eventType);
3043
3612
 
3044
3613
  assert.equal(fakeLocus, locusParser.workingCopy);
3045
3614
  });
@@ -3060,7 +3629,7 @@ describe('plugin-meetings', () => {
3060
3629
 
3061
3630
  sandbox.stub(locusParser, 'isNewFullLocus').returns(false);
3062
3631
 
3063
- locusInfo.onFullLocus(fakeLocus, eventType);
3632
+ locusInfo.onFullLocus('test', fakeLocus, eventType);
3064
3633
 
3065
3634
  spies.forEach((spy) => {
3066
3635
  assert.notCalled(spy);
@@ -3210,7 +3779,11 @@ describe('plugin-meetings', () => {
3210
3779
  }).then(() => {
3211
3780
  assert.calledOnceWithExactly(meeting.meetingRequest.getLocusDTO, {url: 'oldLocusUrl'});
3212
3781
 
3213
- assert.calledOnceWithExactly(meeting.locusInfo.onFullLocus, fakeFullLocusDto);
3782
+ assert.calledOnceWithExactly(
3783
+ meeting.locusInfo.onFullLocus,
3784
+ 'classic Locus sync',
3785
+ fakeFullLocusDto
3786
+ );
3214
3787
  assert.calledOnce(locusInfo.locusParser.resume);
3215
3788
  });
3216
3789
  });
@@ -3308,7 +3881,11 @@ describe('plugin-meetings', () => {
3308
3881
  });
3309
3882
 
3310
3883
  assert.notCalled(meeting.locusInfo.handleLocusDelta);
3311
- assert.calledOnceWithExactly(meeting.locusInfo.onFullLocus, fakeFullLocusDto);
3884
+ assert.calledOnceWithExactly(
3885
+ meeting.locusInfo.onFullLocus,
3886
+ 'classic Locus sync',
3887
+ fakeFullLocusDto
3888
+ );
3312
3889
  assert.calledOnce(locusInfo.locusParser.resume);
3313
3890
  });
3314
3891
  });
@@ -3484,7 +4061,11 @@ describe('plugin-meetings', () => {
3484
4061
  url: 'fake locus DELTA url',
3485
4062
  });
3486
4063
  assert.notCalled(meeting.locusInfo.handleLocusDelta);
3487
- assert.calledOnceWithExactly(meeting.locusInfo.onFullLocus, fakeFullLocusDto);
4064
+ assert.calledOnceWithExactly(
4065
+ meeting.locusInfo.onFullLocus,
4066
+ 'classic Locus sync',
4067
+ fakeFullLocusDto
4068
+ );
3488
4069
  assert.calledOnce(locusInfo.locusParser.resume);
3489
4070
  });
3490
4071
  });
@@ -3505,6 +4086,137 @@ describe('plugin-meetings', () => {
3505
4086
 
3506
4087
  assert.isNull(locusInfo.mainSessionLocusCache);
3507
4088
  });
4089
+
4090
+ it('should map participant with htMeta.elementId.id of 0 (falsy number) to hashTreeObjectId2ParticipantId', () => {
4091
+ const locus = {
4092
+ url: 'url',
4093
+ participants: [
4094
+ {
4095
+ id: 'participant-zero',
4096
+ htMeta: {elementId: {id: 0}},
4097
+ },
4098
+ ],
4099
+ };
4100
+
4101
+ sinon.stub(locusInfo.locusParser, 'isNewFullLocus').returns(true);
4102
+ sinon.stub(locusInfo, 'updateLocusInfo');
4103
+ sinon.stub(locusInfo, 'updateParticipants');
4104
+ sinon.stub(locusInfo, 'isMeetingActive');
4105
+ sinon.stub(locusInfo, 'handleOneOnOneEvent');
4106
+ sinon.stub(locusInfo, 'updateEmbeddedApps');
4107
+ locusInfo.locusParser.workingCopy = null;
4108
+
4109
+ locusInfo.onFullLocus('test', locus);
4110
+
4111
+ assert.equal(locusInfo.hashTreeObjectId2ParticipantId.get(0), 'participant-zero');
4112
+ });
4113
+ });
4114
+
4115
+ describe('#onDeltaLocus', () => {
4116
+ it('should use forceReplaceMembers from jsSdkMeta when it is defined', () => {
4117
+ sinon.stub(locusInfo, 'mergeParticipants');
4118
+ sinon.stub(locusInfo, 'updateLocusInfo').returns(true);
4119
+ sinon.stub(locusInfo, 'updateParticipants');
4120
+ sinon.stub(locusInfo, 'isMeetingActive');
4121
+
4122
+ locusInfo.onDeltaLocus({
4123
+ participants: [],
4124
+ jsSdkMeta: {forceReplaceMembers: true, removedParticipantIds: []},
4125
+ });
4126
+
4127
+ assert.calledOnceWithExactly(locusInfo.updateParticipants, [], [], true);
4128
+ });
4129
+
4130
+ it('should fall back to isNeedReplaceMembers when forceReplaceMembers is not in jsSdkMeta', () => {
4131
+ sinon.stub(locusInfo, 'mergeParticipants');
4132
+ sinon.stub(locusInfo, 'updateLocusInfo').returns(true);
4133
+ sinon.stub(locusInfo, 'updateParticipants');
4134
+ sinon.stub(locusInfo, 'isMeetingActive');
4135
+
4136
+ locusInfo.onDeltaLocus({participants: []});
4137
+
4138
+ // without jsSdkMeta.forceReplaceMembers, uses ControlsUtils.isNeedReplaceMembers result (false by default)
4139
+ assert.calledOnceWithExactly(locusInfo.updateParticipants, [], undefined, false);
4140
+ });
4141
+
4142
+ it('should not call updateParticipants when updateLocusInfo returns false', () => {
4143
+ sinon.stub(locusInfo, 'mergeParticipants');
4144
+ sinon.stub(locusInfo, 'updateLocusInfo').returns(false);
4145
+ sinon.stub(locusInfo, 'updateParticipants');
4146
+ sinon.stub(locusInfo, 'isMeetingActive');
4147
+
4148
+ locusInfo.onDeltaLocus({participants: [], self: {state: 'LEFT', reason: 'MOVED'}});
4149
+
4150
+ assert.notCalled(locusInfo.updateParticipants);
4151
+ });
4152
+
4153
+ it('should call updateParticipants when updateLocusInfo returns true', () => {
4154
+ sinon.stub(locusInfo, 'mergeParticipants');
4155
+ sinon.stub(locusInfo, 'updateLocusInfo').returns(true);
4156
+ sinon.stub(locusInfo, 'updateParticipants');
4157
+ sinon.stub(locusInfo, 'isMeetingActive');
4158
+
4159
+ locusInfo.onDeltaLocus({participants: [{id: 'p1'}]});
4160
+
4161
+ assert.calledOnce(locusInfo.updateParticipants);
4162
+ });
4163
+
4164
+ [
4165
+ {forceReplaceMembers: true, selfInParticipants: false, expectedSelfCopied: true},
4166
+ {forceReplaceMembers: true, selfInParticipants: true, expectedSelfCopied: false},
4167
+ {forceReplaceMembers: false, selfInParticipants: false, expectedSelfCopied: false},
4168
+ {forceReplaceMembers: false, selfInParticipants: true, expectedSelfCopied: false},
4169
+ ].forEach(({forceReplaceMembers, selfInParticipants, expectedSelfCopied}) => {
4170
+ it(`should ${expectedSelfCopied ? '' : 'not '}copy self into participants when forceReplaceMembers=${forceReplaceMembers} and self ${selfInParticipants ? 'is' : 'is not'} in participants`, () => {
4171
+ const self = {identity: 'selfId', state: 'JOINED', devices: [], status: {}};
4172
+ const participant = {identity: selfInParticipants ? 'selfId' : 'other'};
4173
+ const locus = {
4174
+ participants: [participant],
4175
+ self,
4176
+ jsSdkMeta: {forceReplaceMembers, removedParticipantIds: []},
4177
+ };
4178
+
4179
+ locusInfo.onDeltaLocus(locus);
4180
+
4181
+ const expectedParticipants = expectedSelfCopied ? [participant, self] : [participant];
4182
+ assert.deepEqual(locus.participants, expectedParticipants);
4183
+ });
4184
+ });
4185
+ });
4186
+
4187
+ describe('#updateLocusInfo', () => {
4188
+ it('should return false when self.reason is MOVED and self.state is LEFT', () => {
4189
+ sinon.stub(locusInfo, 'updateControls');
4190
+
4191
+ const result = locusInfo.updateLocusInfo({self: {reason: 'MOVED', state: 'LEFT'}});
4192
+
4193
+ assert.isFalse(result);
4194
+ assert.notCalled(locusInfo.updateControls);
4195
+ });
4196
+
4197
+ it('should return true when self is not in MOVED/LEFT state', () => {
4198
+ sinon.stub(locusInfo, 'updateControls');
4199
+ sinon.stub(locusInfo, 'updateConversationUrl');
4200
+ sinon.stub(locusInfo, 'updateCreated');
4201
+ sinon.stub(locusInfo, 'updateFullState');
4202
+ sinon.stub(locusInfo, 'updateHostInfo');
4203
+ sinon.stub(locusInfo, 'updateLocusUrl');
4204
+ sinon.stub(locusInfo, 'updateMeetingInfo');
4205
+ sinon.stub(locusInfo, 'updateMediaShares');
4206
+ sinon.stub(locusInfo, 'updateReplaces');
4207
+ sinon.stub(locusInfo, 'updateSelf');
4208
+ sinon.stub(locusInfo, 'updateAclUrl');
4209
+ sinon.stub(locusInfo, 'updateBasequence');
4210
+ sinon.stub(locusInfo, 'updateSequence');
4211
+ sinon.stub(locusInfo, 'updateEmbeddedApps');
4212
+ sinon.stub(locusInfo, 'updateLinks');
4213
+ sinon.stub(locusInfo, 'compareAndUpdate');
4214
+
4215
+ const result = locusInfo.updateLocusInfo({self: {state: 'JOINED'}});
4216
+
4217
+ assert.isTrue(result);
4218
+ assert.calledOnce(locusInfo.updateControls);
4219
+ });
3508
4220
  });
3509
4221
 
3510
4222
  describe('#getTheLocusToUpdate', () => {
@@ -3856,7 +4568,7 @@ describe('plugin-meetings', () => {
3856
4568
 
3857
4569
  describe('#updateLocusUrl', () => {
3858
4570
  it('trigger LOCUS_INFO_UPDATE_URL event with isMainLocus is true as default', () => {
3859
- const fakeUrl = "https://fake.com/locus";
4571
+ const fakeUrl = 'https://fake.com/locus';
3860
4572
  locusInfo.emitScoped = sinon.stub();
3861
4573
  locusInfo.updateLocusUrl(fakeUrl);
3862
4574
 
@@ -3869,12 +4581,12 @@ describe('plugin-meetings', () => {
3869
4581
  EVENTS.LOCUS_INFO_UPDATE_URL,
3870
4582
  {
3871
4583
  url: fakeUrl,
3872
- isMainLocus: true
3873
- },
4584
+ isMainLocus: true,
4585
+ }
3874
4586
  );
3875
4587
  });
3876
4588
  it('trigger LOCUS_INFO_UPDATE_URL event with isMainLocus is false', () => {
3877
- const fakeUrl = "https://fake.com/locus";
4589
+ const fakeUrl = 'https://fake.com/locus';
3878
4590
  locusInfo.emitScoped = sinon.stub();
3879
4591
  locusInfo.updateLocusUrl(fakeUrl, false);
3880
4592
 
@@ -3887,8 +4599,8 @@ describe('plugin-meetings', () => {
3887
4599
  EVENTS.LOCUS_INFO_UPDATE_URL,
3888
4600
  {
3889
4601
  url: fakeUrl,
3890
- isMainLocus: false
3891
- },
4602
+ isMainLocus: false,
4603
+ }
3892
4604
  );
3893
4605
  });
3894
4606
  });
@@ -3940,8 +4652,8 @@ describe('plugin-meetings', () => {
3940
4652
 
3941
4653
  sinon.stub(locusInfo, 'updateParticipants');
3942
4654
  sinon.stub(locusInfo, 'isMeetingActive');
3943
- sinon.stub(locusInfo, 'handleOneOnOneEvent');
3944
- (updateLocusInfoStub = sinon.stub(locusInfo, 'updateLocusInfo'));
4655
+ sinon.stub(locusInfo, 'handleOneOnOneEvent');
4656
+ updateLocusInfoStub = sinon.stub(locusInfo, 'updateLocusInfo');
3945
4657
  syncRequestStub = sinon.stub().resolves({body: {}});
3946
4658
 
3947
4659
  mockMeeting.locusInfo = locusInfo;
@@ -3950,7 +4662,7 @@ describe('plugin-meetings', () => {
3950
4662
  getLocusDTO: syncRequestStub,
3951
4663
  };
3952
4664
 
3953
- locusInfo.onFullLocus({
4665
+ locusInfo.onFullLocus('test', {
3954
4666
  sequence: {
3955
4667
  rangeStart: 0,
3956
4668
  rangeEnd: 0,
@@ -4188,7 +4900,9 @@ describe('plugin-meetings', () => {
4188
4900
 
4189
4901
  describe('#parse', () => {
4190
4902
  it('handles hash tree messages correctly', () => {
4903
+ const fakeLocusUrl = 'http://locus-url.com';
4191
4904
  const fakeHashTreeMessage = {
4905
+ locusUrl: fakeLocusUrl,
4192
4906
  locusStateElements: [
4193
4907
  {
4194
4908
  htMeta: {elementId: {type: 'self'}},
@@ -4207,12 +4921,377 @@ describe('plugin-meetings', () => {
4207
4921
  const mockHashTreeParser = {
4208
4922
  handleMessage: sinon.stub(),
4209
4923
  };
4210
- locusInfo.hashTreeParser = mockHashTreeParser;
4924
+ locusInfo.hashTreeParsers.set(fakeLocusUrl, {
4925
+ parser: mockHashTreeParser,
4926
+ initializedFromHashTree: true,
4927
+ });
4211
4928
 
4212
4929
  locusInfo.parse(mockMeeting, data);
4213
4930
 
4214
4931
  assert.calledOnceWithExactly(mockHashTreeParser.handleMessage, fakeHashTreeMessage);
4215
4932
  });
4933
+
4934
+ it('ignores hash tree event when hashTreeParser is not created yet', () => {
4935
+ const data = {
4936
+ eventType: LOCUSEVENT.HASH_TREE_DATA_UPDATED,
4937
+ stateElementsMessage: {
4938
+ locusStateElements: [],
4939
+ dataSets: [],
4940
+ },
4941
+ };
4942
+
4943
+ const loggerSpy = sinon.spy(LoggerProxy.logger, 'info');
4944
+ const getTheLocusToUpdateStub = sinon.stub(locusInfo, 'getTheLocusToUpdate');
4945
+
4946
+ // Ensure we're not using hash trees
4947
+ assert.equal(locusInfo.hashTreeParsers.size, 0);
4948
+
4949
+ locusInfo.parse(mockMeeting, data);
4950
+
4951
+ assert.calledWith(
4952
+ loggerSpy,
4953
+ 'Locus-info:index#parse --> received locus hash tree event before hashTreeParser is created'
4954
+ );
4955
+ assert.notCalled(getTheLocusToUpdateStub);
4956
+ });
4957
+ });
4958
+ });
4959
+
4960
+ describe('#createLocusFromHashTreeMessage', () => {
4961
+ const LOCUS_URL = 'https://locus.example.com/loci/abc-123';
4962
+
4963
+ const createElement = (type, data) => ({
4964
+ htMeta: {elementId: {type, id: 1, version: 1}},
4965
+ data,
4966
+ });
4967
+
4968
+ it('returns locus with url and empty participants when no locusStateElements', () => {
4969
+ const result = createLocusFromHashTreeMessage({locusUrl: LOCUS_URL});
4970
+
4971
+ assert.deepEqual(result.locus, {participants: [], url: LOCUS_URL});
4972
+ assert.isUndefined(result.metadata);
4973
+ });
4974
+
4975
+ it('skips elements without data', () => {
4976
+ const result = createLocusFromHashTreeMessage({
4977
+ locusUrl: LOCUS_URL,
4978
+ locusStateElements: [{htMeta: {elementId: {type: 'Self', id: 1, version: 1}}, data: null}],
4979
+ });
4980
+
4981
+ assert.deepEqual(result.locus, {participants: [], url: LOCUS_URL});
4982
+ });
4983
+
4984
+ [
4985
+ {type: 'Self', locusKey: 'self', data: {id: 'self-1', state: 'JOINED'}},
4986
+ {type: 'Info', locusKey: 'info', data: {webExMeetingId: '123'}},
4987
+ {type: 'FullState', locusKey: 'fullState', data: {state: 'ACTIVE'}},
4988
+ {type: 'Links', locusKey: 'links', data: {resources: {}}},
4989
+ ].forEach(({type, locusKey, data}) => {
4990
+ it(`maps ${type} element to locus.${locusKey}`, () => {
4991
+ const result = createLocusFromHashTreeMessage({
4992
+ locusUrl: LOCUS_URL,
4993
+ locusStateElements: [createElement(type, data)],
4994
+ });
4995
+
4996
+ assert.deepEqual(result.locus[locusKey], data);
4997
+ });
4998
+ });
4999
+
5000
+ it('pushes Participant elements to locus.participants', () => {
5001
+ const p1 = {id: 'p1', state: 'JOINED'};
5002
+ const p2 = {id: 'p2', state: 'LEFT'};
5003
+
5004
+ const result = createLocusFromHashTreeMessage({
5005
+ locusUrl: LOCUS_URL,
5006
+ locusStateElements: [createElement('Participant', p1), createElement('Participant', p2)],
5007
+ });
5008
+
5009
+ assert.deepEqual(result.locus.participants, [p1, p2]);
5010
+ });
5011
+
5012
+ it('pushes MediaShare elements to locus.mediaShares array', () => {
5013
+ const share1 = {name: 'whiteboard'};
5014
+ const share2 = {name: 'content'};
5015
+
5016
+ const result = createLocusFromHashTreeMessage({
5017
+ locusUrl: LOCUS_URL,
5018
+ locusStateElements: [
5019
+ createElement('MediaShare', share1),
5020
+ createElement('MediaShare', share2),
5021
+ ],
5022
+ });
5023
+
5024
+ assert.deepEqual(result.locus.mediaShares, [share1, share2]);
5025
+ });
5026
+
5027
+ it('pushes EmbeddedApp elements to locus.embeddedApps array', () => {
5028
+ const app = {appId: 'app-1', state: 'STARTED'};
5029
+
5030
+ const result = createLocusFromHashTreeMessage({
5031
+ locusUrl: LOCUS_URL,
5032
+ locusStateElements: [createElement('EmbeddedApp', app)],
5033
+ });
5034
+
5035
+ assert.deepEqual(result.locus.embeddedApps, [app]);
5036
+ });
5037
+
5038
+ it('merges ControlEntry elements into locus.controls', () => {
5039
+ const control1 = {record: {recording: true}};
5040
+ const control2 = {lock: {locked: false}};
5041
+
5042
+ const result = createLocusFromHashTreeMessage({
5043
+ locusUrl: LOCUS_URL,
5044
+ locusStateElements: [
5045
+ createElement('ControlEntry', control1),
5046
+ createElement('ControlEntry', control2),
5047
+ ],
5048
+ });
5049
+
5050
+ assert.deepEqual(result.locus.controls, {record: {recording: true}, lock: {locked: false}});
5051
+ });
5052
+
5053
+ it('spreads Locus element data onto top level but removes managed keys', () => {
5054
+ const locusData = {
5055
+ url: 'should-be-overridden',
5056
+ someTopLevelField: 'value',
5057
+ // these are managed by other ObjectTypes and should be removed
5058
+ links: {should: 'be removed'},
5059
+ info: {should: 'be removed'},
5060
+ fullState: {should: 'be removed'},
5061
+ self: {should: 'be removed'},
5062
+ participants: [{should: 'be removed'}],
5063
+ mediaShares: [{should: 'be removed'}],
5064
+ controls: {should: 'be removed'},
5065
+ embeddedApps: [{should: 'be removed'}],
5066
+ };
5067
+
5068
+ const result = createLocusFromHashTreeMessage({
5069
+ locusUrl: LOCUS_URL,
5070
+ locusStateElements: [createElement('Locus', locusData)],
5071
+ });
5072
+
5073
+ assert.equal(result.locus.someTopLevelField, 'value');
5074
+ assert.deepEqual(result.locus.participants, []);
5075
+ assert.isUndefined(result.locus.links);
5076
+ assert.isUndefined(result.locus.info);
5077
+ assert.isUndefined(result.locus.fullState);
5078
+ assert.isUndefined(result.locus.self);
5079
+ assert.isUndefined(result.locus.mediaShares);
5080
+ assert.isUndefined(result.locus.controls);
5081
+ assert.isUndefined(result.locus.embeddedApps);
5082
+ });
5083
+
5084
+ it('extracts Metadata element as metadata in the result', () => {
5085
+ const metadataData = {visibleDataSets: [{name: 'ds1', url: 'http://ds1.url'}]};
5086
+ const htMeta = {elementId: {type: 'Metadata', id: 99, version: 3}};
5087
+
5088
+ const result = createLocusFromHashTreeMessage({
5089
+ locusUrl: LOCUS_URL,
5090
+ locusStateElements: [{htMeta, data: metadataData}],
5091
+ });
5092
+
5093
+ assert.deepEqual(result.metadata, {...metadataData, htMeta});
5094
+ assert.isUndefined(result.locus.metadata);
5095
+ });
5096
+
5097
+ it('handles a message with multiple element types', () => {
5098
+ const selfData = {id: 'self-1'};
5099
+ const participantData = {id: 'p1'};
5100
+ const infoData = {webExMeetingId: '456'};
5101
+
5102
+ const result = createLocusFromHashTreeMessage({
5103
+ locusUrl: LOCUS_URL,
5104
+ locusStateElements: [
5105
+ createElement('Self', selfData),
5106
+ createElement('Participant', participantData),
5107
+ createElement('Info', infoData),
5108
+ ],
5109
+ });
5110
+
5111
+ assert.deepEqual(result.locus.self, selfData);
5112
+ assert.deepEqual(result.locus.participants, [participantData]);
5113
+ assert.deepEqual(result.locus.info, infoData);
5114
+ assert.equal(result.locus.url, LOCUS_URL);
5115
+ });
5116
+
5117
+ it('ignores unknown element types', () => {
5118
+ const result = createLocusFromHashTreeMessage({
5119
+ locusUrl: LOCUS_URL,
5120
+ locusStateElements: [createElement('UnknownType', {foo: 'bar'})],
5121
+ });
5122
+
5123
+ assert.deepEqual(result.locus, {participants: [], url: LOCUS_URL});
5124
+ });
5125
+ });
5126
+
5127
+ describe('findMeetingForHashTreeMessage', () => {
5128
+ const deviceUrl = 'https://devices.example.com/device1';
5129
+
5130
+ function createMockMeetingCollection(meetings) {
5131
+ return {
5132
+ getAll: () => meetings,
5133
+ };
5134
+ }
5135
+
5136
+ function createMockMeeting(id, hashTreeParsersMap) {
5137
+ return {
5138
+ id,
5139
+ locusInfo: {
5140
+ hashTreeParsers: hashTreeParsersMap,
5141
+ },
5142
+ };
5143
+ }
5144
+
5145
+ function createSelfElement(devices) {
5146
+ return {
5147
+ htMeta: {elementId: {type: 'Self'}},
5148
+ data: {
5149
+ devices,
5150
+ },
5151
+ };
5152
+ }
5153
+
5154
+ it('returns the meeting when locusUrl matches a hashTreeParser directly', () => {
5155
+ const locusUrl = 'https://locus.example.com/loci/abc123';
5156
+ const parsersMap = new Map([[locusUrl, {state: 'active'}]]);
5157
+ const meeting = createMockMeeting('meeting1', parsersMap);
5158
+ const collection = createMockMeetingCollection({meeting1: meeting});
5159
+
5160
+ const message = {locusUrl, locusStateElements: []};
5161
+
5162
+ const result = findMeetingForHashTreeMessage(message, collection, deviceUrl);
5163
+
5164
+ assert.equal(result, meeting);
5165
+ });
5166
+
5167
+ it('returns undefined when no meeting matches and message has no locusStateElements', () => {
5168
+ const locusUrl = 'https://locus.example.com/loci/unknown';
5169
+ const parsersMap = new Map([['https://locus.example.com/loci/other', {state: 'active'}]]);
5170
+ const meeting = createMockMeeting('meeting1', parsersMap);
5171
+ const collection = createMockMeetingCollection({meeting1: meeting});
5172
+
5173
+ const message = {locusUrl};
5174
+
5175
+ const result = findMeetingForHashTreeMessage(message, collection, deviceUrl);
5176
+
5177
+ assert.isUndefined(result);
5178
+ });
5179
+
5180
+ it('returns undefined when no meeting matches and self element has no replaces', () => {
5181
+ const locusUrl = 'https://locus.example.com/loci/unknown';
5182
+ const parsersMap = new Map([['https://locus.example.com/loci/other', {state: 'active'}]]);
5183
+ const meeting = createMockMeeting('meeting1', parsersMap);
5184
+ const collection = createMockMeetingCollection({meeting1: meeting});
5185
+
5186
+ const selfElement = createSelfElement([{url: deviceUrl}]);
5187
+ const message = {locusUrl, locusStateElements: [selfElement]};
5188
+
5189
+ const result = findMeetingForHashTreeMessage(message, collection, deviceUrl);
5190
+
5191
+ assert.isUndefined(result);
5192
+ });
5193
+
5194
+ it('returns the meeting when locusUrl from replaces matches a hashTreeParser', () => {
5195
+ const oldLocusUrl = 'https://locus.example.com/loci/old';
5196
+ const newLocusUrl = 'https://locus.example.com/loci/new';
5197
+ const parsersMap = new Map([[oldLocusUrl, {state: 'active'}]]);
5198
+ const meeting = createMockMeeting('meeting1', parsersMap);
5199
+ const collection = createMockMeetingCollection({meeting1: meeting});
5200
+
5201
+ const selfElement = createSelfElement([
5202
+ {url: deviceUrl, replaces: [{locusUrl: oldLocusUrl}]},
5203
+ ]);
5204
+ const message = {locusUrl: newLocusUrl, locusStateElements: [selfElement]};
5205
+
5206
+ const result = findMeetingForHashTreeMessage(message, collection, deviceUrl);
5207
+
5208
+ assert.equal(result, meeting);
5209
+ });
5210
+
5211
+ it('returns undefined when replaces locusUrl does not match any hashTreeParser', () => {
5212
+ const oldLocusUrl = 'https://locus.example.com/loci/old';
5213
+ const newLocusUrl = 'https://locus.example.com/loci/new';
5214
+ const parsersMap = new Map([
5215
+ ['https://locus.example.com/loci/something-else', {state: 'active'}],
5216
+ ]);
5217
+ const meeting = createMockMeeting('meeting1', parsersMap);
5218
+ const collection = createMockMeetingCollection({meeting1: meeting});
5219
+
5220
+ const selfElement = createSelfElement([
5221
+ {url: deviceUrl, replaces: [{locusUrl: oldLocusUrl}]},
5222
+ ]);
5223
+ const message = {locusUrl: newLocusUrl, locusStateElements: [selfElement]};
5224
+
5225
+ const result = findMeetingForHashTreeMessage(message, collection, deviceUrl);
5226
+
5227
+ assert.isUndefined(result);
5228
+ });
5229
+
5230
+ it('returns undefined when meetingCollection is empty', () => {
5231
+ const collection = createMockMeetingCollection({});
5232
+ const message = {locusUrl: 'https://locus.example.com/loci/abc', locusStateElements: []};
5233
+
5234
+ const result = findMeetingForHashTreeMessage(message, collection, deviceUrl);
5235
+
5236
+ assert.isUndefined(result);
5237
+ });
5238
+
5239
+ it('checks multiple meetings and returns the correct one', () => {
5240
+ const targetLocusUrl = 'https://locus.example.com/loci/target';
5241
+ const meeting1 = createMockMeeting(
5242
+ 'meeting1',
5243
+ new Map([['https://locus.example.com/loci/other', {state: 'active'}]])
5244
+ );
5245
+ const meeting2 = createMockMeeting(
5246
+ 'meeting2',
5247
+ new Map([[targetLocusUrl, {state: 'active'}]])
5248
+ );
5249
+ const collection = createMockMeetingCollection({meeting1, meeting2});
5250
+
5251
+ const message = {locusUrl: targetLocusUrl, locusStateElements: []};
5252
+
5253
+ const result = findMeetingForHashTreeMessage(message, collection, deviceUrl);
5254
+
5255
+ assert.equal(result, meeting2);
5256
+ });
5257
+
5258
+ it('ignores devices that do not match deviceUrl when looking for replaces', () => {
5259
+ const oldLocusUrl = 'https://locus.example.com/loci/old';
5260
+ const newLocusUrl = 'https://locus.example.com/loci/new';
5261
+ const parsersMap = new Map([[oldLocusUrl, {state: 'active'}]]);
5262
+ const meeting = createMockMeeting('meeting1', parsersMap);
5263
+ const collection = createMockMeetingCollection({meeting1: meeting});
5264
+
5265
+ // self element has replaces, but on a different device
5266
+ const selfElement = createSelfElement([
5267
+ {url: 'https://devices.example.com/other-device', replaces: [{locusUrl: oldLocusUrl}]},
5268
+ ]);
5269
+ const message = {locusUrl: newLocusUrl, locusStateElements: [selfElement]};
5270
+
5271
+ const result = findMeetingForHashTreeMessage(message, collection, deviceUrl);
5272
+
5273
+ assert.isUndefined(result);
5274
+ });
5275
+
5276
+ it('does not use self element if it is not of type Self', () => {
5277
+ const oldLocusUrl = 'https://locus.example.com/loci/old';
5278
+ const newLocusUrl = 'https://locus.example.com/loci/new';
5279
+ const parsersMap = new Map([[oldLocusUrl, {state: 'active'}]]);
5280
+ const meeting = createMockMeeting('meeting1', parsersMap);
5281
+ const collection = createMockMeetingCollection({meeting1: meeting});
5282
+
5283
+ // element has replaces data but is not of type Self
5284
+ const nonSelfElement = {
5285
+ htMeta: {elementId: {type: 'Participant'}},
5286
+ data: {
5287
+ devices: [{url: deviceUrl, replaces: [{locusUrl: oldLocusUrl}]}],
5288
+ },
5289
+ };
5290
+ const message = {locusUrl: newLocusUrl, locusStateElements: [nonSelfElement]};
5291
+
5292
+ const result = findMeetingForHashTreeMessage(message, collection, deviceUrl);
5293
+
5294
+ assert.isUndefined(result);
4216
5295
  });
4217
5296
  });
4218
5297
  });