@webex/plugin-meetings 3.12.0-mobius-socket.2 → 3.12.0-mobius-socket.4

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 (145) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +15 -2
  3. package/dist/aiEnableRequest/index.js.map +1 -1
  4. package/dist/breakouts/breakout.js +8 -3
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +3 -2
  7. package/dist/breakouts/index.js.map +1 -1
  8. package/dist/config.js +1 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +6 -3
  11. package/dist/constants.js.map +1 -1
  12. package/dist/controls-options-manager/constants.js +11 -1
  13. package/dist/controls-options-manager/constants.js.map +1 -1
  14. package/dist/controls-options-manager/index.js +38 -24
  15. package/dist/controls-options-manager/index.js.map +1 -1
  16. package/dist/controls-options-manager/util.js +91 -0
  17. package/dist/controls-options-manager/util.js.map +1 -1
  18. package/dist/hashTree/constants.js +10 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +651 -382
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +22 -0
  23. package/dist/hashTree/utils.js.map +1 -1
  24. package/dist/index.js +7 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/interceptors/locusRetry.js +23 -8
  27. package/dist/interceptors/locusRetry.js.map +1 -1
  28. package/dist/interpretation/index.js +10 -1
  29. package/dist/interpretation/index.js.map +1 -1
  30. package/dist/interpretation/siLanguage.js +1 -1
  31. package/dist/locus-info/controlsUtils.js +4 -1
  32. package/dist/locus-info/controlsUtils.js.map +1 -1
  33. package/dist/locus-info/index.js +289 -87
  34. package/dist/locus-info/index.js.map +1 -1
  35. package/dist/locus-info/types.js +19 -0
  36. package/dist/locus-info/types.js.map +1 -1
  37. package/dist/media/properties.js +1 -0
  38. package/dist/media/properties.js.map +1 -1
  39. package/dist/meeting/in-meeting-actions.js +3 -1
  40. package/dist/meeting/in-meeting-actions.js.map +1 -1
  41. package/dist/meeting/index.js +848 -582
  42. package/dist/meeting/index.js.map +1 -1
  43. package/dist/meeting/util.js +19 -2
  44. package/dist/meeting/util.js.map +1 -1
  45. package/dist/meetings/index.js +205 -77
  46. package/dist/meetings/index.js.map +1 -1
  47. package/dist/meetings/meetings.types.js +6 -1
  48. package/dist/meetings/meetings.types.js.map +1 -1
  49. package/dist/meetings/request.js +39 -0
  50. package/dist/meetings/request.js.map +1 -1
  51. package/dist/meetings/util.js +67 -5
  52. package/dist/meetings/util.js.map +1 -1
  53. package/dist/member/index.js +10 -0
  54. package/dist/member/index.js.map +1 -1
  55. package/dist/member/types.js.map +1 -1
  56. package/dist/member/util.js +3 -0
  57. package/dist/member/util.js.map +1 -1
  58. package/dist/metrics/constants.js +4 -1
  59. package/dist/metrics/constants.js.map +1 -1
  60. package/dist/multistream/receiveSlot.js +9 -0
  61. package/dist/multistream/receiveSlot.js.map +1 -1
  62. package/dist/reactions/reactions.type.js.map +1 -1
  63. package/dist/recording-controller/index.js +1 -3
  64. package/dist/recording-controller/index.js.map +1 -1
  65. package/dist/types/config.d.ts +1 -0
  66. package/dist/types/constants.d.ts +2 -0
  67. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  68. package/dist/types/controls-options-manager/index.d.ts +10 -0
  69. package/dist/types/hashTree/constants.d.ts +1 -0
  70. package/dist/types/hashTree/hashTreeParser.d.ts +83 -16
  71. package/dist/types/hashTree/utils.d.ts +11 -0
  72. package/dist/types/index.d.ts +2 -0
  73. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  74. package/dist/types/locus-info/index.d.ts +46 -6
  75. package/dist/types/locus-info/types.d.ts +21 -1
  76. package/dist/types/media/properties.d.ts +1 -0
  77. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  78. package/dist/types/meeting/index.d.ts +65 -1
  79. package/dist/types/meeting/util.d.ts +8 -0
  80. package/dist/types/meetings/index.d.ts +20 -2
  81. package/dist/types/meetings/meetings.types.d.ts +15 -0
  82. package/dist/types/meetings/request.d.ts +14 -0
  83. package/dist/types/member/index.d.ts +1 -0
  84. package/dist/types/member/types.d.ts +1 -0
  85. package/dist/types/member/util.d.ts +1 -0
  86. package/dist/types/metrics/constants.d.ts +3 -0
  87. package/dist/types/reactions/reactions.type.d.ts +3 -0
  88. package/dist/webinar/index.js +68 -17
  89. package/dist/webinar/index.js.map +1 -1
  90. package/package.json +22 -22
  91. package/src/aiEnableRequest/index.ts +16 -0
  92. package/src/breakouts/breakout.ts +3 -1
  93. package/src/breakouts/index.ts +1 -0
  94. package/src/config.ts +1 -0
  95. package/src/constants.ts +5 -1
  96. package/src/controls-options-manager/constants.ts +14 -1
  97. package/src/controls-options-manager/index.ts +47 -24
  98. package/src/controls-options-manager/util.ts +81 -1
  99. package/src/hashTree/constants.ts +9 -0
  100. package/src/hashTree/hashTreeParser.ts +375 -197
  101. package/src/hashTree/utils.ts +17 -0
  102. package/src/index.ts +5 -0
  103. package/src/interceptors/locusRetry.ts +25 -4
  104. package/src/interpretation/index.ts +25 -8
  105. package/src/locus-info/controlsUtils.ts +3 -1
  106. package/src/locus-info/index.ts +291 -97
  107. package/src/locus-info/types.ts +25 -1
  108. package/src/media/properties.ts +1 -0
  109. package/src/meeting/in-meeting-actions.ts +4 -0
  110. package/src/meeting/index.ts +260 -23
  111. package/src/meeting/util.ts +20 -2
  112. package/src/meetings/index.ts +109 -43
  113. package/src/meetings/meetings.types.ts +19 -0
  114. package/src/meetings/request.ts +43 -0
  115. package/src/meetings/util.ts +80 -1
  116. package/src/member/index.ts +10 -0
  117. package/src/member/types.ts +1 -0
  118. package/src/member/util.ts +3 -0
  119. package/src/metrics/constants.ts +3 -0
  120. package/src/multistream/receiveSlot.ts +18 -0
  121. package/src/reactions/reactions.type.ts +3 -0
  122. package/src/recording-controller/index.ts +1 -2
  123. package/src/webinar/index.ts +88 -21
  124. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  125. package/test/unit/spec/breakouts/breakout.ts +9 -3
  126. package/test/unit/spec/breakouts/index.ts +2 -0
  127. package/test/unit/spec/controls-options-manager/index.js +140 -29
  128. package/test/unit/spec/controls-options-manager/util.js +165 -0
  129. package/test/unit/spec/hashTree/hashTreeParser.ts +1263 -157
  130. package/test/unit/spec/hashTree/utils.ts +88 -1
  131. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  132. package/test/unit/spec/interpretation/index.ts +26 -4
  133. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  134. package/test/unit/spec/locus-info/index.js +475 -81
  135. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  136. package/test/unit/spec/meeting/index.js +902 -14
  137. package/test/unit/spec/meeting/muteState.js +3 -0
  138. package/test/unit/spec/meeting/utils.js +33 -0
  139. package/test/unit/spec/meetings/index.js +309 -10
  140. package/test/unit/spec/meetings/request.js +141 -0
  141. package/test/unit/spec/meetings/utils.js +161 -0
  142. package/test/unit/spec/member/index.js +7 -0
  143. package/test/unit/spec/member/util.js +24 -0
  144. package/test/unit/spec/recording-controller/index.js +9 -8
  145. package/test/unit/spec/webinar/index.ts +81 -16
@@ -1,12 +1,16 @@
1
1
  import HashTreeParser, {
2
2
  LocusInfoUpdateType,
3
3
  MeetingEndedError,
4
+ LocusNotFoundError,
4
5
  } from '@webex/plugin-meetings/src/hashTree/hashTreeParser';
5
6
  import HashTree from '@webex/plugin-meetings/src/hashTree/hashTree';
6
7
  import {expect} from '@webex/test-helper-chai';
7
8
  import sinon from 'sinon';
8
9
  import {assert} from '@webex/test-helper-chai';
9
10
  import {EMPTY_HASH} from '@webex/plugin-meetings/src/hashTree/constants';
11
+ import { some } from 'lodash';
12
+ import Metrics from '@webex/plugin-meetings/src/metrics';
13
+ import BEHAVIORAL_METRICS from '@webex/plugin-meetings/src/metrics/constants';
10
14
 
11
15
  const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
12
16
 
@@ -151,16 +155,19 @@ describe('HashTreeParser', () => {
151
155
  let webexRequest: sinon.SinonStub;
152
156
  let callback: sinon.SinonStub;
153
157
  let mathRandomStub: sinon.SinonStub;
158
+ let metricsStub: sinon.SinonStub;
154
159
 
155
160
  beforeEach(() => {
156
161
  clock = sinon.useFakeTimers();
157
162
  webexRequest = sinon.stub();
158
163
  callback = sinon.stub();
159
164
  mathRandomStub = sinon.stub(Math, 'random').returns(0);
165
+ metricsStub = sinon.stub(Metrics, 'sendBehavioralMetric');
160
166
  });
161
167
  afterEach(() => {
162
168
  clock.restore();
163
169
  mathRandomStub.restore();
170
+ metricsStub.restore();
164
171
  });
165
172
 
166
173
  // Helper to create a HashTreeParser instance with common defaults
@@ -553,7 +560,7 @@ describe('HashTreeParser', () => {
553
560
  );
554
561
 
555
562
  // Verify callback was called with OBJECTS_UPDATED and correct updatedObjects list
556
- assert.calledWith(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
563
+ assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
557
564
  updatedObjects: [
558
565
  {
559
566
  htMeta: {
@@ -566,6 +573,11 @@ describe('HashTreeParser', () => {
566
573
  },
567
574
  data: {info: {id: 'some-fake-locus-info'}},
568
575
  },
576
+ ],
577
+ });
578
+
579
+ assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
580
+ updatedObjects: [
569
581
  {
570
582
  htMeta: {
571
583
  elementId: {
@@ -596,6 +608,67 @@ describe('HashTreeParser', () => {
596
608
  });
597
609
  });
598
610
 
611
+ it('initializes "main" before "self" regardless of order from Locus', async () => {
612
+ const parser = createHashTreeParser({dataSets: [], locus: null}, null);
613
+
614
+ // Locus returns datasets in non-priority order: atd-active, main, self
615
+ const atdActiveDataSet = createDataSet('atd-active', 4, 500);
616
+ const mainDataSet = createDataSet('main', 16, 1100);
617
+ const selfDataSet = createDataSet('self', 1, 2100);
618
+
619
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
620
+ atdActiveDataSet,
621
+ mainDataSet,
622
+ selfDataSet,
623
+ ]);
624
+
625
+ mockSyncRequest(webexRequest, selfDataSet.url);
626
+ mockSyncRequest(webexRequest, mainDataSet.url);
627
+ mockSyncRequest(webexRequest, atdActiveDataSet.url);
628
+
629
+ await parser.initializeFromMessage({
630
+ dataSets: [],
631
+ visibleDataSetsUrl,
632
+ locusUrl,
633
+ });
634
+
635
+ // Verify sync requests were sent in priority order: main, self, then atd-active
636
+ const syncCalls = webexRequest
637
+ .getCalls()
638
+ .filter((call) => call.args[0]?.method === 'POST' && call.args[0]?.uri?.endsWith('/sync'));
639
+
640
+ expect(syncCalls).to.have.lengthOf(3);
641
+ expect(syncCalls[0].args[0].uri).to.equal(`${mainDataSet.url}/sync`);
642
+ expect(syncCalls[1].args[0].uri).to.equal(`${selfDataSet.url}/sync`);
643
+ expect(syncCalls[2].args[0].uri).to.equal(`${atdActiveDataSet.url}/sync`);
644
+ });
645
+
646
+ it('sends leafCount=1 with a single empty leaf for initialization sync, regardless of actual dataset leafCount', async () => {
647
+ const parser = createHashTreeParser({dataSets: [], locus: null}, null);
648
+
649
+ // Use a dataset with leafCount=16 to verify the initialization sync always uses leafCount=1
650
+ const mainDataSet = createDataSet('main', 16, 1100);
651
+
652
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet]);
653
+ mockSyncRequest(webexRequest, mainDataSet.url);
654
+
655
+ await parser.initializeFromMessage({
656
+ dataSets: [],
657
+ visibleDataSetsUrl,
658
+ locusUrl,
659
+ });
660
+
661
+ assert.calledWith(webexRequest, {
662
+ method: 'POST',
663
+ uri: `${mainDataSet.url}/sync`,
664
+ qs: {rootHash: sinon.match.string},
665
+ body: {
666
+ leafCount: 1,
667
+ leafDataEntries: [{leafIndex: 0, elementIds: []}],
668
+ },
669
+ });
670
+ });
671
+
599
672
  it('handles sync response that has locusStateElements undefined', async () => {
600
673
  const minimalInitialLocus = {
601
674
  dataSets: [],
@@ -636,8 +709,11 @@ describe('HashTreeParser', () => {
636
709
  assert.notCalled(callback);
637
710
  });
638
711
 
639
- [404, 409].forEach((errorCode) => {
640
- it(`emits MeetingEndedError if getting visible datasets returns ${errorCode}`, async () => {
712
+ [
713
+ {errorCode: 404, expectedError: LocusNotFoundError},
714
+ {errorCode: 409, expectedError: MeetingEndedError},
715
+ ].forEach(({errorCode, expectedError}) => {
716
+ it(`throws ${expectedError.name} if getting visible datasets returns ${errorCode}`, async () => {
641
717
  const minimalInitialLocus = {
642
718
  dataSets: [],
643
719
  locus: null,
@@ -660,7 +736,6 @@ describe('HashTreeParser', () => {
660
736
  )
661
737
  .rejects(error);
662
738
 
663
- // initializeFromMessage should throw MeetingEndedError
664
739
  let thrownError;
665
740
  try {
666
741
  await parser.initializeFromMessage({
@@ -672,7 +747,7 @@ describe('HashTreeParser', () => {
672
747
  thrownError = e;
673
748
  }
674
749
 
675
- expect(thrownError).to.be.instanceOf(MeetingEndedError);
750
+ expect(thrownError).to.be.instanceOf(expectedError);
676
751
  });
677
752
  });
678
753
  });
@@ -788,7 +863,7 @@ describe('HashTreeParser', () => {
788
863
  expect(parser.dataSets.self.version).to.equal(2100);
789
864
  expect(parser.dataSets['atd-unmuted'].version).to.equal(3100);
790
865
 
791
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
866
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
792
867
  updatedObjects: [
793
868
  {
794
869
  htMeta: {
@@ -919,7 +994,7 @@ describe('HashTreeParser', () => {
919
994
  {type: 'ControlEntry', id: 10101, version: 100}
920
995
  ]);
921
996
 
922
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
997
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
923
998
  updatedObjects: [
924
999
  {
925
1000
  htMeta: {
@@ -1009,7 +1084,7 @@ describe('HashTreeParser', () => {
1009
1084
  assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 201}]);
1010
1085
 
1011
1086
  // Verify callback was called only for known dataset
1012
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1087
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1013
1088
  updatedObjects: [
1014
1089
  {
1015
1090
  htMeta: {
@@ -1109,7 +1184,7 @@ describe('HashTreeParser', () => {
1109
1184
  assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 51});
1110
1185
 
1111
1186
  // Verify callback was called with metadata object and removed dataset objects
1112
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1187
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1113
1188
  updatedObjects: [
1114
1189
  // updated metadata object:
1115
1190
  {
@@ -1270,7 +1345,7 @@ describe('HashTreeParser', () => {
1270
1345
  assert.notCalled(atdUnmutedPutItemsSpy);
1271
1346
 
1272
1347
  // Verify callback was called with the updated object
1273
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1348
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1274
1349
  updatedObjects: [
1275
1350
  {
1276
1351
  htMeta: {
@@ -1498,7 +1573,7 @@ describe('HashTreeParser', () => {
1498
1573
  ]);
1499
1574
 
1500
1575
  // Verify callback was called with OBJECTS_UPDATED and all updated objects
1501
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1576
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1502
1577
  updatedObjects: [
1503
1578
  {
1504
1579
  htMeta: {
@@ -1563,9 +1638,7 @@ describe('HashTreeParser', () => {
1563
1638
  parser.handleMessage(sentinelMessage, 'sentinel message');
1564
1639
 
1565
1640
  // Verify callback was called with MEETING_ENDED
1566
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1567
- updatedObjects: undefined,
1568
- });
1641
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1569
1642
 
1570
1643
  // Verify that all timers were stopped
1571
1644
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1587,9 +1660,7 @@ describe('HashTreeParser', () => {
1587
1660
  parser.handleMessage(sentinelMessage, 'sentinel message');
1588
1661
 
1589
1662
  // Verify callback was called with MEETING_ENDED
1590
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1591
- updatedObjects: undefined,
1592
- });
1663
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1593
1664
 
1594
1665
  // Verify that all timers were stopped
1595
1666
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1685,7 +1756,7 @@ describe('HashTreeParser', () => {
1685
1756
  );
1686
1757
 
1687
1758
  // Verify that callback was called with synced objects
1688
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1759
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1689
1760
  updatedObjects: [
1690
1761
  {
1691
1762
  htMeta: {
@@ -1698,12 +1769,10 @@ describe('HashTreeParser', () => {
1698
1769
  });
1699
1770
  });
1700
1771
 
1701
- describe('emits MEETING_ENDED', () => {
1702
- [404, 409].forEach((statusCode) => {
1703
- it(`when /hashtree returns ${statusCode}`, async () => {
1772
+ describe('emits MEETING_ENDED when 409/2403004 is returned', () => {
1773
+ it('when /hashtree returns 409', async () => {
1704
1774
  const parser = createHashTreeParser();
1705
1775
 
1706
- // Send a message to trigger sync algorithm
1707
1776
  const message = {
1708
1777
  dataSets: [createDataSet('main', 16, 1100)],
1709
1778
  visibleDataSetsUrl,
@@ -1728,12 +1797,9 @@ describe('HashTreeParser', () => {
1728
1797
 
1729
1798
  const mainDataSetUrl = parser.dataSets.main.url;
1730
1799
 
1731
- // Mock getHashesFromLocus to reject with the sentinel error
1732
- const error: any = new Error(`Request failed with status ${statusCode}`);
1733
- error.statusCode = statusCode;
1734
- if (statusCode === 409) {
1735
- error.body = {errorCode: 2403004};
1736
- }
1800
+ const error: any = new Error('Request failed with status 409');
1801
+ error.statusCode = 409;
1802
+ error.body = {errorCode: 2403004};
1737
1803
  webexRequest
1738
1804
  .withArgs(
1739
1805
  sinon.match({
@@ -1743,13 +1809,120 @@ describe('HashTreeParser', () => {
1743
1809
  )
1744
1810
  .rejects(error);
1745
1811
 
1746
- // Trigger sync by advancing time
1747
1812
  await clock.tickAsync(1000);
1748
1813
 
1749
- // Verify callback was called with MEETING_ENDED
1750
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1751
- updatedObjects: undefined,
1814
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1815
+
1816
+ Object.values(parser.dataSets).forEach((ds: any) => {
1817
+ assert.isUndefined(ds.timer);
1818
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1819
+ });
1820
+
1821
+ // Verify no sync failure metric was sent for end-meeting sentinel
1822
+ assert.notCalled(metricsStub);
1823
+ });
1824
+
1825
+ it('when /sync returns 409', async () => {
1826
+ const parser = createHashTreeParser();
1827
+
1828
+ const message = {
1829
+ dataSets: [createDataSet('main', 16, 1100)],
1830
+ visibleDataSetsUrl,
1831
+ locusUrl,
1832
+ locusStateElements: [
1833
+ {
1834
+ htMeta: {
1835
+ elementId: {
1836
+ type: 'locus' as const,
1837
+ id: 0,
1838
+ version: 201,
1839
+ },
1840
+ dataSetNames: ['main'],
1841
+ },
1842
+ data: {info: {id: 'initial-update'}},
1843
+ },
1844
+ ],
1845
+ };
1846
+
1847
+ parser.handleMessage(message, 'initial message');
1848
+ callback.resetHistory();
1849
+
1850
+ const mainDataSetUrl = parser.dataSets.main.url;
1851
+
1852
+ mockGetHashesFromLocusResponse(
1853
+ mainDataSetUrl,
1854
+ new Array(16).fill('00000000000000000000000000000000'),
1855
+ createDataSet('main', 16, 1101)
1856
+ );
1857
+
1858
+ const error: any = new Error('Request failed with status 409');
1859
+ error.statusCode = 409;
1860
+ error.body = {errorCode: 2403004};
1861
+ webexRequest
1862
+ .withArgs(
1863
+ sinon.match({
1864
+ method: 'POST',
1865
+ uri: `${mainDataSetUrl}/sync`,
1866
+ })
1867
+ )
1868
+ .rejects(error);
1869
+
1870
+ await clock.tickAsync(1000);
1871
+
1872
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1873
+
1874
+ Object.values(parser.dataSets).forEach((ds: any) => {
1875
+ assert.isUndefined(ds.timer);
1876
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1752
1877
  });
1878
+ });
1879
+ });
1880
+
1881
+ describe('emits LOCUS_NOT_FOUND and stops parser when 404 is returned', () => {
1882
+ it('when /hashtree returns 404', async () => {
1883
+ const parser = createHashTreeParser();
1884
+
1885
+ const message = {
1886
+ dataSets: [createDataSet('main', 16, 1100)],
1887
+ visibleDataSetsUrl,
1888
+ locusUrl,
1889
+ locusStateElements: [
1890
+ {
1891
+ htMeta: {
1892
+ elementId: {
1893
+ type: 'locus' as const,
1894
+ id: 0,
1895
+ version: 201,
1896
+ },
1897
+ dataSetNames: ['main'],
1898
+ },
1899
+ data: {info: {id: 'initial-update'}},
1900
+ },
1901
+ ],
1902
+ };
1903
+
1904
+ parser.handleMessage(message, 'initial message');
1905
+ callback.resetHistory();
1906
+
1907
+ const mainDataSetUrl = parser.dataSets.main.url;
1908
+
1909
+ const error: any = new Error('Request failed with status 404');
1910
+ error.statusCode = 404;
1911
+ webexRequest
1912
+ .withArgs(
1913
+ sinon.match({
1914
+ method: 'GET',
1915
+ uri: `${mainDataSetUrl}/hashtree`,
1916
+ })
1917
+ )
1918
+ .rejects(error);
1919
+
1920
+ await clock.tickAsync(1000);
1921
+
1922
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
1923
+
1924
+ // Verify parser is stopped
1925
+ expect(parser.state).to.equal('stopped');
1753
1926
 
1754
1927
  // Verify all timers are stopped
1755
1928
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1758,10 +1931,9 @@ describe('HashTreeParser', () => {
1758
1931
  });
1759
1932
  });
1760
1933
 
1761
- it(`when /sync returns ${statusCode}`, async () => {
1934
+ it('when /sync returns 404', async () => {
1762
1935
  const parser = createHashTreeParser();
1763
1936
 
1764
- // Send a message to trigger sync algorithm
1765
1937
  const message = {
1766
1938
  dataSets: [createDataSet('main', 16, 1100)],
1767
1939
  visibleDataSetsUrl,
@@ -1786,19 +1958,14 @@ describe('HashTreeParser', () => {
1786
1958
 
1787
1959
  const mainDataSetUrl = parser.dataSets.main.url;
1788
1960
 
1789
- // Mock getHashesFromLocus to succeed
1790
1961
  mockGetHashesFromLocusResponse(
1791
1962
  mainDataSetUrl,
1792
1963
  new Array(16).fill('00000000000000000000000000000000'),
1793
1964
  createDataSet('main', 16, 1101)
1794
1965
  );
1795
1966
 
1796
- // Mock sendSyncRequestToLocus to reject with the sentinel error
1797
- const error: any = new Error(`Request failed with status ${statusCode}`);
1798
- error.statusCode = statusCode;
1799
- if (statusCode === 409) {
1800
- error.body = {errorCode: 2403004};
1801
- }
1967
+ const error: any = new Error('Request failed with status 404');
1968
+ error.statusCode = 404;
1802
1969
  webexRequest
1803
1970
  .withArgs(
1804
1971
  sinon.match({
@@ -1808,21 +1975,22 @@ describe('HashTreeParser', () => {
1808
1975
  )
1809
1976
  .rejects(error);
1810
1977
 
1811
- // Trigger sync by advancing time
1812
1978
  await clock.tickAsync(1000);
1813
1979
 
1814
- // Verify callback was called with MEETING_ENDED
1815
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1816
- updatedObjects: undefined,
1817
- });
1980
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
1981
+
1982
+ // Verify parser is stopped
1983
+ expect(parser.state).to.equal('stopped');
1818
1984
 
1819
1985
  // Verify all timers are stopped
1820
1986
  Object.values(parser.dataSets).forEach((ds: any) => {
1821
1987
  assert.isUndefined(ds.timer);
1822
1988
  assert.isUndefined(ds.heartbeatWatchdogTimer);
1823
1989
  });
1990
+
1991
+ // Verify no sync failure metric was sent for end-meeting sentinel
1992
+ assert.notCalled(metricsStub);
1824
1993
  });
1825
- });
1826
1994
  });
1827
1995
 
1828
1996
  it('requests only mismatched hashes during sync', async () => {
@@ -1993,79 +2161,299 @@ describe('HashTreeParser', () => {
1993
2161
  },
1994
2162
  });
1995
2163
  });
1996
- });
1997
2164
 
1998
- describe('handles visible data sets changes correctly', () => {
1999
- it('handles addition of visible data set (one that does not require async initialization)', async () => {
2000
- // Create a parser with visible datasets
2165
+ it('restarts the sync timer when sync response is empty so that a future sync can be triggered', async () => {
2001
2166
  const parser = createHashTreeParser();
2002
2167
 
2003
- // Stub updateItems on self hash tree to return true
2004
- sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2005
-
2006
- // Send a message with Metadata object that has a new visibleDataSets list
2007
- const message = {
2008
- dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
2168
+ // Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
2169
+ const heartbeatMessage = {
2170
+ dataSets: [
2171
+ {
2172
+ ...createDataSet('main', 16, 1100),
2173
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from ours
2174
+ },
2175
+ ],
2009
2176
  visibleDataSetsUrl,
2010
2177
  locusUrl,
2011
- locusStateElements: [
2178
+ };
2179
+
2180
+ parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
2181
+
2182
+ // The sync timer should be set
2183
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
2184
+
2185
+ // Mock responses for the first sync - return null (204/empty body)
2186
+ const mainDataSetUrl = parser.dataSets.main.url;
2187
+ mockGetHashesFromLocusResponse(
2188
+ mainDataSetUrl,
2189
+ new Array(16).fill('00000000000000000000000000000000'),
2190
+ {
2191
+ ...createDataSet('main', 16, 1101),
2192
+ root: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', // still mismatched
2193
+ }
2194
+ );
2195
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
2196
+
2197
+ // Advance time to fire the sync timer (idleMs=1000 + backoff=0)
2198
+ await clock.tickAsync(1000);
2199
+
2200
+ // Verify sync was triggered
2201
+ assert.calledWith(
2202
+ webexRequest,
2203
+ sinon.match({
2204
+ method: 'POST',
2205
+ uri: `${mainDataSetUrl}/sync`,
2206
+ })
2207
+ );
2208
+
2209
+ // After empty response, runSyncAlgorithm should have been called,
2210
+ // setting a new sync timer as a safety net
2211
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
2212
+
2213
+ // Reset and set up mocks for the second sync
2214
+ webexRequest.resetHistory();
2215
+ mockGetHashesFromLocusResponse(
2216
+ mainDataSetUrl,
2217
+ new Array(16).fill('00000000000000000000000000000000'),
2218
+ {
2219
+ ...createDataSet('main', 16, 1102),
2220
+ root: 'cccccccccccccccccccccccccccccccc', // still mismatched
2221
+ }
2222
+ );
2223
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
2224
+
2225
+ // Advance time again to fire the second sync timer
2226
+ await clock.tickAsync(1000);
2227
+
2228
+ // Verify a second sync was triggered
2229
+ assert.calledWith(
2230
+ webexRequest,
2231
+ sinon.match({
2232
+ method: 'POST',
2233
+ uri: `${mainDataSetUrl}/sync`,
2234
+ })
2235
+ );
2236
+ });
2237
+
2238
+ it('updates dataSet.leafCount when hash tree is resized during sync so that the sync request has the correct leafCount', async () => {
2239
+ const parser = createHashTreeParser();
2240
+
2241
+ // Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
2242
+ const heartbeatMessage = {
2243
+ dataSets: [
2012
2244
  {
2013
- htMeta: {
2014
- elementId: {
2015
- type: 'metadata' as const,
2016
- id: 5,
2017
- version: 51,
2018
- },
2019
- dataSetNames: ['self'],
2020
- },
2021
- data: {
2022
- visibleDataSets: [
2023
- {
2024
- name: 'main',
2025
- url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2026
- },
2027
- {
2028
- name: 'self',
2029
- url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2030
- },
2031
- {
2032
- name: 'atd-unmuted',
2033
- url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2034
- },
2035
- {
2036
- name: 'attendees',
2037
- url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
2038
- },
2039
- ], // added 'attendees'
2040
- },
2245
+ ...createDataSet('main', 16, 1100),
2246
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from ours
2041
2247
  },
2042
2248
  ],
2249
+ visibleDataSetsUrl,
2250
+ locusUrl,
2043
2251
  };
2044
2252
 
2045
- parser.handleMessage(message, 'add visible dataset');
2253
+ parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
2046
2254
 
2047
- // Verify that 'attendees' was added to visibleDataSets
2048
- expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.true;
2255
+ // The sync timer should be set
2256
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
2049
2257
 
2050
- // Verify that a hash tree was created for 'attendees'
2051
- assert.exists(parser.dataSets.attendees.hashTree);
2052
- assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
2258
+ const mainDataSetUrl = parser.dataSets.main.url;
2259
+ const newLeafCount = 32;
2053
2260
 
2054
- // Verify callback was called with the metadata update (appears twice - processed once for visible dataset changes, once in main loop)
2055
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
2056
- updatedObjects: [
2057
- {
2058
- htMeta: {
2059
- elementId: {type: 'metadata', id: 5, version: 51},
2060
- dataSetNames: ['self'],
2061
- },
2062
- data: {
2063
- visibleDataSets: [
2064
- {
2065
- name: 'main',
2066
- url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2067
- },
2068
- {
2261
+ // Mock getHashesFromLocus response with a DIFFERENT leafCount (32 instead of 16)
2262
+ mockGetHashesFromLocusResponse(
2263
+ mainDataSetUrl,
2264
+ new Array(newLeafCount).fill('00000000000000000000000000000000'),
2265
+ createDataSet('main', newLeafCount, 1101)
2266
+ );
2267
+
2268
+ // Mock the sync request - use matching root hash
2269
+ const syncResponseDataSet = createDataSet('main', newLeafCount, 1102);
2270
+ syncResponseDataSet.root = parser.dataSets.main.hashTree.getRootHash();
2271
+ mockSendSyncRequestResponse(mainDataSetUrl, {
2272
+ dataSets: [syncResponseDataSet],
2273
+ visibleDataSetsUrl,
2274
+ locusUrl,
2275
+ locusStateElements: [],
2276
+ });
2277
+
2278
+ // Advance time to fire the sync timer (idleMs=1000 + backoff=0)
2279
+ await clock.tickAsync(1000);
2280
+
2281
+ // Verify the sync request was sent with the NEW leafCount (32), not the old one (16)
2282
+ assert.calledWith(
2283
+ webexRequest,
2284
+ sinon.match({
2285
+ method: 'POST',
2286
+ uri: `${mainDataSetUrl}/sync`,
2287
+ body: sinon.match({
2288
+ leafCount: newLeafCount,
2289
+ }),
2290
+ })
2291
+ );
2292
+ });
2293
+
2294
+ it('sends HASH_TREE_SYNC_FAILURE metric when GET /hashtree request fails', async () => {
2295
+ const parser = createHashTreeParser();
2296
+
2297
+ // Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
2298
+ const heartbeatMessage = {
2299
+ dataSets: [
2300
+ {
2301
+ ...createDataSet('main', 16, 1100),
2302
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
2303
+ },
2304
+ ],
2305
+ visibleDataSetsUrl,
2306
+ locusUrl,
2307
+ };
2308
+
2309
+ parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
2310
+
2311
+ const mainDataSetUrl = parser.dataSets.main.url;
2312
+ const hashTreeError = new Error('server error') as any;
2313
+ hashTreeError.statusCode = 500;
2314
+
2315
+ webexRequest
2316
+ .withArgs(
2317
+ sinon.match({
2318
+ method: 'GET',
2319
+ uri: `${mainDataSetUrl}/hashtree`,
2320
+ })
2321
+ )
2322
+ .rejects(hashTreeError);
2323
+
2324
+ await clock.tickAsync(1000);
2325
+
2326
+ assert.calledOnceWithExactly(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
2327
+ debugId: 'test',
2328
+ dataSetName: 'main',
2329
+ request: 'GET /hashtree',
2330
+ statusCode: 500,
2331
+ reason: 'server error',
2332
+ });
2333
+ });
2334
+
2335
+ it('sends HASH_TREE_SYNC_FAILURE metric when POST /sync request fails', async () => {
2336
+ const parser = createHashTreeParser();
2337
+
2338
+ // Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
2339
+ const heartbeatMessage = {
2340
+ dataSets: [
2341
+ {
2342
+ ...createDataSet('main', 16, 1100),
2343
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
2344
+ },
2345
+ ],
2346
+ visibleDataSetsUrl,
2347
+ locusUrl,
2348
+ };
2349
+
2350
+ parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
2351
+
2352
+ const mainDataSetUrl = parser.dataSets.main.url;
2353
+
2354
+ // Mock getHashesFromLocus to succeed
2355
+ mockGetHashesFromLocusResponse(
2356
+ mainDataSetUrl,
2357
+ new Array(16).fill('00000000000000000000000000000000'),
2358
+ createDataSet('main', 16, 1101)
2359
+ );
2360
+
2361
+ // Mock sendSyncRequestToLocus to fail
2362
+ const syncError = new Error('sync failed') as any;
2363
+ syncError.statusCode = 500;
2364
+
2365
+ webexRequest
2366
+ .withArgs(
2367
+ sinon.match({
2368
+ method: 'POST',
2369
+ uri: `${mainDataSetUrl}/sync`,
2370
+ })
2371
+ )
2372
+ .rejects(syncError);
2373
+
2374
+ await clock.tickAsync(1000);
2375
+
2376
+ assert.calledOnceWithExactly(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
2377
+ debugId: 'test',
2378
+ dataSetName: 'main',
2379
+ request: 'POST /sync',
2380
+ statusCode: 500,
2381
+ reason: 'sync failed',
2382
+ });
2383
+ });
2384
+ });
2385
+
2386
+ describe('handles visible data sets changes correctly', () => {
2387
+ it('handles addition of visible data set (one that does not require async initialization)', async () => {
2388
+ // Create a parser with visible datasets
2389
+ const parser = createHashTreeParser();
2390
+
2391
+ // Stub updateItems on self hash tree to return true
2392
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2393
+
2394
+ // Send a message with Metadata object that has a new visibleDataSets list
2395
+ const message = {
2396
+ dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
2397
+ visibleDataSetsUrl,
2398
+ locusUrl,
2399
+ locusStateElements: [
2400
+ {
2401
+ htMeta: {
2402
+ elementId: {
2403
+ type: 'metadata' as const,
2404
+ id: 5,
2405
+ version: 51,
2406
+ },
2407
+ dataSetNames: ['self'],
2408
+ },
2409
+ data: {
2410
+ visibleDataSets: [
2411
+ {
2412
+ name: 'main',
2413
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2414
+ },
2415
+ {
2416
+ name: 'self',
2417
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2418
+ },
2419
+ {
2420
+ name: 'atd-unmuted',
2421
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2422
+ },
2423
+ {
2424
+ name: 'attendees',
2425
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
2426
+ },
2427
+ ], // added 'attendees'
2428
+ },
2429
+ },
2430
+ ],
2431
+ };
2432
+
2433
+ parser.handleMessage(message, 'add visible dataset');
2434
+
2435
+ // Verify that 'attendees' was added to visibleDataSets
2436
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.true;
2437
+
2438
+ // Verify that a hash tree was created for 'attendees'
2439
+ assert.exists(parser.dataSets.attendees.hashTree);
2440
+ assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
2441
+
2442
+ // Verify callback was called with the metadata update (appears twice - processed once for visible dataset changes, once in main loop)
2443
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2444
+ updatedObjects: [
2445
+ {
2446
+ htMeta: {
2447
+ elementId: {type: 'metadata', id: 5, version: 51},
2448
+ dataSetNames: ['self'],
2449
+ },
2450
+ data: {
2451
+ visibleDataSets: [
2452
+ {
2453
+ name: 'main',
2454
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2455
+ },
2456
+ {
2069
2457
  name: 'self',
2070
2458
  url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2071
2459
  },
@@ -2172,7 +2560,99 @@ describe('HashTreeParser', () => {
2172
2560
  await checkAsyncDatasetInitialization(parser, newDataSet);
2173
2561
  });
2174
2562
 
2175
- it('emits MEETING_ENDED if async init of a new visible dataset fails with 404', async () => {
2563
+ it('initializes new visible data sets in priority order', async () => {
2564
+ // Create a parser that only has "self" as visible (no "main")
2565
+ const initialLocusWithoutMain = {
2566
+ dataSets: [createDataSet('self', 1, 2000)],
2567
+ locus: {
2568
+ ...exampleInitialLocus.locus,
2569
+ },
2570
+ };
2571
+ const metadataWithoutMain = {
2572
+ ...exampleMetadata,
2573
+ visibleDataSets: [
2574
+ {
2575
+ name: 'self',
2576
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2577
+ },
2578
+ ],
2579
+ };
2580
+ const parser = createHashTreeParser(initialLocusWithoutMain, metadataWithoutMain);
2581
+
2582
+ // Verify "main" is not visible initially
2583
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.false;
2584
+
2585
+ // Stub updateItems on self hash tree to return true
2586
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2587
+
2588
+ // Send a message that adds "main" and "atd-active" as new visible datasets.
2589
+ // Neither has info in dataSets, so both require async initialization.
2590
+ const newMainDataSet = createDataSet('main', 16, 6000);
2591
+ const newAtdActiveDataSet = createDataSet('atd-active', 4, 7000);
2592
+
2593
+ const message = {
2594
+ dataSets: [createDataSet('self', 1, 2100)],
2595
+ visibleDataSetsUrl,
2596
+ locusUrl,
2597
+ locusStateElements: [
2598
+ {
2599
+ htMeta: {
2600
+ elementId: {
2601
+ type: 'metadata' as const,
2602
+ id: 5,
2603
+ version: 51,
2604
+ },
2605
+ dataSetNames: ['self'],
2606
+ },
2607
+ data: {
2608
+ visibleDataSets: [
2609
+ {
2610
+ name: 'self',
2611
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2612
+ },
2613
+ // listed in non-priority order: atd-active before main
2614
+ {name: 'atd-active', url: newAtdActiveDataSet.url},
2615
+ {name: 'main', url: newMainDataSet.url},
2616
+ ],
2617
+ },
2618
+ },
2619
+ ],
2620
+ };
2621
+
2622
+ // Mock getAllVisibleDataSetsFromLocus to return both new datasets (in non-priority order)
2623
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
2624
+ newAtdActiveDataSet,
2625
+ newMainDataSet,
2626
+ ]);
2627
+ mockSyncRequest(webexRequest, newMainDataSet.url);
2628
+ mockSyncRequest(webexRequest, newAtdActiveDataSet.url);
2629
+
2630
+ parser.handleMessage(message, 'add main and atd-active datasets');
2631
+
2632
+ // Wait for the async initialization (queueMicrotask) to complete
2633
+ await clock.tickAsync(0);
2634
+
2635
+ // Verify both datasets are initialized
2636
+ expect(parser.dataSets.main?.hashTree).to.exist;
2637
+ expect(parser.dataSets['atd-active']?.hashTree).to.exist;
2638
+
2639
+ // Verify sync requests were sent in priority order: "main" before "atd-active",
2640
+ // even though atd-active was listed first in both the message and the Locus response
2641
+ const syncCalls = webexRequest
2642
+ .getCalls()
2643
+ .filter(
2644
+ (call) =>
2645
+ call.args[0]?.method === 'POST' &&
2646
+ call.args[0]?.uri?.endsWith('/sync') &&
2647
+ (call.args[0]?.uri?.includes('/main/') || call.args[0]?.uri?.includes('/atd-active/'))
2648
+ );
2649
+
2650
+ expect(syncCalls).to.have.lengthOf(2);
2651
+ expect(syncCalls[0].args[0].uri).to.equal(`${newMainDataSet.url}/sync`);
2652
+ expect(syncCalls[1].args[0].uri).to.equal(`${newAtdActiveDataSet.url}/sync`);
2653
+ });
2654
+
2655
+ it('emits LOCUS_NOT_FOUND if async init of a new visible dataset fails with 404', async () => {
2176
2656
  const parser = createHashTreeParser();
2177
2657
 
2178
2658
  // Stub updateItems on self hash tree to return true
@@ -2237,10 +2717,8 @@ describe('HashTreeParser', () => {
2237
2717
  // Wait for the async initialization (queueMicrotask) to complete
2238
2718
  await clock.tickAsync(0);
2239
2719
 
2240
- // Verify callback was called with MEETING_ENDED
2241
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
2242
- updatedObjects: undefined,
2243
- });
2720
+ // Verify callback was called with LOCUS_NOT_FOUND (404 means locus URL is stale, not necessarily meeting ended)
2721
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
2244
2722
  });
2245
2723
 
2246
2724
  it('handles removal of visible data set', async () => {
@@ -2303,7 +2781,7 @@ describe('HashTreeParser', () => {
2303
2781
  assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
2304
2782
 
2305
2783
  // Verify callback was called with the metadata update and the removed objects (metadata appears twice - processed once for dataset changes, once in main loop)
2306
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
2784
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2307
2785
  updatedObjects: [
2308
2786
  {
2309
2787
  htMeta: {
@@ -2400,6 +2878,151 @@ describe('HashTreeParser', () => {
2400
2878
  // Verify callback was NOT called (no updates for non-visible datasets)
2401
2879
  assert.notCalled(callback);
2402
2880
  });
2881
+
2882
+ it('reports update for object that moves from removed visible dataset to new visible dataset even if version is unchanged', async () => {
2883
+ // The purpose of this test is to verify that when an object
2884
+ // moves from one visible dataset to another without version change,
2885
+ // the parser still reports it as an update.
2886
+ // Locus has some additional signalling for this - the "view" property in htMeta.elementId.
2887
+ // When a view changes, the contents of the object may change even if version doesn't.
2888
+ // HashTreeParser doesn't use the "view" property, because it doesn't need to -
2889
+ // the same functionality is achieved thanks to the fact that a new visible data set means
2890
+ // a new hash tree is created, so HashTreeParser still detects the change as new
2891
+ // object is added to the new hash tree.
2892
+
2893
+ // Setup: parser with visible datasets "self" and "unjoined"
2894
+ const unjoinedDataSet = createDataSet('unjoined', 4, 3000);
2895
+ const selfDataSet = createDataSet('self', 1, 2000);
2896
+
2897
+ // start with Locus that has "info" in both "unjoined" and "main" datasets,
2898
+ // but only "unjoined" is visible.
2899
+ const initialLocus = {
2900
+ dataSets: [selfDataSet, unjoinedDataSet],
2901
+ locus: {
2902
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
2903
+ links: {resources: {visibleDataSets: {url: visibleDataSetsUrl}}},
2904
+ // info object in "unjoined" dataset with version 500
2905
+ info: {
2906
+ htMeta: {
2907
+ elementId: {
2908
+ type: 'info',
2909
+ id: 42,
2910
+ version: 500,
2911
+ view: ['unjoined'], // not used by our code, but here for completeness - that's what real Locus would send
2912
+ },
2913
+ dataSetNames: ['main', 'unjoined'],
2914
+ },
2915
+ someField: 'some-initial-value',
2916
+ },
2917
+ self: {
2918
+ htMeta: {
2919
+ elementId: {
2920
+ type: 'self',
2921
+ id: 4,
2922
+ version: 100,
2923
+ },
2924
+ dataSetNames: ['self'],
2925
+ },
2926
+ },
2927
+ },
2928
+ };
2929
+
2930
+ const metadata = {
2931
+ htMeta: {
2932
+ elementId: {
2933
+ type: 'metadata',
2934
+ id: 5,
2935
+ version: 50,
2936
+ },
2937
+ dataSetNames: ['self'],
2938
+ },
2939
+ visibleDataSets: [
2940
+ {name: 'self', url: selfDataSet.url},
2941
+ {name: 'unjoined', url: unjoinedDataSet.url},
2942
+ ],
2943
+ };
2944
+
2945
+ const parser = createHashTreeParser(initialLocus, metadata);
2946
+
2947
+ // Verify initial state: unjoined is visible and has the info object
2948
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.true;
2949
+ assert.exists(parser.dataSets.unjoined.hashTree);
2950
+ assert.equal(parser.dataSets.unjoined.hashTree?.getItemVersion(42, 'info'), 500);
2951
+
2952
+ // Stub updateItems on self hash tree to return true for metadata update
2953
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2954
+
2955
+ // Now send a message that:
2956
+ // 1. Changes visible datasets: removes "unjoined", adds "main"
2957
+ // 2. Contains the same info object (same id=42, same version=500) but we see the view from "main" dataset
2958
+ const mainDataSet = createDataSet('main', 16, 1000);
2959
+
2960
+ const message = {
2961
+ dataSets: [selfDataSet, mainDataSet],
2962
+ visibleDataSetsUrl,
2963
+ locusUrl,
2964
+ locusStateElements: [
2965
+ {
2966
+ htMeta: {
2967
+ elementId: {
2968
+ type: 'metadata' as const,
2969
+ id: 5,
2970
+ version: 51,
2971
+ },
2972
+ dataSetNames: ['self'],
2973
+ },
2974
+ data: {
2975
+ visibleDataSets: [
2976
+ {name: 'self', url: selfDataSet.url},
2977
+ {name: 'main', url: mainDataSet.url},
2978
+ // "unjoined" is no longer here
2979
+ ],
2980
+ },
2981
+ },
2982
+ {
2983
+ htMeta: {
2984
+ elementId: {
2985
+ type: 'info' as const,
2986
+ id: 42,
2987
+ version: 500, // same version as before
2988
+ view: ['main'], // now points to "main" instead of "unjoined"
2989
+ },
2990
+ dataSetNames: ['main', 'unjoined'], // still in both datasets, but only "main" is visible now
2991
+ },
2992
+ data: {someNewField: 'some-value'},
2993
+ },
2994
+ ],
2995
+ };
2996
+
2997
+ parser.handleMessage(message, 'visible dataset swap with same-version object');
2998
+
2999
+ // Verify "unjoined" is no longer visible and "main" is now visible
3000
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.false;
3001
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
3002
+
3003
+ // Verify the info object is now in the "main" hash tree
3004
+ assert.exists(parser.dataSets.main.hashTree);
3005
+ assert.equal(parser.dataSets.main.hashTree?.getItemVersion(42, 'info'), 500);
3006
+
3007
+ // The key assertion: callback should be called with the info object update even though
3008
+ // its version hasn't changed - because visible datasets changed (moved from unjoined to main)
3009
+ assert.calledOnce(callback);
3010
+ const callbackArgs = callback.firstCall.args[0];
3011
+ assert.equal(callbackArgs.updateType, LocusInfoUpdateType.OBJECTS_UPDATED);
3012
+
3013
+ // Should contain the info object update (with data)
3014
+ const infoUpdate = callbackArgs.updatedObjects.find(
3015
+ (obj) => obj.htMeta.elementId.type === 'info' && obj.htMeta.elementId.id === 42
3016
+ );
3017
+ assert.exists(infoUpdate);
3018
+ assert.deepEqual(infoUpdate.htMeta.elementId, {
3019
+ type: 'info',
3020
+ id: 42,
3021
+ version: 500,
3022
+ view: ['main'],
3023
+ });
3024
+ assert.deepEqual(infoUpdate.data, {someNewField: 'some-value'});
3025
+ });
2403
3026
  });
2404
3027
 
2405
3028
  describe('heartbeat watchdog', () => {
@@ -2449,6 +3072,12 @@ describe('HashTreeParser', () => {
2449
3072
  })
2450
3073
  );
2451
3074
 
3075
+ // Verify behavioral metric was sent for the watchdog expiration
3076
+ assert.calledWith(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_HEARTBEAT_WATCHDOG_EXPIRED, {
3077
+ debugId: 'test',
3078
+ dataSetName: 'main',
3079
+ });
3080
+
2452
3081
  // Verify no sync requests were sent for other datasets
2453
3082
  assert.neverCalledWith(
2454
3083
  webexRequest,
@@ -2492,6 +3121,12 @@ describe('HashTreeParser', () => {
2492
3121
  // Advance time past watchdog delay
2493
3122
  await clock.tickAsync(heartbeatIntervalMs);
2494
3123
 
3124
+ // Verify behavioral metric was sent for the watchdog expiration
3125
+ assert.calledWith(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_HEARTBEAT_WATCHDOG_EXPIRED, {
3126
+ debugId: 'test',
3127
+ dataSetName: 'self',
3128
+ });
3129
+
2495
3130
  // For leafCount === 1, performSync skips GET hashtree and goes straight to POST sync
2496
3131
  assert.neverCalledWith(
2497
3132
  webexRequest,
@@ -2776,56 +3411,126 @@ describe('HashTreeParser', () => {
2776
3411
  // At 5500ms, 'main' watchdog fires and performSync runs immediately
2777
3412
  await clock.tickAsync(1);
2778
3413
 
2779
- // main sync should have triggered immediately (GET hashtree + POST sync)
3414
+ // main sync should have triggered immediately (GET hashtree + POST sync)
3415
+ assert.calledWith(
3416
+ webexRequest,
3417
+ sinon.match({
3418
+ method: 'GET',
3419
+ uri: `${parser.dataSets.main.url}/hashtree`,
3420
+ })
3421
+ );
3422
+
3423
+ webexRequest.resetHistory();
3424
+
3425
+ // At 7000ms, 'self' watchdog fires and performSync runs immediately
3426
+ await clock.tickAsync(1500);
3427
+
3428
+ // self sync should have also triggered (POST sync only, leafCount === 1)
3429
+ assert.calledWith(
3430
+ webexRequest,
3431
+ sinon.match({
3432
+ method: 'POST',
3433
+ uri: `${parser.dataSets.self.url}/sync`,
3434
+ })
3435
+ );
3436
+ });
3437
+
3438
+ it('does not set watchdog for data sets without a hash tree', async () => {
3439
+ const parser = createHashTreeParser();
3440
+ const heartbeatIntervalMs = 5000;
3441
+
3442
+ // 'atd-active' is in the initial locus but is not visible (no hash tree)
3443
+ // Send heartbeat mentioning a non-visible dataset
3444
+ const heartbeatMessage = {
3445
+ dataSets: [
3446
+ {
3447
+ ...createDataSet('main', 16, 1100),
3448
+ root: parser.dataSets.main.hashTree.getRootHash(),
3449
+ },
3450
+ createDataSet('atd-active', 16, 4000),
3451
+ ],
3452
+ visibleDataSetsUrl,
3453
+ locusUrl,
3454
+ heartbeatIntervalMs,
3455
+ };
3456
+
3457
+ parser.handleMessage(heartbeatMessage, 'heartbeat with non-visible dataset');
3458
+
3459
+ // Watchdog set for main (visible) but not for atd-active (no hash tree)
3460
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
3461
+ expect(parser.dataSets['atd-active']?.heartbeatWatchdogTimer).to.be.undefined;
3462
+ });
3463
+
3464
+ it('restarts the watchdog timer after it fires so that future missed heartbeats still trigger syncs', async () => {
3465
+ const parser = createHashTreeParser();
3466
+ const heartbeatIntervalMs = 5000;
3467
+
3468
+ // Send initial heartbeat for 'main'
3469
+ const heartbeatMessage = {
3470
+ dataSets: [
3471
+ {
3472
+ ...createDataSet('main', 16, 1100),
3473
+ root: parser.dataSets.main.hashTree.getRootHash(),
3474
+ },
3475
+ ],
3476
+ visibleDataSetsUrl,
3477
+ locusUrl,
3478
+ heartbeatIntervalMs,
3479
+ };
3480
+
3481
+ parser.handleMessage(heartbeatMessage, 'initial heartbeat');
3482
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
3483
+
3484
+ // Mock responses for performSync - return null (204/empty body)
3485
+ const mainDataSetUrl = parser.dataSets.main.url;
3486
+ mockGetHashesFromLocusResponse(
3487
+ mainDataSetUrl,
3488
+ new Array(16).fill('00000000000000000000000000000000'),
3489
+ createDataSet('main', 16, 1101)
3490
+ );
3491
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
3492
+
3493
+ // Advance time past heartbeatIntervalMs to fire the watchdog
3494
+ await clock.tickAsync(heartbeatIntervalMs);
3495
+
3496
+ // Verify sync was triggered
2780
3497
  assert.calledWith(
2781
3498
  webexRequest,
2782
3499
  sinon.match({
2783
3500
  method: 'GET',
2784
- uri: `${parser.dataSets.main.url}/hashtree`,
3501
+ uri: `${mainDataSetUrl}/hashtree`,
2785
3502
  })
2786
3503
  );
2787
3504
 
3505
+ // The watchdog timer should have been restarted after firing
3506
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
3507
+
3508
+ // Reset call history and set up new mock responses for the second sync
2788
3509
  webexRequest.resetHistory();
3510
+ mockGetHashesFromLocusResponse(
3511
+ mainDataSetUrl,
3512
+ new Array(16).fill('00000000000000000000000000000000'),
3513
+ createDataSet('main', 16, 1102)
3514
+ );
3515
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
2789
3516
 
2790
- // At 7000ms, 'self' watchdog fires and performSync runs immediately
2791
- await clock.tickAsync(1500);
3517
+ // Advance time again to fire the watchdog a second time
3518
+ await clock.tickAsync(heartbeatIntervalMs);
2792
3519
 
2793
- // self sync should have also triggered (POST sync only, leafCount === 1)
3520
+ // Verify a second sync was triggered
2794
3521
  assert.calledWith(
2795
3522
  webexRequest,
2796
3523
  sinon.match({
2797
- method: 'POST',
2798
- uri: `${parser.dataSets.self.url}/sync`,
3524
+ method: 'GET',
3525
+ uri: `${mainDataSetUrl}/hashtree`,
2799
3526
  })
2800
3527
  );
2801
- });
2802
-
2803
- it('does not set watchdog for data sets without a hash tree', async () => {
2804
- const parser = createHashTreeParser();
2805
- const heartbeatIntervalMs = 5000;
2806
-
2807
- // 'atd-active' is in the initial locus but is not visible (no hash tree)
2808
- // Send heartbeat mentioning a non-visible dataset
2809
- const heartbeatMessage = {
2810
- dataSets: [
2811
- {
2812
- ...createDataSet('main', 16, 1100),
2813
- root: parser.dataSets.main.hashTree.getRootHash(),
2814
- },
2815
- createDataSet('atd-active', 16, 4000),
2816
- ],
2817
- visibleDataSetsUrl,
2818
- locusUrl,
2819
- heartbeatIntervalMs,
2820
- };
2821
-
2822
- parser.handleMessage(heartbeatMessage, 'heartbeat with non-visible dataset');
2823
3528
 
2824
- // Watchdog set for main (visible) but not for atd-active (no hash tree)
3529
+ // And the watchdog should still be running
2825
3530
  expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2826
- expect(parser.dataSets['atd-active']?.heartbeatWatchdogTimer).to.be.undefined;
2827
3531
  });
2828
3532
  });
3533
+
2829
3534
  });
2830
3535
 
2831
3536
  describe('#callLocusInfoUpdateCallback filtering', () => {
@@ -2922,7 +3627,7 @@ describe('HashTreeParser', () => {
2922
3627
  parser.handleMessage(updateMessage, 'update with newer version');
2923
3628
 
2924
3629
  // Callback should be called with the update
2925
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3630
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2926
3631
  updatedObjects: [
2927
3632
  {
2928
3633
  htMeta: {
@@ -2993,7 +3698,7 @@ describe('HashTreeParser', () => {
2993
3698
  parser.handleMessage(removalMessage, 'removal of non-existent object');
2994
3699
 
2995
3700
  // Callback should be called with the removal
2996
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3701
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2997
3702
  updatedObjects: [
2998
3703
  {
2999
3704
  htMeta: {
@@ -3128,7 +3833,7 @@ describe('HashTreeParser', () => {
3128
3833
  parser.handleMessage(mixedMessage, 'mixed updates');
3129
3834
 
3130
3835
  // Callback should be called with only the valid updates (participant 1 v110 and participant 3 v10)
3131
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3836
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
3132
3837
  updatedObjects: [
3133
3838
  {
3134
3839
  htMeta: {
@@ -3282,6 +3987,9 @@ describe('HashTreeParser', () => {
3282
3987
  parser.handleMessage(emptyMessage, 'empty elements');
3283
3988
 
3284
3989
  assert.notCalled(callback);
3990
+ assert.calledWith(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_EMPTY_LOCUS_STATE_ELEMENTS, {
3991
+ debugId: 'test',
3992
+ });
3285
3993
  });
3286
3994
 
3287
3995
  it('always calls callback for MEETING_ENDED regardless of filtering', () => {
@@ -3306,9 +4014,7 @@ describe('HashTreeParser', () => {
3306
4014
  parser.handleMessage(sentinelMessage as any, 'sentinel message');
3307
4015
 
3308
4016
  // Callback should be called with MEETING_ENDED
3309
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
3310
- updatedObjects: undefined,
3311
- });
4017
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
3312
4018
  });
3313
4019
  });
3314
4020
 
@@ -3514,7 +4220,7 @@ describe('HashTreeParser', () => {
3514
4220
  });
3515
4221
  });
3516
4222
 
3517
- describe('#resume', () => {
4223
+ describe('#resumeFromMessage', () => {
3518
4224
  const createResumeMessage = (visibleDataSets?, dataSets?) => ({
3519
4225
  locusUrl,
3520
4226
  visibleDataSetsUrl,
@@ -3541,7 +4247,7 @@ describe('HashTreeParser', () => {
3541
4247
 
3542
4248
  expect(parser.state).to.equal('stopped');
3543
4249
 
3544
- parser.resume(createResumeMessage());
4250
+ parser.resumeFromMessage(createResumeMessage());
3545
4251
 
3546
4252
  expect(parser.state).to.equal('active');
3547
4253
  });
@@ -3550,7 +4256,7 @@ describe('HashTreeParser', () => {
3550
4256
  const parser = createHashTreeParser();
3551
4257
  parser.stop();
3552
4258
 
3553
- parser.resume({
4259
+ parser.resumeFromMessage({
3554
4260
  locusUrl,
3555
4261
  visibleDataSetsUrl,
3556
4262
  dataSets: [createDataSet('main', 16, 2000)],
@@ -3569,7 +4275,7 @@ describe('HashTreeParser', () => {
3569
4275
  createDataSet('self', 2, 6000),
3570
4276
  ];
3571
4277
 
3572
- parser.resume(createResumeMessage(undefined, newDataSets));
4278
+ parser.resumeFromMessage(createResumeMessage(undefined, newDataSets));
3573
4279
 
3574
4280
  expect(Object.keys(parser.dataSets)).to.have.lengthOf(2);
3575
4281
  expect(parser.dataSets.main.leafCount).to.equal(8);
@@ -3591,7 +4297,7 @@ describe('HashTreeParser', () => {
3591
4297
  {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3592
4298
  ];
3593
4299
 
3594
- parser.resume(createResumeMessage(visibleDataSets, dataSets));
4300
+ parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
3595
4301
 
3596
4302
  expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
3597
4303
  expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
@@ -3605,7 +4311,7 @@ describe('HashTreeParser', () => {
3605
4311
  const handleMessageStub = sinon.stub(parser, 'handleMessage');
3606
4312
 
3607
4313
  const message = createResumeMessage();
3608
- parser.resume(message);
4314
+ parser.resumeFromMessage(message);
3609
4315
 
3610
4316
  assert.calledOnceWithExactly(handleMessageStub, message, 'on resume');
3611
4317
  });
@@ -3625,7 +4331,7 @@ describe('HashTreeParser', () => {
3625
4331
  {name: 'atd-unmuted', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'},
3626
4332
  ];
3627
4333
 
3628
- parser.resume(createResumeMessage(visibleDataSets, dataSets));
4334
+ parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
3629
4335
 
3630
4336
  expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
3631
4337
  expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
@@ -3633,6 +4339,67 @@ describe('HashTreeParser', () => {
3633
4339
  });
3634
4340
  });
3635
4341
 
4342
+ describe('#resumeFromApiResponse', () => {
4343
+ const exampleLocus = {
4344
+ participants: [],
4345
+ } as any;
4346
+
4347
+ it('should set state to active', async () => {
4348
+ const parser = createHashTreeParser();
4349
+ parser.stop();
4350
+
4351
+ expect(parser.state).to.equal('stopped');
4352
+
4353
+ sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
4354
+
4355
+ await parser.resumeFromApiResponse(exampleLocus);
4356
+
4357
+ expect(parser.state).to.equal('active');
4358
+ });
4359
+
4360
+ it('should reset dataSets to empty', async () => {
4361
+ const parser = createHashTreeParser();
4362
+
4363
+ expect(Object.keys(parser.dataSets).length).to.be.greaterThan(0);
4364
+
4365
+ parser.stop();
4366
+
4367
+ sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
4368
+
4369
+ await parser.resumeFromApiResponse(exampleLocus);
4370
+
4371
+ expect(parser.dataSets).to.deep.equal({});
4372
+ });
4373
+
4374
+ it('should call initializeFromGetLociResponse with the provided locus', async () => {
4375
+ const parser = createHashTreeParser();
4376
+ parser.stop();
4377
+
4378
+ const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
4379
+
4380
+ await parser.resumeFromApiResponse(exampleLocus);
4381
+
4382
+ assert.calledOnceWithExactly(initStub, exampleLocus);
4383
+ });
4384
+
4385
+ it('should propagate errors from initializeFromGetLociResponse', async () => {
4386
+ const parser = createHashTreeParser();
4387
+ parser.stop();
4388
+
4389
+ const error = new Error('initialization failed');
4390
+ const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').rejects(error);
4391
+
4392
+ let caughtError: Error | undefined;
4393
+ try {
4394
+ await parser.resumeFromApiResponse(exampleLocus);
4395
+ } catch (e) {
4396
+ caughtError = e;
4397
+ }
4398
+
4399
+ expect(caughtError).to.equal(error);
4400
+ });
4401
+ });
4402
+
3636
4403
  describe('#handleLocusUpdate when stopped', () => {
3637
4404
  it('should return early without processing when parser is stopped', () => {
3638
4405
  const parser = createHashTreeParser();
@@ -3667,4 +4434,343 @@ describe('HashTreeParser', () => {
3667
4434
  assert.notCalled(callback);
3668
4435
  });
3669
4436
  });
4437
+
4438
+ describe('#syncAllDatasets', () => {
4439
+ it('should sync all datasets that have hash trees in priority order', async () => {
4440
+ const parser = createHashTreeParser();
4441
+
4442
+ // parser starts with main (leafCount=16) and self (leafCount=1) as visible datasets with hash trees
4443
+ // atd-unmuted has no hash tree (not visible)
4444
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
4445
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
4446
+
4447
+ const mainUrl = parser.dataSets.main.url;
4448
+ const selfUrl = parser.dataSets.self.url;
4449
+
4450
+ // Mock GET hashtree for main (leafCount > 1, so it does GET first)
4451
+ mockGetHashesFromLocusResponse(
4452
+ mainUrl,
4453
+ new Array(16).fill(EMPTY_HASH),
4454
+ createDataSet('main', 16, 1100)
4455
+ );
4456
+
4457
+ // Mock POST sync for main - return matching root hash so no further sync needed
4458
+ const mainSyncDataSet = createDataSet('main', 16, 1100);
4459
+ mainSyncDataSet.root = parser.dataSets.main.hashTree.getRootHash();
4460
+ mockSendSyncRequestResponse(mainUrl, {
4461
+ dataSets: [mainSyncDataSet],
4462
+ visibleDataSetsUrl,
4463
+ locusUrl,
4464
+ locusStateElements: [],
4465
+ });
4466
+
4467
+ // Mock POST sync for self (leafCount=1, skips GET hashtree)
4468
+ const selfSyncDataSet = createDataSet('self', 1, 2100);
4469
+ selfSyncDataSet.root = parser.dataSets.self.hashTree.getRootHash();
4470
+ mockSendSyncRequestResponse(selfUrl, {
4471
+ dataSets: [selfSyncDataSet],
4472
+ visibleDataSetsUrl,
4473
+ locusUrl,
4474
+ locusStateElements: [],
4475
+ });
4476
+
4477
+ await parser.syncAllDatasets();
4478
+
4479
+ // Verify GET hashtree was called for main only (not self, because leafCount=1)
4480
+ assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
4481
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4482
+
4483
+ // Verify POST sync was called for both
4484
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
4485
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4486
+
4487
+ // Verify main was synced before self (priority order)
4488
+ const mainSyncCallIndex = webexRequest.args.findIndex(
4489
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4490
+ );
4491
+ const selfSyncCallIndex = webexRequest.args.findIndex(
4492
+ (args) => args[0]?.method === 'POST' && args[0]?.uri === `${selfUrl}/sync`
4493
+ );
4494
+ expect(mainSyncCallIndex).to.be.lessThan(selfSyncCallIndex);
4495
+
4496
+ // Verify isSyncAllInProgress is reset
4497
+ expect(parser.isSyncAllInProgress).to.be.false;
4498
+ });
4499
+
4500
+ it('should return immediately when state is stopped', async () => {
4501
+ const parser = createHashTreeParser();
4502
+ parser.stop();
4503
+
4504
+ await parser.syncAllDatasets();
4505
+
4506
+ // No sync requests should have been made (only the initial sync from constructor)
4507
+ // Reset history to clear constructor calls then verify
4508
+ const callCountBefore = webexRequest.callCount;
4509
+ await parser.syncAllDatasets();
4510
+ assert.equal(webexRequest.callCount, callCountBefore);
4511
+ });
4512
+
4513
+ it('should guard against concurrent calls', async () => {
4514
+ const parser = createHashTreeParser();
4515
+
4516
+ const mainUrl = parser.dataSets.main.url;
4517
+ const selfUrl = parser.dataSets.self.url;
4518
+
4519
+ // Use a deferred promise for the main sync to control timing
4520
+ let resolveMainSync;
4521
+ webexRequest
4522
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4523
+ .returns(new Promise((resolve) => { resolveMainSync = resolve; }));
4524
+
4525
+ mockSendSyncRequestResponse(mainUrl, {
4526
+ dataSets: [createDataSet('main', 16, 1100)],
4527
+ visibleDataSetsUrl,
4528
+ locusUrl,
4529
+ locusStateElements: [],
4530
+ });
4531
+
4532
+ mockSendSyncRequestResponse(selfUrl, {
4533
+ dataSets: [createDataSet('self', 1, 2100)],
4534
+ visibleDataSetsUrl,
4535
+ locusUrl,
4536
+ locusStateElements: [],
4537
+ });
4538
+
4539
+ // Start first call
4540
+ const promise1 = parser.syncAllDatasets();
4541
+ // Start second call while first is in progress
4542
+ const promise2 = parser.syncAllDatasets();
4543
+
4544
+ // Resolve the pending request
4545
+ resolveMainSync({
4546
+ body: {
4547
+ hashes: new Array(16).fill(EMPTY_HASH),
4548
+ dataSet: createDataSet('main', 16, 1100),
4549
+ },
4550
+ });
4551
+
4552
+ await promise1;
4553
+ await promise2;
4554
+
4555
+ // GET hashtree for main should only be called once (second syncAllDatasets returned immediately)
4556
+ const getHashtreeCalls = webexRequest.args.filter(
4557
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4558
+ );
4559
+ expect(getHashtreeCalls).to.have.lengthOf(1);
4560
+ });
4561
+
4562
+ it('should skip datasets that do not have a hash tree', async () => {
4563
+ // Create parser with metadata that only has main and self as visible (not atd-unmuted)
4564
+ const metadataWithoutAtd = {
4565
+ ...exampleMetadata,
4566
+ visibleDataSets: exampleMetadata.visibleDataSets.filter((ds) => ds.name !== 'atd-unmuted'),
4567
+ };
4568
+ const parser = createHashTreeParser(exampleInitialLocus, metadataWithoutAtd);
4569
+
4570
+ // atd-unmuted is in dataSets but has no hashTree (not visible)
4571
+ expect(parser.dataSets['atd-unmuted']).to.exist;
4572
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
4573
+
4574
+ const atdUrl = parser.dataSets['atd-unmuted'].url;
4575
+ const mainUrl = parser.dataSets.main.url;
4576
+ const selfUrl = parser.dataSets.self.url;
4577
+
4578
+ mockGetHashesFromLocusResponse(
4579
+ mainUrl,
4580
+ new Array(16).fill(EMPTY_HASH),
4581
+ createDataSet('main', 16, 1100)
4582
+ );
4583
+
4584
+ const mainSyncDs = createDataSet('main', 16, 1100);
4585
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4586
+ mockSendSyncRequestResponse(mainUrl, {
4587
+ dataSets: [mainSyncDs],
4588
+ visibleDataSetsUrl,
4589
+ locusUrl,
4590
+ locusStateElements: [],
4591
+ });
4592
+
4593
+ const selfSyncDs = createDataSet('self', 1, 2100);
4594
+ selfSyncDs.root = parser.dataSets.self.hashTree.getRootHash();
4595
+ mockSendSyncRequestResponse(selfUrl, {
4596
+ dataSets: [selfSyncDs],
4597
+ visibleDataSetsUrl,
4598
+ locusUrl,
4599
+ locusStateElements: [],
4600
+ });
4601
+
4602
+ await parser.syncAllDatasets();
4603
+
4604
+ // No requests should have been made for atd-unmuted
4605
+ assert.neverCalledWith(webexRequest, sinon.match({uri: sinon.match(atdUrl)}));
4606
+ });
4607
+ });
4608
+
4609
+ describe('#handleMessage sync queue', () => {
4610
+ it('should deduplicate: not sync the same dataset twice when enqueued multiple times', async () => {
4611
+ const parser = createHashTreeParser();
4612
+
4613
+ const mainUrl = parser.dataSets.main.url;
4614
+
4615
+ // Setup mocks before triggering syncs
4616
+ mockGetHashesFromLocusResponse(
4617
+ mainUrl,
4618
+ new Array(16).fill(EMPTY_HASH),
4619
+ createDataSet('main', 16, 1101)
4620
+ );
4621
+
4622
+ const mainSyncDs = createDataSet('main', 16, 1101);
4623
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4624
+ mockSendSyncRequestResponse(mainUrl, {
4625
+ dataSets: [mainSyncDs],
4626
+ visibleDataSetsUrl,
4627
+ locusUrl,
4628
+ locusStateElements: [],
4629
+ });
4630
+
4631
+ // Send two heartbeat messages (no locusStateElements) with different root hashes for main
4632
+ parser.handleMessage(createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'), 'first');
4633
+ parser.handleMessage(createHeartbeatMessage('main', 16, 1101, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2'), 'second');
4634
+
4635
+ // The second call resets the timer. After 1000ms, only one sync fires.
4636
+ await clock.tickAsync(1000);
4637
+
4638
+ // Only one GET hashtree call should have been made for main
4639
+ const getHashtreeCalls = webexRequest.args.filter(
4640
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4641
+ );
4642
+ expect(getHashtreeCalls).to.have.lengthOf(1);
4643
+ });
4644
+
4645
+ it('should stop processing the sync queue when parser is stopped mid-queue', async () => {
4646
+ const parser = createHashTreeParser();
4647
+
4648
+ const mainUrl = parser.dataSets.main.url;
4649
+ const selfUrl = parser.dataSets.self.url;
4650
+
4651
+ // Mock main GET hashtree with a deferred promise so we can control when it resolves
4652
+ let resolveMainHashtree;
4653
+ webexRequest
4654
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4655
+ .callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
4656
+
4657
+ // Send a heartbeat message that triggers sync timers for both main and self
4658
+ parser.handleMessage(
4659
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4660
+ 'trigger main sync'
4661
+ );
4662
+ parser.handleMessage(
4663
+ createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
4664
+ 'trigger self sync'
4665
+ );
4666
+
4667
+ // Fire the timers - main sync starts (calls GET hashtree, which blocks)
4668
+ await clock.tickAsync(1000);
4669
+
4670
+ // Stop the parser while main sync is in progress
4671
+ parser.stop();
4672
+
4673
+ // Resolve the pending main GET request
4674
+ resolveMainHashtree({
4675
+ body: {
4676
+ hashes: new Array(16).fill(EMPTY_HASH),
4677
+ dataSet: createDataSet('main', 16, 1100),
4678
+ },
4679
+ });
4680
+
4681
+ await clock.tickAsync(0);
4682
+
4683
+ // Self sync should NOT have been triggered because parser was stopped
4684
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4685
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4686
+ });
4687
+ });
4688
+
4689
+ describe('#stop sync queue', () => {
4690
+ it('should clear the syncQueue when stopped so remaining queued items are not processed', async () => {
4691
+ const parser = createHashTreeParser();
4692
+
4693
+ const mainUrl = parser.dataSets.main.url;
4694
+ const selfUrl = parser.dataSets.self.url;
4695
+
4696
+ // Mock main GET hashtree with a deferred promise so we can control when it resolves
4697
+ let resolveMainHashtree;
4698
+ webexRequest
4699
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4700
+ .callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
4701
+
4702
+ // Enqueue syncs for both main and self by sending heartbeat messages
4703
+ parser.handleMessage(
4704
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4705
+ 'trigger main sync'
4706
+ );
4707
+ parser.handleMessage(
4708
+ createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
4709
+ 'trigger self sync'
4710
+ );
4711
+
4712
+ // Fire the timers - main sync starts and blocks on GET hashtree
4713
+ await clock.tickAsync(1000);
4714
+
4715
+ // Verify that self is still in the queue (main is being processed, self is waiting)
4716
+ // Now stop the parser - this should clear the syncQueue
4717
+ parser.stop();
4718
+
4719
+ // Resolve the pending main GET request so the in-flight sync can finish
4720
+ resolveMainHashtree({
4721
+ body: {
4722
+ hashes: new Array(16).fill(EMPTY_HASH),
4723
+ dataSet: createDataSet('main', 16, 1100),
4724
+ },
4725
+ });
4726
+
4727
+ await clock.tickAsync(0);
4728
+
4729
+ // Self should never have been synced because stop() cleared the queue
4730
+ const selfGetCalls = webexRequest.args.filter(
4731
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${selfUrl}/hashtree`
4732
+ );
4733
+ expect(selfGetCalls).to.have.lengthOf(0);
4734
+ });
4735
+ });
4736
+
4737
+ describe('#cleanUp', () => {
4738
+ it('should stop the parser, clear all timers and clear all dataSets', () => {
4739
+ const parser = createHashTreeParser();
4740
+
4741
+ // Send a message to set up sync timers via runSyncAlgorithm
4742
+ const message = {
4743
+ dataSets: [
4744
+ {
4745
+ ...createDataSet('main', 16, 1100),
4746
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
4747
+ },
4748
+ ],
4749
+ visibleDataSetsUrl,
4750
+ locusUrl,
4751
+ heartbeatIntervalMs: 5000,
4752
+ locusStateElements: [
4753
+ {
4754
+ htMeta: {
4755
+ elementId: {type: 'locus' as const, id: 0, version: 201},
4756
+ dataSetNames: ['main'],
4757
+ },
4758
+ data: {someData: 'value'},
4759
+ },
4760
+ ],
4761
+ };
4762
+
4763
+ parser.handleMessage(message, 'setup timers');
4764
+
4765
+ // Verify timers were set by handleMessage
4766
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
4767
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
4768
+
4769
+ parser.cleanUp();
4770
+
4771
+ expect(parser.state).to.equal('stopped');
4772
+ expect(parser.visibleDataSets).to.deep.equal([]);
4773
+ expect(parser.dataSets).to.deep.equal({});
4774
+ });
4775
+ });
3670
4776
  });