@webex/plugin-meetings 3.12.0-next.7 → 3.12.0-next.70
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.
- package/AGENTS.md +9 -0
- package/dist/aiEnableRequest/index.js +15 -2
- package/dist/aiEnableRequest/index.js.map +1 -1
- package/dist/breakouts/breakout.js +8 -3
- package/dist/breakouts/breakout.js.map +1 -1
- package/dist/breakouts/index.js +26 -2
- package/dist/breakouts/index.js.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/constants.js +30 -7
- package/dist/constants.js.map +1 -1
- package/dist/controls-options-manager/constants.js +11 -1
- package/dist/controls-options-manager/constants.js.map +1 -1
- package/dist/controls-options-manager/index.js +38 -24
- package/dist/controls-options-manager/index.js.map +1 -1
- package/dist/controls-options-manager/util.js +91 -0
- package/dist/controls-options-manager/util.js.map +1 -1
- package/dist/hashTree/constants.js +13 -1
- package/dist/hashTree/constants.js.map +1 -1
- package/dist/hashTree/hashTreeParser.js +880 -382
- package/dist/hashTree/hashTreeParser.js.map +1 -1
- package/dist/hashTree/utils.js +42 -0
- package/dist/hashTree/utils.js.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/interceptors/dataChannelAuthToken.js +75 -15
- package/dist/interceptors/dataChannelAuthToken.js.map +1 -1
- package/dist/interceptors/locusRetry.js +23 -8
- package/dist/interceptors/locusRetry.js.map +1 -1
- package/dist/interpretation/index.js +10 -1
- package/dist/interpretation/index.js.map +1 -1
- package/dist/interpretation/interpretation.types.js +7 -0
- package/dist/interpretation/interpretation.types.js.map +1 -0
- package/dist/interpretation/siLanguage.js +1 -1
- package/dist/locus-info/controlsUtils.js +4 -1
- package/dist/locus-info/controlsUtils.js.map +1 -1
- package/dist/locus-info/index.js +298 -87
- package/dist/locus-info/index.js.map +1 -1
- package/dist/locus-info/types.js +19 -0
- package/dist/locus-info/types.js.map +1 -1
- package/dist/media/index.js +3 -1
- package/dist/media/index.js.map +1 -1
- package/dist/media/properties.js +1 -0
- package/dist/media/properties.js.map +1 -1
- package/dist/meeting/in-meeting-actions.js +3 -1
- package/dist/meeting/in-meeting-actions.js.map +1 -1
- package/dist/meeting/index.js +1046 -689
- package/dist/meeting/index.js.map +1 -1
- package/dist/meeting/muteState.js +10 -1
- package/dist/meeting/muteState.js.map +1 -1
- package/dist/meeting/request.js +5 -2
- package/dist/meeting/request.js.map +1 -1
- package/dist/meeting/util.js +20 -2
- package/dist/meeting/util.js.map +1 -1
- package/dist/meeting-info/meeting-info-v2.js +2 -2
- package/dist/meeting-info/meeting-info-v2.js.map +1 -1
- package/dist/meetings/index.js +231 -78
- package/dist/meetings/index.js.map +1 -1
- package/dist/meetings/meetings.types.js +6 -1
- package/dist/meetings/meetings.types.js.map +1 -1
- package/dist/meetings/request.js +39 -0
- package/dist/meetings/request.js.map +1 -1
- package/dist/meetings/util.js +79 -5
- package/dist/meetings/util.js.map +1 -1
- package/dist/member/index.js +10 -0
- package/dist/member/index.js.map +1 -1
- package/dist/member/types.js.map +1 -1
- package/dist/member/util.js +3 -0
- package/dist/member/util.js.map +1 -1
- package/dist/metrics/constants.js +4 -1
- package/dist/metrics/constants.js.map +1 -1
- package/dist/multistream/codec/constants.js +63 -0
- package/dist/multistream/codec/constants.js.map +1 -0
- package/dist/multistream/mediaRequestManager.js +62 -15
- package/dist/multistream/mediaRequestManager.js.map +1 -1
- package/dist/multistream/receiveSlot.js +9 -0
- package/dist/multistream/receiveSlot.js.map +1 -1
- package/dist/reactions/reactions.type.js.map +1 -1
- package/dist/recording-controller/index.js +1 -3
- package/dist/recording-controller/index.js.map +1 -1
- package/dist/types/config.d.ts +2 -0
- package/dist/types/constants.d.ts +9 -1
- package/dist/types/controls-options-manager/constants.d.ts +6 -1
- package/dist/types/controls-options-manager/index.d.ts +10 -0
- package/dist/types/hashTree/constants.d.ts +2 -0
- package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
- package/dist/types/hashTree/utils.d.ts +18 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/interceptors/locusRetry.d.ts +4 -4
- package/dist/types/interpretation/interpretation.types.d.ts +10 -0
- package/dist/types/locus-info/index.d.ts +50 -6
- package/dist/types/locus-info/types.d.ts +21 -1
- package/dist/types/media/properties.d.ts +1 -0
- package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
- package/dist/types/meeting/index.d.ts +78 -5
- package/dist/types/meeting/request.d.ts +1 -0
- package/dist/types/meeting/util.d.ts +8 -0
- package/dist/types/meetings/index.d.ts +30 -2
- package/dist/types/meetings/meetings.types.d.ts +15 -0
- package/dist/types/meetings/request.d.ts +14 -0
- package/dist/types/member/index.d.ts +1 -0
- package/dist/types/member/types.d.ts +1 -0
- package/dist/types/member/util.d.ts +1 -0
- package/dist/types/metrics/constants.d.ts +3 -0
- package/dist/types/multistream/codec/constants.d.ts +7 -0
- package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
- package/dist/types/reactions/reactions.type.d.ts +3 -0
- package/dist/webinar/index.js +305 -159
- package/dist/webinar/index.js.map +1 -1
- package/package.json +22 -22
- package/src/aiEnableRequest/index.ts +16 -0
- package/src/breakouts/breakout.ts +3 -1
- package/src/breakouts/index.ts +31 -0
- package/src/config.ts +2 -0
- package/src/constants.ts +13 -2
- package/src/controls-options-manager/constants.ts +14 -1
- package/src/controls-options-manager/index.ts +47 -24
- package/src/controls-options-manager/util.ts +81 -1
- package/src/hashTree/constants.ts +16 -0
- package/src/hashTree/hashTreeParser.ts +580 -196
- package/src/hashTree/utils.ts +36 -0
- package/src/index.ts +6 -0
- package/src/interceptors/dataChannelAuthToken.ts +88 -12
- package/src/interceptors/locusRetry.ts +25 -4
- package/src/interpretation/index.ts +27 -9
- package/src/interpretation/interpretation.types.ts +11 -0
- package/src/locus-info/controlsUtils.ts +3 -1
- package/src/locus-info/index.ts +293 -97
- package/src/locus-info/types.ts +25 -1
- package/src/media/index.ts +3 -0
- package/src/media/properties.ts +1 -0
- package/src/meeting/in-meeting-actions.ts +4 -0
- package/src/meeting/index.ts +386 -48
- package/src/meeting/muteState.ts +10 -1
- package/src/meeting/request.ts +11 -0
- package/src/meeting/util.ts +21 -2
- package/src/meeting-info/meeting-info-v2.ts +4 -2
- package/src/meetings/index.ts +134 -44
- package/src/meetings/meetings.types.ts +19 -0
- package/src/meetings/request.ts +43 -0
- package/src/meetings/util.ts +97 -1
- package/src/member/index.ts +10 -0
- package/src/member/types.ts +1 -0
- package/src/member/util.ts +3 -0
- package/src/metrics/constants.ts +3 -0
- package/src/multistream/codec/constants.ts +58 -0
- package/src/multistream/mediaRequestManager.ts +119 -28
- package/src/multistream/receiveSlot.ts +18 -0
- package/src/reactions/reactions.type.ts +3 -0
- package/src/recording-controller/index.ts +1 -2
- package/src/webinar/index.ts +214 -36
- package/test/unit/spec/aiEnableRequest/index.ts +86 -0
- package/test/unit/spec/breakouts/breakout.ts +9 -3
- package/test/unit/spec/breakouts/index.ts +49 -0
- package/test/unit/spec/controls-options-manager/index.js +140 -29
- package/test/unit/spec/controls-options-manager/util.js +165 -0
- package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
- package/test/unit/spec/hashTree/utils.ts +125 -1
- package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
- package/test/unit/spec/interceptors/locusRetry.ts +205 -4
- package/test/unit/spec/interpretation/index.ts +26 -4
- package/test/unit/spec/locus-info/controlsUtils.js +172 -57
- package/test/unit/spec/locus-info/index.js +487 -81
- package/test/unit/spec/media/index.ts +31 -0
- package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
- package/test/unit/spec/meeting/index.js +1240 -37
- package/test/unit/spec/meeting/muteState.js +81 -0
- package/test/unit/spec/meeting/request.js +12 -0
- package/test/unit/spec/meeting/utils.js +33 -0
- package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
- package/test/unit/spec/meetings/index.js +360 -10
- package/test/unit/spec/meetings/request.js +141 -0
- package/test/unit/spec/meetings/utils.js +189 -0
- package/test/unit/spec/member/index.js +7 -0
- package/test/unit/spec/member/util.js +24 -0
- package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
- package/test/unit/spec/recording-controller/index.js +9 -8
- 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
|
-
[
|
|
640
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1732
|
-
|
|
1733
|
-
error.
|
|
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
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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(
|
|
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
|
-
|
|
1797
|
-
|
|
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
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2014
|
-
|
|
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(
|
|
2256
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
|
|
2046
2257
|
|
|
2047
|
-
//
|
|
2048
|
-
expect(parser.
|
|
2258
|
+
// The sync timer should be set
|
|
2259
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
2049
2260
|
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
|
|
2261
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
2262
|
+
const newLeafCount = 32;
|
|
2053
2263
|
|
|
2054
|
-
//
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
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('
|
|
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
|
|
2241
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.
|
|
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
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
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
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
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
|
|
2948
|
+
const parser = createHashTreeParser(initialLocus, metadata);
|
|
2424
2949
|
|
|
2425
|
-
// Verify
|
|
2426
|
-
expect(parser.
|
|
2427
|
-
|
|
2428
|
-
|
|
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
|
-
//
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
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 =
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
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('#
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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('#
|
|
3637
|
-
|
|
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.
|
|
3642
|
-
dataSets: [createDataSet('main', 16, 2000)],
|
|
3643
|
-
locus: {participants: []},
|
|
3644
|
-
});
|
|
4458
|
+
expect(parser.state).to.equal('stopped');
|
|
3645
4459
|
|
|
3646
|
-
|
|
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
|
-
|
|
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
|
});
|