@webex/plugin-meetings 3.12.0-next.7 → 3.12.0-next.71

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 (178) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +15 -2
  3. package/dist/aiEnableRequest/index.js.map +1 -1
  4. package/dist/breakouts/breakout.js +8 -3
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +26 -2
  7. package/dist/breakouts/index.js.map +1 -1
  8. package/dist/config.js +2 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +30 -7
  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 +13 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +880 -382
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +42 -0
  23. package/dist/hashTree/utils.js.map +1 -1
  24. package/dist/index.js +7 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/interceptors/dataChannelAuthToken.js +75 -15
  27. package/dist/interceptors/dataChannelAuthToken.js.map +1 -1
  28. package/dist/interceptors/locusRetry.js +23 -8
  29. package/dist/interceptors/locusRetry.js.map +1 -1
  30. package/dist/interpretation/index.js +10 -1
  31. package/dist/interpretation/index.js.map +1 -1
  32. package/dist/interpretation/interpretation.types.js +7 -0
  33. package/dist/interpretation/interpretation.types.js.map +1 -0
  34. package/dist/interpretation/siLanguage.js +1 -1
  35. package/dist/locus-info/controlsUtils.js +4 -1
  36. package/dist/locus-info/controlsUtils.js.map +1 -1
  37. package/dist/locus-info/index.js +298 -87
  38. package/dist/locus-info/index.js.map +1 -1
  39. package/dist/locus-info/types.js +19 -0
  40. package/dist/locus-info/types.js.map +1 -1
  41. package/dist/media/index.js +3 -1
  42. package/dist/media/index.js.map +1 -1
  43. package/dist/media/properties.js +1 -0
  44. package/dist/media/properties.js.map +1 -1
  45. package/dist/meeting/in-meeting-actions.js +3 -1
  46. package/dist/meeting/in-meeting-actions.js.map +1 -1
  47. package/dist/meeting/index.js +1046 -689
  48. package/dist/meeting/index.js.map +1 -1
  49. package/dist/meeting/muteState.js +10 -1
  50. package/dist/meeting/muteState.js.map +1 -1
  51. package/dist/meeting/request.js +5 -2
  52. package/dist/meeting/request.js.map +1 -1
  53. package/dist/meeting/util.js +20 -2
  54. package/dist/meeting/util.js.map +1 -1
  55. package/dist/meeting-info/meeting-info-v2.js +2 -2
  56. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  57. package/dist/meetings/index.js +231 -78
  58. package/dist/meetings/index.js.map +1 -1
  59. package/dist/meetings/meetings.types.js +6 -1
  60. package/dist/meetings/meetings.types.js.map +1 -1
  61. package/dist/meetings/request.js +39 -0
  62. package/dist/meetings/request.js.map +1 -1
  63. package/dist/meetings/util.js +79 -5
  64. package/dist/meetings/util.js.map +1 -1
  65. package/dist/member/index.js +10 -0
  66. package/dist/member/index.js.map +1 -1
  67. package/dist/member/types.js.map +1 -1
  68. package/dist/member/util.js +3 -0
  69. package/dist/member/util.js.map +1 -1
  70. package/dist/metrics/constants.js +4 -1
  71. package/dist/metrics/constants.js.map +1 -1
  72. package/dist/multistream/codec/constants.js +63 -0
  73. package/dist/multistream/codec/constants.js.map +1 -0
  74. package/dist/multistream/mediaRequestManager.js +62 -15
  75. package/dist/multistream/mediaRequestManager.js.map +1 -1
  76. package/dist/multistream/receiveSlot.js +9 -0
  77. package/dist/multistream/receiveSlot.js.map +1 -1
  78. package/dist/reactions/reactions.type.js.map +1 -1
  79. package/dist/recording-controller/index.js +1 -3
  80. package/dist/recording-controller/index.js.map +1 -1
  81. package/dist/types/config.d.ts +2 -0
  82. package/dist/types/constants.d.ts +9 -1
  83. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  84. package/dist/types/controls-options-manager/index.d.ts +10 -0
  85. package/dist/types/hashTree/constants.d.ts +2 -0
  86. package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
  87. package/dist/types/hashTree/utils.d.ts +18 -0
  88. package/dist/types/index.d.ts +3 -0
  89. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  90. package/dist/types/interpretation/interpretation.types.d.ts +10 -0
  91. package/dist/types/locus-info/index.d.ts +50 -6
  92. package/dist/types/locus-info/types.d.ts +21 -1
  93. package/dist/types/media/properties.d.ts +1 -0
  94. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  95. package/dist/types/meeting/index.d.ts +78 -5
  96. package/dist/types/meeting/request.d.ts +1 -0
  97. package/dist/types/meeting/util.d.ts +8 -0
  98. package/dist/types/meetings/index.d.ts +30 -2
  99. package/dist/types/meetings/meetings.types.d.ts +15 -0
  100. package/dist/types/meetings/request.d.ts +14 -0
  101. package/dist/types/member/index.d.ts +1 -0
  102. package/dist/types/member/types.d.ts +1 -0
  103. package/dist/types/member/util.d.ts +1 -0
  104. package/dist/types/metrics/constants.d.ts +3 -0
  105. package/dist/types/multistream/codec/constants.d.ts +7 -0
  106. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  107. package/dist/types/reactions/reactions.type.d.ts +3 -0
  108. package/dist/webinar/index.js +305 -159
  109. package/dist/webinar/index.js.map +1 -1
  110. package/package.json +22 -22
  111. package/src/aiEnableRequest/index.ts +16 -0
  112. package/src/breakouts/breakout.ts +3 -1
  113. package/src/breakouts/index.ts +31 -0
  114. package/src/config.ts +2 -0
  115. package/src/constants.ts +13 -2
  116. package/src/controls-options-manager/constants.ts +14 -1
  117. package/src/controls-options-manager/index.ts +47 -24
  118. package/src/controls-options-manager/util.ts +81 -1
  119. package/src/hashTree/constants.ts +16 -0
  120. package/src/hashTree/hashTreeParser.ts +580 -196
  121. package/src/hashTree/utils.ts +36 -0
  122. package/src/index.ts +6 -0
  123. package/src/interceptors/dataChannelAuthToken.ts +88 -12
  124. package/src/interceptors/locusRetry.ts +25 -4
  125. package/src/interpretation/index.ts +27 -9
  126. package/src/interpretation/interpretation.types.ts +11 -0
  127. package/src/locus-info/controlsUtils.ts +3 -1
  128. package/src/locus-info/index.ts +293 -97
  129. package/src/locus-info/types.ts +25 -1
  130. package/src/media/index.ts +3 -0
  131. package/src/media/properties.ts +1 -0
  132. package/src/meeting/in-meeting-actions.ts +4 -0
  133. package/src/meeting/index.ts +386 -48
  134. package/src/meeting/muteState.ts +10 -1
  135. package/src/meeting/request.ts +11 -0
  136. package/src/meeting/util.ts +21 -2
  137. package/src/meeting-info/meeting-info-v2.ts +4 -2
  138. package/src/meetings/index.ts +134 -44
  139. package/src/meetings/meetings.types.ts +19 -0
  140. package/src/meetings/request.ts +43 -0
  141. package/src/meetings/util.ts +97 -1
  142. package/src/member/index.ts +10 -0
  143. package/src/member/types.ts +1 -0
  144. package/src/member/util.ts +3 -0
  145. package/src/metrics/constants.ts +3 -0
  146. package/src/multistream/codec/constants.ts +58 -0
  147. package/src/multistream/mediaRequestManager.ts +119 -28
  148. package/src/multistream/receiveSlot.ts +18 -0
  149. package/src/reactions/reactions.type.ts +3 -0
  150. package/src/recording-controller/index.ts +1 -2
  151. package/src/webinar/index.ts +214 -36
  152. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  153. package/test/unit/spec/breakouts/breakout.ts +9 -3
  154. package/test/unit/spec/breakouts/index.ts +49 -0
  155. package/test/unit/spec/controls-options-manager/index.js +140 -29
  156. package/test/unit/spec/controls-options-manager/util.js +165 -0
  157. package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
  158. package/test/unit/spec/hashTree/utils.ts +125 -1
  159. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
  160. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  161. package/test/unit/spec/interpretation/index.ts +26 -4
  162. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  163. package/test/unit/spec/locus-info/index.js +487 -81
  164. package/test/unit/spec/media/index.ts +31 -0
  165. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  166. package/test/unit/spec/meeting/index.js +1240 -37
  167. package/test/unit/spec/meeting/muteState.js +81 -0
  168. package/test/unit/spec/meeting/request.js +12 -0
  169. package/test/unit/spec/meeting/utils.js +33 -0
  170. package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
  171. package/test/unit/spec/meetings/index.js +360 -10
  172. package/test/unit/spec/meetings/request.js +141 -0
  173. package/test/unit/spec/meetings/utils.js +189 -0
  174. package/test/unit/spec/member/index.js +7 -0
  175. package/test/unit/spec/member/util.js +24 -0
  176. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  177. package/test/unit/spec/recording-controller/index.js +9 -8
  178. package/test/unit/spec/webinar/index.ts +329 -28
@@ -1,12 +1,18 @@
1
1
  import HashTreeParser, {
2
2
  LocusInfoUpdateType,
3
3
  MeetingEndedError,
4
+ LocusNotFoundError,
5
+ SyncAllBackoffType,
4
6
  } from '@webex/plugin-meetings/src/hashTree/hashTreeParser';
5
7
  import HashTree from '@webex/plugin-meetings/src/hashTree/hashTree';
6
8
  import {expect} from '@webex/test-helper-chai';
7
9
  import sinon from 'sinon';
8
10
  import {assert} from '@webex/test-helper-chai';
9
11
  import {EMPTY_HASH} from '@webex/plugin-meetings/src/hashTree/constants';
12
+ import testUtils from '@webex/plugin-meetings/test/utils/testUtils';
13
+ import { some } from 'lodash';
14
+ import Metrics from '@webex/plugin-meetings/src/metrics';
15
+ import BEHAVIORAL_METRICS from '@webex/plugin-meetings/src/metrics/constants';
10
16
 
11
17
  const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
12
18
 
@@ -111,6 +117,7 @@ function createDataSet(name: string, leafCount: number, version = 1) {
111
117
  name,
112
118
  idleMs: 1000,
113
119
  backoff: {maxMs: 1000, exponent: 2},
120
+ heartbeatIntervalMs: 5000,
114
121
  };
115
122
  }
116
123
 
@@ -151,16 +158,19 @@ describe('HashTreeParser', () => {
151
158
  let webexRequest: sinon.SinonStub;
152
159
  let callback: sinon.SinonStub;
153
160
  let mathRandomStub: sinon.SinonStub;
161
+ let metricsStub: sinon.SinonStub;
154
162
 
155
163
  beforeEach(() => {
156
164
  clock = sinon.useFakeTimers();
157
165
  webexRequest = sinon.stub();
158
166
  callback = sinon.stub();
159
167
  mathRandomStub = sinon.stub(Math, 'random').returns(0);
168
+ metricsStub = sinon.stub(Metrics, 'sendBehavioralMetric');
160
169
  });
161
170
  afterEach(() => {
162
171
  clock.restore();
163
172
  mathRandomStub.restore();
173
+ metricsStub.restore();
164
174
  });
165
175
 
166
176
  // Helper to create a HashTreeParser instance with common defaults
@@ -553,7 +563,7 @@ describe('HashTreeParser', () => {
553
563
  );
554
564
 
555
565
  // Verify callback was called with OBJECTS_UPDATED and correct updatedObjects list
556
- assert.calledWith(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
566
+ assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
557
567
  updatedObjects: [
558
568
  {
559
569
  htMeta: {
@@ -566,6 +576,11 @@ describe('HashTreeParser', () => {
566
576
  },
567
577
  data: {info: {id: 'some-fake-locus-info'}},
568
578
  },
579
+ ],
580
+ });
581
+
582
+ assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
583
+ updatedObjects: [
569
584
  {
570
585
  htMeta: {
571
586
  elementId: {
@@ -596,6 +611,67 @@ describe('HashTreeParser', () => {
596
611
  });
597
612
  });
598
613
 
614
+ it('initializes "main" before "self" regardless of order from Locus', async () => {
615
+ const parser = createHashTreeParser({dataSets: [], locus: null}, null);
616
+
617
+ // Locus returns datasets in non-priority order: atd-active, main, self
618
+ const atdActiveDataSet = createDataSet('atd-active', 4, 500);
619
+ const mainDataSet = createDataSet('main', 16, 1100);
620
+ const selfDataSet = createDataSet('self', 1, 2100);
621
+
622
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
623
+ atdActiveDataSet,
624
+ mainDataSet,
625
+ selfDataSet,
626
+ ]);
627
+
628
+ mockSyncRequest(webexRequest, selfDataSet.url);
629
+ mockSyncRequest(webexRequest, mainDataSet.url);
630
+ mockSyncRequest(webexRequest, atdActiveDataSet.url);
631
+
632
+ await parser.initializeFromMessage({
633
+ dataSets: [],
634
+ visibleDataSetsUrl,
635
+ locusUrl,
636
+ });
637
+
638
+ // Verify sync requests were sent in priority order: main, self, then atd-active
639
+ const syncCalls = webexRequest
640
+ .getCalls()
641
+ .filter((call) => call.args[0]?.method === 'POST' && call.args[0]?.uri?.endsWith('/sync'));
642
+
643
+ expect(syncCalls).to.have.lengthOf(3);
644
+ expect(syncCalls[0].args[0].uri).to.equal(`${mainDataSet.url}/sync`);
645
+ expect(syncCalls[1].args[0].uri).to.equal(`${selfDataSet.url}/sync`);
646
+ expect(syncCalls[2].args[0].uri).to.equal(`${atdActiveDataSet.url}/sync`);
647
+ });
648
+
649
+ it('sends leafCount=1 with a single empty leaf for initialization sync, regardless of actual dataset leafCount', async () => {
650
+ const parser = createHashTreeParser({dataSets: [], locus: null}, null);
651
+
652
+ // Use a dataset with leafCount=16 to verify the initialization sync always uses leafCount=1
653
+ const mainDataSet = createDataSet('main', 16, 1100);
654
+
655
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet]);
656
+ mockSyncRequest(webexRequest, mainDataSet.url);
657
+
658
+ await parser.initializeFromMessage({
659
+ dataSets: [],
660
+ visibleDataSetsUrl,
661
+ locusUrl,
662
+ });
663
+
664
+ assert.calledWith(webexRequest, {
665
+ method: 'POST',
666
+ uri: `${mainDataSet.url}/sync`,
667
+ qs: {rootHash: sinon.match.string},
668
+ body: {
669
+ leafCount: 1,
670
+ leafDataEntries: [{leafIndex: 0, elementIds: []}],
671
+ },
672
+ });
673
+ });
674
+
599
675
  it('handles sync response that has locusStateElements undefined', async () => {
600
676
  const minimalInitialLocus = {
601
677
  dataSets: [],
@@ -636,8 +712,11 @@ describe('HashTreeParser', () => {
636
712
  assert.notCalled(callback);
637
713
  });
638
714
 
639
- [404, 409].forEach((errorCode) => {
640
- it(`emits MeetingEndedError if getting visible datasets returns ${errorCode}`, async () => {
715
+ [
716
+ {errorCode: 404, expectedError: LocusNotFoundError},
717
+ {errorCode: 409, expectedError: MeetingEndedError},
718
+ ].forEach(({errorCode, expectedError}) => {
719
+ it(`throws ${expectedError.name} if getting visible datasets returns ${errorCode}`, async () => {
641
720
  const minimalInitialLocus = {
642
721
  dataSets: [],
643
722
  locus: null,
@@ -660,7 +739,6 @@ describe('HashTreeParser', () => {
660
739
  )
661
740
  .rejects(error);
662
741
 
663
- // initializeFromMessage should throw MeetingEndedError
664
742
  let thrownError;
665
743
  try {
666
744
  await parser.initializeFromMessage({
@@ -672,7 +750,7 @@ describe('HashTreeParser', () => {
672
750
  thrownError = e;
673
751
  }
674
752
 
675
- expect(thrownError).to.be.instanceOf(MeetingEndedError);
753
+ expect(thrownError).to.be.instanceOf(expectedError);
676
754
  });
677
755
  });
678
756
  });
@@ -788,7 +866,7 @@ describe('HashTreeParser', () => {
788
866
  expect(parser.dataSets.self.version).to.equal(2100);
789
867
  expect(parser.dataSets['atd-unmuted'].version).to.equal(3100);
790
868
 
791
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
869
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
792
870
  updatedObjects: [
793
871
  {
794
872
  htMeta: {
@@ -919,7 +997,7 @@ describe('HashTreeParser', () => {
919
997
  {type: 'ControlEntry', id: 10101, version: 100}
920
998
  ]);
921
999
 
922
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1000
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
923
1001
  updatedObjects: [
924
1002
  {
925
1003
  htMeta: {
@@ -1009,7 +1087,7 @@ describe('HashTreeParser', () => {
1009
1087
  assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 201}]);
1010
1088
 
1011
1089
  // Verify callback was called only for known dataset
1012
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1090
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1013
1091
  updatedObjects: [
1014
1092
  {
1015
1093
  htMeta: {
@@ -1109,7 +1187,7 @@ describe('HashTreeParser', () => {
1109
1187
  assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 51});
1110
1188
 
1111
1189
  // Verify callback was called with metadata object and removed dataset objects
1112
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1190
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1113
1191
  updatedObjects: [
1114
1192
  // updated metadata object:
1115
1193
  {
@@ -1270,7 +1348,7 @@ describe('HashTreeParser', () => {
1270
1348
  assert.notCalled(atdUnmutedPutItemsSpy);
1271
1349
 
1272
1350
  // Verify callback was called with the updated object
1273
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1351
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1274
1352
  updatedObjects: [
1275
1353
  {
1276
1354
  htMeta: {
@@ -1498,7 +1576,7 @@ describe('HashTreeParser', () => {
1498
1576
  ]);
1499
1577
 
1500
1578
  // Verify callback was called with OBJECTS_UPDATED and all updated objects
1501
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1579
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1502
1580
  updatedObjects: [
1503
1581
  {
1504
1582
  htMeta: {
@@ -1563,9 +1641,7 @@ describe('HashTreeParser', () => {
1563
1641
  parser.handleMessage(sentinelMessage, 'sentinel message');
1564
1642
 
1565
1643
  // Verify callback was called with MEETING_ENDED
1566
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1567
- updatedObjects: undefined,
1568
- });
1644
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1569
1645
 
1570
1646
  // Verify that all timers were stopped
1571
1647
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1587,9 +1663,7 @@ describe('HashTreeParser', () => {
1587
1663
  parser.handleMessage(sentinelMessage, 'sentinel message');
1588
1664
 
1589
1665
  // Verify callback was called with MEETING_ENDED
1590
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1591
- updatedObjects: undefined,
1592
- });
1666
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1593
1667
 
1594
1668
  // Verify that all timers were stopped
1595
1669
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1685,7 +1759,7 @@ describe('HashTreeParser', () => {
1685
1759
  );
1686
1760
 
1687
1761
  // Verify that callback was called with synced objects
1688
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1762
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1689
1763
  updatedObjects: [
1690
1764
  {
1691
1765
  htMeta: {
@@ -1698,12 +1772,10 @@ describe('HashTreeParser', () => {
1698
1772
  });
1699
1773
  });
1700
1774
 
1701
- describe('emits MEETING_ENDED', () => {
1702
- [404, 409].forEach((statusCode) => {
1703
- it(`when /hashtree returns ${statusCode}`, async () => {
1775
+ describe('emits MEETING_ENDED when 409/2403004 is returned', () => {
1776
+ it('when /hashtree returns 409', async () => {
1704
1777
  const parser = createHashTreeParser();
1705
1778
 
1706
- // Send a message to trigger sync algorithm
1707
1779
  const message = {
1708
1780
  dataSets: [createDataSet('main', 16, 1100)],
1709
1781
  visibleDataSetsUrl,
@@ -1728,12 +1800,9 @@ describe('HashTreeParser', () => {
1728
1800
 
1729
1801
  const mainDataSetUrl = parser.dataSets.main.url;
1730
1802
 
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
- }
1803
+ const error: any = new Error('Request failed with status 409');
1804
+ error.statusCode = 409;
1805
+ error.body = {errorCode: 2403004};
1737
1806
  webexRequest
1738
1807
  .withArgs(
1739
1808
  sinon.match({
@@ -1743,13 +1812,120 @@ describe('HashTreeParser', () => {
1743
1812
  )
1744
1813
  .rejects(error);
1745
1814
 
1746
- // Trigger sync by advancing time
1747
1815
  await clock.tickAsync(1000);
1748
1816
 
1749
- // Verify callback was called with MEETING_ENDED
1750
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1751
- updatedObjects: undefined,
1817
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1818
+
1819
+ Object.values(parser.dataSets).forEach((ds: any) => {
1820
+ assert.isUndefined(ds.timer);
1821
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1822
+ });
1823
+
1824
+ // Verify no sync failure metric was sent for end-meeting sentinel
1825
+ assert.notCalled(metricsStub);
1826
+ });
1827
+
1828
+ it('when /sync returns 409', async () => {
1829
+ const parser = createHashTreeParser();
1830
+
1831
+ const message = {
1832
+ dataSets: [createDataSet('main', 16, 1100)],
1833
+ visibleDataSetsUrl,
1834
+ locusUrl,
1835
+ locusStateElements: [
1836
+ {
1837
+ htMeta: {
1838
+ elementId: {
1839
+ type: 'locus' as const,
1840
+ id: 0,
1841
+ version: 201,
1842
+ },
1843
+ dataSetNames: ['main'],
1844
+ },
1845
+ data: {info: {id: 'initial-update'}},
1846
+ },
1847
+ ],
1848
+ };
1849
+
1850
+ parser.handleMessage(message, 'initial message');
1851
+ callback.resetHistory();
1852
+
1853
+ const mainDataSetUrl = parser.dataSets.main.url;
1854
+
1855
+ mockGetHashesFromLocusResponse(
1856
+ mainDataSetUrl,
1857
+ new Array(16).fill('00000000000000000000000000000000'),
1858
+ createDataSet('main', 16, 1101)
1859
+ );
1860
+
1861
+ const error: any = new Error('Request failed with status 409');
1862
+ error.statusCode = 409;
1863
+ error.body = {errorCode: 2403004};
1864
+ webexRequest
1865
+ .withArgs(
1866
+ sinon.match({
1867
+ method: 'POST',
1868
+ uri: `${mainDataSetUrl}/sync`,
1869
+ })
1870
+ )
1871
+ .rejects(error);
1872
+
1873
+ await clock.tickAsync(1000);
1874
+
1875
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1876
+
1877
+ Object.values(parser.dataSets).forEach((ds: any) => {
1878
+ assert.isUndefined(ds.timer);
1879
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1752
1880
  });
1881
+ });
1882
+ });
1883
+
1884
+ describe('emits LOCUS_NOT_FOUND and stops parser when 404 is returned', () => {
1885
+ it('when /hashtree returns 404', async () => {
1886
+ const parser = createHashTreeParser();
1887
+
1888
+ const message = {
1889
+ dataSets: [createDataSet('main', 16, 1100)],
1890
+ visibleDataSetsUrl,
1891
+ locusUrl,
1892
+ locusStateElements: [
1893
+ {
1894
+ htMeta: {
1895
+ elementId: {
1896
+ type: 'locus' as const,
1897
+ id: 0,
1898
+ version: 201,
1899
+ },
1900
+ dataSetNames: ['main'],
1901
+ },
1902
+ data: {info: {id: 'initial-update'}},
1903
+ },
1904
+ ],
1905
+ };
1906
+
1907
+ parser.handleMessage(message, 'initial message');
1908
+ callback.resetHistory();
1909
+
1910
+ const mainDataSetUrl = parser.dataSets.main.url;
1911
+
1912
+ const error: any = new Error('Request failed with status 404');
1913
+ error.statusCode = 404;
1914
+ webexRequest
1915
+ .withArgs(
1916
+ sinon.match({
1917
+ method: 'GET',
1918
+ uri: `${mainDataSetUrl}/hashtree`,
1919
+ })
1920
+ )
1921
+ .rejects(error);
1922
+
1923
+ await clock.tickAsync(1000);
1924
+
1925
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
1926
+
1927
+ // Verify parser is stopped
1928
+ expect(parser.state).to.equal('stopped');
1753
1929
 
1754
1930
  // Verify all timers are stopped
1755
1931
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1758,10 +1934,9 @@ describe('HashTreeParser', () => {
1758
1934
  });
1759
1935
  });
1760
1936
 
1761
- it(`when /sync returns ${statusCode}`, async () => {
1937
+ it('when /sync returns 404', async () => {
1762
1938
  const parser = createHashTreeParser();
1763
1939
 
1764
- // Send a message to trigger sync algorithm
1765
1940
  const message = {
1766
1941
  dataSets: [createDataSet('main', 16, 1100)],
1767
1942
  visibleDataSetsUrl,
@@ -1786,19 +1961,14 @@ describe('HashTreeParser', () => {
1786
1961
 
1787
1962
  const mainDataSetUrl = parser.dataSets.main.url;
1788
1963
 
1789
- // Mock getHashesFromLocus to succeed
1790
1964
  mockGetHashesFromLocusResponse(
1791
1965
  mainDataSetUrl,
1792
1966
  new Array(16).fill('00000000000000000000000000000000'),
1793
1967
  createDataSet('main', 16, 1101)
1794
1968
  );
1795
1969
 
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
- }
1970
+ const error: any = new Error('Request failed with status 404');
1971
+ error.statusCode = 404;
1802
1972
  webexRequest
1803
1973
  .withArgs(
1804
1974
  sinon.match({
@@ -1808,21 +1978,22 @@ describe('HashTreeParser', () => {
1808
1978
  )
1809
1979
  .rejects(error);
1810
1980
 
1811
- // Trigger sync by advancing time
1812
1981
  await clock.tickAsync(1000);
1813
1982
 
1814
- // Verify callback was called with MEETING_ENDED
1815
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1816
- updatedObjects: undefined,
1817
- });
1983
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
1984
+
1985
+ // Verify parser is stopped
1986
+ expect(parser.state).to.equal('stopped');
1818
1987
 
1819
1988
  // Verify all timers are stopped
1820
1989
  Object.values(parser.dataSets).forEach((ds: any) => {
1821
1990
  assert.isUndefined(ds.timer);
1822
1991
  assert.isUndefined(ds.heartbeatWatchdogTimer);
1823
1992
  });
1993
+
1994
+ // Verify no sync failure metric was sent for end-meeting sentinel
1995
+ assert.notCalled(metricsStub);
1824
1996
  });
1825
- });
1826
1997
  });
1827
1998
 
1828
1999
  it('requests only mismatched hashes during sync', async () => {
@@ -1993,79 +2164,299 @@ describe('HashTreeParser', () => {
1993
2164
  },
1994
2165
  });
1995
2166
  });
1996
- });
1997
2167
 
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
2168
+ it('restarts the sync timer when sync response is empty so that a future sync can be triggered', async () => {
2001
2169
  const parser = createHashTreeParser();
2002
2170
 
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)],
2171
+ // Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
2172
+ const heartbeatMessage = {
2173
+ dataSets: [
2174
+ {
2175
+ ...createDataSet('main', 16, 1100),
2176
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from ours
2177
+ },
2178
+ ],
2009
2179
  visibleDataSetsUrl,
2010
2180
  locusUrl,
2011
- locusStateElements: [
2181
+ };
2182
+
2183
+ parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
2184
+
2185
+ // The sync timer should be set
2186
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
2187
+
2188
+ // Mock responses for the first sync - return null (204/empty body)
2189
+ const mainDataSetUrl = parser.dataSets.main.url;
2190
+ mockGetHashesFromLocusResponse(
2191
+ mainDataSetUrl,
2192
+ new Array(16).fill('00000000000000000000000000000000'),
2193
+ {
2194
+ ...createDataSet('main', 16, 1101),
2195
+ root: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', // still mismatched
2196
+ }
2197
+ );
2198
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
2199
+
2200
+ // Advance time to fire the sync timer (idleMs=1000 + backoff=0)
2201
+ await clock.tickAsync(1000);
2202
+
2203
+ // Verify sync was triggered
2204
+ assert.calledWith(
2205
+ webexRequest,
2206
+ sinon.match({
2207
+ method: 'POST',
2208
+ uri: `${mainDataSetUrl}/sync`,
2209
+ })
2210
+ );
2211
+
2212
+ // After empty response, runSyncAlgorithm should have been called,
2213
+ // setting a new sync timer as a safety net
2214
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
2215
+
2216
+ // Reset and set up mocks for the second sync
2217
+ webexRequest.resetHistory();
2218
+ mockGetHashesFromLocusResponse(
2219
+ mainDataSetUrl,
2220
+ new Array(16).fill('00000000000000000000000000000000'),
2221
+ {
2222
+ ...createDataSet('main', 16, 1102),
2223
+ root: 'cccccccccccccccccccccccccccccccc', // still mismatched
2224
+ }
2225
+ );
2226
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
2227
+
2228
+ // Advance time again to fire the second sync timer
2229
+ await clock.tickAsync(1000);
2230
+
2231
+ // Verify a second sync was triggered
2232
+ assert.calledWith(
2233
+ webexRequest,
2234
+ sinon.match({
2235
+ method: 'POST',
2236
+ uri: `${mainDataSetUrl}/sync`,
2237
+ })
2238
+ );
2239
+ });
2240
+
2241
+ it('updates dataSet.leafCount when hash tree is resized during sync so that the sync request has the correct leafCount', async () => {
2242
+ const parser = createHashTreeParser();
2243
+
2244
+ // Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
2245
+ const heartbeatMessage = {
2246
+ dataSets: [
2012
2247
  {
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
- },
2248
+ ...createDataSet('main', 16, 1100),
2249
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from ours
2041
2250
  },
2042
2251
  ],
2252
+ visibleDataSetsUrl,
2253
+ locusUrl,
2043
2254
  };
2044
2255
 
2045
- parser.handleMessage(message, 'add visible dataset');
2256
+ parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
2046
2257
 
2047
- // Verify that 'attendees' was added to visibleDataSets
2048
- expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.true;
2258
+ // The sync timer should be set
2259
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
2049
2260
 
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);
2261
+ const mainDataSetUrl = parser.dataSets.main.url;
2262
+ const newLeafCount = 32;
2053
2263
 
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
- {
2264
+ // Mock getHashesFromLocus response with a DIFFERENT leafCount (32 instead of 16)
2265
+ mockGetHashesFromLocusResponse(
2266
+ mainDataSetUrl,
2267
+ new Array(newLeafCount).fill('00000000000000000000000000000000'),
2268
+ createDataSet('main', newLeafCount, 1101)
2269
+ );
2270
+
2271
+ // Mock the sync request - use matching root hash
2272
+ const syncResponseDataSet = createDataSet('main', newLeafCount, 1102);
2273
+ syncResponseDataSet.root = parser.dataSets.main.hashTree.getRootHash();
2274
+ mockSendSyncRequestResponse(mainDataSetUrl, {
2275
+ dataSets: [syncResponseDataSet],
2276
+ visibleDataSetsUrl,
2277
+ locusUrl,
2278
+ locusStateElements: [],
2279
+ });
2280
+
2281
+ // Advance time to fire the sync timer (idleMs=1000 + backoff=0)
2282
+ await clock.tickAsync(1000);
2283
+
2284
+ // Verify the sync request was sent with the NEW leafCount (32), not the old one (16)
2285
+ assert.calledWith(
2286
+ webexRequest,
2287
+ sinon.match({
2288
+ method: 'POST',
2289
+ uri: `${mainDataSetUrl}/sync`,
2290
+ body: sinon.match({
2291
+ leafCount: newLeafCount,
2292
+ }),
2293
+ })
2294
+ );
2295
+ });
2296
+
2297
+ it('sends HASH_TREE_SYNC_FAILURE metric when GET /hashtree request fails', async () => {
2298
+ const parser = createHashTreeParser();
2299
+
2300
+ // Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
2301
+ const heartbeatMessage = {
2302
+ dataSets: [
2303
+ {
2304
+ ...createDataSet('main', 16, 1100),
2305
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
2306
+ },
2307
+ ],
2308
+ visibleDataSetsUrl,
2309
+ locusUrl,
2310
+ };
2311
+
2312
+ parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
2313
+
2314
+ const mainDataSetUrl = parser.dataSets.main.url;
2315
+ const hashTreeError = new Error('server error') as any;
2316
+ hashTreeError.statusCode = 500;
2317
+
2318
+ webexRequest
2319
+ .withArgs(
2320
+ sinon.match({
2321
+ method: 'GET',
2322
+ uri: `${mainDataSetUrl}/hashtree`,
2323
+ })
2324
+ )
2325
+ .rejects(hashTreeError);
2326
+
2327
+ await clock.tickAsync(1000);
2328
+
2329
+ assert.calledOnceWithExactly(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
2330
+ debugId: 'test',
2331
+ dataSetName: 'main',
2332
+ request: 'GET /hashtree',
2333
+ statusCode: 500,
2334
+ reason: 'server error',
2335
+ });
2336
+ });
2337
+
2338
+ it('sends HASH_TREE_SYNC_FAILURE metric when POST /sync request fails', async () => {
2339
+ const parser = createHashTreeParser();
2340
+
2341
+ // Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
2342
+ const heartbeatMessage = {
2343
+ dataSets: [
2344
+ {
2345
+ ...createDataSet('main', 16, 1100),
2346
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
2347
+ },
2348
+ ],
2349
+ visibleDataSetsUrl,
2350
+ locusUrl,
2351
+ };
2352
+
2353
+ parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
2354
+
2355
+ const mainDataSetUrl = parser.dataSets.main.url;
2356
+
2357
+ // Mock getHashesFromLocus to succeed
2358
+ mockGetHashesFromLocusResponse(
2359
+ mainDataSetUrl,
2360
+ new Array(16).fill('00000000000000000000000000000000'),
2361
+ createDataSet('main', 16, 1101)
2362
+ );
2363
+
2364
+ // Mock sendSyncRequestToLocus to fail
2365
+ const syncError = new Error('sync failed') as any;
2366
+ syncError.statusCode = 500;
2367
+
2368
+ webexRequest
2369
+ .withArgs(
2370
+ sinon.match({
2371
+ method: 'POST',
2372
+ uri: `${mainDataSetUrl}/sync`,
2373
+ })
2374
+ )
2375
+ .rejects(syncError);
2376
+
2377
+ await clock.tickAsync(1000);
2378
+
2379
+ assert.calledOnceWithExactly(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
2380
+ debugId: 'test',
2381
+ dataSetName: 'main',
2382
+ request: 'POST /sync',
2383
+ statusCode: 500,
2384
+ reason: 'sync failed',
2385
+ });
2386
+ });
2387
+ });
2388
+
2389
+ describe('handles visible data sets changes correctly', () => {
2390
+ it('handles addition of visible data set (one that does not require async initialization)', async () => {
2391
+ // Create a parser with visible datasets
2392
+ const parser = createHashTreeParser();
2393
+
2394
+ // Stub updateItems on self hash tree to return true
2395
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2396
+
2397
+ // Send a message with Metadata object that has a new visibleDataSets list
2398
+ const message = {
2399
+ dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
2400
+ visibleDataSetsUrl,
2401
+ locusUrl,
2402
+ locusStateElements: [
2403
+ {
2404
+ htMeta: {
2405
+ elementId: {
2406
+ type: 'metadata' as const,
2407
+ id: 5,
2408
+ version: 51,
2409
+ },
2410
+ dataSetNames: ['self'],
2411
+ },
2412
+ data: {
2413
+ visibleDataSets: [
2414
+ {
2415
+ name: 'main',
2416
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2417
+ },
2418
+ {
2419
+ name: 'self',
2420
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2421
+ },
2422
+ {
2423
+ name: 'atd-unmuted',
2424
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2425
+ },
2426
+ {
2427
+ name: 'attendees',
2428
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
2429
+ },
2430
+ ], // added 'attendees'
2431
+ },
2432
+ },
2433
+ ],
2434
+ };
2435
+
2436
+ parser.handleMessage(message, 'add visible dataset');
2437
+
2438
+ // Verify that 'attendees' was added to visibleDataSets
2439
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.true;
2440
+
2441
+ // Verify that a hash tree was created for 'attendees'
2442
+ assert.exists(parser.dataSets.attendees.hashTree);
2443
+ assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
2444
+
2445
+ // Verify callback was called with the metadata update (appears twice - processed once for visible dataset changes, once in main loop)
2446
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2447
+ updatedObjects: [
2448
+ {
2449
+ htMeta: {
2450
+ elementId: {type: 'metadata', id: 5, version: 51},
2451
+ dataSetNames: ['self'],
2452
+ },
2453
+ data: {
2454
+ visibleDataSets: [
2455
+ {
2456
+ name: 'main',
2457
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2458
+ },
2459
+ {
2069
2460
  name: 'self',
2070
2461
  url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2071
2462
  },
@@ -2172,7 +2563,99 @@ describe('HashTreeParser', () => {
2172
2563
  await checkAsyncDatasetInitialization(parser, newDataSet);
2173
2564
  });
2174
2565
 
2175
- it('emits MEETING_ENDED if async init of a new visible dataset fails with 404', async () => {
2566
+ it('initializes new visible data sets in priority order', async () => {
2567
+ // Create a parser that only has "self" as visible (no "main")
2568
+ const initialLocusWithoutMain = {
2569
+ dataSets: [createDataSet('self', 1, 2000)],
2570
+ locus: {
2571
+ ...exampleInitialLocus.locus,
2572
+ },
2573
+ };
2574
+ const metadataWithoutMain = {
2575
+ ...exampleMetadata,
2576
+ visibleDataSets: [
2577
+ {
2578
+ name: 'self',
2579
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2580
+ },
2581
+ ],
2582
+ };
2583
+ const parser = createHashTreeParser(initialLocusWithoutMain, metadataWithoutMain);
2584
+
2585
+ // Verify "main" is not visible initially
2586
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.false;
2587
+
2588
+ // Stub updateItems on self hash tree to return true
2589
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2590
+
2591
+ // Send a message that adds "main" and "atd-active" as new visible datasets.
2592
+ // Neither has info in dataSets, so both require async initialization.
2593
+ const newMainDataSet = createDataSet('main', 16, 6000);
2594
+ const newAtdActiveDataSet = createDataSet('atd-active', 4, 7000);
2595
+
2596
+ const message = {
2597
+ dataSets: [createDataSet('self', 1, 2100)],
2598
+ visibleDataSetsUrl,
2599
+ locusUrl,
2600
+ locusStateElements: [
2601
+ {
2602
+ htMeta: {
2603
+ elementId: {
2604
+ type: 'metadata' as const,
2605
+ id: 5,
2606
+ version: 51,
2607
+ },
2608
+ dataSetNames: ['self'],
2609
+ },
2610
+ data: {
2611
+ visibleDataSets: [
2612
+ {
2613
+ name: 'self',
2614
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2615
+ },
2616
+ // listed in non-priority order: atd-active before main
2617
+ {name: 'atd-active', url: newAtdActiveDataSet.url},
2618
+ {name: 'main', url: newMainDataSet.url},
2619
+ ],
2620
+ },
2621
+ },
2622
+ ],
2623
+ };
2624
+
2625
+ // Mock getAllVisibleDataSetsFromLocus to return both new datasets (in non-priority order)
2626
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
2627
+ newAtdActiveDataSet,
2628
+ newMainDataSet,
2629
+ ]);
2630
+ mockSyncRequest(webexRequest, newMainDataSet.url);
2631
+ mockSyncRequest(webexRequest, newAtdActiveDataSet.url);
2632
+
2633
+ parser.handleMessage(message, 'add main and atd-active datasets');
2634
+
2635
+ // Wait for the async initialization (queueMicrotask) to complete
2636
+ await clock.tickAsync(0);
2637
+
2638
+ // Verify both datasets are initialized
2639
+ expect(parser.dataSets.main?.hashTree).to.exist;
2640
+ expect(parser.dataSets['atd-active']?.hashTree).to.exist;
2641
+
2642
+ // Verify sync requests were sent in priority order: "main" before "atd-active",
2643
+ // even though atd-active was listed first in both the message and the Locus response
2644
+ const syncCalls = webexRequest
2645
+ .getCalls()
2646
+ .filter(
2647
+ (call) =>
2648
+ call.args[0]?.method === 'POST' &&
2649
+ call.args[0]?.uri?.endsWith('/sync') &&
2650
+ (call.args[0]?.uri?.includes('/main/') || call.args[0]?.uri?.includes('/atd-active/'))
2651
+ );
2652
+
2653
+ expect(syncCalls).to.have.lengthOf(2);
2654
+ expect(syncCalls[0].args[0].uri).to.equal(`${newMainDataSet.url}/sync`);
2655
+ expect(syncCalls[1].args[0].uri).to.equal(`${newAtdActiveDataSet.url}/sync`);
2656
+ });
2657
+
2658
+ it('emits LOCUS_NOT_FOUND if async init of a new visible dataset fails with 404', async () => {
2176
2659
  const parser = createHashTreeParser();
2177
2660
 
2178
2661
  // Stub updateItems on self hash tree to return true
@@ -2237,10 +2720,8 @@ describe('HashTreeParser', () => {
2237
2720
  // Wait for the async initialization (queueMicrotask) to complete
2238
2721
  await clock.tickAsync(0);
2239
2722
 
2240
- // Verify callback was called with MEETING_ENDED
2241
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
2242
- updatedObjects: undefined,
2243
- });
2723
+ // Verify callback was called with LOCUS_NOT_FOUND (404 means locus URL is stale, not necessarily meeting ended)
2724
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
2244
2725
  });
2245
2726
 
2246
2727
  it('handles removal of visible data set', async () => {
@@ -2303,7 +2784,7 @@ describe('HashTreeParser', () => {
2303
2784
  assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
2304
2785
 
2305
2786
  // 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, {
2787
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2307
2788
  updatedObjects: [
2308
2789
  {
2309
2790
  htMeta: {
@@ -2400,38 +2881,182 @@ describe('HashTreeParser', () => {
2400
2881
  // Verify callback was NOT called (no updates for non-visible datasets)
2401
2882
  assert.notCalled(callback);
2402
2883
  });
2403
- });
2404
2884
 
2405
- describe('heartbeat watchdog', () => {
2406
- it('initiates sync immediately only for the specific data set whose heartbeat watchdog fires', async () => {
2407
- const parser = createHashTreeParser();
2408
- const heartbeatIntervalMs = 5000;
2885
+ it('reports update for object that moves from removed visible dataset to new visible dataset even if version is unchanged', async () => {
2886
+ // The purpose of this test is to verify that when an object
2887
+ // moves from one visible dataset to another without version change,
2888
+ // the parser still reports it as an update.
2889
+ // Locus has some additional signalling for this - the "view" property in htMeta.elementId.
2890
+ // When a view changes, the contents of the object may change even if version doesn't.
2891
+ // HashTreeParser doesn't use the "view" property, because it doesn't need to -
2892
+ // the same functionality is achieved thanks to the fact that a new visible data set means
2893
+ // a new hash tree is created, so HashTreeParser still detects the change as new
2894
+ // object is added to the new hash tree.
2895
+
2896
+ // Setup: parser with visible datasets "self" and "unjoined"
2897
+ const unjoinedDataSet = createDataSet('unjoined', 4, 3000);
2898
+ const selfDataSet = createDataSet('self', 1, 2000);
2899
+
2900
+ // start with Locus that has "info" in both "unjoined" and "main" datasets,
2901
+ // but only "unjoined" is visible.
2902
+ const initialLocus = {
2903
+ dataSets: [selfDataSet, unjoinedDataSet],
2904
+ locus: {
2905
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
2906
+ links: {resources: {visibleDataSets: {url: visibleDataSetsUrl}}},
2907
+ // info object in "unjoined" dataset with version 500
2908
+ info: {
2909
+ htMeta: {
2910
+ elementId: {
2911
+ type: 'info',
2912
+ id: 42,
2913
+ version: 500,
2914
+ view: ['unjoined'], // not used by our code, but here for completeness - that's what real Locus would send
2915
+ },
2916
+ dataSetNames: ['main', 'unjoined'],
2917
+ },
2918
+ someField: 'some-initial-value',
2919
+ },
2920
+ self: {
2921
+ htMeta: {
2922
+ elementId: {
2923
+ type: 'self',
2924
+ id: 4,
2925
+ version: 100,
2926
+ },
2927
+ dataSetNames: ['self'],
2928
+ },
2929
+ },
2930
+ },
2931
+ };
2409
2932
 
2410
- // Send initial heartbeat message for 'main' only
2411
- const heartbeatMessage = {
2412
- dataSets: [
2413
- {
2414
- ...createDataSet('main', 16, 1100),
2415
- root: parser.dataSets.main.hashTree.getRootHash(),
2933
+ const metadata = {
2934
+ htMeta: {
2935
+ elementId: {
2936
+ type: 'metadata',
2937
+ id: 5,
2938
+ version: 50,
2416
2939
  },
2940
+ dataSetNames: ['self'],
2941
+ },
2942
+ visibleDataSets: [
2943
+ {name: 'self', url: selfDataSet.url},
2944
+ {name: 'unjoined', url: unjoinedDataSet.url},
2417
2945
  ],
2418
- visibleDataSetsUrl,
2419
- locusUrl,
2420
- heartbeatIntervalMs,
2421
2946
  };
2422
2947
 
2423
- parser.handleMessage(heartbeatMessage, 'initial heartbeat');
2948
+ const parser = createHashTreeParser(initialLocus, metadata);
2424
2949
 
2425
- // Verify only 'main' watchdog timer is set
2426
- expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2427
- expect(parser.dataSets.self.heartbeatWatchdogTimer).to.be.undefined;
2428
- expect(parser.dataSets['atd-unmuted'].heartbeatWatchdogTimer).to.be.undefined;
2950
+ // Verify initial state: unjoined is visible and has the info object
2951
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.true;
2952
+ assert.exists(parser.dataSets.unjoined.hashTree);
2953
+ assert.equal(parser.dataSets.unjoined.hashTree?.getItemVersion(42, 'info'), 500);
2429
2954
 
2430
- // Mock responses for performSync (GET hashtree then POST sync for leafCount > 1)
2431
- const mainDataSetUrl = parser.dataSets.main.url;
2432
- mockGetHashesFromLocusResponse(
2433
- mainDataSetUrl,
2434
- new Array(16).fill('00000000000000000000000000000000'),
2955
+ // Stub updateItems on self hash tree to return true for metadata update
2956
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2957
+
2958
+ // Now send a message that:
2959
+ // 1. Changes visible datasets: removes "unjoined", adds "main"
2960
+ // 2. Contains the same info object (same id=42, same version=500) but we see the view from "main" dataset
2961
+ const mainDataSet = createDataSet('main', 16, 1000);
2962
+
2963
+ const message = {
2964
+ dataSets: [selfDataSet, mainDataSet],
2965
+ visibleDataSetsUrl,
2966
+ locusUrl,
2967
+ locusStateElements: [
2968
+ {
2969
+ htMeta: {
2970
+ elementId: {
2971
+ type: 'metadata' as const,
2972
+ id: 5,
2973
+ version: 51,
2974
+ },
2975
+ dataSetNames: ['self'],
2976
+ },
2977
+ data: {
2978
+ visibleDataSets: [
2979
+ {name: 'self', url: selfDataSet.url},
2980
+ {name: 'main', url: mainDataSet.url},
2981
+ // "unjoined" is no longer here
2982
+ ],
2983
+ },
2984
+ },
2985
+ {
2986
+ htMeta: {
2987
+ elementId: {
2988
+ type: 'info' as const,
2989
+ id: 42,
2990
+ version: 500, // same version as before
2991
+ view: ['main'], // now points to "main" instead of "unjoined"
2992
+ },
2993
+ dataSetNames: ['main', 'unjoined'], // still in both datasets, but only "main" is visible now
2994
+ },
2995
+ data: {someNewField: 'some-value'},
2996
+ },
2997
+ ],
2998
+ };
2999
+
3000
+ parser.handleMessage(message, 'visible dataset swap with same-version object');
3001
+
3002
+ // Verify "unjoined" is no longer visible and "main" is now visible
3003
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.false;
3004
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
3005
+
3006
+ // Verify the info object is now in the "main" hash tree
3007
+ assert.exists(parser.dataSets.main.hashTree);
3008
+ assert.equal(parser.dataSets.main.hashTree?.getItemVersion(42, 'info'), 500);
3009
+
3010
+ // The key assertion: callback should be called with the info object update even though
3011
+ // its version hasn't changed - because visible datasets changed (moved from unjoined to main)
3012
+ assert.calledOnce(callback);
3013
+ const callbackArgs = callback.firstCall.args[0];
3014
+ assert.equal(callbackArgs.updateType, LocusInfoUpdateType.OBJECTS_UPDATED);
3015
+
3016
+ // Should contain the info object update (with data)
3017
+ const infoUpdate = callbackArgs.updatedObjects.find(
3018
+ (obj) => obj.htMeta.elementId.type === 'info' && obj.htMeta.elementId.id === 42
3019
+ );
3020
+ assert.exists(infoUpdate);
3021
+ assert.deepEqual(infoUpdate.htMeta.elementId, {
3022
+ type: 'info',
3023
+ id: 42,
3024
+ version: 500,
3025
+ view: ['main'],
3026
+ });
3027
+ assert.deepEqual(infoUpdate.data, {someNewField: 'some-value'});
3028
+ });
3029
+ });
3030
+
3031
+ describe('heartbeat watchdog', () => {
3032
+ it('initiates sync immediately only for the specific data set whose heartbeat watchdog fires', async () => {
3033
+ const parser = createHashTreeParser();
3034
+ const heartbeatIntervalMs = 5000;
3035
+
3036
+ // Send initial heartbeat message for 'main' only
3037
+ const heartbeatMessage = {
3038
+ dataSets: [
3039
+ {
3040
+ ...createDataSet('main', 16, 1100),
3041
+ root: parser.dataSets.main.hashTree.getRootHash(),
3042
+ },
3043
+ ],
3044
+ visibleDataSetsUrl,
3045
+ locusUrl,
3046
+ };
3047
+
3048
+ parser.handleMessage(heartbeatMessage, 'initial heartbeat');
3049
+
3050
+ // Verify only 'main' watchdog timer is set
3051
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
3052
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.be.undefined;
3053
+ expect(parser.dataSets['atd-unmuted'].heartbeatWatchdogTimer).to.be.undefined;
3054
+
3055
+ // Mock responses for performSync (GET hashtree then POST sync for leafCount > 1)
3056
+ const mainDataSetUrl = parser.dataSets.main.url;
3057
+ mockGetHashesFromLocusResponse(
3058
+ mainDataSetUrl,
3059
+ new Array(16).fill('00000000000000000000000000000000'),
2435
3060
  createDataSet('main', 16, 1101)
2436
3061
  );
2437
3062
  mockSendSyncRequestResponse(mainDataSetUrl, null);
@@ -2449,6 +3074,12 @@ describe('HashTreeParser', () => {
2449
3074
  })
2450
3075
  );
2451
3076
 
3077
+ // Verify behavioral metric was sent for the watchdog expiration
3078
+ assert.calledWith(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_HEARTBEAT_WATCHDOG_EXPIRED, {
3079
+ debugId: 'test',
3080
+ dataSetName: 'main',
3081
+ });
3082
+
2452
3083
  // Verify no sync requests were sent for other datasets
2453
3084
  assert.neverCalledWith(
2454
3085
  webexRequest,
@@ -2481,7 +3112,6 @@ describe('HashTreeParser', () => {
2481
3112
  ],
2482
3113
  visibleDataSetsUrl,
2483
3114
  locusUrl,
2484
- heartbeatIntervalMs,
2485
3115
  };
2486
3116
 
2487
3117
  parser.handleMessage(heartbeatMessage, 'self heartbeat');
@@ -2492,6 +3122,12 @@ describe('HashTreeParser', () => {
2492
3122
  // Advance time past watchdog delay
2493
3123
  await clock.tickAsync(heartbeatIntervalMs);
2494
3124
 
3125
+ // Verify behavioral metric was sent for the watchdog expiration
3126
+ assert.calledWith(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_HEARTBEAT_WATCHDOG_EXPIRED, {
3127
+ debugId: 'test',
3128
+ dataSetName: 'self',
3129
+ });
3130
+
2495
3131
  // For leafCount === 1, performSync skips GET hashtree and goes straight to POST sync
2496
3132
  assert.neverCalledWith(
2497
3133
  webexRequest,
@@ -2511,7 +3147,6 @@ describe('HashTreeParser', () => {
2511
3147
 
2512
3148
  it('sets watchdog timers for each data set in the message', async () => {
2513
3149
  const parser = createHashTreeParser();
2514
- const heartbeatIntervalMs = 5000;
2515
3150
 
2516
3151
  // Send heartbeat with multiple datasets
2517
3152
  const heartbeatMessage = {
@@ -2528,7 +3163,6 @@ describe('HashTreeParser', () => {
2528
3163
  ],
2529
3164
  visibleDataSetsUrl,
2530
3165
  locusUrl,
2531
- heartbeatIntervalMs,
2532
3166
  };
2533
3167
 
2534
3168
  parser.handleMessage(heartbeatMessage, 'multi-dataset heartbeat');
@@ -2542,7 +3176,6 @@ describe('HashTreeParser', () => {
2542
3176
 
2543
3177
  it('resets the watchdog timer for a specific data set when a new heartbeat for it is received', async () => {
2544
3178
  const parser = createHashTreeParser();
2545
- const heartbeatIntervalMs = 5000;
2546
3179
 
2547
3180
  // Send first heartbeat for 'main'
2548
3181
  const heartbeat1 = {
@@ -2554,7 +3187,6 @@ describe('HashTreeParser', () => {
2554
3187
  ],
2555
3188
  visibleDataSetsUrl,
2556
3189
  locusUrl,
2557
- heartbeatIntervalMs,
2558
3190
  };
2559
3191
 
2560
3192
  parser.handleMessage(heartbeat1, 'first heartbeat');
@@ -2575,7 +3207,6 @@ describe('HashTreeParser', () => {
2575
3207
  ],
2576
3208
  visibleDataSetsUrl,
2577
3209
  locusUrl,
2578
- heartbeatIntervalMs,
2579
3210
  };
2580
3211
 
2581
3212
  parser.handleMessage(heartbeat2, 'second heartbeat');
@@ -2594,7 +3225,6 @@ describe('HashTreeParser', () => {
2594
3225
 
2595
3226
  it('resets the watchdog timer when a normal message (with locusStateElements) is received', async () => {
2596
3227
  const parser = createHashTreeParser();
2597
- const heartbeatIntervalMs = 5000;
2598
3228
 
2599
3229
  // Send initial heartbeat to start the watchdog for 'main'
2600
3230
  const heartbeat = {
@@ -2606,7 +3236,6 @@ describe('HashTreeParser', () => {
2606
3236
  ],
2607
3237
  visibleDataSetsUrl,
2608
3238
  locusUrl,
2609
- heartbeatIntervalMs,
2610
3239
  };
2611
3240
 
2612
3241
  parser.handleMessage(heartbeat, 'initial heartbeat');
@@ -2634,7 +3263,6 @@ describe('HashTreeParser', () => {
2634
3263
  data: {someData: 'value'},
2635
3264
  },
2636
3265
  ],
2637
- heartbeatIntervalMs,
2638
3266
  };
2639
3267
 
2640
3268
  parser.handleMessage(normalMessage, 'normal message');
@@ -2648,12 +3276,17 @@ describe('HashTreeParser', () => {
2648
3276
  const parser = createHashTreeParser();
2649
3277
 
2650
3278
  // Send a heartbeat message without heartbeatIntervalMs
2651
- const heartbeatMessage = createHeartbeatMessage(
2652
- 'main',
2653
- 16,
2654
- 1100,
2655
- parser.dataSets.main.hashTree.getRootHash()
2656
- );
3279
+ const heartbeatMessage = {
3280
+ dataSets: [
3281
+ {
3282
+ ...createDataSet('main', 16, 1100),
3283
+ root: parser.dataSets.main.hashTree.getRootHash(),
3284
+ heartbeatIntervalMs: undefined,
3285
+ },
3286
+ ],
3287
+ visibleDataSetsUrl,
3288
+ locusUrl,
3289
+ };
2657
3290
 
2658
3291
  parser.handleMessage(heartbeatMessage, 'heartbeat without interval');
2659
3292
 
@@ -2662,7 +3295,6 @@ describe('HashTreeParser', () => {
2662
3295
 
2663
3296
  it('stops all watchdog timers when meeting ends via sentinel message', async () => {
2664
3297
  const parser = createHashTreeParser();
2665
- const heartbeatIntervalMs = 5000;
2666
3298
 
2667
3299
  // Send heartbeat for multiple datasets
2668
3300
  const heartbeat = {
@@ -2679,7 +3311,6 @@ describe('HashTreeParser', () => {
2679
3311
  ],
2680
3312
  visibleDataSetsUrl,
2681
3313
  locusUrl,
2682
- heartbeatIntervalMs,
2683
3314
  };
2684
3315
 
2685
3316
  parser.handleMessage(heartbeat, 'initial heartbeat');
@@ -2730,7 +3361,6 @@ describe('HashTreeParser', () => {
2730
3361
  };
2731
3362
 
2732
3363
  const parser = createHashTreeParser(initialLocus, metadata);
2733
- const heartbeatIntervalMs = 5000;
2734
3364
 
2735
3365
  // Set Math.random to return 1 so that backoff = 1^exponent * maxMs = maxMs
2736
3366
  mathRandomStub.returns(1);
@@ -2752,7 +3382,6 @@ describe('HashTreeParser', () => {
2752
3382
  ],
2753
3383
  visibleDataSetsUrl,
2754
3384
  locusUrl,
2755
- heartbeatIntervalMs,
2756
3385
  };
2757
3386
 
2758
3387
  parser.handleMessage(heartbeat, 'heartbeat');
@@ -2802,7 +3431,6 @@ describe('HashTreeParser', () => {
2802
3431
 
2803
3432
  it('does not set watchdog for data sets without a hash tree', async () => {
2804
3433
  const parser = createHashTreeParser();
2805
- const heartbeatIntervalMs = 5000;
2806
3434
 
2807
3435
  // 'atd-active' is in the initial locus but is not visible (no hash tree)
2808
3436
  // Send heartbeat mentioning a non-visible dataset
@@ -2816,7 +3444,6 @@ describe('HashTreeParser', () => {
2816
3444
  ],
2817
3445
  visibleDataSetsUrl,
2818
3446
  locusUrl,
2819
- heartbeatIntervalMs,
2820
3447
  };
2821
3448
 
2822
3449
  parser.handleMessage(heartbeatMessage, 'heartbeat with non-visible dataset');
@@ -2825,7 +3452,192 @@ describe('HashTreeParser', () => {
2825
3452
  expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2826
3453
  expect(parser.dataSets['atd-active']?.heartbeatWatchdogTimer).to.be.undefined;
2827
3454
  });
3455
+
3456
+ it('restarts the watchdog timer after it fires so that future missed heartbeats still trigger syncs', async () => {
3457
+ const parser = createHashTreeParser();
3458
+ const heartbeatIntervalMs = 5000;
3459
+
3460
+ // Send initial heartbeat for 'main'
3461
+ const heartbeatMessage = {
3462
+ dataSets: [
3463
+ {
3464
+ ...createDataSet('main', 16, 1100),
3465
+ root: parser.dataSets.main.hashTree.getRootHash(),
3466
+ },
3467
+ ],
3468
+ visibleDataSetsUrl,
3469
+ locusUrl,
3470
+ };
3471
+
3472
+ parser.handleMessage(heartbeatMessage, 'initial heartbeat');
3473
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
3474
+
3475
+ // Mock responses for performSync - return null (204/empty body)
3476
+ const mainDataSetUrl = parser.dataSets.main.url;
3477
+ mockGetHashesFromLocusResponse(
3478
+ mainDataSetUrl,
3479
+ new Array(16).fill('00000000000000000000000000000000'),
3480
+ createDataSet('main', 16, 1101)
3481
+ );
3482
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
3483
+
3484
+ // Advance time past heartbeatIntervalMs to fire the watchdog
3485
+ await clock.tickAsync(heartbeatIntervalMs);
3486
+
3487
+ // Verify sync was triggered
3488
+ assert.calledWith(
3489
+ webexRequest,
3490
+ sinon.match({
3491
+ method: 'GET',
3492
+ uri: `${mainDataSetUrl}/hashtree`,
3493
+ })
3494
+ );
3495
+
3496
+ // The watchdog timer should have been restarted after firing
3497
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
3498
+
3499
+ // Reset call history and set up new mock responses for the second sync
3500
+ webexRequest.resetHistory();
3501
+ mockGetHashesFromLocusResponse(
3502
+ mainDataSetUrl,
3503
+ new Array(16).fill('00000000000000000000000000000000'),
3504
+ createDataSet('main', 16, 1102)
3505
+ );
3506
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
3507
+
3508
+ // Advance time again to fire the watchdog a second time
3509
+ await clock.tickAsync(heartbeatIntervalMs);
3510
+
3511
+ // Verify a second sync was triggered
3512
+ assert.calledWith(
3513
+ webexRequest,
3514
+ sinon.match({
3515
+ method: 'GET',
3516
+ uri: `${mainDataSetUrl}/hashtree`,
3517
+ })
3518
+ );
3519
+
3520
+ // And the watchdog should still be running
3521
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
3522
+ });
3523
+
3524
+ it('uses dataset-level heartbeatIntervalMs over top-level value', async () => {
3525
+ const parser = createHashTreeParser();
3526
+ const datasetLevelInterval = 3000;
3527
+ const topLevelInterval = 8000;
3528
+
3529
+ // Send heartbeat with both top-level and dataset-level heartbeatIntervalMs
3530
+ const heartbeatMessage = {
3531
+ dataSets: [
3532
+ {
3533
+ ...createDataSet('main', 16, 1100),
3534
+ root: parser.dataSets.main.hashTree.getRootHash(),
3535
+ heartbeatIntervalMs: datasetLevelInterval,
3536
+ },
3537
+ ],
3538
+ visibleDataSetsUrl,
3539
+ locusUrl,
3540
+ heartbeatIntervalMs: topLevelInterval,
3541
+ };
3542
+
3543
+ parser.handleMessage(heartbeatMessage, 'heartbeat with both levels');
3544
+
3545
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
3546
+
3547
+ // Mock sync responses
3548
+ const mainDataSetUrl = parser.dataSets.main.url;
3549
+ mockGetHashesFromLocusResponse(
3550
+ mainDataSetUrl,
3551
+ new Array(16).fill('00000000000000000000000000000000'),
3552
+ createDataSet('main', 16, 1101)
3553
+ );
3554
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
3555
+
3556
+ // Watchdog should NOT fire at the top-level interval (8000ms)
3557
+ // It should fire at the dataset-level interval (3000ms)
3558
+ await clock.tickAsync(datasetLevelInterval - 1);
3559
+ assert.notCalled(webexRequest);
3560
+
3561
+ await clock.tickAsync(1);
3562
+ // Now at datasetLevelInterval, watchdog should have fired
3563
+ assert.calledWith(
3564
+ webexRequest,
3565
+ sinon.match({
3566
+ method: 'GET',
3567
+ uri: `${mainDataSetUrl}/hashtree`,
3568
+ })
3569
+ );
3570
+ });
3571
+
3572
+ it('falls back to top-level heartbeatIntervalMs when dataset-level is missing', async () => {
3573
+ const parser = createHashTreeParser();
3574
+ const topLevelInterval = 7000;
3575
+
3576
+ // Send heartbeat with top-level heartbeatIntervalMs but no dataset-level
3577
+ const heartbeatMessage = {
3578
+ dataSets: [
3579
+ {
3580
+ ...createDataSet('main', 16, 1100),
3581
+ root: parser.dataSets.main.hashTree.getRootHash(),
3582
+ heartbeatIntervalMs: undefined,
3583
+ },
3584
+ ],
3585
+ visibleDataSetsUrl,
3586
+ locusUrl,
3587
+ heartbeatIntervalMs: topLevelInterval,
3588
+ };
3589
+
3590
+ parser.handleMessage(heartbeatMessage, 'heartbeat with top-level only');
3591
+
3592
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
3593
+
3594
+ // Mock sync responses
3595
+ const mainDataSetUrl = parser.dataSets.main.url;
3596
+ mockGetHashesFromLocusResponse(
3597
+ mainDataSetUrl,
3598
+ new Array(16).fill('00000000000000000000000000000000'),
3599
+ createDataSet('main', 16, 1101)
3600
+ );
3601
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
3602
+
3603
+ // Should fire at the top-level interval
3604
+ await clock.tickAsync(topLevelInterval - 1);
3605
+ assert.notCalled(webexRequest);
3606
+
3607
+ await clock.tickAsync(1);
3608
+ assert.calledWith(
3609
+ webexRequest,
3610
+ sinon.match({
3611
+ method: 'GET',
3612
+ uri: `${mainDataSetUrl}/hashtree`,
3613
+ })
3614
+ );
3615
+ });
3616
+
3617
+ it('does not start watchdog when dataset-level heartbeatIntervalMs is 0 even if top-level is set', async () => {
3618
+ const parser = createHashTreeParser();
3619
+
3620
+ // Send heartbeat with dataset-level 0 and a top-level value
3621
+ const heartbeatMessage = {
3622
+ dataSets: [
3623
+ {
3624
+ ...createDataSet('main', 16, 1100),
3625
+ root: parser.dataSets.main.hashTree.getRootHash(),
3626
+ heartbeatIntervalMs: 0,
3627
+ },
3628
+ ],
3629
+ visibleDataSetsUrl,
3630
+ locusUrl,
3631
+ heartbeatIntervalMs: 5000,
3632
+ };
3633
+
3634
+ parser.handleMessage(heartbeatMessage, 'heartbeat with dataset-level 0');
3635
+
3636
+ // Dataset-level 0 means no watchdog, should NOT fall back to top-level
3637
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
3638
+ });
2828
3639
  });
3640
+
2829
3641
  });
2830
3642
 
2831
3643
  describe('#callLocusInfoUpdateCallback filtering', () => {
@@ -2922,7 +3734,7 @@ describe('HashTreeParser', () => {
2922
3734
  parser.handleMessage(updateMessage, 'update with newer version');
2923
3735
 
2924
3736
  // Callback should be called with the update
2925
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3737
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2926
3738
  updatedObjects: [
2927
3739
  {
2928
3740
  htMeta: {
@@ -2993,7 +3805,7 @@ describe('HashTreeParser', () => {
2993
3805
  parser.handleMessage(removalMessage, 'removal of non-existent object');
2994
3806
 
2995
3807
  // Callback should be called with the removal
2996
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3808
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2997
3809
  updatedObjects: [
2998
3810
  {
2999
3811
  htMeta: {
@@ -3128,7 +3940,7 @@ describe('HashTreeParser', () => {
3128
3940
  parser.handleMessage(mixedMessage, 'mixed updates');
3129
3941
 
3130
3942
  // Callback should be called with only the valid updates (participant 1 v110 and participant 3 v10)
3131
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3943
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
3132
3944
  updatedObjects: [
3133
3945
  {
3134
3946
  htMeta: {
@@ -3282,6 +4094,9 @@ describe('HashTreeParser', () => {
3282
4094
  parser.handleMessage(emptyMessage, 'empty elements');
3283
4095
 
3284
4096
  assert.notCalled(callback);
4097
+ assert.calledWith(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_EMPTY_LOCUS_STATE_ELEMENTS, {
4098
+ debugId: 'test',
4099
+ });
3285
4100
  });
3286
4101
 
3287
4102
  it('always calls callback for MEETING_ENDED regardless of filtering', () => {
@@ -3306,9 +4121,7 @@ describe('HashTreeParser', () => {
3306
4121
  parser.handleMessage(sentinelMessage as any, 'sentinel message');
3307
4122
 
3308
4123
  // Callback should be called with MEETING_ENDED
3309
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
3310
- updatedObjects: undefined,
3311
- });
4124
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
3312
4125
  });
3313
4126
  });
3314
4127
 
@@ -3514,7 +4327,7 @@ describe('HashTreeParser', () => {
3514
4327
  });
3515
4328
  });
3516
4329
 
3517
- describe('#resume', () => {
4330
+ describe('#resumeFromMessage', () => {
3518
4331
  const createResumeMessage = (visibleDataSets?, dataSets?) => ({
3519
4332
  locusUrl,
3520
4333
  visibleDataSetsUrl,
@@ -3541,7 +4354,7 @@ describe('HashTreeParser', () => {
3541
4354
 
3542
4355
  expect(parser.state).to.equal('stopped');
3543
4356
 
3544
- parser.resume(createResumeMessage());
4357
+ parser.resumeFromMessage(createResumeMessage());
3545
4358
 
3546
4359
  expect(parser.state).to.equal('active');
3547
4360
  });
@@ -3550,7 +4363,7 @@ describe('HashTreeParser', () => {
3550
4363
  const parser = createHashTreeParser();
3551
4364
  parser.stop();
3552
4365
 
3553
- parser.resume({
4366
+ parser.resumeFromMessage({
3554
4367
  locusUrl,
3555
4368
  visibleDataSetsUrl,
3556
4369
  dataSets: [createDataSet('main', 16, 2000)],
@@ -3569,7 +4382,7 @@ describe('HashTreeParser', () => {
3569
4382
  createDataSet('self', 2, 6000),
3570
4383
  ];
3571
4384
 
3572
- parser.resume(createResumeMessage(undefined, newDataSets));
4385
+ parser.resumeFromMessage(createResumeMessage(undefined, newDataSets));
3573
4386
 
3574
4387
  expect(Object.keys(parser.dataSets)).to.have.lengthOf(2);
3575
4388
  expect(parser.dataSets.main.leafCount).to.equal(8);
@@ -3591,7 +4404,7 @@ describe('HashTreeParser', () => {
3591
4404
  {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3592
4405
  ];
3593
4406
 
3594
- parser.resume(createResumeMessage(visibleDataSets, dataSets));
4407
+ parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
3595
4408
 
3596
4409
  expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
3597
4410
  expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
@@ -3605,7 +4418,7 @@ describe('HashTreeParser', () => {
3605
4418
  const handleMessageStub = sinon.stub(parser, 'handleMessage');
3606
4419
 
3607
4420
  const message = createResumeMessage();
3608
- parser.resume(message);
4421
+ parser.resumeFromMessage(message);
3609
4422
 
3610
4423
  assert.calledOnceWithExactly(handleMessageStub, message, 'on resume');
3611
4424
  });
@@ -3625,7 +4438,7 @@ describe('HashTreeParser', () => {
3625
4438
  {name: 'atd-unmuted', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'},
3626
4439
  ];
3627
4440
 
3628
- parser.resume(createResumeMessage(visibleDataSets, dataSets));
4441
+ parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
3629
4442
 
3630
4443
  expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
3631
4444
  expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
@@ -3633,21 +4446,82 @@ describe('HashTreeParser', () => {
3633
4446
  });
3634
4447
  });
3635
4448
 
3636
- describe('#handleLocusUpdate when stopped', () => {
3637
- it('should return early without processing when parser is stopped', () => {
4449
+ describe('#resumeFromApiResponse', () => {
4450
+ const exampleLocus = {
4451
+ participants: [],
4452
+ } as any;
4453
+
4454
+ it('should set state to active', async () => {
3638
4455
  const parser = createHashTreeParser();
3639
4456
  parser.stop();
3640
4457
 
3641
- parser.handleLocusUpdate({
3642
- dataSets: [createDataSet('main', 16, 2000)],
3643
- locus: {participants: []},
3644
- });
4458
+ expect(parser.state).to.equal('stopped');
3645
4459
 
3646
- assert.notCalled(callback);
4460
+ sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
4461
+
4462
+ await parser.resumeFromApiResponse(exampleLocus);
4463
+
4464
+ expect(parser.state).to.equal('active');
3647
4465
  });
3648
- });
3649
4466
 
3650
- describe('#handleMessage when stopped', () => {
4467
+ it('should reset dataSets to empty', async () => {
4468
+ const parser = createHashTreeParser();
4469
+
4470
+ expect(Object.keys(parser.dataSets).length).to.be.greaterThan(0);
4471
+
4472
+ parser.stop();
4473
+
4474
+ sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
4475
+
4476
+ await parser.resumeFromApiResponse(exampleLocus);
4477
+
4478
+ expect(parser.dataSets).to.deep.equal({});
4479
+ });
4480
+
4481
+ it('should call initializeFromGetLociResponse with the provided locus', async () => {
4482
+ const parser = createHashTreeParser();
4483
+ parser.stop();
4484
+
4485
+ const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
4486
+
4487
+ await parser.resumeFromApiResponse(exampleLocus);
4488
+
4489
+ assert.calledOnceWithExactly(initStub, exampleLocus);
4490
+ });
4491
+
4492
+ it('should propagate errors from initializeFromGetLociResponse', async () => {
4493
+ const parser = createHashTreeParser();
4494
+ parser.stop();
4495
+
4496
+ const error = new Error('initialization failed');
4497
+ const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').rejects(error);
4498
+
4499
+ let caughtError: Error | undefined;
4500
+ try {
4501
+ await parser.resumeFromApiResponse(exampleLocus);
4502
+ } catch (e) {
4503
+ caughtError = e;
4504
+ }
4505
+
4506
+ expect(caughtError).to.equal(error);
4507
+ });
4508
+ });
4509
+
4510
+ describe('#handleLocusUpdate when stopped', () => {
4511
+ it('should return early without processing when parser is stopped', () => {
4512
+ const parser = createHashTreeParser();
4513
+ parser.stop();
4514
+
4515
+ parser.handleLocusUpdate({
4516
+ dataSets: [createDataSet('main', 16, 2000)],
4517
+ locus: {participants: []},
4518
+ });
4519
+
4520
+ assert.notCalled(callback);
4521
+ });
4522
+ });
4523
+
4524
+ describe('#handleMessage when stopped', () => {
3651
4525
  it('should return early without processing when parser is stopped', () => {
3652
4526
  const parser = createHashTreeParser();
3653
4527
  parser.stop();
@@ -3667,4 +4541,788 @@ describe('HashTreeParser', () => {
3667
4541
  assert.notCalled(callback);
3668
4542
  });
3669
4543
  });
4544
+
4545
+ describe('#syncAllDatasets', () => {
4546
+ it('should sync all datasets that have hash trees in priority order', async () => {
4547
+ const parser = createHashTreeParser();
4548
+
4549
+ // parser starts with main (leafCount=16) and self (leafCount=1) as visible datasets with hash trees
4550
+ // atd-unmuted has no hash tree (not visible)
4551
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
4552
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
4553
+
4554
+ const mainUrl = parser.dataSets.main.url;
4555
+ const selfUrl = parser.dataSets.self.url;
4556
+
4557
+ // Mock GET hashtree for main (leafCount > 1, so it does GET first)
4558
+ mockGetHashesFromLocusResponse(
4559
+ mainUrl,
4560
+ new Array(16).fill(EMPTY_HASH),
4561
+ createDataSet('main', 16, 1100)
4562
+ );
4563
+
4564
+ // Mock POST sync for main - return matching root hash so no further sync needed
4565
+ const mainSyncDataSet = createDataSet('main', 16, 1100);
4566
+ mainSyncDataSet.root = parser.dataSets.main.hashTree.getRootHash();
4567
+ mockSendSyncRequestResponse(mainUrl, {
4568
+ dataSets: [mainSyncDataSet],
4569
+ visibleDataSetsUrl,
4570
+ locusUrl,
4571
+ locusStateElements: [],
4572
+ });
4573
+
4574
+ // Mock POST sync for self (leafCount=1, skips GET hashtree)
4575
+ const selfSyncDataSet = createDataSet('self', 1, 2100);
4576
+ selfSyncDataSet.root = parser.dataSets.self.hashTree.getRootHash();
4577
+ mockSendSyncRequestResponse(selfUrl, {
4578
+ dataSets: [selfSyncDataSet],
4579
+ visibleDataSetsUrl,
4580
+ locusUrl,
4581
+ locusStateElements: [],
4582
+ });
4583
+
4584
+ await parser.syncAllDatasets();
4585
+
4586
+ // Verify GET hashtree was called for main only (not self, because leafCount=1)
4587
+ assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
4588
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4589
+
4590
+ // Verify POST sync was called for both
4591
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
4592
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4593
+
4594
+ // Verify main was synced before self (priority order)
4595
+ const mainSyncCallIndex = webexRequest.args.findIndex(
4596
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4597
+ );
4598
+ const selfSyncCallIndex = webexRequest.args.findIndex(
4599
+ (args) => args[0]?.method === 'POST' && args[0]?.uri === `${selfUrl}/sync`
4600
+ );
4601
+ expect(mainSyncCallIndex).to.be.lessThan(selfSyncCallIndex);
4602
+
4603
+ // Verify syncAllBackoffType is reset
4604
+ expect(parser.syncAllBackoffType).to.equal(SyncAllBackoffType.NONE);
4605
+ });
4606
+
4607
+ it('should return immediately when state is stopped', async () => {
4608
+ const parser = createHashTreeParser();
4609
+ parser.stop();
4610
+
4611
+ await parser.syncAllDatasets();
4612
+
4613
+ // No sync requests should have been made (only the initial sync from constructor)
4614
+ // Reset history to clear constructor calls then verify
4615
+ const callCountBefore = webexRequest.callCount;
4616
+ await parser.syncAllDatasets();
4617
+ assert.equal(webexRequest.callCount, callCountBefore);
4618
+ });
4619
+
4620
+ it('should guard against concurrent calls', async () => {
4621
+ const parser = createHashTreeParser();
4622
+
4623
+ const mainUrl = parser.dataSets.main.url;
4624
+ const selfUrl = parser.dataSets.self.url;
4625
+
4626
+ // Use a deferred promise for the main sync to control timing
4627
+ let resolveMainSync;
4628
+ webexRequest
4629
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4630
+ .returns(new Promise((resolve) => { resolveMainSync = resolve; }));
4631
+
4632
+ mockSendSyncRequestResponse(mainUrl, {
4633
+ dataSets: [createDataSet('main', 16, 1100)],
4634
+ visibleDataSetsUrl,
4635
+ locusUrl,
4636
+ locusStateElements: [],
4637
+ });
4638
+
4639
+ mockSendSyncRequestResponse(selfUrl, {
4640
+ dataSets: [createDataSet('self', 1, 2100)],
4641
+ visibleDataSetsUrl,
4642
+ locusUrl,
4643
+ locusStateElements: [],
4644
+ });
4645
+
4646
+ // Start first call
4647
+ const promise1 = parser.syncAllDatasets();
4648
+ // Start second call while first is in progress
4649
+ const promise2 = parser.syncAllDatasets();
4650
+
4651
+ // Resolve the pending request
4652
+ resolveMainSync({
4653
+ body: {
4654
+ hashes: new Array(16).fill(EMPTY_HASH),
4655
+ dataSet: createDataSet('main', 16, 1100),
4656
+ },
4657
+ });
4658
+
4659
+ await promise1;
4660
+ await promise2;
4661
+
4662
+ // GET hashtree for main should only be called once (second syncAllDatasets returned immediately)
4663
+ const getHashtreeCalls = webexRequest.args.filter(
4664
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4665
+ );
4666
+ expect(getHashtreeCalls).to.have.lengthOf(1);
4667
+ });
4668
+
4669
+ it('should sync only LLM datasets when onlyLLM=true', async () => {
4670
+ const parser = createHashTreeParser();
4671
+
4672
+ const mainUrl = parser.dataSets.main.url;
4673
+ const selfUrl = parser.dataSets.self.url;
4674
+
4675
+ mockGetHashesFromLocusResponse(
4676
+ mainUrl,
4677
+ new Array(16).fill(EMPTY_HASH),
4678
+ createDataSet('main', 16, 1100)
4679
+ );
4680
+
4681
+ const mainSyncDs = createDataSet('main', 16, 1100);
4682
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4683
+ mockSendSyncRequestResponse(mainUrl, {
4684
+ dataSets: [mainSyncDs],
4685
+ visibleDataSetsUrl,
4686
+ locusUrl,
4687
+ locusStateElements: [],
4688
+ });
4689
+
4690
+ await parser.syncAllDatasets({onlyLLM: true});
4691
+
4692
+ // main is an LLM dataset, so it should have been synced
4693
+ assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
4694
+
4695
+ // self is NOT an LLM dataset, so it should NOT have been synced
4696
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4697
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4698
+ });
4699
+
4700
+ it('should upgrade scope from onlyLLM=true to all datasets when onlyLLM=false call arrives during backoff', async () => {
4701
+ // Make Math.random return 1 so backoff = 1^2 * 1000 = 1000ms (non-zero delay for interleaving)
4702
+ mathRandomStub.returns(1);
4703
+
4704
+ const parser = createHashTreeParser();
4705
+
4706
+ const mainUrl = parser.dataSets.main.url;
4707
+ const selfUrl = parser.dataSets.self.url;
4708
+
4709
+ mockGetHashesFromLocusResponse(
4710
+ mainUrl,
4711
+ new Array(16).fill(EMPTY_HASH),
4712
+ createDataSet('main', 16, 1100)
4713
+ );
4714
+
4715
+ const mainSyncDs = createDataSet('main', 16, 1100);
4716
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4717
+ mockSendSyncRequestResponse(mainUrl, {
4718
+ dataSets: [mainSyncDs],
4719
+ visibleDataSetsUrl,
4720
+ locusUrl,
4721
+ locusStateElements: [],
4722
+ });
4723
+
4724
+ const selfSyncDs = createDataSet('self', 1, 2100);
4725
+ selfSyncDs.root = parser.dataSets.self.hashTree.getRootHash();
4726
+ mockSendSyncRequestResponse(selfUrl, {
4727
+ dataSets: [selfSyncDs],
4728
+ visibleDataSetsUrl,
4729
+ locusUrl,
4730
+ locusStateElements: [],
4731
+ });
4732
+
4733
+ // First call with onlyLLM=true starts backoff
4734
+ const promise1 = parser.syncAllDatasets({onlyLLM: true});
4735
+ expect(parser.syncAllBackoffType).to.equal(SyncAllBackoffType.ONLY_LLM);
4736
+
4737
+ // Second call with onlyLLM=false upgrades the scope during backoff
4738
+ const promise2 = parser.syncAllDatasets({onlyLLM: false});
4739
+ expect(parser.syncAllBackoffType).to.equal(SyncAllBackoffType.ALL);
4740
+
4741
+ // Advance clock past the backoff delay (1000ms)
4742
+ await clock.tickAsync(1000);
4743
+
4744
+ await promise1;
4745
+ await promise2;
4746
+
4747
+ // Both main (LLM) and self (non-LLM) should have been synced
4748
+ assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
4749
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4750
+ });
4751
+
4752
+ it('should not downgrade scope from onlyLLM=false when onlyLLM=true call arrives during backoff', async () => {
4753
+ // Make Math.random return 1 so backoff = 1^2 * 1000 = 1000ms (non-zero delay for interleaving)
4754
+ mathRandomStub.returns(1);
4755
+
4756
+ const parser = createHashTreeParser();
4757
+
4758
+ const mainUrl = parser.dataSets.main.url;
4759
+ const selfUrl = parser.dataSets.self.url;
4760
+
4761
+ mockGetHashesFromLocusResponse(
4762
+ mainUrl,
4763
+ new Array(16).fill(EMPTY_HASH),
4764
+ createDataSet('main', 16, 1100)
4765
+ );
4766
+
4767
+ const mainSyncDs = createDataSet('main', 16, 1100);
4768
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4769
+ mockSendSyncRequestResponse(mainUrl, {
4770
+ dataSets: [mainSyncDs],
4771
+ visibleDataSetsUrl,
4772
+ locusUrl,
4773
+ locusStateElements: [],
4774
+ });
4775
+
4776
+ const selfSyncDs = createDataSet('self', 1, 2100);
4777
+ selfSyncDs.root = parser.dataSets.self.hashTree.getRootHash();
4778
+ mockSendSyncRequestResponse(selfUrl, {
4779
+ dataSets: [selfSyncDs],
4780
+ visibleDataSetsUrl,
4781
+ locusUrl,
4782
+ locusStateElements: [],
4783
+ });
4784
+
4785
+ // First call with onlyLLM=false starts backoff with all-datasets scope
4786
+ const promise1 = parser.syncAllDatasets({onlyLLM: false});
4787
+ expect(parser.syncAllBackoffType).to.equal(SyncAllBackoffType.ALL);
4788
+
4789
+ // Second call with onlyLLM=true should NOT downgrade the scope
4790
+ const promise2 = parser.syncAllDatasets({onlyLLM: true});
4791
+ expect(parser.syncAllBackoffType).to.equal(SyncAllBackoffType.ALL);
4792
+
4793
+ // Advance clock past the backoff delay (1000ms)
4794
+ await clock.tickAsync(1000);
4795
+
4796
+ await promise1;
4797
+ await promise2;
4798
+
4799
+ // Both main (LLM) and self (non-LLM) should have been synced (scope was not downgraded)
4800
+ assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
4801
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4802
+ });
4803
+
4804
+ it('should skip datasets that received messages during the backoff sleep', async () => {
4805
+ // Make Math.random return 1 so backoff = 1^2 * 1000 = 1000ms
4806
+ mathRandomStub.returns(1);
4807
+
4808
+ const parser = createHashTreeParser();
4809
+
4810
+ const mainUrl = parser.dataSets.main.url;
4811
+ const selfUrl = parser.dataSets.self.url;
4812
+ const atdUnmutedUrl = parser.dataSets['atd-unmuted'].url;
4813
+
4814
+ // Setup mocks only for self (main and atd-unmuted should be skipped)
4815
+ mockSendSyncRequestResponse(selfUrl, {
4816
+ dataSets: [createDataSet('self', 1, 2100)],
4817
+ visibleDataSetsUrl,
4818
+ locusUrl,
4819
+ locusStateElements: [],
4820
+ });
4821
+
4822
+ // Start syncAllDatasets - begins backoff sleep
4823
+ const promise = parser.syncAllDatasets();
4824
+ expect(parser.syncAllBackoffType).to.equal(SyncAllBackoffType.ALL);
4825
+
4826
+ // Simulate a normal message arriving for "main" during the backoff sleep
4827
+ parser.handleMessage({
4828
+ dataSets: [createDataSet('main', 16, 1100)],
4829
+ visibleDataSetsUrl,
4830
+ locusUrl,
4831
+ locusStateElements: [
4832
+ {
4833
+ htMeta: {
4834
+ elementId: {type: 'locus' as const, id: 0, version: 201},
4835
+ dataSetNames: ['main'],
4836
+ },
4837
+ data: {someData: 'value'},
4838
+ },
4839
+ ],
4840
+ });
4841
+
4842
+ // Simulate a heartbeat message arriving for "atd-unmuted" during the backoff sleep
4843
+ parser.handleMessage(
4844
+ createHeartbeatMessage('atd-unmuted', 1, 1100, parser.dataSets['atd-unmuted'].root)
4845
+ );
4846
+
4847
+ // Advance clock past the backoff delay
4848
+ await clock.tickAsync(1000);
4849
+ await promise;
4850
+
4851
+ // main should NOT have been synced (it received a normal message during backoff)
4852
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
4853
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
4854
+
4855
+ // atd-unmuted should NOT have been synced (it received a heartbeat during backoff)
4856
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${atdUnmutedUrl}/hashtree`}));
4857
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${atdUnmutedUrl}/sync`}));
4858
+
4859
+ // self SHOULD have been synced (no messages received for it during backoff)
4860
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4861
+ });
4862
+
4863
+ it('should skip datasets that do not have a hash tree', async () => {
4864
+ // Create parser with metadata that only has main and self as visible (not atd-unmuted)
4865
+ const metadataWithoutAtd = {
4866
+ ...exampleMetadata,
4867
+ visibleDataSets: exampleMetadata.visibleDataSets.filter((ds) => ds.name !== 'atd-unmuted'),
4868
+ };
4869
+ const parser = createHashTreeParser(exampleInitialLocus, metadataWithoutAtd);
4870
+
4871
+ // atd-unmuted is in dataSets but has no hashTree (not visible)
4872
+ expect(parser.dataSets['atd-unmuted']).to.exist;
4873
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
4874
+
4875
+ const atdUrl = parser.dataSets['atd-unmuted'].url;
4876
+ const mainUrl = parser.dataSets.main.url;
4877
+ const selfUrl = parser.dataSets.self.url;
4878
+
4879
+ mockGetHashesFromLocusResponse(
4880
+ mainUrl,
4881
+ new Array(16).fill(EMPTY_HASH),
4882
+ createDataSet('main', 16, 1100)
4883
+ );
4884
+
4885
+ const mainSyncDs = createDataSet('main', 16, 1100);
4886
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4887
+ mockSendSyncRequestResponse(mainUrl, {
4888
+ dataSets: [mainSyncDs],
4889
+ visibleDataSetsUrl,
4890
+ locusUrl,
4891
+ locusStateElements: [],
4892
+ });
4893
+
4894
+ const selfSyncDs = createDataSet('self', 1, 2100);
4895
+ selfSyncDs.root = parser.dataSets.self.hashTree.getRootHash();
4896
+ mockSendSyncRequestResponse(selfUrl, {
4897
+ dataSets: [selfSyncDs],
4898
+ visibleDataSetsUrl,
4899
+ locusUrl,
4900
+ locusStateElements: [],
4901
+ });
4902
+
4903
+ await parser.syncAllDatasets();
4904
+
4905
+ // No requests should have been made for atd-unmuted
4906
+ assert.neverCalledWith(webexRequest, sinon.match({uri: sinon.match(atdUrl)}));
4907
+ });
4908
+ });
4909
+
4910
+ describe('#handleMessage sync queue', () => {
4911
+ it('should deduplicate: not sync the same dataset twice when enqueued multiple times', async () => {
4912
+ const parser = createHashTreeParser();
4913
+
4914
+ const mainUrl = parser.dataSets.main.url;
4915
+
4916
+ // Setup mocks before triggering syncs
4917
+ mockGetHashesFromLocusResponse(
4918
+ mainUrl,
4919
+ new Array(16).fill(EMPTY_HASH),
4920
+ createDataSet('main', 16, 1101)
4921
+ );
4922
+
4923
+ const mainSyncDs = createDataSet('main', 16, 1101);
4924
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4925
+ mockSendSyncRequestResponse(mainUrl, {
4926
+ dataSets: [mainSyncDs],
4927
+ visibleDataSetsUrl,
4928
+ locusUrl,
4929
+ locusStateElements: [],
4930
+ });
4931
+
4932
+ // Send two heartbeat messages (no locusStateElements) with different root hashes for main
4933
+ parser.handleMessage(createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'), 'first');
4934
+ parser.handleMessage(createHeartbeatMessage('main', 16, 1101, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2'), 'second');
4935
+
4936
+ // The second call resets the timer. After 1000ms, only one sync fires.
4937
+ await clock.tickAsync(1000);
4938
+
4939
+ // Only one GET hashtree call should have been made for main
4940
+ const getHashtreeCalls = webexRequest.args.filter(
4941
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4942
+ );
4943
+ expect(getHashtreeCalls).to.have.lengthOf(1);
4944
+ });
4945
+
4946
+ it('should stop processing the sync queue when parser is stopped mid-queue', async () => {
4947
+ const parser = createHashTreeParser();
4948
+
4949
+ const mainUrl = parser.dataSets.main.url;
4950
+ const selfUrl = parser.dataSets.self.url;
4951
+
4952
+ // Mock main GET hashtree with a deferred promise so we can control when it resolves
4953
+ let resolveMainHashtree;
4954
+ webexRequest
4955
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4956
+ .callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
4957
+
4958
+ // Send a heartbeat message that triggers sync timers for both main and self
4959
+ parser.handleMessage(
4960
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4961
+ 'trigger main sync'
4962
+ );
4963
+ parser.handleMessage(
4964
+ createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
4965
+ 'trigger self sync'
4966
+ );
4967
+
4968
+ // Fire the timers - main sync starts (calls GET hashtree, which blocks)
4969
+ await clock.tickAsync(1000);
4970
+
4971
+ // Stop the parser while main sync is in progress
4972
+ parser.stop();
4973
+
4974
+ // Resolve the pending main GET request
4975
+ resolveMainHashtree({
4976
+ body: {
4977
+ hashes: new Array(16).fill(EMPTY_HASH),
4978
+ dataSet: createDataSet('main', 16, 1100),
4979
+ },
4980
+ });
4981
+
4982
+ await clock.tickAsync(0);
4983
+
4984
+ // Self sync should NOT have been triggered because parser was stopped
4985
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4986
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4987
+ });
4988
+ });
4989
+
4990
+ describe('#stop sync queue', () => {
4991
+ it('should clear the syncQueue when stopped so remaining queued items are not processed', async () => {
4992
+ const parser = createHashTreeParser();
4993
+
4994
+ const mainUrl = parser.dataSets.main.url;
4995
+ const selfUrl = parser.dataSets.self.url;
4996
+
4997
+ // Mock main GET hashtree with a deferred promise so we can control when it resolves
4998
+ let resolveMainHashtree;
4999
+ webexRequest
5000
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
5001
+ .callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
5002
+
5003
+ // Enqueue syncs for both main and self by sending heartbeat messages
5004
+ parser.handleMessage(
5005
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
5006
+ 'trigger main sync'
5007
+ );
5008
+ parser.handleMessage(
5009
+ createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
5010
+ 'trigger self sync'
5011
+ );
5012
+
5013
+ // Fire the timers - main sync starts and blocks on GET hashtree
5014
+ await clock.tickAsync(1000);
5015
+
5016
+ // Verify that self is still in the queue (main is being processed, self is waiting)
5017
+ // Now stop the parser - this should clear the syncQueue
5018
+ parser.stop();
5019
+
5020
+ // Resolve the pending main GET request so the in-flight sync can finish
5021
+ resolveMainHashtree({
5022
+ body: {
5023
+ hashes: new Array(16).fill(EMPTY_HASH),
5024
+ dataSet: createDataSet('main', 16, 1100),
5025
+ },
5026
+ });
5027
+
5028
+ await clock.tickAsync(0);
5029
+
5030
+ // Self should never have been synced because stop() cleared the queue
5031
+ const selfGetCalls = webexRequest.args.filter(
5032
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${selfUrl}/hashtree`
5033
+ );
5034
+ expect(selfGetCalls).to.have.lengthOf(0);
5035
+ });
5036
+ });
5037
+
5038
+ describe('#performSync abort controller', () => {
5039
+ it('should reuse an existing syncAbortController if one is already set on the dataset', async () => {
5040
+ const parser = createHashTreeParser();
5041
+ const mainUrl = parser.dataSets.main.url;
5042
+
5043
+ // Pre-set an AbortController on the dataset before sync starts
5044
+ const existingController = new AbortController();
5045
+ parser.dataSets.main.syncAbortController = existingController;
5046
+
5047
+ // Use a deferred promise for GET hashtree so we can inspect the controller mid-sync
5048
+ let resolveGetHashtree;
5049
+ webexRequest.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`})).callsFake(
5050
+ () =>
5051
+ new Promise((resolve) => {
5052
+ resolveGetHashtree = resolve;
5053
+ })
5054
+ );
5055
+
5056
+ // Trigger sync for main
5057
+ parser.handleMessage(
5058
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
5059
+ 'trigger main sync'
5060
+ );
5061
+
5062
+ await clock.tickAsync(1000);
5063
+
5064
+ // While sync is in-flight, verify the controller is the same one we pre-set
5065
+ expect(parser.dataSets.main.syncAbortController).to.equal(existingController);
5066
+
5067
+ // Resolve GET hashtree with matching hashes (no sync needed)
5068
+ resolveGetHashtree({body: {}});
5069
+ await testUtils.flushPromises();
5070
+
5071
+ // After sync completes, syncAbortController is cleared in finally
5072
+ expect(parser.dataSets.main.syncAbortController).to.be.undefined;
5073
+ });
5074
+
5075
+ it('should abort the sync before /sync request when the controller is aborted during getHashesFromLocus', async () => {
5076
+ const parser = createHashTreeParser();
5077
+ const mainUrl = parser.dataSets.main.url;
5078
+
5079
+ // Use a deferred promise for GET hashtree so we can abort while it's pending
5080
+ let resolveGetHashtree;
5081
+ webexRequest.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`})).callsFake(
5082
+ () =>
5083
+ new Promise((resolve) => {
5084
+ resolveGetHashtree = resolve;
5085
+ })
5086
+ );
5087
+
5088
+ // Mock POST sync - should NOT be called if abort works
5089
+ mockSendSyncRequestResponse(mainUrl, null);
5090
+
5091
+ // Trigger sync for main via heartbeat with mismatched root hash
5092
+ parser.handleMessage(
5093
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
5094
+ 'trigger main sync'
5095
+ );
5096
+
5097
+ // Fire the timer to start the sync
5098
+ await clock.tickAsync(1000);
5099
+
5100
+ // Now abort the controller while getHashesFromLocus is pending
5101
+ expect(parser.dataSets.main.syncAbortController).to.not.be.undefined;
5102
+ parser.dataSets.main.syncAbortController.abort();
5103
+
5104
+ // Resolve GET hashtree with mismatched hashes so the code would normally proceed to /sync
5105
+ resolveGetHashtree({
5106
+ body: {
5107
+ hashes: new Array(16).fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
5108
+ dataSet: createDataSet('main', 16, 1100),
5109
+ },
5110
+ });
5111
+
5112
+ await testUtils.flushPromises();
5113
+
5114
+ // POST sync should NOT have been called because the controller was aborted
5115
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
5116
+ });
5117
+
5118
+ it('should abort the sync before /sync request when the controller is aborted for leafCount === 1 datasets', async () => {
5119
+ const parser = createHashTreeParser();
5120
+ const selfUrl = parser.dataSets.self.url;
5121
+
5122
+ // Pre-set an already-aborted controller so performSync picks it up via ??
5123
+ const abortedController = new AbortController();
5124
+ abortedController.abort();
5125
+ parser.dataSets.self.syncAbortController = abortedController;
5126
+
5127
+ // Mock POST sync - should NOT be called
5128
+ mockSendSyncRequestResponse(selfUrl, null);
5129
+
5130
+ // Trigger sync for self via heartbeat with mismatched root hash
5131
+ parser.handleMessage(
5132
+ {
5133
+ dataSets: [
5134
+ {
5135
+ ...createDataSet('self', 1, 2100),
5136
+ url: parser.dataSets.self.url,
5137
+ root: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1',
5138
+ },
5139
+ ],
5140
+ visibleDataSetsUrl,
5141
+ locusUrl,
5142
+ },
5143
+ 'trigger self sync'
5144
+ );
5145
+
5146
+ // Fire the timer to start the sync
5147
+ await clock.tickAsync(1000);
5148
+
5149
+ // GET hashtree should NOT have been called (leafCount === 1 skips it)
5150
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
5151
+
5152
+ // POST sync should NOT have been called because the controller was already aborted
5153
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
5154
+ });
5155
+
5156
+ it('should unconditionally clear syncAbortController in the finally block', async () => {
5157
+ const parser = createHashTreeParser();
5158
+ const mainUrl = parser.dataSets.main.url;
5159
+
5160
+ // Mock GET hashtree to return matching hashes (early return, no sync needed)
5161
+ webexRequest
5162
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
5163
+ .resolves({body: {}});
5164
+
5165
+ // Trigger sync for main
5166
+ parser.handleMessage(
5167
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
5168
+ 'trigger main sync'
5169
+ );
5170
+
5171
+ await clock.tickAsync(1000);
5172
+
5173
+ // After sync completes (even via early return), syncAbortController should be cleared
5174
+ expect(parser.dataSets.main.syncAbortController).to.be.undefined;
5175
+ });
5176
+
5177
+ it('should unconditionally clear syncAbortController even when sync throws an error', async () => {
5178
+ const parser = createHashTreeParser();
5179
+ const mainUrl = parser.dataSets.main.url;
5180
+
5181
+ // Mock GET hashtree to reject with a non-409 error
5182
+ webexRequest
5183
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
5184
+ .rejects({statusCode: 500, message: 'Internal Server Error'});
5185
+
5186
+ // Trigger sync for main
5187
+ parser.handleMessage(
5188
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
5189
+ 'trigger main sync'
5190
+ );
5191
+
5192
+ await clock.tickAsync(1000);
5193
+
5194
+ // After sync completes with error, syncAbortController should still be cleared
5195
+ expect(parser.dataSets.main.syncAbortController).to.be.undefined;
5196
+ });
5197
+
5198
+ it('should reuse a pre-existing abort controller and respect its aborted state', async () => {
5199
+ const parser = createHashTreeParser();
5200
+ const mainUrl = parser.dataSets.main.url;
5201
+
5202
+ // Pre-set an AbortController and abort it before sync starts
5203
+ const preAbortedController = new AbortController();
5204
+ preAbortedController.abort();
5205
+ parser.dataSets.main.syncAbortController = preAbortedController;
5206
+
5207
+ // Mock GET hashtree to return mismatched hashes
5208
+ mockGetHashesFromLocusResponse(
5209
+ mainUrl,
5210
+ new Array(16).fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
5211
+ createDataSet('main', 16, 1100)
5212
+ );
5213
+
5214
+ // Mock POST sync - should NOT be called
5215
+ mockSendSyncRequestResponse(mainUrl, null);
5216
+
5217
+ // Trigger sync for main
5218
+ parser.handleMessage(
5219
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
5220
+ 'trigger main sync'
5221
+ );
5222
+
5223
+ await clock.tickAsync(1000);
5224
+
5225
+ // POST sync should NOT have been called because the reused controller was already aborted
5226
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
5227
+
5228
+ // syncAbortController should be cleaned up
5229
+ expect(parser.dataSets.main.syncAbortController).to.be.undefined;
5230
+ });
5231
+
5232
+ it('should allow cancelPendingSyncsForDataSets to abort an in-flight sync via the shared controller', async () => {
5233
+ const parser = createHashTreeParser();
5234
+ const mainUrl = parser.dataSets.main.url;
5235
+
5236
+ // Use a deferred promise for GET hashtree
5237
+ let resolveGetHashtree;
5238
+ webexRequest.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`})).callsFake(
5239
+ () =>
5240
+ new Promise((resolve) => {
5241
+ resolveGetHashtree = resolve;
5242
+ })
5243
+ );
5244
+
5245
+ mockSendSyncRequestResponse(mainUrl, null);
5246
+
5247
+ // Trigger sync for main
5248
+ parser.handleMessage(
5249
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
5250
+ 'trigger main sync'
5251
+ );
5252
+
5253
+ // Fire the timer to start sync
5254
+ await clock.tickAsync(1000);
5255
+
5256
+ // Verify controller is set
5257
+ expect(parser.dataSets.main.syncAbortController).to.not.be.undefined;
5258
+
5259
+ // Simulate a new heartbeat arriving that cancels the in-flight sync
5260
+ // (this is what happens in production via parseMessage -> cancelPendingSyncsForDataSets)
5261
+ parser.handleMessage(
5262
+ {
5263
+ dataSets: [
5264
+ {
5265
+ ...createDataSet('main', 16, 1101),
5266
+ root: parser.dataSets.main.hashTree.getRootHash(), // matching hash so no new sync
5267
+ },
5268
+ ],
5269
+ visibleDataSetsUrl,
5270
+ locusUrl,
5271
+ },
5272
+ 'new heartbeat cancels sync'
5273
+ );
5274
+
5275
+ // Resolve the pending GET hashtree with mismatched hashes
5276
+ resolveGetHashtree({
5277
+ body: {
5278
+ hashes: new Array(16).fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
5279
+ dataSet: createDataSet('main', 16, 1101),
5280
+ },
5281
+ });
5282
+
5283
+ await testUtils.flushPromises();
5284
+
5285
+ // POST sync should NOT have been called because cancelPendingSyncsForDataSets aborted the controller
5286
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
5287
+ });
5288
+ });
5289
+
5290
+ describe('#cleanUp', () => {
5291
+ it('should stop the parser, clear all timers and clear all dataSets', () => {
5292
+ const parser = createHashTreeParser();
5293
+
5294
+ // Send a message to set up sync timers via runSyncAlgorithm
5295
+ const message = {
5296
+ dataSets: [
5297
+ {
5298
+ ...createDataSet('main', 16, 1100),
5299
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
5300
+ },
5301
+ ],
5302
+ visibleDataSetsUrl,
5303
+ locusUrl,
5304
+ locusStateElements: [
5305
+ {
5306
+ htMeta: {
5307
+ elementId: {type: 'locus' as const, id: 0, version: 201},
5308
+ dataSetNames: ['main'],
5309
+ },
5310
+ data: {someData: 'value'},
5311
+ },
5312
+ ],
5313
+ };
5314
+
5315
+ parser.handleMessage(message, 'setup timers');
5316
+
5317
+ // Verify timers were set by handleMessage
5318
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
5319
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
5320
+
5321
+ parser.cleanUp();
5322
+
5323
+ expect(parser.state).to.equal('stopped');
5324
+ expect(parser.visibleDataSets).to.deep.equal([]);
5325
+ expect(parser.dataSets).to.deep.equal({});
5326
+ });
5327
+ });
3670
5328
  });