@webex/plugin-meetings 3.12.0-next.3 → 3.12.0-next.31

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 (90) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +1 -1
  3. package/dist/breakouts/breakout.js +1 -1
  4. package/dist/breakouts/index.js +1 -1
  5. package/dist/constants.js +3 -1
  6. package/dist/constants.js.map +1 -1
  7. package/dist/controls-options-manager/constants.js +11 -1
  8. package/dist/controls-options-manager/constants.js.map +1 -1
  9. package/dist/controls-options-manager/index.js +23 -21
  10. package/dist/controls-options-manager/index.js.map +1 -1
  11. package/dist/controls-options-manager/util.js +91 -0
  12. package/dist/controls-options-manager/util.js.map +1 -1
  13. package/dist/hashTree/constants.js +10 -1
  14. package/dist/hashTree/constants.js.map +1 -1
  15. package/dist/hashTree/hashTreeParser.js +550 -346
  16. package/dist/hashTree/hashTreeParser.js.map +1 -1
  17. package/dist/hashTree/utils.js +22 -0
  18. package/dist/hashTree/utils.js.map +1 -1
  19. package/dist/interceptors/locusRetry.js +23 -8
  20. package/dist/interceptors/locusRetry.js.map +1 -1
  21. package/dist/interpretation/index.js +1 -1
  22. package/dist/interpretation/siLanguage.js +1 -1
  23. package/dist/locus-info/index.js +222 -61
  24. package/dist/locus-info/index.js.map +1 -1
  25. package/dist/meeting/index.js +372 -292
  26. package/dist/meeting/index.js.map +1 -1
  27. package/dist/meeting/util.js +1 -0
  28. package/dist/meeting/util.js.map +1 -1
  29. package/dist/meetings/index.js +146 -62
  30. package/dist/meetings/index.js.map +1 -1
  31. package/dist/meetings/util.js +39 -5
  32. package/dist/meetings/util.js.map +1 -1
  33. package/dist/member/index.js +10 -0
  34. package/dist/member/index.js.map +1 -1
  35. package/dist/member/types.js.map +1 -1
  36. package/dist/member/util.js +3 -0
  37. package/dist/member/util.js.map +1 -1
  38. package/dist/metrics/constants.js +5 -1
  39. package/dist/metrics/constants.js.map +1 -1
  40. package/dist/multistream/sendSlotManager.js +116 -2
  41. package/dist/multistream/sendSlotManager.js.map +1 -1
  42. package/dist/types/constants.d.ts +1 -0
  43. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  44. package/dist/types/hashTree/constants.d.ts +1 -0
  45. package/dist/types/hashTree/hashTreeParser.d.ts +53 -15
  46. package/dist/types/hashTree/utils.d.ts +11 -0
  47. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  48. package/dist/types/locus-info/index.d.ts +38 -5
  49. package/dist/types/meeting/index.d.ts +11 -0
  50. package/dist/types/member/index.d.ts +1 -0
  51. package/dist/types/member/types.d.ts +1 -0
  52. package/dist/types/member/util.d.ts +1 -0
  53. package/dist/types/metrics/constants.d.ts +4 -0
  54. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  55. package/dist/webinar/index.js +301 -226
  56. package/dist/webinar/index.js.map +1 -1
  57. package/package.json +16 -16
  58. package/src/constants.ts +1 -0
  59. package/src/controls-options-manager/constants.ts +14 -1
  60. package/src/controls-options-manager/index.ts +26 -19
  61. package/src/controls-options-manager/util.ts +81 -1
  62. package/src/hashTree/constants.ts +9 -0
  63. package/src/hashTree/hashTreeParser.ts +273 -154
  64. package/src/hashTree/utils.ts +17 -0
  65. package/src/interceptors/locusRetry.ts +25 -4
  66. package/src/locus-info/index.ts +233 -79
  67. package/src/meeting/index.ts +98 -11
  68. package/src/meeting/util.ts +1 -0
  69. package/src/meetings/index.ts +58 -34
  70. package/src/meetings/util.ts +44 -1
  71. package/src/member/index.ts +10 -0
  72. package/src/member/types.ts +1 -0
  73. package/src/member/util.ts +3 -0
  74. package/src/metrics/constants.ts +5 -0
  75. package/src/multistream/sendSlotManager.ts +97 -3
  76. package/src/webinar/index.ts +75 -1
  77. package/test/unit/spec/controls-options-manager/index.js +114 -6
  78. package/test/unit/spec/controls-options-manager/util.js +165 -0
  79. package/test/unit/spec/hashTree/hashTreeParser.ts +839 -37
  80. package/test/unit/spec/hashTree/utils.ts +88 -1
  81. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  82. package/test/unit/spec/locus-info/index.js +262 -64
  83. package/test/unit/spec/meeting/index.js +54 -36
  84. package/test/unit/spec/meeting/utils.js +4 -0
  85. package/test/unit/spec/meetings/index.js +190 -8
  86. package/test/unit/spec/meetings/utils.js +124 -0
  87. package/test/unit/spec/member/index.js +7 -0
  88. package/test/unit/spec/member/util.js +24 -0
  89. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  90. package/test/unit/spec/webinar/index.ts +60 -0
@@ -7,6 +7,7 @@ import {expect} from '@webex/test-helper-chai';
7
7
  import sinon from 'sinon';
8
8
  import {assert} from '@webex/test-helper-chai';
9
9
  import {EMPTY_HASH} from '@webex/plugin-meetings/src/hashTree/constants';
10
+ import { some } from 'lodash';
10
11
 
11
12
  const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
12
13
 
@@ -553,7 +554,7 @@ describe('HashTreeParser', () => {
553
554
  );
554
555
 
555
556
  // Verify callback was called with OBJECTS_UPDATED and correct updatedObjects list
556
- assert.calledWith(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
557
+ assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
557
558
  updatedObjects: [
558
559
  {
559
560
  htMeta: {
@@ -566,6 +567,11 @@ describe('HashTreeParser', () => {
566
567
  },
567
568
  data: {info: {id: 'some-fake-locus-info'}},
568
569
  },
570
+ ],
571
+ });
572
+
573
+ assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
574
+ updatedObjects: [
569
575
  {
570
576
  htMeta: {
571
577
  elementId: {
@@ -596,6 +602,67 @@ describe('HashTreeParser', () => {
596
602
  });
597
603
  });
598
604
 
605
+ it('initializes "main" before "self" regardless of order from Locus', async () => {
606
+ const parser = createHashTreeParser({dataSets: [], locus: null}, null);
607
+
608
+ // Locus returns datasets in non-priority order: atd-active, main, self
609
+ const atdActiveDataSet = createDataSet('atd-active', 4, 500);
610
+ const mainDataSet = createDataSet('main', 16, 1100);
611
+ const selfDataSet = createDataSet('self', 1, 2100);
612
+
613
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
614
+ atdActiveDataSet,
615
+ mainDataSet,
616
+ selfDataSet,
617
+ ]);
618
+
619
+ mockSyncRequest(webexRequest, selfDataSet.url);
620
+ mockSyncRequest(webexRequest, mainDataSet.url);
621
+ mockSyncRequest(webexRequest, atdActiveDataSet.url);
622
+
623
+ await parser.initializeFromMessage({
624
+ dataSets: [],
625
+ visibleDataSetsUrl,
626
+ locusUrl,
627
+ });
628
+
629
+ // Verify sync requests were sent in priority order: main, self, then atd-active
630
+ const syncCalls = webexRequest
631
+ .getCalls()
632
+ .filter((call) => call.args[0]?.method === 'POST' && call.args[0]?.uri?.endsWith('/sync'));
633
+
634
+ expect(syncCalls).to.have.lengthOf(3);
635
+ expect(syncCalls[0].args[0].uri).to.equal(`${mainDataSet.url}/sync`);
636
+ expect(syncCalls[1].args[0].uri).to.equal(`${selfDataSet.url}/sync`);
637
+ expect(syncCalls[2].args[0].uri).to.equal(`${atdActiveDataSet.url}/sync`);
638
+ });
639
+
640
+ it('sends leafCount=1 with a single empty leaf for initialization sync, regardless of actual dataset leafCount', async () => {
641
+ const parser = createHashTreeParser({dataSets: [], locus: null}, null);
642
+
643
+ // Use a dataset with leafCount=16 to verify the initialization sync always uses leafCount=1
644
+ const mainDataSet = createDataSet('main', 16, 1100);
645
+
646
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet]);
647
+ mockSyncRequest(webexRequest, mainDataSet.url);
648
+
649
+ await parser.initializeFromMessage({
650
+ dataSets: [],
651
+ visibleDataSetsUrl,
652
+ locusUrl,
653
+ });
654
+
655
+ assert.calledWith(webexRequest, {
656
+ method: 'POST',
657
+ uri: `${mainDataSet.url}/sync`,
658
+ qs: {rootHash: sinon.match.string},
659
+ body: {
660
+ leafCount: 1,
661
+ leafDataEntries: [{leafIndex: 0, elementIds: []}],
662
+ },
663
+ });
664
+ });
665
+
599
666
  it('handles sync response that has locusStateElements undefined', async () => {
600
667
  const minimalInitialLocus = {
601
668
  dataSets: [],
@@ -788,7 +855,7 @@ describe('HashTreeParser', () => {
788
855
  expect(parser.dataSets.self.version).to.equal(2100);
789
856
  expect(parser.dataSets['atd-unmuted'].version).to.equal(3100);
790
857
 
791
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
858
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
792
859
  updatedObjects: [
793
860
  {
794
861
  htMeta: {
@@ -861,6 +928,116 @@ describe('HashTreeParser', () => {
861
928
  });
862
929
  });
863
930
 
931
+ it('handles updates to control entries correctly', () => {
932
+ const parser = createHashTreeParser();
933
+
934
+ const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
935
+
936
+ // Create a locus update with new htMeta information for some things
937
+ const locusUpdate = {
938
+ dataSets: [
939
+ createDataSet('main', 16, 1100),
940
+ ],
941
+ locus: {
942
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
943
+ htMeta: {
944
+ elementId: {
945
+ type: 'locus',
946
+ id: 0,
947
+ version: 200, // same version
948
+ },
949
+ dataSetNames: ['main'],
950
+ },
951
+ participants: [],
952
+ controls: {
953
+ lock: {
954
+ locked: true,
955
+ htMeta: {
956
+ elementId: {
957
+ type: 'ControlEntry',
958
+ id: 10100,
959
+ version: 100,
960
+ },
961
+ dataSetNames: ['main'],
962
+ },
963
+ },
964
+ stream: {
965
+ streaming: true,
966
+ htMeta: {
967
+ elementId: {
968
+ type: 'ControlEntry',
969
+ id: 10101,
970
+ version: 100,
971
+ },
972
+ dataSetNames: ['main'],
973
+ },
974
+ }
975
+ }
976
+ },
977
+ };
978
+
979
+ // Call handleLocusUpdate
980
+ parser.handleLocusUpdate(locusUpdate);
981
+
982
+ // Verify putItems was called on main hash tree with correct data
983
+ assert.calledOnceWithExactly(mainPutItemsSpy, [
984
+ {type: 'locus', id: 0, version: 200},
985
+ {type: 'ControlEntry', id: 10100, version: 100},
986
+ {type: 'ControlEntry', id: 10101, version: 100}
987
+ ]);
988
+
989
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
990
+ updatedObjects: [
991
+ {
992
+ htMeta: {
993
+ elementId: {
994
+ type: 'ControlEntry',
995
+ id: 10100,
996
+ version: 100,
997
+ },
998
+ dataSetNames: ['main'],
999
+ },
1000
+ data: {
1001
+ lock: {
1002
+ locked: true,
1003
+ htMeta: {
1004
+ elementId: {
1005
+ type: 'ControlEntry',
1006
+ id: 10100,
1007
+ version: 100,
1008
+ },
1009
+ dataSetNames: ['main'],
1010
+ },
1011
+ },
1012
+ },
1013
+ },
1014
+ {
1015
+ htMeta: {
1016
+ elementId: {
1017
+ type: 'ControlEntry',
1018
+ id: 10101,
1019
+ version: 100,
1020
+ },
1021
+ dataSetNames: ['main'],
1022
+ },
1023
+ data: {
1024
+ stream: {
1025
+ streaming: true,
1026
+ htMeta: {
1027
+ elementId: {
1028
+ type: 'ControlEntry',
1029
+ id: 10101,
1030
+ version: 100,
1031
+ },
1032
+ dataSetNames: ['main'],
1033
+ },
1034
+ },
1035
+ },
1036
+ }
1037
+ ],
1038
+ });
1039
+ });
1040
+
864
1041
  it('handles unknown datasets gracefully', () => {
865
1042
  const parser = createHashTreeParser();
866
1043
 
@@ -899,7 +1076,7 @@ describe('HashTreeParser', () => {
899
1076
  assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 201}]);
900
1077
 
901
1078
  // Verify callback was called only for known dataset
902
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1079
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
903
1080
  updatedObjects: [
904
1081
  {
905
1082
  htMeta: {
@@ -999,7 +1176,7 @@ describe('HashTreeParser', () => {
999
1176
  assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 51});
1000
1177
 
1001
1178
  // Verify callback was called with metadata object and removed dataset objects
1002
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1179
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1003
1180
  updatedObjects: [
1004
1181
  // updated metadata object:
1005
1182
  {
@@ -1160,7 +1337,7 @@ describe('HashTreeParser', () => {
1160
1337
  assert.notCalled(atdUnmutedPutItemsSpy);
1161
1338
 
1162
1339
  // Verify callback was called with the updated object
1163
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1340
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1164
1341
  updatedObjects: [
1165
1342
  {
1166
1343
  htMeta: {
@@ -1388,7 +1565,7 @@ describe('HashTreeParser', () => {
1388
1565
  ]);
1389
1566
 
1390
1567
  // Verify callback was called with OBJECTS_UPDATED and all updated objects
1391
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1568
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1392
1569
  updatedObjects: [
1393
1570
  {
1394
1571
  htMeta: {
@@ -1453,9 +1630,7 @@ describe('HashTreeParser', () => {
1453
1630
  parser.handleMessage(sentinelMessage, 'sentinel message');
1454
1631
 
1455
1632
  // Verify callback was called with MEETING_ENDED
1456
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1457
- updatedObjects: undefined,
1458
- });
1633
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1459
1634
 
1460
1635
  // Verify that all timers were stopped
1461
1636
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1477,9 +1652,7 @@ describe('HashTreeParser', () => {
1477
1652
  parser.handleMessage(sentinelMessage, 'sentinel message');
1478
1653
 
1479
1654
  // Verify callback was called with MEETING_ENDED
1480
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1481
- updatedObjects: undefined,
1482
- });
1655
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1483
1656
 
1484
1657
  // Verify that all timers were stopped
1485
1658
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1575,7 +1748,7 @@ describe('HashTreeParser', () => {
1575
1748
  );
1576
1749
 
1577
1750
  // Verify that callback was called with synced objects
1578
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1751
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1579
1752
  updatedObjects: [
1580
1753
  {
1581
1754
  htMeta: {
@@ -1637,9 +1810,7 @@ describe('HashTreeParser', () => {
1637
1810
  await clock.tickAsync(1000);
1638
1811
 
1639
1812
  // Verify callback was called with MEETING_ENDED
1640
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1641
- updatedObjects: undefined,
1642
- });
1813
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1643
1814
 
1644
1815
  // Verify all timers are stopped
1645
1816
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1702,9 +1873,7 @@ describe('HashTreeParser', () => {
1702
1873
  await clock.tickAsync(1000);
1703
1874
 
1704
1875
  // Verify callback was called with MEETING_ENDED
1705
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1706
- updatedObjects: undefined,
1707
- });
1876
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1708
1877
 
1709
1878
  // Verify all timers are stopped
1710
1879
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1942,7 +2111,7 @@ describe('HashTreeParser', () => {
1942
2111
  assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
1943
2112
 
1944
2113
  // Verify callback was called with the metadata update (appears twice - processed once for visible dataset changes, once in main loop)
1945
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
2114
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1946
2115
  updatedObjects: [
1947
2116
  {
1948
2117
  htMeta: {
@@ -2062,6 +2231,98 @@ describe('HashTreeParser', () => {
2062
2231
  await checkAsyncDatasetInitialization(parser, newDataSet);
2063
2232
  });
2064
2233
 
2234
+ it('initializes new visible data sets in priority order', async () => {
2235
+ // Create a parser that only has "self" as visible (no "main")
2236
+ const initialLocusWithoutMain = {
2237
+ dataSets: [createDataSet('self', 1, 2000)],
2238
+ locus: {
2239
+ ...exampleInitialLocus.locus,
2240
+ },
2241
+ };
2242
+ const metadataWithoutMain = {
2243
+ ...exampleMetadata,
2244
+ visibleDataSets: [
2245
+ {
2246
+ name: 'self',
2247
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2248
+ },
2249
+ ],
2250
+ };
2251
+ const parser = createHashTreeParser(initialLocusWithoutMain, metadataWithoutMain);
2252
+
2253
+ // Verify "main" is not visible initially
2254
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.false;
2255
+
2256
+ // Stub updateItems on self hash tree to return true
2257
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2258
+
2259
+ // Send a message that adds "main" and "atd-active" as new visible datasets.
2260
+ // Neither has info in dataSets, so both require async initialization.
2261
+ const newMainDataSet = createDataSet('main', 16, 6000);
2262
+ const newAtdActiveDataSet = createDataSet('atd-active', 4, 7000);
2263
+
2264
+ const message = {
2265
+ dataSets: [createDataSet('self', 1, 2100)],
2266
+ visibleDataSetsUrl,
2267
+ locusUrl,
2268
+ locusStateElements: [
2269
+ {
2270
+ htMeta: {
2271
+ elementId: {
2272
+ type: 'metadata' as const,
2273
+ id: 5,
2274
+ version: 51,
2275
+ },
2276
+ dataSetNames: ['self'],
2277
+ },
2278
+ data: {
2279
+ visibleDataSets: [
2280
+ {
2281
+ name: 'self',
2282
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2283
+ },
2284
+ // listed in non-priority order: atd-active before main
2285
+ {name: 'atd-active', url: newAtdActiveDataSet.url},
2286
+ {name: 'main', url: newMainDataSet.url},
2287
+ ],
2288
+ },
2289
+ },
2290
+ ],
2291
+ };
2292
+
2293
+ // Mock getAllVisibleDataSetsFromLocus to return both new datasets (in non-priority order)
2294
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
2295
+ newAtdActiveDataSet,
2296
+ newMainDataSet,
2297
+ ]);
2298
+ mockSyncRequest(webexRequest, newMainDataSet.url);
2299
+ mockSyncRequest(webexRequest, newAtdActiveDataSet.url);
2300
+
2301
+ parser.handleMessage(message, 'add main and atd-active datasets');
2302
+
2303
+ // Wait for the async initialization (queueMicrotask) to complete
2304
+ await clock.tickAsync(0);
2305
+
2306
+ // Verify both datasets are initialized
2307
+ expect(parser.dataSets.main?.hashTree).to.exist;
2308
+ expect(parser.dataSets['atd-active']?.hashTree).to.exist;
2309
+
2310
+ // Verify sync requests were sent in priority order: "main" before "atd-active",
2311
+ // even though atd-active was listed first in both the message and the Locus response
2312
+ const syncCalls = webexRequest
2313
+ .getCalls()
2314
+ .filter(
2315
+ (call) =>
2316
+ call.args[0]?.method === 'POST' &&
2317
+ call.args[0]?.uri?.endsWith('/sync') &&
2318
+ (call.args[0]?.uri?.includes('/main/') || call.args[0]?.uri?.includes('/atd-active/'))
2319
+ );
2320
+
2321
+ expect(syncCalls).to.have.lengthOf(2);
2322
+ expect(syncCalls[0].args[0].uri).to.equal(`${newMainDataSet.url}/sync`);
2323
+ expect(syncCalls[1].args[0].uri).to.equal(`${newAtdActiveDataSet.url}/sync`);
2324
+ });
2325
+
2065
2326
  it('emits MEETING_ENDED if async init of a new visible dataset fails with 404', async () => {
2066
2327
  const parser = createHashTreeParser();
2067
2328
 
@@ -2128,9 +2389,7 @@ describe('HashTreeParser', () => {
2128
2389
  await clock.tickAsync(0);
2129
2390
 
2130
2391
  // Verify callback was called with MEETING_ENDED
2131
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
2132
- updatedObjects: undefined,
2133
- });
2392
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
2134
2393
  });
2135
2394
 
2136
2395
  it('handles removal of visible data set', async () => {
@@ -2193,7 +2452,7 @@ describe('HashTreeParser', () => {
2193
2452
  assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
2194
2453
 
2195
2454
  // Verify callback was called with the metadata update and the removed objects (metadata appears twice - processed once for dataset changes, once in main loop)
2196
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
2455
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2197
2456
  updatedObjects: [
2198
2457
  {
2199
2458
  htMeta: {
@@ -2290,6 +2549,151 @@ describe('HashTreeParser', () => {
2290
2549
  // Verify callback was NOT called (no updates for non-visible datasets)
2291
2550
  assert.notCalled(callback);
2292
2551
  });
2552
+
2553
+ it('reports update for object that moves from removed visible dataset to new visible dataset even if version is unchanged', async () => {
2554
+ // The purpose of this test is to verify that when an object
2555
+ // moves from one visible dataset to another without version change,
2556
+ // the parser still reports it as an update.
2557
+ // Locus has some additional signalling for this - the "view" property in htMeta.elementId.
2558
+ // When a view changes, the contents of the object may change even if version doesn't.
2559
+ // HashTreeParser doesn't use the "view" property, because it doesn't need to -
2560
+ // the same functionality is achieved thanks to the fact that a new visible data set means
2561
+ // a new hash tree is created, so HashTreeParser still detects the change as new
2562
+ // object is added to the new hash tree.
2563
+
2564
+ // Setup: parser with visible datasets "self" and "unjoined"
2565
+ const unjoinedDataSet = createDataSet('unjoined', 4, 3000);
2566
+ const selfDataSet = createDataSet('self', 1, 2000);
2567
+
2568
+ // start with Locus that has "info" in both "unjoined" and "main" datasets,
2569
+ // but only "unjoined" is visible.
2570
+ const initialLocus = {
2571
+ dataSets: [selfDataSet, unjoinedDataSet],
2572
+ locus: {
2573
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
2574
+ links: {resources: {visibleDataSets: {url: visibleDataSetsUrl}}},
2575
+ // info object in "unjoined" dataset with version 500
2576
+ info: {
2577
+ htMeta: {
2578
+ elementId: {
2579
+ type: 'info',
2580
+ id: 42,
2581
+ version: 500,
2582
+ view: ['unjoined'], // not used by our code, but here for completeness - that's what real Locus would send
2583
+ },
2584
+ dataSetNames: ['main', 'unjoined'],
2585
+ },
2586
+ someField: 'some-initial-value',
2587
+ },
2588
+ self: {
2589
+ htMeta: {
2590
+ elementId: {
2591
+ type: 'self',
2592
+ id: 4,
2593
+ version: 100,
2594
+ },
2595
+ dataSetNames: ['self'],
2596
+ },
2597
+ },
2598
+ },
2599
+ };
2600
+
2601
+ const metadata = {
2602
+ htMeta: {
2603
+ elementId: {
2604
+ type: 'metadata',
2605
+ id: 5,
2606
+ version: 50,
2607
+ },
2608
+ dataSetNames: ['self'],
2609
+ },
2610
+ visibleDataSets: [
2611
+ {name: 'self', url: selfDataSet.url},
2612
+ {name: 'unjoined', url: unjoinedDataSet.url},
2613
+ ],
2614
+ };
2615
+
2616
+ const parser = createHashTreeParser(initialLocus, metadata);
2617
+
2618
+ // Verify initial state: unjoined is visible and has the info object
2619
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.true;
2620
+ assert.exists(parser.dataSets.unjoined.hashTree);
2621
+ assert.equal(parser.dataSets.unjoined.hashTree?.getItemVersion(42, 'info'), 500);
2622
+
2623
+ // Stub updateItems on self hash tree to return true for metadata update
2624
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2625
+
2626
+ // Now send a message that:
2627
+ // 1. Changes visible datasets: removes "unjoined", adds "main"
2628
+ // 2. Contains the same info object (same id=42, same version=500) but we see the view from "main" dataset
2629
+ const mainDataSet = createDataSet('main', 16, 1000);
2630
+
2631
+ const message = {
2632
+ dataSets: [selfDataSet, mainDataSet],
2633
+ visibleDataSetsUrl,
2634
+ locusUrl,
2635
+ locusStateElements: [
2636
+ {
2637
+ htMeta: {
2638
+ elementId: {
2639
+ type: 'metadata' as const,
2640
+ id: 5,
2641
+ version: 51,
2642
+ },
2643
+ dataSetNames: ['self'],
2644
+ },
2645
+ data: {
2646
+ visibleDataSets: [
2647
+ {name: 'self', url: selfDataSet.url},
2648
+ {name: 'main', url: mainDataSet.url},
2649
+ // "unjoined" is no longer here
2650
+ ],
2651
+ },
2652
+ },
2653
+ {
2654
+ htMeta: {
2655
+ elementId: {
2656
+ type: 'info' as const,
2657
+ id: 42,
2658
+ version: 500, // same version as before
2659
+ view: ['main'], // now points to "main" instead of "unjoined"
2660
+ },
2661
+ dataSetNames: ['main', 'unjoined'], // still in both datasets, but only "main" is visible now
2662
+ },
2663
+ data: {someNewField: 'some-value'},
2664
+ },
2665
+ ],
2666
+ };
2667
+
2668
+ parser.handleMessage(message, 'visible dataset swap with same-version object');
2669
+
2670
+ // Verify "unjoined" is no longer visible and "main" is now visible
2671
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.false;
2672
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
2673
+
2674
+ // Verify the info object is now in the "main" hash tree
2675
+ assert.exists(parser.dataSets.main.hashTree);
2676
+ assert.equal(parser.dataSets.main.hashTree?.getItemVersion(42, 'info'), 500);
2677
+
2678
+ // The key assertion: callback should be called with the info object update even though
2679
+ // its version hasn't changed - because visible datasets changed (moved from unjoined to main)
2680
+ assert.calledOnce(callback);
2681
+ const callbackArgs = callback.firstCall.args[0];
2682
+ assert.equal(callbackArgs.updateType, LocusInfoUpdateType.OBJECTS_UPDATED);
2683
+
2684
+ // Should contain the info object update (with data)
2685
+ const infoUpdate = callbackArgs.updatedObjects.find(
2686
+ (obj) => obj.htMeta.elementId.type === 'info' && obj.htMeta.elementId.id === 42
2687
+ );
2688
+ assert.exists(infoUpdate);
2689
+ assert.deepEqual(infoUpdate.htMeta.elementId, {
2690
+ type: 'info',
2691
+ id: 42,
2692
+ version: 500,
2693
+ view: ['main'],
2694
+ });
2695
+ assert.deepEqual(infoUpdate.data, {someNewField: 'some-value'});
2696
+ });
2293
2697
  });
2294
2698
 
2295
2699
  describe('heartbeat watchdog', () => {
@@ -2812,7 +3216,7 @@ describe('HashTreeParser', () => {
2812
3216
  parser.handleMessage(updateMessage, 'update with newer version');
2813
3217
 
2814
3218
  // Callback should be called with the update
2815
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3219
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2816
3220
  updatedObjects: [
2817
3221
  {
2818
3222
  htMeta: {
@@ -2883,7 +3287,7 @@ describe('HashTreeParser', () => {
2883
3287
  parser.handleMessage(removalMessage, 'removal of non-existent object');
2884
3288
 
2885
3289
  // Callback should be called with the removal
2886
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3290
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2887
3291
  updatedObjects: [
2888
3292
  {
2889
3293
  htMeta: {
@@ -3018,7 +3422,7 @@ describe('HashTreeParser', () => {
3018
3422
  parser.handleMessage(mixedMessage, 'mixed updates');
3019
3423
 
3020
3424
  // Callback should be called with only the valid updates (participant 1 v110 and participant 3 v10)
3021
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3425
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
3022
3426
  updatedObjects: [
3023
3427
  {
3024
3428
  htMeta: {
@@ -3196,9 +3600,7 @@ describe('HashTreeParser', () => {
3196
3600
  parser.handleMessage(sentinelMessage as any, 'sentinel message');
3197
3601
 
3198
3602
  // Callback should be called with MEETING_ENDED
3199
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
3200
- updatedObjects: undefined,
3201
- });
3603
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
3202
3604
  });
3203
3605
  });
3204
3606
 
@@ -3404,7 +3806,7 @@ describe('HashTreeParser', () => {
3404
3806
  });
3405
3807
  });
3406
3808
 
3407
- describe('#resume', () => {
3809
+ describe('#resumeFromMessage', () => {
3408
3810
  const createResumeMessage = (visibleDataSets?, dataSets?) => ({
3409
3811
  locusUrl,
3410
3812
  visibleDataSetsUrl,
@@ -3431,7 +3833,7 @@ describe('HashTreeParser', () => {
3431
3833
 
3432
3834
  expect(parser.state).to.equal('stopped');
3433
3835
 
3434
- parser.resume(createResumeMessage());
3836
+ parser.resumeFromMessage(createResumeMessage());
3435
3837
 
3436
3838
  expect(parser.state).to.equal('active');
3437
3839
  });
@@ -3440,7 +3842,7 @@ describe('HashTreeParser', () => {
3440
3842
  const parser = createHashTreeParser();
3441
3843
  parser.stop();
3442
3844
 
3443
- parser.resume({
3845
+ parser.resumeFromMessage({
3444
3846
  locusUrl,
3445
3847
  visibleDataSetsUrl,
3446
3848
  dataSets: [createDataSet('main', 16, 2000)],
@@ -3459,7 +3861,7 @@ describe('HashTreeParser', () => {
3459
3861
  createDataSet('self', 2, 6000),
3460
3862
  ];
3461
3863
 
3462
- parser.resume(createResumeMessage(undefined, newDataSets));
3864
+ parser.resumeFromMessage(createResumeMessage(undefined, newDataSets));
3463
3865
 
3464
3866
  expect(Object.keys(parser.dataSets)).to.have.lengthOf(2);
3465
3867
  expect(parser.dataSets.main.leafCount).to.equal(8);
@@ -3481,7 +3883,7 @@ describe('HashTreeParser', () => {
3481
3883
  {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3482
3884
  ];
3483
3885
 
3484
- parser.resume(createResumeMessage(visibleDataSets, dataSets));
3886
+ parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
3485
3887
 
3486
3888
  expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
3487
3889
  expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
@@ -3495,7 +3897,7 @@ describe('HashTreeParser', () => {
3495
3897
  const handleMessageStub = sinon.stub(parser, 'handleMessage');
3496
3898
 
3497
3899
  const message = createResumeMessage();
3498
- parser.resume(message);
3900
+ parser.resumeFromMessage(message);
3499
3901
 
3500
3902
  assert.calledOnceWithExactly(handleMessageStub, message, 'on resume');
3501
3903
  });
@@ -3515,7 +3917,7 @@ describe('HashTreeParser', () => {
3515
3917
  {name: 'atd-unmuted', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'},
3516
3918
  ];
3517
3919
 
3518
- parser.resume(createResumeMessage(visibleDataSets, dataSets));
3920
+ parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
3519
3921
 
3520
3922
  expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
3521
3923
  expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
@@ -3523,6 +3925,67 @@ describe('HashTreeParser', () => {
3523
3925
  });
3524
3926
  });
3525
3927
 
3928
+ describe('#resumeFromApiResponse', () => {
3929
+ const exampleLocus = {
3930
+ participants: [],
3931
+ } as any;
3932
+
3933
+ it('should set state to active', async () => {
3934
+ const parser = createHashTreeParser();
3935
+ parser.stop();
3936
+
3937
+ expect(parser.state).to.equal('stopped');
3938
+
3939
+ sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
3940
+
3941
+ await parser.resumeFromApiResponse(exampleLocus);
3942
+
3943
+ expect(parser.state).to.equal('active');
3944
+ });
3945
+
3946
+ it('should reset dataSets to empty', async () => {
3947
+ const parser = createHashTreeParser();
3948
+
3949
+ expect(Object.keys(parser.dataSets).length).to.be.greaterThan(0);
3950
+
3951
+ parser.stop();
3952
+
3953
+ sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
3954
+
3955
+ await parser.resumeFromApiResponse(exampleLocus);
3956
+
3957
+ expect(parser.dataSets).to.deep.equal({});
3958
+ });
3959
+
3960
+ it('should call initializeFromGetLociResponse with the provided locus', async () => {
3961
+ const parser = createHashTreeParser();
3962
+ parser.stop();
3963
+
3964
+ const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
3965
+
3966
+ await parser.resumeFromApiResponse(exampleLocus);
3967
+
3968
+ assert.calledOnceWithExactly(initStub, exampleLocus);
3969
+ });
3970
+
3971
+ it('should propagate errors from initializeFromGetLociResponse', async () => {
3972
+ const parser = createHashTreeParser();
3973
+ parser.stop();
3974
+
3975
+ const error = new Error('initialization failed');
3976
+ const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').rejects(error);
3977
+
3978
+ let caughtError: Error | undefined;
3979
+ try {
3980
+ await parser.resumeFromApiResponse(exampleLocus);
3981
+ } catch (e) {
3982
+ caughtError = e;
3983
+ }
3984
+
3985
+ expect(caughtError).to.equal(error);
3986
+ });
3987
+ });
3988
+
3526
3989
  describe('#handleLocusUpdate when stopped', () => {
3527
3990
  it('should return early without processing when parser is stopped', () => {
3528
3991
  const parser = createHashTreeParser();
@@ -3557,4 +4020,343 @@ describe('HashTreeParser', () => {
3557
4020
  assert.notCalled(callback);
3558
4021
  });
3559
4022
  });
4023
+
4024
+ describe('#syncAllDatasets', () => {
4025
+ it('should sync all datasets that have hash trees in priority order', async () => {
4026
+ const parser = createHashTreeParser();
4027
+
4028
+ // parser starts with main (leafCount=16) and self (leafCount=1) as visible datasets with hash trees
4029
+ // atd-unmuted has no hash tree (not visible)
4030
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
4031
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
4032
+
4033
+ const mainUrl = parser.dataSets.main.url;
4034
+ const selfUrl = parser.dataSets.self.url;
4035
+
4036
+ // Mock GET hashtree for main (leafCount > 1, so it does GET first)
4037
+ mockGetHashesFromLocusResponse(
4038
+ mainUrl,
4039
+ new Array(16).fill(EMPTY_HASH),
4040
+ createDataSet('main', 16, 1100)
4041
+ );
4042
+
4043
+ // Mock POST sync for main - return matching root hash so no further sync needed
4044
+ const mainSyncDataSet = createDataSet('main', 16, 1100);
4045
+ mainSyncDataSet.root = parser.dataSets.main.hashTree.getRootHash();
4046
+ mockSendSyncRequestResponse(mainUrl, {
4047
+ dataSets: [mainSyncDataSet],
4048
+ visibleDataSetsUrl,
4049
+ locusUrl,
4050
+ locusStateElements: [],
4051
+ });
4052
+
4053
+ // Mock POST sync for self (leafCount=1, skips GET hashtree)
4054
+ const selfSyncDataSet = createDataSet('self', 1, 2100);
4055
+ selfSyncDataSet.root = parser.dataSets.self.hashTree.getRootHash();
4056
+ mockSendSyncRequestResponse(selfUrl, {
4057
+ dataSets: [selfSyncDataSet],
4058
+ visibleDataSetsUrl,
4059
+ locusUrl,
4060
+ locusStateElements: [],
4061
+ });
4062
+
4063
+ await parser.syncAllDatasets();
4064
+
4065
+ // Verify GET hashtree was called for main only (not self, because leafCount=1)
4066
+ assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
4067
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4068
+
4069
+ // Verify POST sync was called for both
4070
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
4071
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4072
+
4073
+ // Verify main was synced before self (priority order)
4074
+ const mainSyncCallIndex = webexRequest.args.findIndex(
4075
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4076
+ );
4077
+ const selfSyncCallIndex = webexRequest.args.findIndex(
4078
+ (args) => args[0]?.method === 'POST' && args[0]?.uri === `${selfUrl}/sync`
4079
+ );
4080
+ expect(mainSyncCallIndex).to.be.lessThan(selfSyncCallIndex);
4081
+
4082
+ // Verify isSyncAllInProgress is reset
4083
+ expect(parser.isSyncAllInProgress).to.be.false;
4084
+ });
4085
+
4086
+ it('should return immediately when state is stopped', async () => {
4087
+ const parser = createHashTreeParser();
4088
+ parser.stop();
4089
+
4090
+ await parser.syncAllDatasets();
4091
+
4092
+ // No sync requests should have been made (only the initial sync from constructor)
4093
+ // Reset history to clear constructor calls then verify
4094
+ const callCountBefore = webexRequest.callCount;
4095
+ await parser.syncAllDatasets();
4096
+ assert.equal(webexRequest.callCount, callCountBefore);
4097
+ });
4098
+
4099
+ it('should guard against concurrent calls', async () => {
4100
+ const parser = createHashTreeParser();
4101
+
4102
+ const mainUrl = parser.dataSets.main.url;
4103
+ const selfUrl = parser.dataSets.self.url;
4104
+
4105
+ // Use a deferred promise for the main sync to control timing
4106
+ let resolveMainSync;
4107
+ webexRequest
4108
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4109
+ .returns(new Promise((resolve) => { resolveMainSync = resolve; }));
4110
+
4111
+ mockSendSyncRequestResponse(mainUrl, {
4112
+ dataSets: [createDataSet('main', 16, 1100)],
4113
+ visibleDataSetsUrl,
4114
+ locusUrl,
4115
+ locusStateElements: [],
4116
+ });
4117
+
4118
+ mockSendSyncRequestResponse(selfUrl, {
4119
+ dataSets: [createDataSet('self', 1, 2100)],
4120
+ visibleDataSetsUrl,
4121
+ locusUrl,
4122
+ locusStateElements: [],
4123
+ });
4124
+
4125
+ // Start first call
4126
+ const promise1 = parser.syncAllDatasets();
4127
+ // Start second call while first is in progress
4128
+ const promise2 = parser.syncAllDatasets();
4129
+
4130
+ // Resolve the pending request
4131
+ resolveMainSync({
4132
+ body: {
4133
+ hashes: new Array(16).fill(EMPTY_HASH),
4134
+ dataSet: createDataSet('main', 16, 1100),
4135
+ },
4136
+ });
4137
+
4138
+ await promise1;
4139
+ await promise2;
4140
+
4141
+ // GET hashtree for main should only be called once (second syncAllDatasets returned immediately)
4142
+ const getHashtreeCalls = webexRequest.args.filter(
4143
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4144
+ );
4145
+ expect(getHashtreeCalls).to.have.lengthOf(1);
4146
+ });
4147
+
4148
+ it('should skip datasets that do not have a hash tree', async () => {
4149
+ // Create parser with metadata that only has main and self as visible (not atd-unmuted)
4150
+ const metadataWithoutAtd = {
4151
+ ...exampleMetadata,
4152
+ visibleDataSets: exampleMetadata.visibleDataSets.filter((ds) => ds.name !== 'atd-unmuted'),
4153
+ };
4154
+ const parser = createHashTreeParser(exampleInitialLocus, metadataWithoutAtd);
4155
+
4156
+ // atd-unmuted is in dataSets but has no hashTree (not visible)
4157
+ expect(parser.dataSets['atd-unmuted']).to.exist;
4158
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
4159
+
4160
+ const atdUrl = parser.dataSets['atd-unmuted'].url;
4161
+ const mainUrl = parser.dataSets.main.url;
4162
+ const selfUrl = parser.dataSets.self.url;
4163
+
4164
+ mockGetHashesFromLocusResponse(
4165
+ mainUrl,
4166
+ new Array(16).fill(EMPTY_HASH),
4167
+ createDataSet('main', 16, 1100)
4168
+ );
4169
+
4170
+ const mainSyncDs = createDataSet('main', 16, 1100);
4171
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4172
+ mockSendSyncRequestResponse(mainUrl, {
4173
+ dataSets: [mainSyncDs],
4174
+ visibleDataSetsUrl,
4175
+ locusUrl,
4176
+ locusStateElements: [],
4177
+ });
4178
+
4179
+ const selfSyncDs = createDataSet('self', 1, 2100);
4180
+ selfSyncDs.root = parser.dataSets.self.hashTree.getRootHash();
4181
+ mockSendSyncRequestResponse(selfUrl, {
4182
+ dataSets: [selfSyncDs],
4183
+ visibleDataSetsUrl,
4184
+ locusUrl,
4185
+ locusStateElements: [],
4186
+ });
4187
+
4188
+ await parser.syncAllDatasets();
4189
+
4190
+ // No requests should have been made for atd-unmuted
4191
+ assert.neverCalledWith(webexRequest, sinon.match({uri: sinon.match(atdUrl)}));
4192
+ });
4193
+ });
4194
+
4195
+ describe('#handleMessage sync queue', () => {
4196
+ it('should deduplicate: not sync the same dataset twice when enqueued multiple times', async () => {
4197
+ const parser = createHashTreeParser();
4198
+
4199
+ const mainUrl = parser.dataSets.main.url;
4200
+
4201
+ // Setup mocks before triggering syncs
4202
+ mockGetHashesFromLocusResponse(
4203
+ mainUrl,
4204
+ new Array(16).fill(EMPTY_HASH),
4205
+ createDataSet('main', 16, 1101)
4206
+ );
4207
+
4208
+ const mainSyncDs = createDataSet('main', 16, 1101);
4209
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4210
+ mockSendSyncRequestResponse(mainUrl, {
4211
+ dataSets: [mainSyncDs],
4212
+ visibleDataSetsUrl,
4213
+ locusUrl,
4214
+ locusStateElements: [],
4215
+ });
4216
+
4217
+ // Send two heartbeat messages (no locusStateElements) with different root hashes for main
4218
+ parser.handleMessage(createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'), 'first');
4219
+ parser.handleMessage(createHeartbeatMessage('main', 16, 1101, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2'), 'second');
4220
+
4221
+ // The second call resets the timer. After 1000ms, only one sync fires.
4222
+ await clock.tickAsync(1000);
4223
+
4224
+ // Only one GET hashtree call should have been made for main
4225
+ const getHashtreeCalls = webexRequest.args.filter(
4226
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4227
+ );
4228
+ expect(getHashtreeCalls).to.have.lengthOf(1);
4229
+ });
4230
+
4231
+ it('should stop processing the sync queue when parser is stopped mid-queue', async () => {
4232
+ const parser = createHashTreeParser();
4233
+
4234
+ const mainUrl = parser.dataSets.main.url;
4235
+ const selfUrl = parser.dataSets.self.url;
4236
+
4237
+ // Mock main GET hashtree with a deferred promise so we can control when it resolves
4238
+ let resolveMainHashtree;
4239
+ webexRequest
4240
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4241
+ .callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
4242
+
4243
+ // Send a heartbeat message that triggers sync timers for both main and self
4244
+ parser.handleMessage(
4245
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4246
+ 'trigger main sync'
4247
+ );
4248
+ parser.handleMessage(
4249
+ createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
4250
+ 'trigger self sync'
4251
+ );
4252
+
4253
+ // Fire the timers - main sync starts (calls GET hashtree, which blocks)
4254
+ await clock.tickAsync(1000);
4255
+
4256
+ // Stop the parser while main sync is in progress
4257
+ parser.stop();
4258
+
4259
+ // Resolve the pending main GET request
4260
+ resolveMainHashtree({
4261
+ body: {
4262
+ hashes: new Array(16).fill(EMPTY_HASH),
4263
+ dataSet: createDataSet('main', 16, 1100),
4264
+ },
4265
+ });
4266
+
4267
+ await clock.tickAsync(0);
4268
+
4269
+ // Self sync should NOT have been triggered because parser was stopped
4270
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4271
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4272
+ });
4273
+ });
4274
+
4275
+ describe('#stop sync queue', () => {
4276
+ it('should clear the syncQueue when stopped so remaining queued items are not processed', async () => {
4277
+ const parser = createHashTreeParser();
4278
+
4279
+ const mainUrl = parser.dataSets.main.url;
4280
+ const selfUrl = parser.dataSets.self.url;
4281
+
4282
+ // Mock main GET hashtree with a deferred promise so we can control when it resolves
4283
+ let resolveMainHashtree;
4284
+ webexRequest
4285
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4286
+ .callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
4287
+
4288
+ // Enqueue syncs for both main and self by sending heartbeat messages
4289
+ parser.handleMessage(
4290
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4291
+ 'trigger main sync'
4292
+ );
4293
+ parser.handleMessage(
4294
+ createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
4295
+ 'trigger self sync'
4296
+ );
4297
+
4298
+ // Fire the timers - main sync starts and blocks on GET hashtree
4299
+ await clock.tickAsync(1000);
4300
+
4301
+ // Verify that self is still in the queue (main is being processed, self is waiting)
4302
+ // Now stop the parser - this should clear the syncQueue
4303
+ parser.stop();
4304
+
4305
+ // Resolve the pending main GET request so the in-flight sync can finish
4306
+ resolveMainHashtree({
4307
+ body: {
4308
+ hashes: new Array(16).fill(EMPTY_HASH),
4309
+ dataSet: createDataSet('main', 16, 1100),
4310
+ },
4311
+ });
4312
+
4313
+ await clock.tickAsync(0);
4314
+
4315
+ // Self should never have been synced because stop() cleared the queue
4316
+ const selfGetCalls = webexRequest.args.filter(
4317
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${selfUrl}/hashtree`
4318
+ );
4319
+ expect(selfGetCalls).to.have.lengthOf(0);
4320
+ });
4321
+ });
4322
+
4323
+ describe('#cleanUp', () => {
4324
+ it('should stop the parser, clear all timers and clear all dataSets', () => {
4325
+ const parser = createHashTreeParser();
4326
+
4327
+ // Send a message to set up sync timers via runSyncAlgorithm
4328
+ const message = {
4329
+ dataSets: [
4330
+ {
4331
+ ...createDataSet('main', 16, 1100),
4332
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
4333
+ },
4334
+ ],
4335
+ visibleDataSetsUrl,
4336
+ locusUrl,
4337
+ heartbeatIntervalMs: 5000,
4338
+ locusStateElements: [
4339
+ {
4340
+ htMeta: {
4341
+ elementId: {type: 'locus' as const, id: 0, version: 201},
4342
+ dataSetNames: ['main'],
4343
+ },
4344
+ data: {someData: 'value'},
4345
+ },
4346
+ ],
4347
+ };
4348
+
4349
+ parser.handleMessage(message, 'setup timers');
4350
+
4351
+ // Verify timers were set by handleMessage
4352
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
4353
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
4354
+
4355
+ parser.cleanUp();
4356
+
4357
+ expect(parser.state).to.equal('stopped');
4358
+ expect(parser.visibleDataSets).to.deep.equal([]);
4359
+ expect(parser.dataSets).to.deep.equal({});
4360
+ });
4361
+ });
3560
4362
  });