@webex/plugin-meetings 3.12.0-next.6 → 3.12.0-next.61

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