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

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 (176) 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 +13 -2
  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 +869 -420
  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 +32 -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 +1304 -928
  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 +6 -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/multistream/sendSlotManager.js +116 -2
  69. package/dist/multistream/sendSlotManager.js.map +1 -1
  70. package/dist/reachability/index.js +18 -10
  71. package/dist/reachability/index.js.map +1 -1
  72. package/dist/reactions/reactions.type.js.map +1 -1
  73. package/dist/reconnection-manager/index.js +0 -1
  74. package/dist/reconnection-manager/index.js.map +1 -1
  75. package/dist/types/aiEnableRequest/index.d.ts +5 -0
  76. package/dist/types/aiEnableRequest/utils.d.ts +2 -0
  77. package/dist/types/config.d.ts +4 -0
  78. package/dist/types/constants.d.ts +23 -1
  79. package/dist/types/hashTree/constants.d.ts +2 -0
  80. package/dist/types/hashTree/hashTree.d.ts +7 -0
  81. package/dist/types/hashTree/hashTreeParser.d.ts +122 -14
  82. package/dist/types/hashTree/types.d.ts +3 -0
  83. package/dist/types/hashTree/utils.d.ts +17 -0
  84. package/dist/types/index.d.ts +1 -0
  85. package/dist/types/interceptors/constant.d.ts +5 -0
  86. package/dist/types/interceptors/dataChannelAuthToken.d.ts +43 -0
  87. package/dist/types/interceptors/index.d.ts +2 -1
  88. package/dist/types/interceptors/utils.d.ts +1 -0
  89. package/dist/types/locus-info/index.d.ts +60 -8
  90. package/dist/types/locus-info/types.d.ts +7 -0
  91. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  92. package/dist/types/media/properties.d.ts +2 -1
  93. package/dist/types/meeting/in-meeting-actions.d.ts +6 -0
  94. package/dist/types/meeting/index.d.ts +72 -7
  95. package/dist/types/meeting/request.d.ts +16 -1
  96. package/dist/types/meeting/request.type.d.ts +5 -0
  97. package/dist/types/meeting/util.d.ts +31 -0
  98. package/dist/types/meetings/index.d.ts +4 -2
  99. package/dist/types/member/index.d.ts +1 -0
  100. package/dist/types/member/util.d.ts +5 -0
  101. package/dist/types/metrics/constants.d.ts +5 -0
  102. package/dist/types/multistream/mediaRequestManager.d.ts +0 -23
  103. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  104. package/dist/types/reactions/reactions.type.d.ts +1 -0
  105. package/dist/types/webinar/utils.d.ts +6 -0
  106. package/dist/webinar/index.js +438 -163
  107. package/dist/webinar/index.js.map +1 -1
  108. package/dist/webinar/utils.js +25 -0
  109. package/dist/webinar/utils.js.map +1 -0
  110. package/package.json +24 -23
  111. package/src/aiEnableRequest/README.md +84 -0
  112. package/src/aiEnableRequest/index.ts +170 -0
  113. package/src/aiEnableRequest/utils.ts +25 -0
  114. package/src/annotation/index.ts +27 -7
  115. package/src/config.ts +4 -0
  116. package/src/constants.ts +29 -1
  117. package/src/hashTree/constants.ts +10 -0
  118. package/src/hashTree/hashTree.ts +17 -0
  119. package/src/hashTree/hashTreeParser.ts +764 -264
  120. package/src/hashTree/types.ts +4 -0
  121. package/src/hashTree/utils.ts +26 -0
  122. package/src/index.ts +8 -1
  123. package/src/interceptors/constant.ts +6 -0
  124. package/src/interceptors/dataChannelAuthToken.ts +170 -0
  125. package/src/interceptors/index.ts +2 -1
  126. package/src/interceptors/utils.ts +16 -0
  127. package/src/interpretation/index.ts +2 -2
  128. package/src/locus-info/controlsUtils.ts +11 -0
  129. package/src/locus-info/index.ts +579 -113
  130. package/src/locus-info/selfUtils.ts +1 -0
  131. package/src/locus-info/types.ts +8 -0
  132. package/src/media/MediaConnectionAwaiter.ts +41 -1
  133. package/src/media/properties.ts +3 -1
  134. package/src/meeting/in-meeting-actions.ts +12 -0
  135. package/src/meeting/index.ts +389 -87
  136. package/src/meeting/request.ts +42 -0
  137. package/src/meeting/request.type.ts +6 -0
  138. package/src/meeting/util.ts +160 -2
  139. package/src/meetings/index.ts +157 -44
  140. package/src/member/index.ts +10 -0
  141. package/src/member/util.ts +12 -0
  142. package/src/metrics/constants.ts +6 -0
  143. package/src/multistream/mediaRequestManager.ts +4 -54
  144. package/src/multistream/remoteMediaManager.ts +13 -0
  145. package/src/multistream/sendSlotManager.ts +97 -3
  146. package/src/reachability/index.ts +9 -0
  147. package/src/reactions/reactions.type.ts +1 -0
  148. package/src/reconnection-manager/index.ts +0 -1
  149. package/src/webinar/index.ts +265 -6
  150. package/src/webinar/utils.ts +16 -0
  151. package/test/unit/spec/aiEnableRequest/index.ts +981 -0
  152. package/test/unit/spec/aiEnableRequest/utils.ts +130 -0
  153. package/test/unit/spec/annotation/index.ts +69 -7
  154. package/test/unit/spec/hashTree/hashTree.ts +66 -0
  155. package/test/unit/spec/hashTree/hashTreeParser.ts +2469 -195
  156. package/test/unit/spec/hashTree/utils.ts +88 -1
  157. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +210 -0
  158. package/test/unit/spec/interceptors/utils.ts +75 -0
  159. package/test/unit/spec/locus-info/controlsUtils.js +29 -0
  160. package/test/unit/spec/locus-info/index.js +1134 -55
  161. package/test/unit/spec/media/MediaConnectionAwaiter.ts +41 -1
  162. package/test/unit/spec/media/properties.ts +12 -3
  163. package/test/unit/spec/meeting/in-meeting-actions.ts +8 -2
  164. package/test/unit/spec/meeting/index.js +884 -152
  165. package/test/unit/spec/meeting/request.js +70 -0
  166. package/test/unit/spec/meeting/utils.js +438 -26
  167. package/test/unit/spec/meetings/index.js +653 -32
  168. package/test/unit/spec/member/index.js +28 -4
  169. package/test/unit/spec/member/util.js +65 -27
  170. package/test/unit/spec/multistream/mediaRequestManager.ts +2 -85
  171. package/test/unit/spec/multistream/remoteMediaManager.ts +30 -0
  172. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  173. package/test/unit/spec/reachability/index.ts +23 -0
  174. package/test/unit/spec/reconnection-manager/index.js +4 -8
  175. package/test/unit/spec/webinar/index.ts +534 -37
  176. package/test/unit/spec/webinar/utils.ts +39 -0
@@ -1,10 +1,14 @@
1
1
  import HashTreeParser, {
2
2
  LocusInfoUpdateType,
3
+ MeetingEndedError,
3
4
  } from '@webex/plugin-meetings/src/hashTree/hashTreeParser';
4
5
  import HashTree from '@webex/plugin-meetings/src/hashTree/hashTree';
5
6
  import {expect} from '@webex/test-helper-chai';
6
7
  import sinon from 'sinon';
7
8
  import {assert} from '@webex/test-helper-chai';
9
+ import {EMPTY_HASH} from '@webex/plugin-meetings/src/hashTree/constants';
10
+
11
+ const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
8
12
 
9
13
  const exampleInitialLocus = {
10
14
  dataSets: [
@@ -46,6 +50,7 @@ const exampleInitialLocus = {
46
50
  },
47
51
  dataSetNames: ['main'],
48
52
  },
53
+ links: {resources: {visibleDataSets: {url: visibleDataSetsUrl}}},
49
54
  participants: [
50
55
  {
51
56
  url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
@@ -62,7 +67,6 @@ const exampleInitialLocus = {
62
67
  ],
63
68
  self: {
64
69
  url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
65
- visibleDataSets: ['main', 'self', 'atd-unmuted'],
66
70
  person: {},
67
71
  htMeta: {
68
72
  elementId: {
@@ -76,6 +80,28 @@ const exampleInitialLocus = {
76
80
  },
77
81
  };
78
82
 
83
+ const exampleMetadata = {
84
+ htMeta: {
85
+ elementId: {
86
+ type: 'metadata',
87
+ id: 5,
88
+ version: 50,
89
+ },
90
+ dataSetNames: ['self'],
91
+ },
92
+ visibleDataSets: [
93
+ {name: 'main', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main'},
94
+ {
95
+ name: 'self',
96
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
97
+ },
98
+ {
99
+ name: 'atd-unmuted',
100
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
101
+ },
102
+ ],
103
+ };
104
+
79
105
  function createDataSet(name: string, leafCount: number, version = 1) {
80
106
  return {
81
107
  url: `https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/${name}`,
@@ -119,7 +145,6 @@ function mockSyncRequest(webexRequest: sinon.SinonStub, datasetUrl: string, resp
119
145
  }
120
146
 
121
147
  describe('HashTreeParser', () => {
122
- const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
123
148
  const locusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f';
124
149
 
125
150
  let clock;
@@ -139,12 +164,18 @@ describe('HashTreeParser', () => {
139
164
  });
140
165
 
141
166
  // Helper to create a HashTreeParser instance with common defaults
142
- function createHashTreeParser(initialLocus: any = exampleInitialLocus) {
167
+ function createHashTreeParser(
168
+ initialLocus: any = exampleInitialLocus,
169
+ metadata: any = exampleMetadata,
170
+ excludedDataSets?: string[]
171
+ ) {
143
172
  return new HashTreeParser({
144
173
  initialLocus,
174
+ metadata,
145
175
  webexRequest,
146
176
  locusInfoUpdateCallback: callback,
147
177
  debugId: 'test',
178
+ excludedDataSets,
148
179
  });
149
180
  }
150
181
 
@@ -197,9 +228,50 @@ describe('HashTreeParser', () => {
197
228
  body: response,
198
229
  });
199
230
  }
231
+
232
+ async function checkAsyncDatasetInitialization(
233
+ parser: HashTreeParser,
234
+ newDataSet: {name: string; leafCount: number; url: string}
235
+ ) {
236
+ // immediately we don't have the dataset yet, so it should not be in visibleDataSets
237
+ // and no hash tree should exist yet
238
+ expect(parser.visibleDataSets.some((vds) => vds.name === newDataSet.name)).to.be.false;
239
+ assert.isUndefined(parser.dataSets[newDataSet.name]);
240
+
241
+ // Wait for the async initialization to complete (queued as microtask)
242
+ await clock.tickAsync(0);
243
+
244
+ // The visibleDataSets is updated from the metadata object data
245
+ expect(parser.visibleDataSets.some((vds) => vds.name === newDataSet.name)).to.be.true;
246
+
247
+ // Verify that a hash tree was created for newDataSet
248
+ assert.exists(parser.dataSets[newDataSet.name].hashTree);
249
+ assert.equal(parser.dataSets[newDataSet.name].hashTree.numLeaves, newDataSet.leafCount);
250
+
251
+ // Verify getAllDataSetsMetadata was called for async initialization
252
+ assert.calledWith(
253
+ webexRequest,
254
+ sinon.match({
255
+ method: 'GET',
256
+ uri: visibleDataSetsUrl,
257
+ })
258
+ );
259
+
260
+ // Verify sync request was sent for the new dataset
261
+ assert.calledWith(
262
+ webexRequest,
263
+ sinon.match({
264
+ method: 'POST',
265
+ uri: `${newDataSet.url}/sync`,
266
+ })
267
+ );
268
+ }
200
269
  it('should correctly initialize trees from initialLocus data', () => {
201
270
  const parser = createHashTreeParser();
202
271
 
272
+ // verify that visibleDataSetsUrl is read out from inside locus
273
+ expect(parser.visibleDataSetsUrl).to.equal(visibleDataSetsUrl);
274
+
203
275
  // Check that the correct number of trees are created
204
276
  expect(Object.keys(parser.dataSets).length).to.equal(3);
205
277
 
@@ -215,7 +287,11 @@ describe('HashTreeParser', () => {
215
287
  const selfTree = parser.dataSets.self.hashTree;
216
288
  expect(selfTree).to.be.instanceOf(HashTree);
217
289
  const expectedSelfLeaves = new Array(1).fill(null).map(() => ({}));
218
- expectedSelfLeaves[4 % 1] = {self: {4: {type: 'self', id: 4, version: 100}}};
290
+ // Both self (id=4) and metadata (id=5) map to the same leaf (4%1=0, 5%1=0)
291
+ expectedSelfLeaves[0] = {
292
+ self: {4: {type: 'self', id: 4, version: 100}},
293
+ metadata: {5: {type: 'metadata', id: 5, version: 50}},
294
+ };
219
295
  expect(selfTree.leaves).to.deep.equal(expectedSelfLeaves);
220
296
  expect(selfTree.numLeaves).to.equal(1);
221
297
 
@@ -247,7 +323,7 @@ describe('HashTreeParser', () => {
247
323
  name: 'empty-set',
248
324
  });
249
325
 
250
- const parser = createHashTreeParser(modifiedLocus);
326
+ const parser = createHashTreeParser(modifiedLocus, exampleMetadata);
251
327
 
252
328
  expect(Object.keys(parser.dataSets).length).to.equal(4); // main, self, atd-unmuted (now empty), empty-set
253
329
 
@@ -260,7 +336,10 @@ describe('HashTreeParser', () => {
260
336
 
261
337
  const selfTree = parser.dataSets.self.hashTree;
262
338
  const expectedSelfLeaves = new Array(1).fill(null).map(() => ({}));
263
- expectedSelfLeaves[4 % 1] = {self: {4: {type: 'self', id: 4, version: 100}}};
339
+ expectedSelfLeaves[4 % 1] = {
340
+ self: {4: {type: 'self', id: 4, version: 100}},
341
+ metadata: {5: exampleMetadata.htMeta.elementId},
342
+ };
264
343
  expect(selfTree.leaves).to.deep.equal(expectedSelfLeaves);
265
344
  expect(selfTree.numLeaves).to.equal(1);
266
345
 
@@ -277,31 +356,100 @@ describe('HashTreeParser', () => {
277
356
  expect(emptySet.hashTree).to.be.undefined;
278
357
  });
279
358
 
359
+ it('should exclude datasets listed in excludedDataSets during initialization', () => {
360
+ const parser = createHashTreeParser(exampleInitialLocus, exampleMetadata, ['atd-unmuted']);
361
+
362
+ // 'atd-unmuted' should be excluded from visibleDataSets
363
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
364
+
365
+ // 'main' and 'self' should still be visible
366
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
367
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'self')).to.be.true;
368
+
369
+ // 'atd-unmuted' dataset entry should exist but without a hash tree (because it's not visible)
370
+ expect(parser.dataSets['atd-unmuted']).to.exist;
371
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
372
+
373
+ // 'main' and 'self' should have hash trees
374
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
375
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
376
+ });
377
+
378
+ it('should exclude datasets listed in excludedDataSets when adding new visible datasets', async () => {
379
+ // Create parser without 'atd-unmuted' in initial metadata visibleDataSets
380
+ const metadataWithoutAtdUnmuted = {
381
+ ...exampleMetadata,
382
+ visibleDataSets: exampleMetadata.visibleDataSets.filter((vds) => vds.name !== 'atd-unmuted'),
383
+ };
384
+ const parser = createHashTreeParser(exampleInitialLocus, metadataWithoutAtdUnmuted, [
385
+ 'atd-unmuted',
386
+ ]);
387
+
388
+ // 'atd-unmuted' should not be in visibleDataSets initially
389
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
390
+
391
+ // Now simulate initializeDataSets which calls addToVisibleDataSetsList
392
+ const atdUnmutedDataSet = createDataSet('atd-unmuted', 16, 3000);
393
+
394
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
395
+ createDataSet('main', 16, 1000),
396
+ createDataSet('self', 1, 2000),
397
+ atdUnmutedDataSet,
398
+ ]);
399
+
400
+ mockSyncRequest(
401
+ webexRequest,
402
+ 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'
403
+ );
404
+
405
+ const message = {
406
+ dataSets: [createDataSet('main', 16, 1000)],
407
+ visibleDataSetsUrl,
408
+ locusUrl,
409
+ };
410
+ await parser.initializeFromMessage(message);
411
+
412
+ // 'atd-unmuted' should still not be in visibleDataSets because it is excluded
413
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
414
+ // but 'main' and 'self' should be there
415
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
416
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'self')).to.be.true;
417
+ });
418
+
280
419
  // helper method, needed because both initializeFromMessage and initializeFromGetLociResponse
281
420
  // do almost exactly the same thing
282
421
  const testInitializationOfDatasetsAndHashTrees = async (testCallback) => {
283
422
  // Create a parser with minimal initial data
284
423
  const minimalInitialLocus = {
285
424
  dataSets: [],
286
- locus: {
287
- self: {
288
- visibleDataSets: ['main', 'self'],
425
+ locus: null,
426
+ };
427
+
428
+ const minimalMetadata = {
429
+ htMeta: {
430
+ elementId: {
431
+ type: 'metadata',
432
+ id: 5,
433
+ version: 50,
289
434
  },
435
+ dataSetNames: ['self'],
290
436
  },
437
+ visibleDataSets: [
438
+ {name: 'main', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main'},
439
+ {
440
+ name: 'self',
441
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
442
+ },
443
+ ],
291
444
  };
292
445
 
293
- const hashTreeParser = createHashTreeParser(minimalInitialLocus);
446
+ const hashTreeParser = createHashTreeParser(minimalInitialLocus, minimalMetadata);
294
447
 
295
448
  // Setup the datasets that will be returned from getAllDataSetsMetadata
296
449
  const mainDataSet = createDataSet('main', 16, 1100);
297
450
  const selfDataSet = createDataSet('self', 1, 2100);
298
- const invisibleDataSet = createDataSet('invisible', 4, 4000);
299
451
 
300
- mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
301
- mainDataSet,
302
- selfDataSet,
303
- invisibleDataSet,
304
- ]);
452
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet, selfDataSet]);
305
453
 
306
454
  // Mock sync requests for visible datasets with some updated objects
307
455
  const mainSyncResponse = {
@@ -357,15 +505,16 @@ describe('HashTreeParser', () => {
357
505
  })
358
506
  );
359
507
 
360
- // Verify all datasets are added to dataSets
508
+ // verify that visibleDataSetsUrl is set on the parser
509
+ expect(hashTreeParser.visibleDataSetsUrl).to.equal(visibleDataSetsUrl);
510
+
511
+ // Verify all datasets returned from visibleDataSetsUrl are added to dataSets
361
512
  expect(hashTreeParser.dataSets.main).to.exist;
362
513
  expect(hashTreeParser.dataSets.self).to.exist;
363
- expect(hashTreeParser.dataSets.invisible).to.exist;
364
514
 
365
515
  // Verify hash trees are created only for visible datasets
366
516
  expect(hashTreeParser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
367
517
  expect(hashTreeParser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
368
- expect(hashTreeParser.dataSets.invisible.hashTree).to.be.undefined;
369
518
 
370
519
  // Verify hash trees have correct leaf counts
371
520
  expect(hashTreeParser.dataSets.main.hashTree.numLeaves).to.equal(16);
@@ -403,16 +552,8 @@ describe('HashTreeParser', () => {
403
552
  })
404
553
  );
405
554
 
406
- // Verify sync request was NOT sent for invisible dataset
407
- assert.neverCalledWith(
408
- webexRequest,
409
- sinon.match({
410
- method: 'POST',
411
- uri: `${invisibleDataSet.url}/sync`,
412
- })
413
- );
414
-
415
555
  // Verify callback was called with OBJECTS_UPDATED and correct updatedObjects list
556
+ // Note: main is initialized before self due to sortByInitPriority
416
557
  assert.calledWith(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
417
558
  updatedObjects: [
418
559
  {
@@ -443,8 +584,6 @@ describe('HashTreeParser', () => {
443
584
  // verify that sync timers are set for visible datasets
444
585
  expect(hashTreeParser.dataSets.main.timer).to.not.be.undefined;
445
586
  expect(hashTreeParser.dataSets.self.timer).to.not.be.undefined;
446
- // and not for invisible dataset
447
- expect(hashTreeParser.dataSets.invisible.timer).to.be.undefined;
448
587
  };
449
588
 
450
589
  describe('#initializeFromMessage', () => {
@@ -457,11 +596,126 @@ describe('HashTreeParser', () => {
457
596
  });
458
597
  });
459
598
  });
599
+
600
+ it('initializes "main" before "self" regardless of order from Locus', async () => {
601
+ const parser = createHashTreeParser({dataSets: [], locus: null}, null);
602
+
603
+ // Locus returns datasets in non-priority order: atd-active, main, self
604
+ const atdActiveDataSet = createDataSet('atd-active', 4, 500);
605
+ const mainDataSet = createDataSet('main', 16, 1100);
606
+ const selfDataSet = createDataSet('self', 1, 2100);
607
+
608
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
609
+ atdActiveDataSet,
610
+ mainDataSet,
611
+ selfDataSet,
612
+ ]);
613
+
614
+ mockSyncRequest(webexRequest, selfDataSet.url);
615
+ mockSyncRequest(webexRequest, mainDataSet.url);
616
+ mockSyncRequest(webexRequest, atdActiveDataSet.url);
617
+
618
+ await parser.initializeFromMessage({
619
+ dataSets: [],
620
+ visibleDataSetsUrl,
621
+ locusUrl,
622
+ });
623
+
624
+ // Verify sync requests were sent in priority order: main, self, then atd-active
625
+ const syncCalls = webexRequest
626
+ .getCalls()
627
+ .filter((call) => call.args[0]?.method === 'POST' && call.args[0]?.uri?.endsWith('/sync'));
628
+
629
+ expect(syncCalls).to.have.lengthOf(3);
630
+ expect(syncCalls[0].args[0].uri).to.equal(`${mainDataSet.url}/sync`);
631
+ expect(syncCalls[1].args[0].uri).to.equal(`${selfDataSet.url}/sync`);
632
+ expect(syncCalls[2].args[0].uri).to.equal(`${atdActiveDataSet.url}/sync`);
633
+ });
634
+
635
+ it('handles sync response that has locusStateElements undefined', async () => {
636
+ const minimalInitialLocus = {
637
+ dataSets: [],
638
+ locus: null,
639
+ };
640
+
641
+ const parser = createHashTreeParser(minimalInitialLocus, null);
642
+
643
+ const mainDataSet = createDataSet('main', 16, 1100);
644
+
645
+ // Mock getAllVisibleDataSetsFromLocus to return the main dataset
646
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet]);
647
+
648
+ // Mock the sync response to have locusStateElements: undefined
649
+ // This is what sendInitializationSyncRequestToLocus will receive and pass to parseMessage
650
+ mockSyncRequest(webexRequest, mainDataSet.url, {
651
+ dataSets: [mainDataSet],
652
+ visibleDataSetsUrl,
653
+ locusUrl,
654
+ locusStateElements: undefined,
655
+ });
656
+
657
+ // Trigger sendInitializationSyncRequestToLocus via initializeFromMessage
658
+ await parser.initializeFromMessage({
659
+ dataSets: [],
660
+ visibleDataSetsUrl,
661
+ locusUrl,
662
+ });
663
+
664
+ // Verify the hash tree was created for main dataset
665
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
666
+
667
+ // updateItems should NOT have been called because locusStateElements is undefined
668
+ const mainUpdateItemsStub = sinon.spy(parser.dataSets.main.hashTree, 'updateItems');
669
+ assert.notCalled(mainUpdateItemsStub);
670
+
671
+ // callback should not be called, because there are no updates
672
+ assert.notCalled(callback);
673
+ });
674
+
675
+ [404, 409].forEach((errorCode) => {
676
+ it(`emits MeetingEndedError if getting visible datasets returns ${errorCode}`, async () => {
677
+ const minimalInitialLocus = {
678
+ dataSets: [],
679
+ locus: null,
680
+ };
681
+
682
+ const parser = createHashTreeParser(minimalInitialLocus, null);
683
+
684
+ // Mock getAllVisibleDataSetsFromLocus to reject with the error code
685
+ const error: any = new Error(`Request failed with status ${errorCode}`);
686
+ error.statusCode = errorCode;
687
+ if (errorCode === 409) {
688
+ error.body = {errorCode: 2403004};
689
+ }
690
+ webexRequest
691
+ .withArgs(
692
+ sinon.match({
693
+ method: 'GET',
694
+ uri: visibleDataSetsUrl,
695
+ })
696
+ )
697
+ .rejects(error);
698
+
699
+ // initializeFromMessage should throw MeetingEndedError
700
+ let thrownError;
701
+ try {
702
+ await parser.initializeFromMessage({
703
+ dataSets: [],
704
+ visibleDataSetsUrl,
705
+ locusUrl,
706
+ });
707
+ } catch (e) {
708
+ thrownError = e;
709
+ }
710
+
711
+ expect(thrownError).to.be.instanceOf(MeetingEndedError);
712
+ });
713
+ });
460
714
  });
461
715
 
462
716
  describe('#initializeFromGetLociResponse', () => {
463
717
  it('does nothing if url for visibleDataSets is missing from locus', async () => {
464
- const parser = createHashTreeParser({dataSets: [], locus: {}});
718
+ const parser = createHashTreeParser({dataSets: [], locus: {}}, null);
465
719
 
466
720
  await parser.initializeFromGetLociResponse({participants: []});
467
721
 
@@ -488,12 +742,9 @@ describe('HashTreeParser', () => {
488
742
  it('updates hash trees based on provided new locus', () => {
489
743
  const parser = createHashTreeParser();
490
744
 
491
- const mainPutItemsSpy = sinon
492
- .spy(parser.dataSets.main.hashTree, 'putItems');
493
- const selfPutItemsSpy = sinon
494
- .spy(parser.dataSets.self.hashTree, 'putItems');
495
- const atdUnmutedPutItemsSpy = sinon
496
- .spy(parser.dataSets['atd-unmuted'].hashTree, 'putItems');
745
+ const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
746
+ const selfPutItemsSpy = sinon.spy(parser.dataSets.self.hashTree, 'putItems');
747
+ const atdUnmutedPutItemsSpy = sinon.spy(parser.dataSets['atd-unmuted'].hashTree, 'putItems');
497
748
 
498
749
  // Create a locus update with new htMeta information for some things
499
750
  const locusUpdate = {
@@ -540,7 +791,6 @@ describe('HashTreeParser', () => {
540
791
  ],
541
792
  self: {
542
793
  url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
543
- visibleDataSets: ['main', 'self', 'atd-unmuted'],
544
794
  person: {},
545
795
  htMeta: {
546
796
  elementId: {
@@ -647,6 +897,116 @@ describe('HashTreeParser', () => {
647
897
  });
648
898
  });
649
899
 
900
+ it('handles updates to control entries correctly', () => {
901
+ const parser = createHashTreeParser();
902
+
903
+ const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
904
+
905
+ // Create a locus update with new htMeta information for some things
906
+ const locusUpdate = {
907
+ dataSets: [
908
+ createDataSet('main', 16, 1100),
909
+ ],
910
+ locus: {
911
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
912
+ htMeta: {
913
+ elementId: {
914
+ type: 'locus',
915
+ id: 0,
916
+ version: 200, // same version
917
+ },
918
+ dataSetNames: ['main'],
919
+ },
920
+ participants: [],
921
+ controls: {
922
+ lock: {
923
+ locked: true,
924
+ htMeta: {
925
+ elementId: {
926
+ type: 'ControlEntry',
927
+ id: 10100,
928
+ version: 100,
929
+ },
930
+ dataSetNames: ['main'],
931
+ },
932
+ },
933
+ stream: {
934
+ streaming: true,
935
+ htMeta: {
936
+ elementId: {
937
+ type: 'ControlEntry',
938
+ id: 10101,
939
+ version: 100,
940
+ },
941
+ dataSetNames: ['main'],
942
+ },
943
+ }
944
+ }
945
+ },
946
+ };
947
+
948
+ // Call handleLocusUpdate
949
+ parser.handleLocusUpdate(locusUpdate);
950
+
951
+ // Verify putItems was called on main hash tree with correct data
952
+ assert.calledOnceWithExactly(mainPutItemsSpy, [
953
+ {type: 'locus', id: 0, version: 200},
954
+ {type: 'ControlEntry', id: 10100, version: 100},
955
+ {type: 'ControlEntry', id: 10101, version: 100}
956
+ ]);
957
+
958
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
959
+ updatedObjects: [
960
+ {
961
+ htMeta: {
962
+ elementId: {
963
+ type: 'ControlEntry',
964
+ id: 10100,
965
+ version: 100,
966
+ },
967
+ dataSetNames: ['main'],
968
+ },
969
+ data: {
970
+ lock: {
971
+ locked: true,
972
+ htMeta: {
973
+ elementId: {
974
+ type: 'ControlEntry',
975
+ id: 10100,
976
+ version: 100,
977
+ },
978
+ dataSetNames: ['main'],
979
+ },
980
+ },
981
+ },
982
+ },
983
+ {
984
+ htMeta: {
985
+ elementId: {
986
+ type: 'ControlEntry',
987
+ id: 10101,
988
+ version: 100,
989
+ },
990
+ dataSetNames: ['main'],
991
+ },
992
+ data: {
993
+ stream: {
994
+ streaming: true,
995
+ htMeta: {
996
+ elementId: {
997
+ type: 'ControlEntry',
998
+ id: 10101,
999
+ version: 100,
1000
+ },
1001
+ dataSetNames: ['main'],
1002
+ },
1003
+ },
1004
+ },
1005
+ }
1006
+ ],
1007
+ });
1008
+ });
1009
+
650
1010
  it('handles unknown datasets gracefully', () => {
651
1011
  const parser = createHashTreeParser();
652
1012
 
@@ -711,38 +1071,304 @@ describe('HashTreeParser', () => {
711
1071
  ],
712
1072
  });
713
1073
  });
714
- });
715
1074
 
716
- describe('#handleMessage', () => {
717
- it('handles root hash heartbeat message correctly', async () => {
1075
+ it('handles metadata updates with new version', async () => {
718
1076
  const parser = createHashTreeParser();
719
1077
 
720
- // Step 1: Send a normal message with locusStateElements to start the sync timer
721
- const normalMessage = {
722
- dataSets: [
723
- {
724
- ...createDataSet('main', 16, 1100),
725
- root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from our hash
1078
+ const selfPutItemSpy = sinon.spy(parser.dataSets.self.hashTree, 'putItem');
1079
+
1080
+ // Create a locus update with updated metadata
1081
+ const locusUpdate = {
1082
+ dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
1083
+ locus: {
1084
+ links: {resources: {visibleDataSets: {url: visibleDataSetsUrl}}},
1085
+ participants: [
1086
+ {
1087
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/15',
1088
+ person: {},
1089
+ htMeta: {
1090
+ elementId: {
1091
+ type: 'participant',
1092
+ id: 15, // new participant
1093
+ version: 999,
1094
+ },
1095
+ dataSetNames: ['attendees'],
1096
+ },
1097
+ },
1098
+ ],
1099
+ },
1100
+ metadata: {
1101
+ htMeta: {
1102
+ elementId: {
1103
+ type: 'metadata',
1104
+ id: 5,
1105
+ version: 51, // incremented version
1106
+ },
1107
+ dataSetNames: ['self'],
726
1108
  },
727
- ],
1109
+ // new visibleDataSets: atd-unmuted removed, "attendees" and "new-dataset" added
1110
+ visibleDataSets: [
1111
+ {
1112
+ name: 'main',
1113
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
1114
+ },
1115
+ {
1116
+ name: 'self',
1117
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
1118
+ },
1119
+ {
1120
+ name: 'new-dataset', // this one is not in dataSets, so will require async initialization
1121
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
1122
+ },
1123
+ {
1124
+ name: 'attendees', // this one is in dataSets, so should be processed immediately
1125
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
1126
+ },
1127
+ ],
1128
+ },
1129
+ };
1130
+
1131
+ // Mock the async initialization of the new dataset
1132
+ const newDataSet = createDataSet('new-dataset', 4, 5000);
1133
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [newDataSet]);
1134
+ mockSyncRequest(webexRequest, newDataSet.url, {
1135
+ dataSets: [newDataSet],
728
1136
  visibleDataSetsUrl,
729
1137
  locusUrl,
730
- locusStateElements: [
1138
+ locusStateElements: [],
1139
+ });
1140
+
1141
+ // Call handleLocusUpdate
1142
+ parser.handleLocusUpdate(locusUpdate);
1143
+
1144
+ // Verify putItem was called on self hash tree with metadata
1145
+ assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 51});
1146
+
1147
+ // Verify callback was called with metadata object and removed dataset objects
1148
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1149
+ updatedObjects: [
1150
+ // updated metadata object:
731
1151
  {
732
1152
  htMeta: {
733
1153
  elementId: {
734
- type: 'locus' as const,
735
- id: 0,
736
- version: 201,
1154
+ type: 'metadata',
1155
+ id: 5,
1156
+ version: 51,
737
1157
  },
738
- dataSetNames: ['main'],
1158
+ dataSetNames: ['self'],
1159
+ },
1160
+ data: {
1161
+ htMeta: {
1162
+ elementId: {
1163
+ type: 'metadata',
1164
+ id: 5,
1165
+ version: 51,
1166
+ },
1167
+ dataSetNames: ['self'],
1168
+ },
1169
+ visibleDataSets: [
1170
+ {
1171
+ name: 'main',
1172
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
1173
+ },
1174
+ {
1175
+ name: 'self',
1176
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
1177
+ },
1178
+ {
1179
+ name: 'new-dataset',
1180
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
1181
+ },
1182
+ {
1183
+ name: 'attendees',
1184
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
1185
+ },
1186
+ ],
739
1187
  },
740
- data: {someData: 'value'},
1188
+ },
1189
+ // removed participant from a removed dataset 'atd-unmuted':
1190
+ {
1191
+ htMeta: {
1192
+ elementId: {
1193
+ type: 'participant',
1194
+ id: 14,
1195
+ version: 300,
1196
+ },
1197
+ dataSetNames: ['atd-unmuted'],
1198
+ },
1199
+ data: null,
1200
+ },
1201
+ // new participant from a new data set 'attendees':
1202
+ {
1203
+ htMeta: {
1204
+ elementId: {
1205
+ type: 'participant',
1206
+ id: 15,
1207
+ version: 999,
1208
+ },
1209
+ dataSetNames: ['attendees'],
1210
+ },
1211
+ data: {
1212
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/15',
1213
+ person: {},
1214
+ htMeta: {
1215
+ elementId: {
1216
+ type: 'participant',
1217
+ id: 15,
1218
+ version: 999,
1219
+ },
1220
+ dataSetNames: ['attendees'],
1221
+ },
1222
+ },
1223
+ },
1224
+ ],
1225
+ });
1226
+
1227
+ // verify also that an async initialization was done for
1228
+ await checkAsyncDatasetInitialization(parser, newDataSet);
1229
+ });
1230
+
1231
+ it('handles metadata updates with same version (no callback)', () => {
1232
+ const parser = createHashTreeParser();
1233
+
1234
+ const selfPutItemSpy = sinon.spy(parser.dataSets.self.hashTree, 'putItem');
1235
+
1236
+ // Create a locus update with metadata that has the same version and same visibleDataSets
1237
+ const locusUpdate = {
1238
+ dataSets: [createDataSet('self', 1, 2100)],
1239
+ locus: {},
1240
+ metadata: {
1241
+ htMeta: {
1242
+ elementId: {
1243
+ type: 'metadata',
1244
+ id: 5,
1245
+ version: 50, // same version as initial
1246
+ },
1247
+ dataSetNames: ['self'],
1248
+ },
1249
+ visibleDataSets: [
1250
+ {
1251
+ name: 'main',
1252
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
1253
+ },
1254
+ {
1255
+ name: 'self',
1256
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
1257
+ },
1258
+ {
1259
+ name: 'atd-unmuted',
1260
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
1261
+ },
1262
+ ],
1263
+ },
1264
+ };
1265
+
1266
+ // Call handleLocusUpdate
1267
+ parser.handleLocusUpdate(locusUpdate);
1268
+
1269
+ // Verify putItem was called on self hash tree
1270
+ assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 50});
1271
+
1272
+ // Verify callback was NOT called because version didn't change
1273
+ assert.notCalled(callback);
1274
+ });
1275
+
1276
+ it('handles updates with no dataSets and metadata fields gracefully', () => {
1277
+ const parser = createHashTreeParser();
1278
+
1279
+ const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
1280
+ const selfPutItemsSpy = sinon.spy(parser.dataSets.self.hashTree, 'putItems');
1281
+ const atdUnmutedPutItemsSpy = sinon.spy(parser.dataSets['atd-unmuted'].hashTree, 'putItems');
1282
+
1283
+ // Create a locus update with no dataSets and no metadata
1284
+ const locusUpdate = {
1285
+ locus: {
1286
+ htMeta: {
1287
+ elementId: {
1288
+ type: 'locus',
1289
+ id: 0,
1290
+ version: 201,
1291
+ },
1292
+ dataSetNames: ['main'],
1293
+ },
1294
+ someData: 'value',
1295
+ },
1296
+ };
1297
+
1298
+ // Call handleLocusUpdate - should not throw
1299
+ parser.handleLocusUpdate(locusUpdate);
1300
+
1301
+ // Verify putItems was still called for the dataset referenced in locus
1302
+ assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 201}]);
1303
+
1304
+ // Verify putItems was not called on other hash trees
1305
+ assert.notCalled(selfPutItemsSpy);
1306
+ assert.notCalled(atdUnmutedPutItemsSpy);
1307
+
1308
+ // Verify callback was called with the updated object
1309
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1310
+ updatedObjects: [
1311
+ {
1312
+ htMeta: {
1313
+ elementId: {
1314
+ type: 'locus',
1315
+ id: 0,
1316
+ version: 201,
1317
+ },
1318
+ dataSetNames: ['main'],
1319
+ },
1320
+ data: {
1321
+ someData: 'value',
1322
+ htMeta: {
1323
+ elementId: {
1324
+ type: 'locus',
1325
+ id: 0,
1326
+ version: 201,
1327
+ },
1328
+ dataSetNames: ['main'],
1329
+ },
1330
+ },
1331
+ },
1332
+ ],
1333
+ });
1334
+
1335
+ // Verify that dataset versions were NOT updated (no dataSets in the update)
1336
+ expect(parser.dataSets.main.version).to.equal(1000);
1337
+ expect(parser.dataSets.self.version).to.equal(2000);
1338
+ expect(parser.dataSets['atd-unmuted'].version).to.equal(3000);
1339
+ });
1340
+ });
1341
+
1342
+ describe('#handleMessage', () => {
1343
+ it('handles root hash heartbeat message correctly', async () => {
1344
+ const parser = createHashTreeParser();
1345
+
1346
+ // Step 1: Send a normal message with locusStateElements to start the sync timer
1347
+ const normalMessage = {
1348
+ dataSets: [
1349
+ {
1350
+ ...createDataSet('main', 16, 1100),
1351
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from our hash
1352
+ },
1353
+ ],
1354
+ visibleDataSetsUrl,
1355
+ locusUrl,
1356
+ locusStateElements: [
1357
+ {
1358
+ htMeta: {
1359
+ elementId: {
1360
+ type: 'locus' as const,
1361
+ id: 0,
1362
+ version: 201,
1363
+ },
1364
+ dataSetNames: ['main'],
1365
+ },
1366
+ data: {someData: 'value'},
741
1367
  },
742
1368
  ],
743
1369
  };
744
1370
 
745
- await parser.handleMessage(normalMessage, 'initial message');
1371
+ parser.handleMessage(normalMessage, 'initial message');
746
1372
 
747
1373
  // Verify the timer was set (the sync algorithm should have started)
748
1374
  expect(parser.dataSets.main.timer).to.not.be.undefined;
@@ -762,7 +1388,7 @@ describe('HashTreeParser', () => {
762
1388
  'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' // still different from our hash
763
1389
  );
764
1390
 
765
- await parser.handleMessage(heartbeatMessage, 'heartbeat message');
1391
+ parser.handleMessage(heartbeatMessage, 'heartbeat message');
766
1392
 
767
1393
  // Verify the timer was restarted (should still exist)
768
1394
  expect(parser.dataSets.main.timer).to.not.be.undefined;
@@ -889,7 +1515,7 @@ describe('HashTreeParser', () => {
889
1515
  ],
890
1516
  };
891
1517
 
892
- await parser.handleMessage(message, 'normal update');
1518
+ parser.handleMessage(message, 'normal update');
893
1519
 
894
1520
  // Verify updateItems was called on main hash tree
895
1521
  assert.calledOnceWithExactly(mainUpdateItemsStub, [
@@ -910,13 +1536,6 @@ describe('HashTreeParser', () => {
910
1536
  // Verify callback was called with OBJECTS_UPDATED and all updated objects
911
1537
  assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
912
1538
  updatedObjects: [
913
- {
914
- htMeta: {
915
- elementId: {type: 'self', id: 4, version: 101},
916
- dataSetNames: ['self'],
917
- },
918
- data: {person: {name: 'updated self name'}},
919
- },
920
1539
  {
921
1540
  htMeta: {
922
1541
  elementId: {type: 'locus', id: 0, version: 201},
@@ -924,10 +1543,6 @@ describe('HashTreeParser', () => {
924
1543
  },
925
1544
  data: {info: {id: 'updated-locus-info'}},
926
1545
  },
927
- // self updates appear twice, because they are processed twice in HashTreeParser.parseMessage()
928
- // (first for checking for visibleDataSets changes and again with the rest of updates in the main part of parseMessage())
929
- // this is only temporary until SPARK-744859 is done and having them twice here is not harmful
930
- // so keeping it like this for now
931
1546
  {
932
1547
  htMeta: {
933
1548
  elementId: {type: 'self', id: 4, version: 101},
@@ -953,43 +1568,71 @@ describe('HashTreeParser', () => {
953
1568
  });
954
1569
  });
955
1570
 
956
- it('detects roster drop correctly', async () => {
957
- const parser = createHashTreeParser();
958
-
959
- // Stub updateItems to return true (indicating the change was applied)
960
- sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1571
+ describe('handles sentinel messages correctly', () => {
1572
+ ['main', 'self', 'unjoined'].forEach((dataSetName) => {
1573
+ it('emits MEETING_ENDED for sentinel message with dataset ' + dataSetName, async () => {
1574
+ const parser = createHashTreeParser();
1575
+
1576
+ // Create a sentinel message: leafCount=1, root=EMPTY_HASH, version higher than current
1577
+ const sentinelMessage = createHeartbeatMessage(
1578
+ dataSetName,
1579
+ 1,
1580
+ parser.dataSets[dataSetName]?.version
1581
+ ? parser.dataSets[dataSetName].version + 1
1582
+ : 10000,
1583
+ EMPTY_HASH
1584
+ );
1585
+
1586
+ // If the dataset doesn't exist yet (e.g. 'unjoined'), create it
1587
+ if (!parser.dataSets[dataSetName]) {
1588
+ parser.dataSets[dataSetName] = {
1589
+ url: `https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/${dataSetName}`,
1590
+ name: dataSetName,
1591
+ version: 1,
1592
+ leafCount: 16,
1593
+ root: '0'.repeat(32),
1594
+ idleMs: 1000,
1595
+ backoff: {maxMs: 1000, exponent: 2},
1596
+ } as any;
1597
+ }
961
1598
 
962
- // Send a roster drop message (SELF object with no data)
963
- const rosterDropMessage = {
964
- dataSets: [createDataSet('self', 1, 2101)],
965
- visibleDataSetsUrl,
966
- locusUrl,
967
- locusStateElements: [
968
- {
969
- htMeta: {
970
- elementId: {
971
- type: 'self' as const,
972
- id: 4,
973
- version: 102,
974
- },
975
- dataSetNames: ['self'],
976
- },
977
- data: undefined, // No data - this indicates roster drop
978
- },
979
- ],
980
- };
1599
+ parser.handleMessage(sentinelMessage, 'sentinel message');
981
1600
 
982
- await parser.handleMessage(rosterDropMessage, 'roster drop message');
1601
+ // Verify callback was called with MEETING_ENDED
1602
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1603
+ updatedObjects: undefined,
1604
+ });
983
1605
 
984
- // Verify callback was called with MEETING_ENDED
985
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
986
- updatedObjects: undefined,
1606
+ // Verify that all timers were stopped
1607
+ Object.values(parser.dataSets).forEach((ds: any) => {
1608
+ assert.isUndefined(ds.timer);
1609
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1610
+ });
1611
+ });
987
1612
  });
988
1613
 
989
- // Verify that all timers were stopped (timer should be undefined after roster drop)
990
- assert.equal(parser.dataSets.self.timer, undefined);
991
- assert.equal(parser.dataSets.main.timer, undefined);
992
- assert.equal(parser.dataSets['atd-unmuted'].timer, undefined);
1614
+ it('emits MEETING_ENDED for sentinel message with unknown dataset', async () => {
1615
+ const parser = createHashTreeParser();
1616
+
1617
+ // 'unjoined' is a valid sentinel dataset name but is not tracked by the parser
1618
+ assert.isUndefined(parser.dataSets['unjoined']);
1619
+
1620
+ // Create a sentinel message for 'unjoined' dataset which the parser has never seen
1621
+ const sentinelMessage = createHeartbeatMessage('unjoined', 1, 10000, EMPTY_HASH);
1622
+
1623
+ parser.handleMessage(sentinelMessage, 'sentinel message');
1624
+
1625
+ // Verify callback was called with MEETING_ENDED
1626
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1627
+ updatedObjects: undefined,
1628
+ });
1629
+
1630
+ // Verify that all timers were stopped
1631
+ Object.values(parser.dataSets).forEach((ds: any) => {
1632
+ assert.isUndefined(ds.timer);
1633
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1634
+ });
1635
+ });
993
1636
  });
994
1637
 
995
1638
  describe('sync algorithm', () => {
@@ -1020,7 +1663,7 @@ describe('HashTreeParser', () => {
1020
1663
  ],
1021
1664
  };
1022
1665
 
1023
- await parser.handleMessage(message, 'initial message');
1666
+ parser.handleMessage(message, 'initial message');
1024
1667
 
1025
1668
  // Verify callback was called with initial updates
1026
1669
  assert.calledOnce(callback);
@@ -1090,6 +1733,134 @@ describe('HashTreeParser', () => {
1090
1733
  ],
1091
1734
  });
1092
1735
  });
1736
+
1737
+ describe('emits MEETING_ENDED', () => {
1738
+ [404, 409].forEach((statusCode) => {
1739
+ it(`when /hashtree returns ${statusCode}`, async () => {
1740
+ const parser = createHashTreeParser();
1741
+
1742
+ // Send a message to trigger sync algorithm
1743
+ const message = {
1744
+ dataSets: [createDataSet('main', 16, 1100)],
1745
+ visibleDataSetsUrl,
1746
+ locusUrl,
1747
+ locusStateElements: [
1748
+ {
1749
+ htMeta: {
1750
+ elementId: {
1751
+ type: 'locus' as const,
1752
+ id: 0,
1753
+ version: 201,
1754
+ },
1755
+ dataSetNames: ['main'],
1756
+ },
1757
+ data: {info: {id: 'initial-update'}},
1758
+ },
1759
+ ],
1760
+ };
1761
+
1762
+ parser.handleMessage(message, 'initial message');
1763
+ callback.resetHistory();
1764
+
1765
+ const mainDataSetUrl = parser.dataSets.main.url;
1766
+
1767
+ // Mock getHashesFromLocus to reject with the sentinel error
1768
+ const error: any = new Error(`Request failed with status ${statusCode}`);
1769
+ error.statusCode = statusCode;
1770
+ if (statusCode === 409) {
1771
+ error.body = {errorCode: 2403004};
1772
+ }
1773
+ webexRequest
1774
+ .withArgs(
1775
+ sinon.match({
1776
+ method: 'GET',
1777
+ uri: `${mainDataSetUrl}/hashtree`,
1778
+ })
1779
+ )
1780
+ .rejects(error);
1781
+
1782
+ // Trigger sync by advancing time
1783
+ await clock.tickAsync(1000);
1784
+
1785
+ // Verify callback was called with MEETING_ENDED
1786
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1787
+ updatedObjects: undefined,
1788
+ });
1789
+
1790
+ // Verify all timers are stopped
1791
+ Object.values(parser.dataSets).forEach((ds: any) => {
1792
+ assert.isUndefined(ds.timer);
1793
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1794
+ });
1795
+ });
1796
+
1797
+ it(`when /sync returns ${statusCode}`, async () => {
1798
+ const parser = createHashTreeParser();
1799
+
1800
+ // Send a message to trigger sync algorithm
1801
+ const message = {
1802
+ dataSets: [createDataSet('main', 16, 1100)],
1803
+ visibleDataSetsUrl,
1804
+ locusUrl,
1805
+ locusStateElements: [
1806
+ {
1807
+ htMeta: {
1808
+ elementId: {
1809
+ type: 'locus' as const,
1810
+ id: 0,
1811
+ version: 201,
1812
+ },
1813
+ dataSetNames: ['main'],
1814
+ },
1815
+ data: {info: {id: 'initial-update'}},
1816
+ },
1817
+ ],
1818
+ };
1819
+
1820
+ parser.handleMessage(message, 'initial message');
1821
+ callback.resetHistory();
1822
+
1823
+ const mainDataSetUrl = parser.dataSets.main.url;
1824
+
1825
+ // Mock getHashesFromLocus to succeed
1826
+ mockGetHashesFromLocusResponse(
1827
+ mainDataSetUrl,
1828
+ new Array(16).fill('00000000000000000000000000000000'),
1829
+ createDataSet('main', 16, 1101)
1830
+ );
1831
+
1832
+ // Mock sendSyncRequestToLocus to reject with the sentinel error
1833
+ const error: any = new Error(`Request failed with status ${statusCode}`);
1834
+ error.statusCode = statusCode;
1835
+ if (statusCode === 409) {
1836
+ error.body = {errorCode: 2403004};
1837
+ }
1838
+ webexRequest
1839
+ .withArgs(
1840
+ sinon.match({
1841
+ method: 'POST',
1842
+ uri: `${mainDataSetUrl}/sync`,
1843
+ })
1844
+ )
1845
+ .rejects(error);
1846
+
1847
+ // Trigger sync by advancing time
1848
+ await clock.tickAsync(1000);
1849
+
1850
+ // Verify callback was called with MEETING_ENDED
1851
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1852
+ updatedObjects: undefined,
1853
+ });
1854
+
1855
+ // Verify all timers are stopped
1856
+ Object.values(parser.dataSets).forEach((ds: any) => {
1857
+ assert.isUndefined(ds.timer);
1858
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1859
+ });
1860
+ });
1861
+ });
1862
+ });
1863
+
1093
1864
  it('requests only mismatched hashes during sync', async () => {
1094
1865
  const parser = createHashTreeParser();
1095
1866
 
@@ -1135,7 +1906,7 @@ describe('HashTreeParser', () => {
1135
1906
  ],
1136
1907
  };
1137
1908
 
1138
- await parser.handleMessage(message, 'initial message');
1909
+ parser.handleMessage(message, 'initial message');
1139
1910
 
1140
1911
  callback.resetHistory();
1141
1912
 
@@ -1179,6 +1950,9 @@ describe('HashTreeParser', () => {
1179
1950
  sinon.match({
1180
1951
  method: 'GET',
1181
1952
  uri: `${mainDataSetUrl}/hashtree`,
1953
+ qs: {
1954
+ rootHash: hashTree.getRootHash(),
1955
+ },
1182
1956
  })
1183
1957
  );
1184
1958
 
@@ -1186,6 +1960,7 @@ describe('HashTreeParser', () => {
1186
1960
  assert.calledWith(webexRequest, {
1187
1961
  method: 'POST',
1188
1962
  uri: `${mainDataSetUrl}/sync`,
1963
+ qs: {rootHash: hashTree.getRootHash()},
1189
1964
  body: {
1190
1965
  leafCount: 16,
1191
1966
  leafDataEntries: [
@@ -1219,7 +1994,7 @@ describe('HashTreeParser', () => {
1219
1994
  ],
1220
1995
  };
1221
1996
 
1222
- await parser.handleMessage(message, 'message with self update');
1997
+ parser.handleMessage(message, 'message with self update');
1223
1998
 
1224
1999
  callback.resetHistory();
1225
2000
 
@@ -1239,10 +2014,17 @@ describe('HashTreeParser', () => {
1239
2014
  assert.calledWith(webexRequest, {
1240
2015
  method: 'POST',
1241
2016
  uri: `${parser.dataSets.self.url}/sync`,
2017
+ qs: {rootHash: parser.dataSets.self.hashTree.getRootHash()},
1242
2018
  body: {
1243
2019
  leafCount: 1,
1244
2020
  leafDataEntries: [
1245
- {leafIndex: 0, elementIds: [{type: 'self', id: 4, version: 102}]},
2021
+ {
2022
+ leafIndex: 0,
2023
+ elementIds: [
2024
+ {type: 'self', id: 4, version: 102},
2025
+ {type: 'metadata', id: 5, version: 50},
2026
+ ],
2027
+ },
1246
2028
  ],
1247
2029
  },
1248
2030
  });
@@ -1257,7 +2039,7 @@ describe('HashTreeParser', () => {
1257
2039
  // Stub updateItems on self hash tree to return true
1258
2040
  sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1259
2041
 
1260
- // Send a message with SELF object that has a new visibleDataSets list
2042
+ // Send a message with Metadata object that has a new visibleDataSets list
1261
2043
  const message = {
1262
2044
  dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
1263
2045
  visibleDataSetsUrl,
@@ -1266,47 +2048,98 @@ describe('HashTreeParser', () => {
1266
2048
  {
1267
2049
  htMeta: {
1268
2050
  elementId: {
1269
- type: 'self' as const,
1270
- id: 4,
1271
- version: 101,
2051
+ type: 'metadata' as const,
2052
+ id: 5,
2053
+ version: 51,
1272
2054
  },
1273
2055
  dataSetNames: ['self'],
1274
2056
  },
1275
2057
  data: {
1276
- visibleDataSets: ['main', 'self', 'atd-unmuted', 'attendees'], // added 'attendees'
2058
+ visibleDataSets: [
2059
+ {
2060
+ name: 'main',
2061
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2062
+ },
2063
+ {
2064
+ name: 'self',
2065
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2066
+ },
2067
+ {
2068
+ name: 'atd-unmuted',
2069
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2070
+ },
2071
+ {
2072
+ name: 'attendees',
2073
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
2074
+ },
2075
+ ], // added 'attendees'
1277
2076
  },
1278
2077
  },
1279
2078
  ],
1280
2079
  };
1281
2080
 
1282
- await parser.handleMessage(message, 'add visible dataset');
2081
+ parser.handleMessage(message, 'add visible dataset');
1283
2082
 
1284
2083
  // Verify that 'attendees' was added to visibleDataSets
1285
- assert.include(parser.visibleDataSets, 'attendees');
2084
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.true;
1286
2085
 
1287
2086
  // Verify that a hash tree was created for 'attendees'
1288
2087
  assert.exists(parser.dataSets.attendees.hashTree);
1289
2088
  assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
1290
2089
 
1291
- // Verify callback was called with the self update (appears twice due to SPARK-744859)
2090
+ // Verify callback was called with the metadata update (appears twice - processed once for visible dataset changes, once in main loop)
1292
2091
  assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1293
2092
  updatedObjects: [
1294
2093
  {
1295
2094
  htMeta: {
1296
- elementId: {type: 'self', id: 4, version: 101},
2095
+ elementId: {type: 'metadata', id: 5, version: 51},
1297
2096
  dataSetNames: ['self'],
1298
2097
  },
1299
2098
  data: {
1300
- visibleDataSets: ['main', 'self', 'atd-unmuted', 'attendees'],
2099
+ visibleDataSets: [
2100
+ {
2101
+ name: 'main',
2102
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2103
+ },
2104
+ {
2105
+ name: 'self',
2106
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2107
+ },
2108
+ {
2109
+ name: 'atd-unmuted',
2110
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2111
+ },
2112
+ {
2113
+ name: 'attendees',
2114
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
2115
+ },
2116
+ ],
1301
2117
  },
1302
2118
  },
1303
2119
  {
1304
2120
  htMeta: {
1305
- elementId: {type: 'self', id: 4, version: 101},
2121
+ elementId: {type: 'metadata', id: 5, version: 51},
1306
2122
  dataSetNames: ['self'],
1307
2123
  },
1308
2124
  data: {
1309
- visibleDataSets: ['main', 'self', 'atd-unmuted', 'attendees'],
2125
+ visibleDataSets: [
2126
+ {
2127
+ name: 'main',
2128
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2129
+ },
2130
+ {
2131
+ name: 'self',
2132
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2133
+ },
2134
+ {
2135
+ name: 'atd-unmuted',
2136
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2137
+ },
2138
+ {
2139
+ name: 'attendees',
2140
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
2141
+ },
2142
+ ],
1310
2143
  },
1311
2144
  },
1312
2145
  ],
@@ -1320,7 +2153,7 @@ describe('HashTreeParser', () => {
1320
2153
  // Stub updateItems on self hash tree to return true
1321
2154
  sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1322
2155
 
1323
- // Send a message with SELF object that has a new visibleDataSets list (adding 'new-dataset')
2156
+ // Send a message with Metadata object that has a new visibleDataSets list (adding 'new-dataset')
1324
2157
  // but WITHOUT providing info about the new dataset in dataSets array
1325
2158
  const message = {
1326
2159
  dataSets: [createDataSet('self', 1, 2100)],
@@ -1330,14 +2163,31 @@ describe('HashTreeParser', () => {
1330
2163
  {
1331
2164
  htMeta: {
1332
2165
  elementId: {
1333
- type: 'self' as const,
1334
- id: 4,
1335
- version: 101,
2166
+ type: 'metadata' as const,
2167
+ id: 5,
2168
+ version: 51,
1336
2169
  },
1337
2170
  dataSetNames: ['self'],
1338
2171
  },
1339
2172
  data: {
1340
- visibleDataSets: ['main', 'self', 'atd-unmuted', 'new-dataset'],
2173
+ visibleDataSets: [
2174
+ {
2175
+ name: 'main',
2176
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2177
+ },
2178
+ {
2179
+ name: 'self',
2180
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2181
+ },
2182
+ {
2183
+ name: 'atd-unmuted',
2184
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2185
+ },
2186
+ {
2187
+ name: 'new-dataset',
2188
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
2189
+ },
2190
+ ],
1341
2191
  },
1342
2192
  },
1343
2193
  ],
@@ -1353,60 +2203,41 @@ describe('HashTreeParser', () => {
1353
2203
  locusStateElements: [],
1354
2204
  });
1355
2205
 
1356
- await parser.handleMessage(message, 'add new dataset requiring async init');
1357
-
1358
- // immediately we don't have the dataset yet, so it should not be in visibleDataSets
1359
- // and no hash tree should exist yet
1360
- assert.isFalse(parser.visibleDataSets.includes('new-dataset'));
1361
- assert.isUndefined(parser.dataSets['new-dataset']);
1362
-
1363
- // Wait for the async initialization to complete (queued as microtask)
1364
- await clock.tickAsync(0);
1365
-
1366
- // The visibleDataSets is updated from the self object data
1367
- assert.include(parser.visibleDataSets, 'new-dataset');
1368
-
1369
- // Verify that a hash tree was created for 'new-dataset'
1370
- assert.exists(parser.dataSets['new-dataset'].hashTree);
1371
- assert.equal(parser.dataSets['new-dataset'].hashTree.numLeaves, 4);
2206
+ parser.handleMessage(message, 'add new dataset requiring async init');
1372
2207
 
1373
- // Verify getAllDataSetsMetadata was called for async initialization
1374
- assert.calledWith(
1375
- webexRequest,
1376
- sinon.match({
1377
- method: 'GET',
1378
- uri: visibleDataSetsUrl,
1379
- })
1380
- );
1381
-
1382
- // Verify sync request was sent for the new dataset
1383
- assert.calledWith(
1384
- webexRequest,
1385
- sinon.match({
1386
- method: 'POST',
1387
- uri: `${newDataSet.url}/sync`,
1388
- })
1389
- );
2208
+ await checkAsyncDatasetInitialization(parser, newDataSet);
1390
2209
  });
1391
2210
 
1392
- it('handles removal of visible data set', async () => {
1393
- // Create a parser with visible datasets
1394
- const parser = createHashTreeParser();
1395
-
1396
- // Store the initial hash tree for atd-unmuted to verify it gets deleted
1397
- const atdUnmutedHashTree = parser.dataSets['atd-unmuted'].hashTree;
1398
- assert.exists(atdUnmutedHashTree);
2211
+ it('initializes new visible data sets in priority order', async () => {
2212
+ // Create a parser that only has "self" as visible (no "main")
2213
+ const initialLocusWithoutMain = {
2214
+ dataSets: [createDataSet('self', 1, 2000)],
2215
+ locus: {
2216
+ ...exampleInitialLocus.locus,
2217
+ },
2218
+ };
2219
+ const metadataWithoutMain = {
2220
+ ...exampleMetadata,
2221
+ visibleDataSets: [
2222
+ {
2223
+ name: 'self',
2224
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2225
+ },
2226
+ ],
2227
+ };
2228
+ const parser = createHashTreeParser(initialLocusWithoutMain, metadataWithoutMain);
1399
2229
 
1400
- // Stub getLeafData to return some items that will be marked as removed
1401
- // It's called for each leaf (16 leaves), so return an array for leaf 14 and empty for others
1402
- const getLeafDataStub = sinon.stub(atdUnmutedHashTree, 'getLeafData');
1403
- getLeafDataStub.withArgs(14).returns([{type: 'participant', id: 14, version: 301}]);
1404
- getLeafDataStub.returns([]);
2230
+ // Verify "main" is not visible initially
2231
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.false;
1405
2232
 
1406
2233
  // Stub updateItems on self hash tree to return true
1407
2234
  sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1408
2235
 
1409
- // Send a message with SELF object that has removed 'atd-unmuted' from visibleDataSets
2236
+ // Send a message that adds "main" and "atd-active" as new visible datasets.
2237
+ // Neither has info in dataSets, so both require async initialization.
2238
+ const newMainDataSet = createDataSet('main', 16, 6000);
2239
+ const newAtdActiveDataSet = createDataSet('atd-active', 4, 7000);
2240
+
1410
2241
  const message = {
1411
2242
  dataSets: [createDataSet('self', 1, 2100)],
1412
2243
  visibleDataSetsUrl,
@@ -1415,56 +2246,234 @@ describe('HashTreeParser', () => {
1415
2246
  {
1416
2247
  htMeta: {
1417
2248
  elementId: {
1418
- type: 'self' as const,
1419
- id: 4,
1420
- version: 101,
2249
+ type: 'metadata' as const,
2250
+ id: 5,
2251
+ version: 51,
1421
2252
  },
1422
2253
  dataSetNames: ['self'],
1423
2254
  },
1424
2255
  data: {
1425
- visibleDataSets: ['main', 'self'], // removed 'atd-unmuted'
2256
+ visibleDataSets: [
2257
+ {
2258
+ name: 'self',
2259
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2260
+ },
2261
+ // listed in non-priority order: atd-active before main
2262
+ {name: 'atd-active', url: newAtdActiveDataSet.url},
2263
+ {name: 'main', url: newMainDataSet.url},
2264
+ ],
1426
2265
  },
1427
2266
  },
1428
2267
  ],
1429
2268
  };
1430
2269
 
1431
- await parser.handleMessage(message, 'remove visible dataset');
2270
+ // Mock getAllVisibleDataSetsFromLocus to return both new datasets (in non-priority order)
2271
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
2272
+ newAtdActiveDataSet,
2273
+ newMainDataSet,
2274
+ ]);
2275
+ mockSyncRequest(webexRequest, newMainDataSet.url);
2276
+ mockSyncRequest(webexRequest, newAtdActiveDataSet.url);
1432
2277
 
1433
- // Verify that 'atd-unmuted' was removed from visibleDataSets
1434
- assert.notInclude(parser.visibleDataSets, 'atd-unmuted');
2278
+ parser.handleMessage(message, 'add main and atd-active datasets');
1435
2279
 
1436
- // Verify that the hash tree for 'atd-unmuted' was deleted
1437
- assert.isUndefined(parser.dataSets['atd-unmuted'].hashTree);
2280
+ // Wait for the async initialization (queueMicrotask) to complete
2281
+ await clock.tickAsync(0);
1438
2282
 
1439
- // Verify that the timer was cleared
1440
- assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
2283
+ // Verify both datasets are initialized
2284
+ expect(parser.dataSets.main?.hashTree).to.exist;
2285
+ expect(parser.dataSets['atd-active']?.hashTree).to.exist;
2286
+
2287
+ // Verify sync requests were sent in priority order: "main" before "atd-active",
2288
+ // even though atd-active was listed first in both the message and the Locus response
2289
+ const syncCalls = webexRequest
2290
+ .getCalls()
2291
+ .filter(
2292
+ (call) =>
2293
+ call.args[0]?.method === 'POST' &&
2294
+ call.args[0]?.uri?.endsWith('/sync') &&
2295
+ (call.args[0]?.uri?.includes('/main/') || call.args[0]?.uri?.includes('/atd-active/'))
2296
+ );
2297
+
2298
+ expect(syncCalls).to.have.lengthOf(2);
2299
+ expect(syncCalls[0].args[0].uri).to.equal(`${newMainDataSet.url}/sync`);
2300
+ expect(syncCalls[1].args[0].uri).to.equal(`${newAtdActiveDataSet.url}/sync`);
2301
+ });
1441
2302
 
1442
- // Verify callback was called with both the self update and the removed objects
1443
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1444
- updatedObjects: [
2303
+ it('emits MEETING_ENDED if async init of a new visible dataset fails with 404', async () => {
2304
+ const parser = createHashTreeParser();
2305
+
2306
+ // Stub updateItems on self hash tree to return true
2307
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2308
+
2309
+ // Send a message with Metadata object that adds a new visible dataset
2310
+ const message = {
2311
+ dataSets: [createDataSet('self', 1, 2100)],
2312
+ visibleDataSetsUrl,
2313
+ locusUrl,
2314
+ locusStateElements: [
1445
2315
  {
1446
2316
  htMeta: {
1447
- elementId: {type: 'self', id: 4, version: 101},
2317
+ elementId: {
2318
+ type: 'metadata' as const,
2319
+ id: 5,
2320
+ version: 51,
2321
+ },
1448
2322
  dataSetNames: ['self'],
1449
2323
  },
1450
2324
  data: {
1451
- visibleDataSets: ['main', 'self'],
2325
+ visibleDataSets: [
2326
+ {
2327
+ name: 'main',
2328
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2329
+ },
2330
+ {
2331
+ name: 'self',
2332
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2333
+ },
2334
+ {
2335
+ name: 'atd-unmuted',
2336
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2337
+ },
2338
+ {
2339
+ name: 'new-dataset',
2340
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
2341
+ },
2342
+ ],
1452
2343
  },
1453
2344
  },
1454
- {
1455
- htMeta: {
1456
- elementId: {type: 'participant', id: 14, version: 301},
1457
- dataSetNames: ['atd-unmuted'],
1458
- },
1459
- data: null,
1460
- },
1461
- {
2345
+ ],
2346
+ };
2347
+
2348
+ // Mock getAllDataSetsMetadata to reject with 404
2349
+ const error: any = new Error('Request failed with status 404');
2350
+ error.statusCode = 404;
2351
+ webexRequest
2352
+ .withArgs(
2353
+ sinon.match({
2354
+ method: 'GET',
2355
+ uri: visibleDataSetsUrl,
2356
+ })
2357
+ )
2358
+ .rejects(error);
2359
+
2360
+ parser.handleMessage(message, 'add new dataset triggering 404');
2361
+
2362
+ // The first callback call is from parseMessage with the metadata update
2363
+ callback.resetHistory();
2364
+
2365
+ // Wait for the async initialization (queueMicrotask) to complete
2366
+ await clock.tickAsync(0);
2367
+
2368
+ // Verify callback was called with MEETING_ENDED
2369
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
2370
+ updatedObjects: undefined,
2371
+ });
2372
+ });
2373
+
2374
+ it('handles removal of visible data set', async () => {
2375
+ // Create a parser with visible datasets
2376
+ const parser = createHashTreeParser();
2377
+
2378
+ // Store the initial hash tree for atd-unmuted to verify it gets deleted
2379
+ const atdUnmutedHashTree = parser.dataSets['atd-unmuted'].hashTree;
2380
+ assert.exists(atdUnmutedHashTree);
2381
+
2382
+ // Stub getLeafData to return some items that will be marked as removed
2383
+ // It's called for each leaf (16 leaves), so return an array for leaf 14 and empty for others
2384
+ const getLeafDataStub = sinon.stub(atdUnmutedHashTree, 'getLeafData');
2385
+ getLeafDataStub.withArgs(14).returns([{type: 'participant', id: 14, version: 301}]);
2386
+ getLeafDataStub.returns([]);
2387
+
2388
+ // Stub updateItems on self hash tree to return true
2389
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2390
+
2391
+ // Send a message with Metadata object that has removed 'atd-unmuted' from visibleDataSets
2392
+ const message = {
2393
+ dataSets: [createDataSet('self', 1, 2100)],
2394
+ visibleDataSetsUrl,
2395
+ locusUrl,
2396
+ locusStateElements: [
2397
+ {
2398
+ htMeta: {
2399
+ elementId: {
2400
+ type: 'metadata' as const,
2401
+ id: 5,
2402
+ version: 51,
2403
+ },
2404
+ dataSetNames: ['self'],
2405
+ },
2406
+ data: {
2407
+ visibleDataSets: [
2408
+ {
2409
+ name: 'main',
2410
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2411
+ },
2412
+ {
2413
+ name: 'self',
2414
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2415
+ },
2416
+ ], // removed 'atd-unmuted'
2417
+ },
2418
+ },
2419
+ ],
2420
+ };
2421
+
2422
+ parser.handleMessage(message, 'remove visible dataset');
2423
+
2424
+ // Verify that 'atd-unmuted' was removed from visibleDataSets
2425
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
2426
+
2427
+ // Verify that the hash tree for 'atd-unmuted' was deleted
2428
+ assert.isUndefined(parser.dataSets['atd-unmuted'].hashTree);
2429
+
2430
+ // Verify that the timer was cleared
2431
+ assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
2432
+
2433
+ // Verify callback was called with the metadata update and the removed objects (metadata appears twice - processed once for dataset changes, once in main loop)
2434
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
2435
+ updatedObjects: [
2436
+ {
2437
+ htMeta: {
2438
+ elementId: {type: 'metadata', id: 5, version: 51},
2439
+ dataSetNames: ['self'],
2440
+ },
2441
+ data: {
2442
+ visibleDataSets: [
2443
+ {
2444
+ name: 'main',
2445
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2446
+ },
2447
+ {
2448
+ name: 'self',
2449
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2450
+ },
2451
+ ],
2452
+ },
2453
+ },
2454
+ {
2455
+ htMeta: {
2456
+ elementId: {type: 'participant', id: 14, version: 301},
2457
+ dataSetNames: ['atd-unmuted'],
2458
+ },
2459
+ data: null,
2460
+ },
2461
+ {
1462
2462
  htMeta: {
1463
- elementId: {type: 'self', id: 4, version: 101}, // 2nd self because of SPARK-744859
2463
+ elementId: {type: 'metadata', id: 5, version: 51},
1464
2464
  dataSetNames: ['self'],
1465
2465
  },
1466
2466
  data: {
1467
- visibleDataSets: ['main', 'self'],
2467
+ visibleDataSets: [
2468
+ {
2469
+ name: 'main',
2470
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2471
+ },
2472
+ {
2473
+ name: 'self',
2474
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2475
+ },
2476
+ ],
1468
2477
  },
1469
2478
  },
1470
2479
  ],
@@ -1489,7 +2498,7 @@ describe('HashTreeParser', () => {
1489
2498
  });
1490
2499
 
1491
2500
  // Verify attendees is NOT in visibleDataSets
1492
- assert.notInclude(parser.visibleDataSets, 'attendees');
2501
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.false;
1493
2502
 
1494
2503
  // Send a message with attendees data
1495
2504
  const message = {
@@ -1511,7 +2520,7 @@ describe('HashTreeParser', () => {
1511
2520
  ],
1512
2521
  };
1513
2522
 
1514
- await parser.handleMessage(message, 'message with non-visible dataset');
2523
+ parser.handleMessage(message, 'message with non-visible dataset');
1515
2524
 
1516
2525
  // Verify that no hash tree was created for attendees
1517
2526
  assert.isUndefined(parser.dataSets.attendees.hashTree);
@@ -1520,5 +2529,1270 @@ describe('HashTreeParser', () => {
1520
2529
  assert.notCalled(callback);
1521
2530
  });
1522
2531
  });
2532
+
2533
+ describe('heartbeat watchdog', () => {
2534
+ it('initiates sync immediately only for the specific data set whose heartbeat watchdog fires', async () => {
2535
+ const parser = createHashTreeParser();
2536
+ const heartbeatIntervalMs = 5000;
2537
+
2538
+ // Send initial heartbeat message for 'main' only
2539
+ const heartbeatMessage = {
2540
+ dataSets: [
2541
+ {
2542
+ ...createDataSet('main', 16, 1100),
2543
+ root: parser.dataSets.main.hashTree.getRootHash(),
2544
+ },
2545
+ ],
2546
+ visibleDataSetsUrl,
2547
+ locusUrl,
2548
+ heartbeatIntervalMs,
2549
+ };
2550
+
2551
+ parser.handleMessage(heartbeatMessage, 'initial heartbeat');
2552
+
2553
+ // Verify only 'main' watchdog timer is set
2554
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2555
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.be.undefined;
2556
+ expect(parser.dataSets['atd-unmuted'].heartbeatWatchdogTimer).to.be.undefined;
2557
+
2558
+ // Mock responses for performSync (GET hashtree then POST sync for leafCount > 1)
2559
+ const mainDataSetUrl = parser.dataSets.main.url;
2560
+ mockGetHashesFromLocusResponse(
2561
+ mainDataSetUrl,
2562
+ new Array(16).fill('00000000000000000000000000000000'),
2563
+ createDataSet('main', 16, 1101)
2564
+ );
2565
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
2566
+
2567
+ // Advance time past heartbeatIntervalMs + backoff (Math.random returns 0, so backoff = 0)
2568
+ // performSync is called immediately when the watchdog fires - no additional delay
2569
+ await clock.tickAsync(heartbeatIntervalMs);
2570
+
2571
+ // Verify sync request was sent immediately for 'main' (GET hashtree + POST sync)
2572
+ assert.calledWith(
2573
+ webexRequest,
2574
+ sinon.match({
2575
+ method: 'GET',
2576
+ uri: `${mainDataSetUrl}/hashtree`,
2577
+ })
2578
+ );
2579
+
2580
+ // Verify no sync requests were sent for other datasets
2581
+ assert.neverCalledWith(
2582
+ webexRequest,
2583
+ sinon.match({
2584
+ method: 'POST',
2585
+ uri: `${parser.dataSets.self.url}/sync`,
2586
+ })
2587
+ );
2588
+ assert.neverCalledWith(
2589
+ webexRequest,
2590
+ sinon.match({
2591
+ method: 'GET',
2592
+ uri: `${parser.dataSets['atd-unmuted'].url}/hashtree`,
2593
+ })
2594
+ );
2595
+ });
2596
+
2597
+ it('calls POST sync directly for leafCount === 1 data sets', async () => {
2598
+ const parser = createHashTreeParser();
2599
+ const heartbeatIntervalMs = 5000;
2600
+
2601
+ // Send heartbeat for 'self' (leafCount === 1)
2602
+ const heartbeatMessage = {
2603
+ dataSets: [
2604
+ {
2605
+ ...createDataSet('self', 1, 2100),
2606
+ url: parser.dataSets.self.url,
2607
+ root: parser.dataSets.self.hashTree.getRootHash(),
2608
+ },
2609
+ ],
2610
+ visibleDataSetsUrl,
2611
+ locusUrl,
2612
+ heartbeatIntervalMs,
2613
+ };
2614
+
2615
+ parser.handleMessage(heartbeatMessage, 'self heartbeat');
2616
+
2617
+ // Mock sync response for self
2618
+ mockSendSyncRequestResponse(parser.dataSets.self.url, null);
2619
+
2620
+ // Advance time past watchdog delay
2621
+ await clock.tickAsync(heartbeatIntervalMs);
2622
+
2623
+ // For leafCount === 1, performSync skips GET hashtree and goes straight to POST sync
2624
+ assert.neverCalledWith(
2625
+ webexRequest,
2626
+ sinon.match({
2627
+ method: 'GET',
2628
+ uri: `${parser.dataSets.self.url}/hashtree`,
2629
+ })
2630
+ );
2631
+ assert.calledWith(
2632
+ webexRequest,
2633
+ sinon.match({
2634
+ method: 'POST',
2635
+ uri: `${parser.dataSets.self.url}/sync`,
2636
+ })
2637
+ );
2638
+ });
2639
+
2640
+ it('sets watchdog timers for each data set in the message', async () => {
2641
+ const parser = createHashTreeParser();
2642
+ const heartbeatIntervalMs = 5000;
2643
+
2644
+ // Send heartbeat with multiple datasets
2645
+ const heartbeatMessage = {
2646
+ dataSets: [
2647
+ {
2648
+ ...createDataSet('main', 16, 1100),
2649
+ root: parser.dataSets.main.hashTree.getRootHash(),
2650
+ },
2651
+ {
2652
+ ...createDataSet('self', 1, 2100),
2653
+ url: parser.dataSets.self.url,
2654
+ root: parser.dataSets.self.hashTree.getRootHash(),
2655
+ },
2656
+ ],
2657
+ visibleDataSetsUrl,
2658
+ locusUrl,
2659
+ heartbeatIntervalMs,
2660
+ };
2661
+
2662
+ parser.handleMessage(heartbeatMessage, 'multi-dataset heartbeat');
2663
+
2664
+ // Watchdog timers should be set for both datasets in the message
2665
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2666
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.not.be.undefined;
2667
+ // But not for datasets not in the message
2668
+ expect(parser.dataSets['atd-unmuted'].heartbeatWatchdogTimer).to.be.undefined;
2669
+ });
2670
+
2671
+ it('resets the watchdog timer for a specific data set when a new heartbeat for it is received', async () => {
2672
+ const parser = createHashTreeParser();
2673
+ const heartbeatIntervalMs = 5000;
2674
+
2675
+ // Send first heartbeat for 'main'
2676
+ const heartbeat1 = {
2677
+ dataSets: [
2678
+ {
2679
+ ...createDataSet('main', 16, 1100),
2680
+ root: parser.dataSets.main.hashTree.getRootHash(),
2681
+ },
2682
+ ],
2683
+ visibleDataSetsUrl,
2684
+ locusUrl,
2685
+ heartbeatIntervalMs,
2686
+ };
2687
+
2688
+ parser.handleMessage(heartbeat1, 'first heartbeat');
2689
+
2690
+ const firstTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2691
+ expect(firstTimer).to.not.be.undefined;
2692
+
2693
+ // Advance time to just before the watchdog would fire
2694
+ clock.tick(4000);
2695
+
2696
+ // Send second heartbeat for 'main' - this should reset the watchdog
2697
+ const heartbeat2 = {
2698
+ dataSets: [
2699
+ {
2700
+ ...createDataSet('main', 16, 1101),
2701
+ root: parser.dataSets.main.hashTree.getRootHash(),
2702
+ },
2703
+ ],
2704
+ visibleDataSetsUrl,
2705
+ locusUrl,
2706
+ heartbeatIntervalMs,
2707
+ };
2708
+
2709
+ parser.handleMessage(heartbeat2, 'second heartbeat');
2710
+
2711
+ const secondTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2712
+ expect(secondTimer).to.not.be.undefined;
2713
+ expect(secondTimer).to.not.equal(firstTimer);
2714
+
2715
+ // Advance another 4000ms (total 8000ms from start, but only 4000ms since last heartbeat)
2716
+ // The watchdog should NOT fire yet
2717
+ await clock.tickAsync(4000);
2718
+
2719
+ // No sync requests should have been sent
2720
+ assert.notCalled(webexRequest);
2721
+ });
2722
+
2723
+ it('resets the watchdog timer when a normal message (with locusStateElements) is received', async () => {
2724
+ const parser = createHashTreeParser();
2725
+ const heartbeatIntervalMs = 5000;
2726
+
2727
+ // Send initial heartbeat to start the watchdog for 'main'
2728
+ const heartbeat = {
2729
+ dataSets: [
2730
+ {
2731
+ ...createDataSet('main', 16, 1100),
2732
+ root: parser.dataSets.main.hashTree.getRootHash(),
2733
+ },
2734
+ ],
2735
+ visibleDataSetsUrl,
2736
+ locusUrl,
2737
+ heartbeatIntervalMs,
2738
+ };
2739
+
2740
+ parser.handleMessage(heartbeat, 'initial heartbeat');
2741
+
2742
+ const firstTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2743
+ expect(firstTimer).to.not.be.undefined;
2744
+
2745
+ // Advance time partially
2746
+ clock.tick(3000);
2747
+
2748
+ // Stub updateItems so the normal message is processed
2749
+ sinon.stub(parser.dataSets.main.hashTree, 'updateItems').returns([true]);
2750
+
2751
+ // Send a normal message (with locusStateElements) for 'main' - should also reset watchdog
2752
+ const normalMessage = {
2753
+ dataSets: [createDataSet('main', 16, 1101)],
2754
+ visibleDataSetsUrl,
2755
+ locusUrl,
2756
+ locusStateElements: [
2757
+ {
2758
+ htMeta: {
2759
+ elementId: {type: 'locus' as const, id: 0, version: 201},
2760
+ dataSetNames: ['main'],
2761
+ },
2762
+ data: {someData: 'value'},
2763
+ },
2764
+ ],
2765
+ heartbeatIntervalMs,
2766
+ };
2767
+
2768
+ parser.handleMessage(normalMessage, 'normal message');
2769
+
2770
+ const secondTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2771
+ expect(secondTimer).to.not.be.undefined;
2772
+ expect(secondTimer).to.not.equal(firstTimer);
2773
+ });
2774
+
2775
+ it('does not set the watchdog timer when heartbeatIntervalMs is not set', async () => {
2776
+ const parser = createHashTreeParser();
2777
+
2778
+ // Send a heartbeat message without heartbeatIntervalMs
2779
+ const heartbeatMessage = createHeartbeatMessage(
2780
+ 'main',
2781
+ 16,
2782
+ 1100,
2783
+ parser.dataSets.main.hashTree.getRootHash()
2784
+ );
2785
+
2786
+ parser.handleMessage(heartbeatMessage, 'heartbeat without interval');
2787
+
2788
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
2789
+ });
2790
+
2791
+ it('stops all watchdog timers when meeting ends via sentinel message', async () => {
2792
+ const parser = createHashTreeParser();
2793
+ const heartbeatIntervalMs = 5000;
2794
+
2795
+ // Send heartbeat for multiple datasets
2796
+ const heartbeat = {
2797
+ dataSets: [
2798
+ {
2799
+ ...createDataSet('main', 16, 1100),
2800
+ root: parser.dataSets.main.hashTree.getRootHash(),
2801
+ },
2802
+ {
2803
+ ...createDataSet('self', 1, 2100),
2804
+ url: parser.dataSets.self.url,
2805
+ root: parser.dataSets.self.hashTree.getRootHash(),
2806
+ },
2807
+ ],
2808
+ visibleDataSetsUrl,
2809
+ locusUrl,
2810
+ heartbeatIntervalMs,
2811
+ };
2812
+
2813
+ parser.handleMessage(heartbeat, 'initial heartbeat');
2814
+
2815
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2816
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.not.be.undefined;
2817
+
2818
+ // Send a sentinel END MEETING message
2819
+ const sentinelMessage = createHeartbeatMessage(
2820
+ 'main',
2821
+ 1,
2822
+ parser.dataSets.main.version + 1,
2823
+ EMPTY_HASH
2824
+ );
2825
+
2826
+ parser.handleMessage(sentinelMessage as any, 'sentinel message');
2827
+
2828
+ // All watchdog timers should have been stopped
2829
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
2830
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.be.undefined;
2831
+ });
2832
+
2833
+ it("uses each data set's own backoff for its watchdog delay", async () => {
2834
+ // Create a parser where datasets have different backoff configs
2835
+ const initialLocus = {
2836
+ dataSets: [
2837
+ {
2838
+ ...createDataSet('main', 16, 1000),
2839
+ backoff: {maxMs: 500, exponent: 2},
2840
+ },
2841
+ {
2842
+ ...createDataSet('self', 1, 2000),
2843
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2844
+ backoff: {maxMs: 2000, exponent: 3},
2845
+ },
2846
+ ],
2847
+ locus: {
2848
+ ...exampleInitialLocus.locus,
2849
+ },
2850
+ };
2851
+
2852
+ const metadata = {
2853
+ ...exampleMetadata,
2854
+ visibleDataSets: [
2855
+ {name: 'main', url: initialLocus.dataSets[0].url},
2856
+ {name: 'self', url: initialLocus.dataSets[1].url},
2857
+ ],
2858
+ };
2859
+
2860
+ const parser = createHashTreeParser(initialLocus, metadata);
2861
+ const heartbeatIntervalMs = 5000;
2862
+
2863
+ // Set Math.random to return 1 so that backoff = 1^exponent * maxMs = maxMs
2864
+ mathRandomStub.returns(1);
2865
+
2866
+ // Send heartbeat for both datasets
2867
+ const heartbeat = {
2868
+ dataSets: [
2869
+ {
2870
+ ...createDataSet('main', 16, 1100),
2871
+ backoff: {maxMs: 500, exponent: 2},
2872
+ root: parser.dataSets.main.hashTree.getRootHash(),
2873
+ },
2874
+ {
2875
+ ...createDataSet('self', 1, 2100),
2876
+ url: parser.dataSets.self.url,
2877
+ backoff: {maxMs: 2000, exponent: 3},
2878
+ root: parser.dataSets.self.hashTree.getRootHash(),
2879
+ },
2880
+ ],
2881
+ visibleDataSetsUrl,
2882
+ locusUrl,
2883
+ heartbeatIntervalMs,
2884
+ };
2885
+
2886
+ parser.handleMessage(heartbeat, 'heartbeat');
2887
+
2888
+ // 'main' watchdog delay = 5000 + 1^2 * 500 = 5500ms
2889
+ // 'self' watchdog delay = 5000 + 1^3 * 2000 = 7000ms
2890
+
2891
+ // Mock sync responses
2892
+ mockGetHashesFromLocusResponse(
2893
+ parser.dataSets.main.url,
2894
+ new Array(16).fill('00000000000000000000000000000000'),
2895
+ createDataSet('main', 16, 1101)
2896
+ );
2897
+ mockSendSyncRequestResponse(parser.dataSets.main.url, null);
2898
+ mockSendSyncRequestResponse(parser.dataSets.self.url, null);
2899
+
2900
+ // At 5499ms, neither watchdog should have fired
2901
+ await clock.tickAsync(5499);
2902
+ assert.notCalled(webexRequest);
2903
+
2904
+ // At 5500ms, 'main' watchdog fires and performSync runs immediately
2905
+ await clock.tickAsync(1);
2906
+
2907
+ // main sync should have triggered immediately (GET hashtree + POST sync)
2908
+ assert.calledWith(
2909
+ webexRequest,
2910
+ sinon.match({
2911
+ method: 'GET',
2912
+ uri: `${parser.dataSets.main.url}/hashtree`,
2913
+ })
2914
+ );
2915
+
2916
+ webexRequest.resetHistory();
2917
+
2918
+ // At 7000ms, 'self' watchdog fires and performSync runs immediately
2919
+ await clock.tickAsync(1500);
2920
+
2921
+ // self sync should have also triggered (POST sync only, leafCount === 1)
2922
+ assert.calledWith(
2923
+ webexRequest,
2924
+ sinon.match({
2925
+ method: 'POST',
2926
+ uri: `${parser.dataSets.self.url}/sync`,
2927
+ })
2928
+ );
2929
+ });
2930
+
2931
+ it('does not set watchdog for data sets without a hash tree', async () => {
2932
+ const parser = createHashTreeParser();
2933
+ const heartbeatIntervalMs = 5000;
2934
+
2935
+ // 'atd-active' is in the initial locus but is not visible (no hash tree)
2936
+ // Send heartbeat mentioning a non-visible dataset
2937
+ const heartbeatMessage = {
2938
+ dataSets: [
2939
+ {
2940
+ ...createDataSet('main', 16, 1100),
2941
+ root: parser.dataSets.main.hashTree.getRootHash(),
2942
+ },
2943
+ createDataSet('atd-active', 16, 4000),
2944
+ ],
2945
+ visibleDataSetsUrl,
2946
+ locusUrl,
2947
+ heartbeatIntervalMs,
2948
+ };
2949
+
2950
+ parser.handleMessage(heartbeatMessage, 'heartbeat with non-visible dataset');
2951
+
2952
+ // Watchdog set for main (visible) but not for atd-active (no hash tree)
2953
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2954
+ expect(parser.dataSets['atd-active']?.heartbeatWatchdogTimer).to.be.undefined;
2955
+ });
2956
+ });
2957
+ });
2958
+
2959
+ describe('#callLocusInfoUpdateCallback filtering', () => {
2960
+ // Helper to setup parser with initial objects and reset callback history
2961
+ function setupParserWithObjects(locusStateElements: any[]) {
2962
+ const parser = createHashTreeParser();
2963
+
2964
+ if (locusStateElements.length > 0) {
2965
+ // Determine which datasets to include based on the objects' dataSetNames
2966
+ const dataSetNames = new Set<string>();
2967
+ locusStateElements.forEach((element) => {
2968
+ element.htMeta?.dataSetNames?.forEach((name) => dataSetNames.add(name));
2969
+ });
2970
+
2971
+ const dataSets = [];
2972
+ if (dataSetNames.has('main')) dataSets.push(createDataSet('main', 16, 1100));
2973
+ if (dataSetNames.has('self')) dataSets.push(createDataSet('self', 1, 2100));
2974
+ if (dataSetNames.has('atd-unmuted')) dataSets.push(createDataSet('atd-unmuted', 16, 3100));
2975
+
2976
+ const setupMessage = {
2977
+ dataSets,
2978
+ visibleDataSetsUrl,
2979
+ locusUrl,
2980
+ locusStateElements,
2981
+ };
2982
+
2983
+ parser.handleMessage(setupMessage, 'setup');
2984
+ }
2985
+
2986
+ callback.resetHistory();
2987
+ return parser;
2988
+ }
2989
+
2990
+ it('filters out updates when a dataset has a higher version', () => {
2991
+ const parser = setupParserWithObjects([
2992
+ {
2993
+ htMeta: {
2994
+ elementId: {type: 'locus' as const, id: 5, version: 100},
2995
+ dataSetNames: ['main'],
2996
+ },
2997
+ data: {existingField: 'existing'},
2998
+ },
2999
+ ]);
3000
+
3001
+ // Try to update with an older version (90)
3002
+ const updateMessage = {
3003
+ dataSets: [createDataSet('main', 16, 1101)],
3004
+ visibleDataSetsUrl,
3005
+ locusUrl,
3006
+ locusStateElements: [
3007
+ {
3008
+ htMeta: {
3009
+ elementId: {type: 'locus' as const, id: 5, version: 90},
3010
+ dataSetNames: ['main'],
3011
+ },
3012
+ data: {someField: 'value'},
3013
+ },
3014
+ ],
3015
+ };
3016
+
3017
+ parser.handleMessage(updateMessage, 'update with older version');
3018
+
3019
+ // Callback should not be called because the update was filtered out
3020
+ assert.notCalled(callback);
3021
+ });
3022
+
3023
+ it('allows updates when version is newer than existing', () => {
3024
+ const parser = setupParserWithObjects([
3025
+ {
3026
+ htMeta: {
3027
+ elementId: {type: 'locus' as const, id: 5, version: 100},
3028
+ dataSetNames: ['main'],
3029
+ },
3030
+ data: {existingField: 'existing'},
3031
+ },
3032
+ ]);
3033
+
3034
+ // Try to update with a newer version (110)
3035
+ const updateMessage = {
3036
+ dataSets: [createDataSet('main', 16, 1101)],
3037
+ visibleDataSetsUrl,
3038
+ locusUrl,
3039
+ locusStateElements: [
3040
+ {
3041
+ htMeta: {
3042
+ elementId: {type: 'locus' as const, id: 5, version: 110},
3043
+ dataSetNames: ['main'],
3044
+ },
3045
+ data: {someField: 'new value'},
3046
+ },
3047
+ ],
3048
+ };
3049
+
3050
+ parser.handleMessage(updateMessage, 'update with newer version');
3051
+
3052
+ // Callback should be called with the update
3053
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3054
+ updatedObjects: [
3055
+ {
3056
+ htMeta: {
3057
+ elementId: {type: 'locus', id: 5, version: 110},
3058
+ dataSetNames: ['main'],
3059
+ },
3060
+ data: {someField: 'new value'},
3061
+ },
3062
+ ],
3063
+ });
3064
+ });
3065
+
3066
+ it('filters out removal when object still exists in any dataset', () => {
3067
+ const parser = setupParserWithObjects([
3068
+ {
3069
+ htMeta: {
3070
+ elementId: {type: 'participant' as const, id: 10, version: 50},
3071
+ dataSetNames: ['main', 'atd-unmuted'],
3072
+ },
3073
+ data: {name: 'participant'},
3074
+ },
3075
+ ]);
3076
+
3077
+ // Try to remove the object from main only (it still exists in atd-unmuted)
3078
+ const removalMessage = {
3079
+ dataSets: [createDataSet('main', 16, 1101)],
3080
+ visibleDataSetsUrl,
3081
+ locusUrl,
3082
+ locusStateElements: [
3083
+ {
3084
+ htMeta: {
3085
+ elementId: {type: 'participant' as const, id: 10, version: 50},
3086
+ dataSetNames: ['main'],
3087
+ },
3088
+ data: null, // removal
3089
+ },
3090
+ ],
3091
+ };
3092
+
3093
+ parser.handleMessage(removalMessage, 'removal from one dataset');
3094
+
3095
+ // Callback should not be called because object still exists in atd-unmuted
3096
+ assert.notCalled(callback);
3097
+ });
3098
+
3099
+ it('allows removal when object does not exist in any dataset', () => {
3100
+ const parser = setupParserWithObjects([]);
3101
+
3102
+ // Stub updateItems to return true (simulating that the removal was "applied")
3103
+ sinon.stub(parser.dataSets.main.hashTree, 'updateItems').returns([true]);
3104
+
3105
+ // Try to remove an object that doesn't exist anywhere
3106
+ const removalMessage = {
3107
+ dataSets: [createDataSet('main', 16, 1100)],
3108
+ visibleDataSetsUrl,
3109
+ locusUrl,
3110
+ locusStateElements: [
3111
+ {
3112
+ htMeta: {
3113
+ elementId: {type: 'participant' as const, id: 99, version: 10},
3114
+ dataSetNames: ['main'],
3115
+ },
3116
+ data: null, // removal
3117
+ },
3118
+ ],
3119
+ };
3120
+
3121
+ parser.handleMessage(removalMessage, 'removal of non-existent object');
3122
+
3123
+ // Callback should be called with the removal
3124
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3125
+ updatedObjects: [
3126
+ {
3127
+ htMeta: {
3128
+ elementId: {type: 'participant', id: 99, version: 10},
3129
+ dataSetNames: ['main'],
3130
+ },
3131
+ data: null,
3132
+ },
3133
+ ],
3134
+ });
3135
+ });
3136
+
3137
+ it('filters out removal when object exists in another dataset with newer version', () => {
3138
+ const parser = createHashTreeParser();
3139
+
3140
+ // Setup: Add object to main with version 40
3141
+ parser.handleMessage(
3142
+ {
3143
+ dataSets: [createDataSet('main', 16, 1100)],
3144
+ visibleDataSetsUrl,
3145
+ locusUrl,
3146
+ locusStateElements: [
3147
+ {
3148
+ htMeta: {
3149
+ elementId: {type: 'participant' as const, id: 10, version: 40},
3150
+ dataSetNames: ['main'],
3151
+ },
3152
+ data: {name: 'participant v40'},
3153
+ },
3154
+ ],
3155
+ },
3156
+ 'setup main'
3157
+ );
3158
+
3159
+ // Add object to atd-unmuted with version 50
3160
+ parser.handleMessage(
3161
+ {
3162
+ dataSets: [createDataSet('atd-unmuted', 16, 3100)],
3163
+ visibleDataSetsUrl,
3164
+ locusUrl,
3165
+ locusStateElements: [
3166
+ {
3167
+ htMeta: {
3168
+ elementId: {type: 'participant' as const, id: 10, version: 50},
3169
+ dataSetNames: ['atd-unmuted'],
3170
+ },
3171
+ data: {name: 'participant v50'},
3172
+ },
3173
+ ],
3174
+ },
3175
+ 'setup atd-unmuted'
3176
+ );
3177
+ callback.resetHistory();
3178
+
3179
+ // Try to remove with version 40 from main
3180
+ const removalMessage = {
3181
+ dataSets: [createDataSet('main', 16, 1101)],
3182
+ visibleDataSetsUrl,
3183
+ locusUrl,
3184
+ locusStateElements: [
3185
+ {
3186
+ htMeta: {
3187
+ elementId: {type: 'participant' as const, id: 10, version: 40},
3188
+ dataSetNames: ['main'],
3189
+ },
3190
+ data: null, // removal
3191
+ },
3192
+ ],
3193
+ };
3194
+
3195
+ parser.handleMessage(removalMessage, 'removal with older version');
3196
+
3197
+ // Callback should not be called because object still exists with newer version
3198
+ assert.notCalled(callback);
3199
+ });
3200
+
3201
+ it('filters mixed updates correctly - some pass, some filtered', () => {
3202
+ const parser = setupParserWithObjects([
3203
+ {
3204
+ htMeta: {
3205
+ elementId: {type: 'participant' as const, id: 1, version: 100},
3206
+ dataSetNames: ['main'],
3207
+ },
3208
+ data: {name: 'participant 1'},
3209
+ },
3210
+ {
3211
+ htMeta: {
3212
+ elementId: {type: 'participant' as const, id: 2, version: 50},
3213
+ dataSetNames: ['atd-unmuted'],
3214
+ },
3215
+ data: {name: 'participant 2'},
3216
+ },
3217
+ ]);
3218
+
3219
+ // Send mixed updates
3220
+ const mixedMessage = {
3221
+ dataSets: [createDataSet('main', 16, 1101)],
3222
+ visibleDataSetsUrl,
3223
+ locusUrl,
3224
+ locusStateElements: [
3225
+ {
3226
+ htMeta: {
3227
+ elementId: {type: 'participant' as const, id: 1, version: 110}, // newer version - should pass
3228
+ dataSetNames: ['main'],
3229
+ },
3230
+ data: {name: 'updated'},
3231
+ },
3232
+ {
3233
+ htMeta: {
3234
+ elementId: {type: 'participant' as const, id: 1, version: 90}, // older version - should be filtered
3235
+ dataSetNames: ['main'],
3236
+ },
3237
+ data: {name: 'old'},
3238
+ },
3239
+ {
3240
+ htMeta: {
3241
+ elementId: {type: 'participant' as const, id: 3, version: 10}, // new object - should pass
3242
+ dataSetNames: ['main'],
3243
+ },
3244
+ data: {name: 'new'},
3245
+ },
3246
+ {
3247
+ htMeta: {
3248
+ elementId: {type: 'participant' as const, id: 2, version: 50}, // removal but exists in atd-unmuted - should be filtered
3249
+ dataSetNames: ['main'],
3250
+ },
3251
+ data: null,
3252
+ },
3253
+ ],
3254
+ };
3255
+
3256
+ parser.handleMessage(mixedMessage, 'mixed updates');
3257
+
3258
+ // Callback should be called with only the valid updates (participant 1 v110 and participant 3 v10)
3259
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3260
+ updatedObjects: [
3261
+ {
3262
+ htMeta: {
3263
+ elementId: {type: 'participant', id: 1, version: 110},
3264
+ dataSetNames: ['main'],
3265
+ },
3266
+ data: {name: 'updated'},
3267
+ },
3268
+ {
3269
+ htMeta: {
3270
+ elementId: {type: 'participant', id: 3, version: 10},
3271
+ dataSetNames: ['main'],
3272
+ },
3273
+ data: {name: 'new'},
3274
+ },
3275
+ ],
3276
+ });
3277
+ });
3278
+
3279
+ it('does not call callback when all updates are filtered out', () => {
3280
+ const parser = setupParserWithObjects([
3281
+ {
3282
+ htMeta: {
3283
+ elementId: {type: 'locus' as const, id: 5, version: 100},
3284
+ dataSetNames: ['main'],
3285
+ },
3286
+ data: {existingField: 'existing'},
3287
+ },
3288
+ ]);
3289
+
3290
+ // Try to update with older versions (all should be filtered)
3291
+ const updateMessage = {
3292
+ dataSets: [createDataSet('main', 16, 1101)],
3293
+ visibleDataSetsUrl,
3294
+ locusUrl,
3295
+ locusStateElements: [
3296
+ {
3297
+ htMeta: {
3298
+ elementId: {type: 'locus' as const, id: 5, version: 80},
3299
+ dataSetNames: ['main'],
3300
+ },
3301
+ data: {someField: 'value'},
3302
+ },
3303
+ {
3304
+ htMeta: {
3305
+ elementId: {type: 'locus' as const, id: 5, version: 90},
3306
+ dataSetNames: ['main'],
3307
+ },
3308
+ data: {someField: 'another value'},
3309
+ },
3310
+ ],
3311
+ };
3312
+
3313
+ parser.handleMessage(updateMessage, 'all filtered updates');
3314
+
3315
+ // Callback should not be called at all
3316
+ assert.notCalled(callback);
3317
+ });
3318
+
3319
+ it('checks all visible datasets when filtering', () => {
3320
+ const parser = createHashTreeParser();
3321
+
3322
+ // Setup: Add same object to multiple datasets with different versions
3323
+ parser.handleMessage(
3324
+ {
3325
+ dataSets: [createDataSet('main', 16, 1100)],
3326
+ visibleDataSetsUrl,
3327
+ locusUrl,
3328
+ locusStateElements: [
3329
+ {
3330
+ htMeta: {
3331
+ elementId: {type: 'participant' as const, id: 10, version: 100},
3332
+ dataSetNames: ['main'],
3333
+ },
3334
+ data: {name: 'v100'},
3335
+ },
3336
+ ],
3337
+ },
3338
+ 'setup main'
3339
+ );
3340
+
3341
+ parser.handleMessage(
3342
+ {
3343
+ dataSets: [createDataSet('self', 1, 2100)],
3344
+ visibleDataSetsUrl,
3345
+ locusUrl,
3346
+ locusStateElements: [
3347
+ {
3348
+ htMeta: {
3349
+ elementId: {type: 'participant' as const, id: 10, version: 120}, // highest
3350
+ dataSetNames: ['self'],
3351
+ },
3352
+ data: {name: 'v120'},
3353
+ },
3354
+ ],
3355
+ },
3356
+ 'setup self'
3357
+ );
3358
+
3359
+ parser.handleMessage(
3360
+ {
3361
+ dataSets: [createDataSet('atd-unmuted', 16, 3100)],
3362
+ visibleDataSetsUrl,
3363
+ locusUrl,
3364
+ locusStateElements: [
3365
+ {
3366
+ htMeta: {
3367
+ elementId: {type: 'participant' as const, id: 10, version: 110},
3368
+ dataSetNames: ['atd-unmuted'],
3369
+ },
3370
+ data: {name: 'v110'},
3371
+ },
3372
+ ],
3373
+ },
3374
+ 'setup atd-unmuted'
3375
+ );
3376
+ callback.resetHistory();
3377
+
3378
+ // Try to update with version 115 (newer than main and atd-unmuted, but older than self)
3379
+ const updateMessage = {
3380
+ dataSets: [createDataSet('main', 16, 1101)],
3381
+ visibleDataSetsUrl,
3382
+ locusUrl,
3383
+ locusStateElements: [
3384
+ {
3385
+ htMeta: {
3386
+ elementId: {type: 'participant' as const, id: 10, version: 115},
3387
+ dataSetNames: ['main'],
3388
+ },
3389
+ data: {name: 'update'},
3390
+ },
3391
+ ],
3392
+ };
3393
+
3394
+ parser.handleMessage(updateMessage, 'update with v115');
3395
+
3396
+ // Should be filtered out because self dataset has version 120
3397
+ assert.notCalled(callback);
3398
+ });
3399
+
3400
+ it('does not call callback for empty locusStateElements', () => {
3401
+ const parser = setupParserWithObjects([]);
3402
+
3403
+ const emptyMessage = {
3404
+ dataSets: [createDataSet('main', 16, 1100)],
3405
+ visibleDataSetsUrl,
3406
+ locusUrl,
3407
+ locusStateElements: [],
3408
+ };
3409
+
3410
+ parser.handleMessage(emptyMessage, 'empty elements');
3411
+
3412
+ assert.notCalled(callback);
3413
+ });
3414
+
3415
+ it('always calls callback for MEETING_ENDED regardless of filtering', () => {
3416
+ const parser = setupParserWithObjects([
3417
+ {
3418
+ htMeta: {
3419
+ elementId: {type: 'locus' as const, id: 0, version: 100},
3420
+ dataSetNames: ['main'],
3421
+ },
3422
+ data: {info: 'data'},
3423
+ },
3424
+ ]);
3425
+
3426
+ // Send a sentinel END MEETING message
3427
+ const sentinelMessage = createHeartbeatMessage(
3428
+ 'main',
3429
+ 1,
3430
+ parser.dataSets.main.version + 1,
3431
+ EMPTY_HASH
3432
+ );
3433
+
3434
+ parser.handleMessage(sentinelMessage as any, 'sentinel message');
3435
+
3436
+ // Callback should be called with MEETING_ENDED
3437
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
3438
+ updatedObjects: undefined,
3439
+ });
3440
+ });
3441
+ });
3442
+
3443
+ describe('#state', () => {
3444
+ it('should be initialized to active', () => {
3445
+ const parser = createHashTreeParser();
3446
+
3447
+ expect(parser.state).to.equal('active');
3448
+ });
3449
+ });
3450
+
3451
+ describe('#stop', () => {
3452
+ it('should set state to stopped', () => {
3453
+ const parser = createHashTreeParser();
3454
+
3455
+ parser.stop();
3456
+
3457
+ expect(parser.state).to.equal('stopped');
3458
+ });
3459
+
3460
+ it('should clear all hash trees', () => {
3461
+ const parser = createHashTreeParser();
3462
+
3463
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
3464
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
3465
+
3466
+ parser.stop();
3467
+
3468
+ expect(parser.dataSets.main.hashTree).to.be.undefined;
3469
+ expect(parser.dataSets.self.hashTree).to.be.undefined;
3470
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
3471
+ });
3472
+
3473
+ it('should clear visibleDataSets', () => {
3474
+ const parser = createHashTreeParser();
3475
+
3476
+ expect(parser.visibleDataSets).to.have.length.greaterThan(0);
3477
+
3478
+ parser.stop();
3479
+
3480
+ expect(parser.visibleDataSets).to.deep.equal([]);
3481
+ });
3482
+
3483
+ it('should stop all timers', () => {
3484
+ const parser = createHashTreeParser();
3485
+
3486
+ // manually set timers on data sets
3487
+ parser.dataSets.main.timer = setTimeout(() => {}, 10000);
3488
+ parser.dataSets.main.heartbeatWatchdogTimer = setTimeout(() => {}, 10000);
3489
+
3490
+ parser.stop();
3491
+
3492
+ expect(parser.dataSets.main.timer).to.be.undefined;
3493
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
3494
+ });
3495
+
3496
+ it('should not call locusInfoUpdateCallback when async initialization of a new visible dataset completes after stop()', async () => {
3497
+ const parser = createHashTreeParser();
3498
+
3499
+ // Stub updateItems on self hash tree to return true so the metadata update is applied
3500
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
3501
+
3502
+ // Send a message with Metadata that adds a new visible dataset requiring async initialization
3503
+ // (the new dataset is NOT in parser.dataSets, so it will go through queueInitForNewVisibleDataSets)
3504
+ const message = {
3505
+ dataSets: [createDataSet('self', 1, 2100)],
3506
+ visibleDataSetsUrl,
3507
+ locusUrl,
3508
+ locusStateElements: [
3509
+ {
3510
+ htMeta: {
3511
+ elementId: {
3512
+ type: 'metadata' as const,
3513
+ id: 5,
3514
+ version: 51,
3515
+ },
3516
+ dataSetNames: ['self'],
3517
+ },
3518
+ data: {
3519
+ visibleDataSets: [
3520
+ {
3521
+ name: 'main',
3522
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
3523
+ },
3524
+ {
3525
+ name: 'self',
3526
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
3527
+ },
3528
+ {
3529
+ name: 'atd-unmuted',
3530
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
3531
+ },
3532
+ {
3533
+ name: 'new-dataset',
3534
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
3535
+ },
3536
+ ],
3537
+ },
3538
+ },
3539
+ ],
3540
+ };
3541
+
3542
+ // Mock the async initialization - getAllVisibleDataSetsFromLocus and sync request
3543
+ const newDataSet = createDataSet('new-dataset', 4, 5000);
3544
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [newDataSet]);
3545
+ mockSyncRequest(webexRequest, newDataSet.url, {
3546
+ dataSets: [newDataSet],
3547
+ visibleDataSetsUrl,
3548
+ locusUrl,
3549
+ locusStateElements: [
3550
+ {
3551
+ htMeta: {
3552
+ elementId: {type: 'participant' as const, id: 20, version: 100},
3553
+ dataSetNames: ['new-dataset'],
3554
+ },
3555
+ data: {person: {name: 'some participant'}},
3556
+ },
3557
+ ],
3558
+ });
3559
+
3560
+ // handleMessage triggers queueInitForNewVisibleDataSets (via queueMicrotask)
3561
+ parser.handleMessage(message, 'add new dataset then stop');
3562
+
3563
+ // callback is called once synchronously by handleMessage for the metadata update
3564
+ callback.resetHistory();
3565
+
3566
+ // Stop the parser before the async initialization completes
3567
+ parser.stop();
3568
+
3569
+ // Let the queued microtask and async initialization complete
3570
+ await clock.tickAsync(0);
3571
+
3572
+ // The callback should NOT have been called again after stop()
3573
+ assert.notCalled(callback);
3574
+
3575
+ // parseMessage should not have processed the sync response data,
3576
+ // so no hash tree should exist for new-dataset (stop() clears all hash trees)
3577
+ assert.isUndefined(parser.dataSets['new-dataset']?.hashTree);
3578
+ });
3579
+
3580
+ it('should not call locusInfoUpdateCallback when initializeFromMessage completes after stop()', async () => {
3581
+ const minimalInitialLocus = {
3582
+ dataSets: [],
3583
+ locus: null,
3584
+ };
3585
+ const parser = createHashTreeParser(minimalInitialLocus, null);
3586
+
3587
+ const mainDataSet = createDataSet('main', 16, 1100);
3588
+
3589
+ // Use a deferred promise so we can control when getAllVisibleDataSetsFromLocus resolves
3590
+ let resolveGetDataSets;
3591
+ webexRequest
3592
+ .withArgs(
3593
+ sinon.match({
3594
+ method: 'GET',
3595
+ uri: visibleDataSetsUrl,
3596
+ })
3597
+ )
3598
+ .returns(
3599
+ new Promise((resolve) => {
3600
+ resolveGetDataSets = resolve;
3601
+ })
3602
+ );
3603
+
3604
+ mockSyncRequest(webexRequest, mainDataSet.url, {
3605
+ dataSets: [mainDataSet],
3606
+ visibleDataSetsUrl,
3607
+ locusUrl,
3608
+ locusStateElements: [
3609
+ {
3610
+ htMeta: {
3611
+ elementId: {type: 'locus' as const, id: 1, version: 210},
3612
+ dataSetNames: ['main'],
3613
+ },
3614
+ data: {info: {id: 'some-locus-info'}},
3615
+ },
3616
+ ],
3617
+ });
3618
+
3619
+ // Start initializeFromMessage but don't await it
3620
+ const initPromise = parser.initializeFromMessage({
3621
+ dataSets: [],
3622
+ visibleDataSetsUrl,
3623
+ locusUrl,
3624
+ });
3625
+
3626
+ // Stop the parser before the GET response arrives
3627
+ parser.stop();
3628
+
3629
+ // Now resolve the pending GET request
3630
+ resolveGetDataSets({body: {dataSets: [mainDataSet]}});
3631
+
3632
+ // Wait for the initializeFromMessage to finish
3633
+ await initPromise;
3634
+
3635
+ // The callback should NOT have been called because the parser was stopped
3636
+ assert.notCalled(callback);
3637
+
3638
+ // Even though initializeDataSets may create a hash tree entry, parseMessage
3639
+ // should have returned [] without processing the sync response objects.
3640
+ // After stop(), hash trees are cleared, so verify that main has no hash tree.
3641
+ assert.isUndefined(parser.dataSets.main?.hashTree);
3642
+ });
3643
+ });
3644
+
3645
+ describe('#resume', () => {
3646
+ const createResumeMessage = (visibleDataSets?, dataSets?) => ({
3647
+ locusUrl,
3648
+ visibleDataSetsUrl,
3649
+ dataSets: dataSets || [
3650
+ createDataSet('main', 16, 2000),
3651
+ createDataSet('self', 1, 3000),
3652
+ ],
3653
+ locusStateElements: [
3654
+ {
3655
+ htMeta: {elementId: {type: 'metadata' as const, id: 5, version: 60}, dataSetNames: ['self']},
3656
+ data: {
3657
+ visibleDataSets: visibleDataSets || [
3658
+ {name: 'main', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main'},
3659
+ {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3660
+ ],
3661
+ },
3662
+ },
3663
+ ],
3664
+ });
3665
+
3666
+ it('should set state back to active', () => {
3667
+ const parser = createHashTreeParser();
3668
+ parser.stop();
3669
+
3670
+ expect(parser.state).to.equal('stopped');
3671
+
3672
+ parser.resume(createResumeMessage());
3673
+
3674
+ expect(parser.state).to.equal('active');
3675
+ });
3676
+
3677
+ it('should not resume if message is missing metadata with visibleDataSets', () => {
3678
+ const parser = createHashTreeParser();
3679
+ parser.stop();
3680
+
3681
+ parser.resume({
3682
+ locusUrl,
3683
+ visibleDataSetsUrl,
3684
+ dataSets: [createDataSet('main', 16, 2000)],
3685
+ locusStateElements: [],
3686
+ });
3687
+
3688
+ expect(parser.state).to.equal('stopped');
3689
+ });
3690
+
3691
+ it('should re-initialize dataSets from the message', () => {
3692
+ const parser = createHashTreeParser();
3693
+ parser.stop();
3694
+
3695
+ const newDataSets = [
3696
+ createDataSet('main', 8, 5000),
3697
+ createDataSet('self', 2, 6000),
3698
+ ];
3699
+
3700
+ parser.resume(createResumeMessage(undefined, newDataSets));
3701
+
3702
+ expect(Object.keys(parser.dataSets)).to.have.lengthOf(2);
3703
+ expect(parser.dataSets.main.leafCount).to.equal(8);
3704
+ expect(parser.dataSets.main.version).to.equal(5000);
3705
+ expect(parser.dataSets.self.leafCount).to.equal(2);
3706
+ });
3707
+
3708
+ it('should create hash trees only for visible data sets', () => {
3709
+ const parser = createHashTreeParser();
3710
+ parser.stop();
3711
+
3712
+ const dataSets = [
3713
+ createDataSet('main', 16, 2000),
3714
+ createDataSet('self', 1, 3000),
3715
+ createDataSet('atd-unmuted', 16, 4000),
3716
+ ];
3717
+ const visibleDataSets = [
3718
+ {name: 'main', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main'},
3719
+ {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3720
+ ];
3721
+
3722
+ parser.resume(createResumeMessage(visibleDataSets, dataSets));
3723
+
3724
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
3725
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
3726
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
3727
+ });
3728
+
3729
+ it('should call handleMessage with the resume message', () => {
3730
+ const parser = createHashTreeParser();
3731
+ parser.stop();
3732
+
3733
+ const handleMessageStub = sinon.stub(parser, 'handleMessage');
3734
+
3735
+ const message = createResumeMessage();
3736
+ parser.resume(message);
3737
+
3738
+ assert.calledOnceWithExactly(handleMessageStub, message, 'on resume');
3739
+ });
3740
+
3741
+ it('should set visibleDataSets from message metadata filtered by excludedDataSets', () => {
3742
+ const parser = createHashTreeParser(exampleInitialLocus, exampleMetadata, ['atd-unmuted']);
3743
+ parser.stop();
3744
+
3745
+ const dataSets = [
3746
+ createDataSet('main', 16, 2000),
3747
+ createDataSet('self', 1, 3000),
3748
+ createDataSet('atd-unmuted', 16, 4000),
3749
+ ];
3750
+ const visibleDataSets = [
3751
+ {name: 'main', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main'},
3752
+ {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3753
+ {name: 'atd-unmuted', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'},
3754
+ ];
3755
+
3756
+ parser.resume(createResumeMessage(visibleDataSets, dataSets));
3757
+
3758
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
3759
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
3760
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'self')).to.be.true;
3761
+ });
3762
+ });
3763
+
3764
+ describe('#handleLocusUpdate when stopped', () => {
3765
+ it('should return early without processing when parser is stopped', () => {
3766
+ const parser = createHashTreeParser();
3767
+ parser.stop();
3768
+
3769
+ parser.handleLocusUpdate({
3770
+ dataSets: [createDataSet('main', 16, 2000)],
3771
+ locus: {participants: []},
3772
+ });
3773
+
3774
+ assert.notCalled(callback);
3775
+ });
3776
+ });
3777
+
3778
+ describe('#handleMessage when stopped', () => {
3779
+ it('should return early without processing when parser is stopped', () => {
3780
+ const parser = createHashTreeParser();
3781
+ parser.stop();
3782
+
3783
+ parser.handleMessage({
3784
+ dataSets: [createDataSet('main', 16, 2000)],
3785
+ visibleDataSetsUrl,
3786
+ locusUrl,
3787
+ locusStateElements: [
3788
+ {
3789
+ htMeta: {elementId: {type: 'self' as const, id: 4, version: 200}, dataSetNames: ['self']},
3790
+ data: {id: 'new-self'},
3791
+ },
3792
+ ],
3793
+ });
3794
+
3795
+ assert.notCalled(callback);
3796
+ });
1523
3797
  });
1524
3798
  });