@webex/plugin-meetings 3.12.0-next.4 → 3.12.0-next.41
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 +6 -2
- package/dist/breakouts/breakout.js.map +1 -1
- package/dist/breakouts/index.js +1 -1
- package/dist/constants.js +1 -1
- 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 +23 -21
- 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 +10 -1
- package/dist/hashTree/constants.js.map +1 -1
- package/dist/hashTree/hashTreeParser.js +554 -350
- package/dist/hashTree/hashTreeParser.js.map +1 -1
- package/dist/hashTree/utils.js +22 -0
- package/dist/hashTree/utils.js.map +1 -1
- package/dist/interceptors/locusRetry.js +23 -8
- package/dist/interceptors/locusRetry.js.map +1 -1
- package/dist/interpretation/index.js +1 -1
- package/dist/interpretation/siLanguage.js +1 -1
- package/dist/locus-info/index.js +274 -85
- package/dist/locus-info/index.js.map +1 -1
- package/dist/locus-info/types.js +16 -0
- package/dist/locus-info/types.js.map +1 -1
- package/dist/meeting/index.js +710 -499
- package/dist/meeting/index.js.map +1 -1
- package/dist/meeting/util.js +1 -0
- package/dist/meeting/util.js.map +1 -1
- package/dist/meetings/index.js +174 -77
- package/dist/meetings/index.js.map +1 -1
- package/dist/meetings/util.js +49 -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/types/controls-options-manager/constants.d.ts +6 -1
- package/dist/types/hashTree/constants.d.ts +1 -0
- package/dist/types/hashTree/hashTreeParser.d.ts +53 -15
- package/dist/types/hashTree/utils.d.ts +11 -0
- package/dist/types/interceptors/locusRetry.d.ts +4 -4
- package/dist/types/locus-info/index.d.ts +46 -6
- package/dist/types/locus-info/types.d.ts +17 -1
- package/dist/types/meeting/index.d.ts +64 -1
- 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/webinar/index.js +301 -226
- 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 +2 -1
- package/src/constants.ts +1 -1
- package/src/controls-options-manager/constants.ts +14 -1
- package/src/controls-options-manager/index.ts +26 -19
- package/src/controls-options-manager/util.ts +81 -1
- package/src/hashTree/constants.ts +9 -0
- package/src/hashTree/hashTreeParser.ts +278 -160
- package/src/hashTree/utils.ts +17 -0
- package/src/interceptors/locusRetry.ts +25 -4
- package/src/locus-info/index.ts +274 -93
- package/src/locus-info/types.ts +19 -1
- package/src/meeting/index.ts +206 -22
- package/src/meeting/util.ts +1 -0
- package/src/meetings/index.ts +77 -43
- package/src/meetings/util.ts +56 -1
- package/src/member/index.ts +10 -0
- package/src/member/types.ts +1 -0
- package/src/member/util.ts +3 -0
- package/src/webinar/index.ts +75 -1
- package/test/unit/spec/aiEnableRequest/index.ts +86 -0
- package/test/unit/spec/breakouts/breakout.ts +7 -3
- package/test/unit/spec/controls-options-manager/index.js +114 -6
- package/test/unit/spec/controls-options-manager/util.js +165 -0
- package/test/unit/spec/hashTree/hashTreeParser.ts +996 -51
- package/test/unit/spec/hashTree/utils.ts +88 -1
- package/test/unit/spec/interceptors/locusRetry.ts +205 -4
- package/test/unit/spec/locus-info/index.js +397 -81
- package/test/unit/spec/meeting/index.js +271 -44
- package/test/unit/spec/meeting/utils.js +4 -0
- package/test/unit/spec/meetings/index.js +195 -13
- package/test/unit/spec/meetings/utils.js +137 -0
- package/test/unit/spec/member/index.js +7 -0
- package/test/unit/spec/member/util.js +24 -0
- package/test/unit/spec/webinar/index.ts +60 -0
|
@@ -7,6 +7,7 @@ import {expect} from '@webex/test-helper-chai';
|
|
|
7
7
|
import sinon from 'sinon';
|
|
8
8
|
import {assert} from '@webex/test-helper-chai';
|
|
9
9
|
import {EMPTY_HASH} from '@webex/plugin-meetings/src/hashTree/constants';
|
|
10
|
+
import { some } from 'lodash';
|
|
10
11
|
|
|
11
12
|
const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
|
|
12
13
|
|
|
@@ -553,7 +554,7 @@ describe('HashTreeParser', () => {
|
|
|
553
554
|
);
|
|
554
555
|
|
|
555
556
|
// Verify callback was called with OBJECTS_UPDATED and correct updatedObjects list
|
|
556
|
-
assert.calledWith(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
557
|
+
assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
557
558
|
updatedObjects: [
|
|
558
559
|
{
|
|
559
560
|
htMeta: {
|
|
@@ -566,6 +567,11 @@ describe('HashTreeParser', () => {
|
|
|
566
567
|
},
|
|
567
568
|
data: {info: {id: 'some-fake-locus-info'}},
|
|
568
569
|
},
|
|
570
|
+
],
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
574
|
+
updatedObjects: [
|
|
569
575
|
{
|
|
570
576
|
htMeta: {
|
|
571
577
|
elementId: {
|
|
@@ -596,6 +602,67 @@ describe('HashTreeParser', () => {
|
|
|
596
602
|
});
|
|
597
603
|
});
|
|
598
604
|
|
|
605
|
+
it('initializes "main" before "self" regardless of order from Locus', async () => {
|
|
606
|
+
const parser = createHashTreeParser({dataSets: [], locus: null}, null);
|
|
607
|
+
|
|
608
|
+
// Locus returns datasets in non-priority order: atd-active, main, self
|
|
609
|
+
const atdActiveDataSet = createDataSet('atd-active', 4, 500);
|
|
610
|
+
const mainDataSet = createDataSet('main', 16, 1100);
|
|
611
|
+
const selfDataSet = createDataSet('self', 1, 2100);
|
|
612
|
+
|
|
613
|
+
mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
|
|
614
|
+
atdActiveDataSet,
|
|
615
|
+
mainDataSet,
|
|
616
|
+
selfDataSet,
|
|
617
|
+
]);
|
|
618
|
+
|
|
619
|
+
mockSyncRequest(webexRequest, selfDataSet.url);
|
|
620
|
+
mockSyncRequest(webexRequest, mainDataSet.url);
|
|
621
|
+
mockSyncRequest(webexRequest, atdActiveDataSet.url);
|
|
622
|
+
|
|
623
|
+
await parser.initializeFromMessage({
|
|
624
|
+
dataSets: [],
|
|
625
|
+
visibleDataSetsUrl,
|
|
626
|
+
locusUrl,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// Verify sync requests were sent in priority order: main, self, then atd-active
|
|
630
|
+
const syncCalls = webexRequest
|
|
631
|
+
.getCalls()
|
|
632
|
+
.filter((call) => call.args[0]?.method === 'POST' && call.args[0]?.uri?.endsWith('/sync'));
|
|
633
|
+
|
|
634
|
+
expect(syncCalls).to.have.lengthOf(3);
|
|
635
|
+
expect(syncCalls[0].args[0].uri).to.equal(`${mainDataSet.url}/sync`);
|
|
636
|
+
expect(syncCalls[1].args[0].uri).to.equal(`${selfDataSet.url}/sync`);
|
|
637
|
+
expect(syncCalls[2].args[0].uri).to.equal(`${atdActiveDataSet.url}/sync`);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('sends leafCount=1 with a single empty leaf for initialization sync, regardless of actual dataset leafCount', async () => {
|
|
641
|
+
const parser = createHashTreeParser({dataSets: [], locus: null}, null);
|
|
642
|
+
|
|
643
|
+
// Use a dataset with leafCount=16 to verify the initialization sync always uses leafCount=1
|
|
644
|
+
const mainDataSet = createDataSet('main', 16, 1100);
|
|
645
|
+
|
|
646
|
+
mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet]);
|
|
647
|
+
mockSyncRequest(webexRequest, mainDataSet.url);
|
|
648
|
+
|
|
649
|
+
await parser.initializeFromMessage({
|
|
650
|
+
dataSets: [],
|
|
651
|
+
visibleDataSetsUrl,
|
|
652
|
+
locusUrl,
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
assert.calledWith(webexRequest, {
|
|
656
|
+
method: 'POST',
|
|
657
|
+
uri: `${mainDataSet.url}/sync`,
|
|
658
|
+
qs: {rootHash: sinon.match.string},
|
|
659
|
+
body: {
|
|
660
|
+
leafCount: 1,
|
|
661
|
+
leafDataEntries: [{leafIndex: 0, elementIds: []}],
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
599
666
|
it('handles sync response that has locusStateElements undefined', async () => {
|
|
600
667
|
const minimalInitialLocus = {
|
|
601
668
|
dataSets: [],
|
|
@@ -788,7 +855,7 @@ describe('HashTreeParser', () => {
|
|
|
788
855
|
expect(parser.dataSets.self.version).to.equal(2100);
|
|
789
856
|
expect(parser.dataSets['atd-unmuted'].version).to.equal(3100);
|
|
790
857
|
|
|
791
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
858
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
792
859
|
updatedObjects: [
|
|
793
860
|
{
|
|
794
861
|
htMeta: {
|
|
@@ -861,6 +928,116 @@ describe('HashTreeParser', () => {
|
|
|
861
928
|
});
|
|
862
929
|
});
|
|
863
930
|
|
|
931
|
+
it('handles updates to control entries correctly', () => {
|
|
932
|
+
const parser = createHashTreeParser();
|
|
933
|
+
|
|
934
|
+
const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
|
|
935
|
+
|
|
936
|
+
// Create a locus update with new htMeta information for some things
|
|
937
|
+
const locusUpdate = {
|
|
938
|
+
dataSets: [
|
|
939
|
+
createDataSet('main', 16, 1100),
|
|
940
|
+
],
|
|
941
|
+
locus: {
|
|
942
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
|
|
943
|
+
htMeta: {
|
|
944
|
+
elementId: {
|
|
945
|
+
type: 'locus',
|
|
946
|
+
id: 0,
|
|
947
|
+
version: 200, // same version
|
|
948
|
+
},
|
|
949
|
+
dataSetNames: ['main'],
|
|
950
|
+
},
|
|
951
|
+
participants: [],
|
|
952
|
+
controls: {
|
|
953
|
+
lock: {
|
|
954
|
+
locked: true,
|
|
955
|
+
htMeta: {
|
|
956
|
+
elementId: {
|
|
957
|
+
type: 'ControlEntry',
|
|
958
|
+
id: 10100,
|
|
959
|
+
version: 100,
|
|
960
|
+
},
|
|
961
|
+
dataSetNames: ['main'],
|
|
962
|
+
},
|
|
963
|
+
},
|
|
964
|
+
stream: {
|
|
965
|
+
streaming: true,
|
|
966
|
+
htMeta: {
|
|
967
|
+
elementId: {
|
|
968
|
+
type: 'ControlEntry',
|
|
969
|
+
id: 10101,
|
|
970
|
+
version: 100,
|
|
971
|
+
},
|
|
972
|
+
dataSetNames: ['main'],
|
|
973
|
+
},
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
// Call handleLocusUpdate
|
|
980
|
+
parser.handleLocusUpdate(locusUpdate);
|
|
981
|
+
|
|
982
|
+
// Verify putItems was called on main hash tree with correct data
|
|
983
|
+
assert.calledOnceWithExactly(mainPutItemsSpy, [
|
|
984
|
+
{type: 'locus', id: 0, version: 200},
|
|
985
|
+
{type: 'ControlEntry', id: 10100, version: 100},
|
|
986
|
+
{type: 'ControlEntry', id: 10101, version: 100}
|
|
987
|
+
]);
|
|
988
|
+
|
|
989
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
990
|
+
updatedObjects: [
|
|
991
|
+
{
|
|
992
|
+
htMeta: {
|
|
993
|
+
elementId: {
|
|
994
|
+
type: 'ControlEntry',
|
|
995
|
+
id: 10100,
|
|
996
|
+
version: 100,
|
|
997
|
+
},
|
|
998
|
+
dataSetNames: ['main'],
|
|
999
|
+
},
|
|
1000
|
+
data: {
|
|
1001
|
+
lock: {
|
|
1002
|
+
locked: true,
|
|
1003
|
+
htMeta: {
|
|
1004
|
+
elementId: {
|
|
1005
|
+
type: 'ControlEntry',
|
|
1006
|
+
id: 10100,
|
|
1007
|
+
version: 100,
|
|
1008
|
+
},
|
|
1009
|
+
dataSetNames: ['main'],
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
htMeta: {
|
|
1016
|
+
elementId: {
|
|
1017
|
+
type: 'ControlEntry',
|
|
1018
|
+
id: 10101,
|
|
1019
|
+
version: 100,
|
|
1020
|
+
},
|
|
1021
|
+
dataSetNames: ['main'],
|
|
1022
|
+
},
|
|
1023
|
+
data: {
|
|
1024
|
+
stream: {
|
|
1025
|
+
streaming: true,
|
|
1026
|
+
htMeta: {
|
|
1027
|
+
elementId: {
|
|
1028
|
+
type: 'ControlEntry',
|
|
1029
|
+
id: 10101,
|
|
1030
|
+
version: 100,
|
|
1031
|
+
},
|
|
1032
|
+
dataSetNames: ['main'],
|
|
1033
|
+
},
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
}
|
|
1037
|
+
],
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
|
|
864
1041
|
it('handles unknown datasets gracefully', () => {
|
|
865
1042
|
const parser = createHashTreeParser();
|
|
866
1043
|
|
|
@@ -899,7 +1076,7 @@ describe('HashTreeParser', () => {
|
|
|
899
1076
|
assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 201}]);
|
|
900
1077
|
|
|
901
1078
|
// Verify callback was called only for known dataset
|
|
902
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1079
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
903
1080
|
updatedObjects: [
|
|
904
1081
|
{
|
|
905
1082
|
htMeta: {
|
|
@@ -999,7 +1176,7 @@ describe('HashTreeParser', () => {
|
|
|
999
1176
|
assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 51});
|
|
1000
1177
|
|
|
1001
1178
|
// Verify callback was called with metadata object and removed dataset objects
|
|
1002
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1179
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1003
1180
|
updatedObjects: [
|
|
1004
1181
|
// updated metadata object:
|
|
1005
1182
|
{
|
|
@@ -1160,7 +1337,7 @@ describe('HashTreeParser', () => {
|
|
|
1160
1337
|
assert.notCalled(atdUnmutedPutItemsSpy);
|
|
1161
1338
|
|
|
1162
1339
|
// Verify callback was called with the updated object
|
|
1163
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1340
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1164
1341
|
updatedObjects: [
|
|
1165
1342
|
{
|
|
1166
1343
|
htMeta: {
|
|
@@ -1388,7 +1565,7 @@ describe('HashTreeParser', () => {
|
|
|
1388
1565
|
]);
|
|
1389
1566
|
|
|
1390
1567
|
// Verify callback was called with OBJECTS_UPDATED and all updated objects
|
|
1391
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1568
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1392
1569
|
updatedObjects: [
|
|
1393
1570
|
{
|
|
1394
1571
|
htMeta: {
|
|
@@ -1453,9 +1630,7 @@ describe('HashTreeParser', () => {
|
|
|
1453
1630
|
parser.handleMessage(sentinelMessage, 'sentinel message');
|
|
1454
1631
|
|
|
1455
1632
|
// Verify callback was called with MEETING_ENDED
|
|
1456
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
1457
|
-
updatedObjects: undefined,
|
|
1458
|
-
});
|
|
1633
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1459
1634
|
|
|
1460
1635
|
// Verify that all timers were stopped
|
|
1461
1636
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
@@ -1477,9 +1652,7 @@ describe('HashTreeParser', () => {
|
|
|
1477
1652
|
parser.handleMessage(sentinelMessage, 'sentinel message');
|
|
1478
1653
|
|
|
1479
1654
|
// Verify callback was called with MEETING_ENDED
|
|
1480
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
1481
|
-
updatedObjects: undefined,
|
|
1482
|
-
});
|
|
1655
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1483
1656
|
|
|
1484
1657
|
// Verify that all timers were stopped
|
|
1485
1658
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
@@ -1575,7 +1748,7 @@ describe('HashTreeParser', () => {
|
|
|
1575
1748
|
);
|
|
1576
1749
|
|
|
1577
1750
|
// Verify that callback was called with synced objects
|
|
1578
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1751
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1579
1752
|
updatedObjects: [
|
|
1580
1753
|
{
|
|
1581
1754
|
htMeta: {
|
|
@@ -1637,9 +1810,7 @@ describe('HashTreeParser', () => {
|
|
|
1637
1810
|
await clock.tickAsync(1000);
|
|
1638
1811
|
|
|
1639
1812
|
// Verify callback was called with MEETING_ENDED
|
|
1640
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
1641
|
-
updatedObjects: undefined,
|
|
1642
|
-
});
|
|
1813
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1643
1814
|
|
|
1644
1815
|
// Verify all timers are stopped
|
|
1645
1816
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
@@ -1702,9 +1873,7 @@ describe('HashTreeParser', () => {
|
|
|
1702
1873
|
await clock.tickAsync(1000);
|
|
1703
1874
|
|
|
1704
1875
|
// Verify callback was called with MEETING_ENDED
|
|
1705
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
1706
|
-
updatedObjects: undefined,
|
|
1707
|
-
});
|
|
1876
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1708
1877
|
|
|
1709
1878
|
// Verify all timers are stopped
|
|
1710
1879
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
@@ -1883,6 +2052,79 @@ describe('HashTreeParser', () => {
|
|
|
1883
2052
|
},
|
|
1884
2053
|
});
|
|
1885
2054
|
});
|
|
2055
|
+
|
|
2056
|
+
it('restarts the sync timer when sync response is empty so that a future sync can be triggered', async () => {
|
|
2057
|
+
const parser = createHashTreeParser();
|
|
2058
|
+
|
|
2059
|
+
// Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
|
|
2060
|
+
const heartbeatMessage = {
|
|
2061
|
+
dataSets: [
|
|
2062
|
+
{
|
|
2063
|
+
...createDataSet('main', 16, 1100),
|
|
2064
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from ours
|
|
2065
|
+
},
|
|
2066
|
+
],
|
|
2067
|
+
visibleDataSetsUrl,
|
|
2068
|
+
locusUrl,
|
|
2069
|
+
};
|
|
2070
|
+
|
|
2071
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
|
|
2072
|
+
|
|
2073
|
+
// The sync timer should be set
|
|
2074
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
2075
|
+
|
|
2076
|
+
// Mock responses for the first sync - return null (204/empty body)
|
|
2077
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
2078
|
+
mockGetHashesFromLocusResponse(
|
|
2079
|
+
mainDataSetUrl,
|
|
2080
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
2081
|
+
{
|
|
2082
|
+
...createDataSet('main', 16, 1101),
|
|
2083
|
+
root: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', // still mismatched
|
|
2084
|
+
}
|
|
2085
|
+
);
|
|
2086
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
2087
|
+
|
|
2088
|
+
// Advance time to fire the sync timer (idleMs=1000 + backoff=0)
|
|
2089
|
+
await clock.tickAsync(1000);
|
|
2090
|
+
|
|
2091
|
+
// Verify sync was triggered
|
|
2092
|
+
assert.calledWith(
|
|
2093
|
+
webexRequest,
|
|
2094
|
+
sinon.match({
|
|
2095
|
+
method: 'POST',
|
|
2096
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
2097
|
+
})
|
|
2098
|
+
);
|
|
2099
|
+
|
|
2100
|
+
// After empty response, runSyncAlgorithm should have been called,
|
|
2101
|
+
// setting a new sync timer as a safety net
|
|
2102
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
2103
|
+
|
|
2104
|
+
// Reset and set up mocks for the second sync
|
|
2105
|
+
webexRequest.resetHistory();
|
|
2106
|
+
mockGetHashesFromLocusResponse(
|
|
2107
|
+
mainDataSetUrl,
|
|
2108
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
2109
|
+
{
|
|
2110
|
+
...createDataSet('main', 16, 1102),
|
|
2111
|
+
root: 'cccccccccccccccccccccccccccccccc', // still mismatched
|
|
2112
|
+
}
|
|
2113
|
+
);
|
|
2114
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
2115
|
+
|
|
2116
|
+
// Advance time again to fire the second sync timer
|
|
2117
|
+
await clock.tickAsync(1000);
|
|
2118
|
+
|
|
2119
|
+
// Verify a second sync was triggered
|
|
2120
|
+
assert.calledWith(
|
|
2121
|
+
webexRequest,
|
|
2122
|
+
sinon.match({
|
|
2123
|
+
method: 'POST',
|
|
2124
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
2125
|
+
})
|
|
2126
|
+
);
|
|
2127
|
+
});
|
|
1886
2128
|
});
|
|
1887
2129
|
|
|
1888
2130
|
describe('handles visible data sets changes correctly', () => {
|
|
@@ -1942,7 +2184,7 @@ describe('HashTreeParser', () => {
|
|
|
1942
2184
|
assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
|
|
1943
2185
|
|
|
1944
2186
|
// Verify callback was called with the metadata update (appears twice - processed once for visible dataset changes, once in main loop)
|
|
1945
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2187
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1946
2188
|
updatedObjects: [
|
|
1947
2189
|
{
|
|
1948
2190
|
htMeta: {
|
|
@@ -2062,6 +2304,98 @@ describe('HashTreeParser', () => {
|
|
|
2062
2304
|
await checkAsyncDatasetInitialization(parser, newDataSet);
|
|
2063
2305
|
});
|
|
2064
2306
|
|
|
2307
|
+
it('initializes new visible data sets in priority order', async () => {
|
|
2308
|
+
// Create a parser that only has "self" as visible (no "main")
|
|
2309
|
+
const initialLocusWithoutMain = {
|
|
2310
|
+
dataSets: [createDataSet('self', 1, 2000)],
|
|
2311
|
+
locus: {
|
|
2312
|
+
...exampleInitialLocus.locus,
|
|
2313
|
+
},
|
|
2314
|
+
};
|
|
2315
|
+
const metadataWithoutMain = {
|
|
2316
|
+
...exampleMetadata,
|
|
2317
|
+
visibleDataSets: [
|
|
2318
|
+
{
|
|
2319
|
+
name: 'self',
|
|
2320
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
|
|
2321
|
+
},
|
|
2322
|
+
],
|
|
2323
|
+
};
|
|
2324
|
+
const parser = createHashTreeParser(initialLocusWithoutMain, metadataWithoutMain);
|
|
2325
|
+
|
|
2326
|
+
// Verify "main" is not visible initially
|
|
2327
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.false;
|
|
2328
|
+
|
|
2329
|
+
// Stub updateItems on self hash tree to return true
|
|
2330
|
+
sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
|
|
2331
|
+
|
|
2332
|
+
// Send a message that adds "main" and "atd-active" as new visible datasets.
|
|
2333
|
+
// Neither has info in dataSets, so both require async initialization.
|
|
2334
|
+
const newMainDataSet = createDataSet('main', 16, 6000);
|
|
2335
|
+
const newAtdActiveDataSet = createDataSet('atd-active', 4, 7000);
|
|
2336
|
+
|
|
2337
|
+
const message = {
|
|
2338
|
+
dataSets: [createDataSet('self', 1, 2100)],
|
|
2339
|
+
visibleDataSetsUrl,
|
|
2340
|
+
locusUrl,
|
|
2341
|
+
locusStateElements: [
|
|
2342
|
+
{
|
|
2343
|
+
htMeta: {
|
|
2344
|
+
elementId: {
|
|
2345
|
+
type: 'metadata' as const,
|
|
2346
|
+
id: 5,
|
|
2347
|
+
version: 51,
|
|
2348
|
+
},
|
|
2349
|
+
dataSetNames: ['self'],
|
|
2350
|
+
},
|
|
2351
|
+
data: {
|
|
2352
|
+
visibleDataSets: [
|
|
2353
|
+
{
|
|
2354
|
+
name: 'self',
|
|
2355
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
|
|
2356
|
+
},
|
|
2357
|
+
// listed in non-priority order: atd-active before main
|
|
2358
|
+
{name: 'atd-active', url: newAtdActiveDataSet.url},
|
|
2359
|
+
{name: 'main', url: newMainDataSet.url},
|
|
2360
|
+
],
|
|
2361
|
+
},
|
|
2362
|
+
},
|
|
2363
|
+
],
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
// Mock getAllVisibleDataSetsFromLocus to return both new datasets (in non-priority order)
|
|
2367
|
+
mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
|
|
2368
|
+
newAtdActiveDataSet,
|
|
2369
|
+
newMainDataSet,
|
|
2370
|
+
]);
|
|
2371
|
+
mockSyncRequest(webexRequest, newMainDataSet.url);
|
|
2372
|
+
mockSyncRequest(webexRequest, newAtdActiveDataSet.url);
|
|
2373
|
+
|
|
2374
|
+
parser.handleMessage(message, 'add main and atd-active datasets');
|
|
2375
|
+
|
|
2376
|
+
// Wait for the async initialization (queueMicrotask) to complete
|
|
2377
|
+
await clock.tickAsync(0);
|
|
2378
|
+
|
|
2379
|
+
// Verify both datasets are initialized
|
|
2380
|
+
expect(parser.dataSets.main?.hashTree).to.exist;
|
|
2381
|
+
expect(parser.dataSets['atd-active']?.hashTree).to.exist;
|
|
2382
|
+
|
|
2383
|
+
// Verify sync requests were sent in priority order: "main" before "atd-active",
|
|
2384
|
+
// even though atd-active was listed first in both the message and the Locus response
|
|
2385
|
+
const syncCalls = webexRequest
|
|
2386
|
+
.getCalls()
|
|
2387
|
+
.filter(
|
|
2388
|
+
(call) =>
|
|
2389
|
+
call.args[0]?.method === 'POST' &&
|
|
2390
|
+
call.args[0]?.uri?.endsWith('/sync') &&
|
|
2391
|
+
(call.args[0]?.uri?.includes('/main/') || call.args[0]?.uri?.includes('/atd-active/'))
|
|
2392
|
+
);
|
|
2393
|
+
|
|
2394
|
+
expect(syncCalls).to.have.lengthOf(2);
|
|
2395
|
+
expect(syncCalls[0].args[0].uri).to.equal(`${newMainDataSet.url}/sync`);
|
|
2396
|
+
expect(syncCalls[1].args[0].uri).to.equal(`${newAtdActiveDataSet.url}/sync`);
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2065
2399
|
it('emits MEETING_ENDED if async init of a new visible dataset fails with 404', async () => {
|
|
2066
2400
|
const parser = createHashTreeParser();
|
|
2067
2401
|
|
|
@@ -2128,9 +2462,7 @@ describe('HashTreeParser', () => {
|
|
|
2128
2462
|
await clock.tickAsync(0);
|
|
2129
2463
|
|
|
2130
2464
|
// Verify callback was called with MEETING_ENDED
|
|
2131
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
2132
|
-
updatedObjects: undefined,
|
|
2133
|
-
});
|
|
2465
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
2134
2466
|
});
|
|
2135
2467
|
|
|
2136
2468
|
it('handles removal of visible data set', async () => {
|
|
@@ -2193,7 +2525,7 @@ describe('HashTreeParser', () => {
|
|
|
2193
2525
|
assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
|
|
2194
2526
|
|
|
2195
2527
|
// Verify callback was called with the metadata update and the removed objects (metadata appears twice - processed once for dataset changes, once in main loop)
|
|
2196
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2528
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2197
2529
|
updatedObjects: [
|
|
2198
2530
|
{
|
|
2199
2531
|
htMeta: {
|
|
@@ -2290,24 +2622,169 @@ describe('HashTreeParser', () => {
|
|
|
2290
2622
|
// Verify callback was NOT called (no updates for non-visible datasets)
|
|
2291
2623
|
assert.notCalled(callback);
|
|
2292
2624
|
});
|
|
2293
|
-
});
|
|
2294
2625
|
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2626
|
+
it('reports update for object that moves from removed visible dataset to new visible dataset even if version is unchanged', async () => {
|
|
2627
|
+
// The purpose of this test is to verify that when an object
|
|
2628
|
+
// moves from one visible dataset to another without version change,
|
|
2629
|
+
// the parser still reports it as an update.
|
|
2630
|
+
// Locus has some additional signalling for this - the "view" property in htMeta.elementId.
|
|
2631
|
+
// When a view changes, the contents of the object may change even if version doesn't.
|
|
2632
|
+
// HashTreeParser doesn't use the "view" property, because it doesn't need to -
|
|
2633
|
+
// the same functionality is achieved thanks to the fact that a new visible data set means
|
|
2634
|
+
// a new hash tree is created, so HashTreeParser still detects the change as new
|
|
2635
|
+
// object is added to the new hash tree.
|
|
2636
|
+
|
|
2637
|
+
// Setup: parser with visible datasets "self" and "unjoined"
|
|
2638
|
+
const unjoinedDataSet = createDataSet('unjoined', 4, 3000);
|
|
2639
|
+
const selfDataSet = createDataSet('self', 1, 2000);
|
|
2640
|
+
|
|
2641
|
+
// start with Locus that has "info" in both "unjoined" and "main" datasets,
|
|
2642
|
+
// but only "unjoined" is visible.
|
|
2643
|
+
const initialLocus = {
|
|
2644
|
+
dataSets: [selfDataSet, unjoinedDataSet],
|
|
2645
|
+
locus: {
|
|
2646
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
|
|
2647
|
+
links: {resources: {visibleDataSets: {url: visibleDataSetsUrl}}},
|
|
2648
|
+
// info object in "unjoined" dataset with version 500
|
|
2649
|
+
info: {
|
|
2650
|
+
htMeta: {
|
|
2651
|
+
elementId: {
|
|
2652
|
+
type: 'info',
|
|
2653
|
+
id: 42,
|
|
2654
|
+
version: 500,
|
|
2655
|
+
view: ['unjoined'], // not used by our code, but here for completeness - that's what real Locus would send
|
|
2656
|
+
},
|
|
2657
|
+
dataSetNames: ['main', 'unjoined'],
|
|
2658
|
+
},
|
|
2659
|
+
someField: 'some-initial-value',
|
|
2660
|
+
},
|
|
2661
|
+
self: {
|
|
2662
|
+
htMeta: {
|
|
2663
|
+
elementId: {
|
|
2664
|
+
type: 'self',
|
|
2665
|
+
id: 4,
|
|
2666
|
+
version: 100,
|
|
2667
|
+
},
|
|
2668
|
+
dataSetNames: ['self'],
|
|
2669
|
+
},
|
|
2670
|
+
},
|
|
2671
|
+
},
|
|
2672
|
+
};
|
|
2299
2673
|
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2674
|
+
const metadata = {
|
|
2675
|
+
htMeta: {
|
|
2676
|
+
elementId: {
|
|
2677
|
+
type: 'metadata',
|
|
2678
|
+
id: 5,
|
|
2679
|
+
version: 50,
|
|
2306
2680
|
},
|
|
2681
|
+
dataSetNames: ['self'],
|
|
2682
|
+
},
|
|
2683
|
+
visibleDataSets: [
|
|
2684
|
+
{name: 'self', url: selfDataSet.url},
|
|
2685
|
+
{name: 'unjoined', url: unjoinedDataSet.url},
|
|
2307
2686
|
],
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2687
|
+
};
|
|
2688
|
+
|
|
2689
|
+
const parser = createHashTreeParser(initialLocus, metadata);
|
|
2690
|
+
|
|
2691
|
+
// Verify initial state: unjoined is visible and has the info object
|
|
2692
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.true;
|
|
2693
|
+
assert.exists(parser.dataSets.unjoined.hashTree);
|
|
2694
|
+
assert.equal(parser.dataSets.unjoined.hashTree?.getItemVersion(42, 'info'), 500);
|
|
2695
|
+
|
|
2696
|
+
// Stub updateItems on self hash tree to return true for metadata update
|
|
2697
|
+
sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
|
|
2698
|
+
|
|
2699
|
+
// Now send a message that:
|
|
2700
|
+
// 1. Changes visible datasets: removes "unjoined", adds "main"
|
|
2701
|
+
// 2. Contains the same info object (same id=42, same version=500) but we see the view from "main" dataset
|
|
2702
|
+
const mainDataSet = createDataSet('main', 16, 1000);
|
|
2703
|
+
|
|
2704
|
+
const message = {
|
|
2705
|
+
dataSets: [selfDataSet, mainDataSet],
|
|
2706
|
+
visibleDataSetsUrl,
|
|
2707
|
+
locusUrl,
|
|
2708
|
+
locusStateElements: [
|
|
2709
|
+
{
|
|
2710
|
+
htMeta: {
|
|
2711
|
+
elementId: {
|
|
2712
|
+
type: 'metadata' as const,
|
|
2713
|
+
id: 5,
|
|
2714
|
+
version: 51,
|
|
2715
|
+
},
|
|
2716
|
+
dataSetNames: ['self'],
|
|
2717
|
+
},
|
|
2718
|
+
data: {
|
|
2719
|
+
visibleDataSets: [
|
|
2720
|
+
{name: 'self', url: selfDataSet.url},
|
|
2721
|
+
{name: 'main', url: mainDataSet.url},
|
|
2722
|
+
// "unjoined" is no longer here
|
|
2723
|
+
],
|
|
2724
|
+
},
|
|
2725
|
+
},
|
|
2726
|
+
{
|
|
2727
|
+
htMeta: {
|
|
2728
|
+
elementId: {
|
|
2729
|
+
type: 'info' as const,
|
|
2730
|
+
id: 42,
|
|
2731
|
+
version: 500, // same version as before
|
|
2732
|
+
view: ['main'], // now points to "main" instead of "unjoined"
|
|
2733
|
+
},
|
|
2734
|
+
dataSetNames: ['main', 'unjoined'], // still in both datasets, but only "main" is visible now
|
|
2735
|
+
},
|
|
2736
|
+
data: {someNewField: 'some-value'},
|
|
2737
|
+
},
|
|
2738
|
+
],
|
|
2739
|
+
};
|
|
2740
|
+
|
|
2741
|
+
parser.handleMessage(message, 'visible dataset swap with same-version object');
|
|
2742
|
+
|
|
2743
|
+
// Verify "unjoined" is no longer visible and "main" is now visible
|
|
2744
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.false;
|
|
2745
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
|
|
2746
|
+
|
|
2747
|
+
// Verify the info object is now in the "main" hash tree
|
|
2748
|
+
assert.exists(parser.dataSets.main.hashTree);
|
|
2749
|
+
assert.equal(parser.dataSets.main.hashTree?.getItemVersion(42, 'info'), 500);
|
|
2750
|
+
|
|
2751
|
+
// The key assertion: callback should be called with the info object update even though
|
|
2752
|
+
// its version hasn't changed - because visible datasets changed (moved from unjoined to main)
|
|
2753
|
+
assert.calledOnce(callback);
|
|
2754
|
+
const callbackArgs = callback.firstCall.args[0];
|
|
2755
|
+
assert.equal(callbackArgs.updateType, LocusInfoUpdateType.OBJECTS_UPDATED);
|
|
2756
|
+
|
|
2757
|
+
// Should contain the info object update (with data)
|
|
2758
|
+
const infoUpdate = callbackArgs.updatedObjects.find(
|
|
2759
|
+
(obj) => obj.htMeta.elementId.type === 'info' && obj.htMeta.elementId.id === 42
|
|
2760
|
+
);
|
|
2761
|
+
assert.exists(infoUpdate);
|
|
2762
|
+
assert.deepEqual(infoUpdate.htMeta.elementId, {
|
|
2763
|
+
type: 'info',
|
|
2764
|
+
id: 42,
|
|
2765
|
+
version: 500,
|
|
2766
|
+
view: ['main'],
|
|
2767
|
+
});
|
|
2768
|
+
assert.deepEqual(infoUpdate.data, {someNewField: 'some-value'});
|
|
2769
|
+
});
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
describe('heartbeat watchdog', () => {
|
|
2773
|
+
it('initiates sync immediately only for the specific data set whose heartbeat watchdog fires', async () => {
|
|
2774
|
+
const parser = createHashTreeParser();
|
|
2775
|
+
const heartbeatIntervalMs = 5000;
|
|
2776
|
+
|
|
2777
|
+
// Send initial heartbeat message for 'main' only
|
|
2778
|
+
const heartbeatMessage = {
|
|
2779
|
+
dataSets: [
|
|
2780
|
+
{
|
|
2781
|
+
...createDataSet('main', 16, 1100),
|
|
2782
|
+
root: parser.dataSets.main.hashTree.getRootHash(),
|
|
2783
|
+
},
|
|
2784
|
+
],
|
|
2785
|
+
visibleDataSetsUrl,
|
|
2786
|
+
locusUrl,
|
|
2787
|
+
heartbeatIntervalMs,
|
|
2311
2788
|
};
|
|
2312
2789
|
|
|
2313
2790
|
parser.handleMessage(heartbeatMessage, 'initial heartbeat');
|
|
@@ -2715,7 +3192,77 @@ describe('HashTreeParser', () => {
|
|
|
2715
3192
|
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
2716
3193
|
expect(parser.dataSets['atd-active']?.heartbeatWatchdogTimer).to.be.undefined;
|
|
2717
3194
|
});
|
|
3195
|
+
|
|
3196
|
+
it('restarts the watchdog timer after it fires so that future missed heartbeats still trigger syncs', async () => {
|
|
3197
|
+
const parser = createHashTreeParser();
|
|
3198
|
+
const heartbeatIntervalMs = 5000;
|
|
3199
|
+
|
|
3200
|
+
// Send initial heartbeat for 'main'
|
|
3201
|
+
const heartbeatMessage = {
|
|
3202
|
+
dataSets: [
|
|
3203
|
+
{
|
|
3204
|
+
...createDataSet('main', 16, 1100),
|
|
3205
|
+
root: parser.dataSets.main.hashTree.getRootHash(),
|
|
3206
|
+
},
|
|
3207
|
+
],
|
|
3208
|
+
visibleDataSetsUrl,
|
|
3209
|
+
locusUrl,
|
|
3210
|
+
heartbeatIntervalMs,
|
|
3211
|
+
};
|
|
3212
|
+
|
|
3213
|
+
parser.handleMessage(heartbeatMessage, 'initial heartbeat');
|
|
3214
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
3215
|
+
|
|
3216
|
+
// Mock responses for performSync - return null (204/empty body)
|
|
3217
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
3218
|
+
mockGetHashesFromLocusResponse(
|
|
3219
|
+
mainDataSetUrl,
|
|
3220
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
3221
|
+
createDataSet('main', 16, 1101)
|
|
3222
|
+
);
|
|
3223
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
3224
|
+
|
|
3225
|
+
// Advance time past heartbeatIntervalMs to fire the watchdog
|
|
3226
|
+
await clock.tickAsync(heartbeatIntervalMs);
|
|
3227
|
+
|
|
3228
|
+
// Verify sync was triggered
|
|
3229
|
+
assert.calledWith(
|
|
3230
|
+
webexRequest,
|
|
3231
|
+
sinon.match({
|
|
3232
|
+
method: 'GET',
|
|
3233
|
+
uri: `${mainDataSetUrl}/hashtree`,
|
|
3234
|
+
})
|
|
3235
|
+
);
|
|
3236
|
+
|
|
3237
|
+
// The watchdog timer should have been restarted after firing
|
|
3238
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
3239
|
+
|
|
3240
|
+
// Reset call history and set up new mock responses for the second sync
|
|
3241
|
+
webexRequest.resetHistory();
|
|
3242
|
+
mockGetHashesFromLocusResponse(
|
|
3243
|
+
mainDataSetUrl,
|
|
3244
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
3245
|
+
createDataSet('main', 16, 1102)
|
|
3246
|
+
);
|
|
3247
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
3248
|
+
|
|
3249
|
+
// Advance time again to fire the watchdog a second time
|
|
3250
|
+
await clock.tickAsync(heartbeatIntervalMs);
|
|
3251
|
+
|
|
3252
|
+
// Verify a second sync was triggered
|
|
3253
|
+
assert.calledWith(
|
|
3254
|
+
webexRequest,
|
|
3255
|
+
sinon.match({
|
|
3256
|
+
method: 'GET',
|
|
3257
|
+
uri: `${mainDataSetUrl}/hashtree`,
|
|
3258
|
+
})
|
|
3259
|
+
);
|
|
3260
|
+
|
|
3261
|
+
// And the watchdog should still be running
|
|
3262
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
3263
|
+
});
|
|
2718
3264
|
});
|
|
3265
|
+
|
|
2719
3266
|
});
|
|
2720
3267
|
|
|
2721
3268
|
describe('#callLocusInfoUpdateCallback filtering', () => {
|
|
@@ -2812,7 +3359,7 @@ describe('HashTreeParser', () => {
|
|
|
2812
3359
|
parser.handleMessage(updateMessage, 'update with newer version');
|
|
2813
3360
|
|
|
2814
3361
|
// Callback should be called with the update
|
|
2815
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3362
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2816
3363
|
updatedObjects: [
|
|
2817
3364
|
{
|
|
2818
3365
|
htMeta: {
|
|
@@ -2883,7 +3430,7 @@ describe('HashTreeParser', () => {
|
|
|
2883
3430
|
parser.handleMessage(removalMessage, 'removal of non-existent object');
|
|
2884
3431
|
|
|
2885
3432
|
// Callback should be called with the removal
|
|
2886
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3433
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2887
3434
|
updatedObjects: [
|
|
2888
3435
|
{
|
|
2889
3436
|
htMeta: {
|
|
@@ -3018,7 +3565,7 @@ describe('HashTreeParser', () => {
|
|
|
3018
3565
|
parser.handleMessage(mixedMessage, 'mixed updates');
|
|
3019
3566
|
|
|
3020
3567
|
// Callback should be called with only the valid updates (participant 1 v110 and participant 3 v10)
|
|
3021
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3568
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3022
3569
|
updatedObjects: [
|
|
3023
3570
|
{
|
|
3024
3571
|
htMeta: {
|
|
@@ -3196,9 +3743,7 @@ describe('HashTreeParser', () => {
|
|
|
3196
3743
|
parser.handleMessage(sentinelMessage as any, 'sentinel message');
|
|
3197
3744
|
|
|
3198
3745
|
// Callback should be called with MEETING_ENDED
|
|
3199
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
3200
|
-
updatedObjects: undefined,
|
|
3201
|
-
});
|
|
3746
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
3202
3747
|
});
|
|
3203
3748
|
});
|
|
3204
3749
|
|
|
@@ -3404,7 +3949,7 @@ describe('HashTreeParser', () => {
|
|
|
3404
3949
|
});
|
|
3405
3950
|
});
|
|
3406
3951
|
|
|
3407
|
-
describe('#
|
|
3952
|
+
describe('#resumeFromMessage', () => {
|
|
3408
3953
|
const createResumeMessage = (visibleDataSets?, dataSets?) => ({
|
|
3409
3954
|
locusUrl,
|
|
3410
3955
|
visibleDataSetsUrl,
|
|
@@ -3431,7 +3976,7 @@ describe('HashTreeParser', () => {
|
|
|
3431
3976
|
|
|
3432
3977
|
expect(parser.state).to.equal('stopped');
|
|
3433
3978
|
|
|
3434
|
-
parser.
|
|
3979
|
+
parser.resumeFromMessage(createResumeMessage());
|
|
3435
3980
|
|
|
3436
3981
|
expect(parser.state).to.equal('active');
|
|
3437
3982
|
});
|
|
@@ -3440,7 +3985,7 @@ describe('HashTreeParser', () => {
|
|
|
3440
3985
|
const parser = createHashTreeParser();
|
|
3441
3986
|
parser.stop();
|
|
3442
3987
|
|
|
3443
|
-
parser.
|
|
3988
|
+
parser.resumeFromMessage({
|
|
3444
3989
|
locusUrl,
|
|
3445
3990
|
visibleDataSetsUrl,
|
|
3446
3991
|
dataSets: [createDataSet('main', 16, 2000)],
|
|
@@ -3459,7 +4004,7 @@ describe('HashTreeParser', () => {
|
|
|
3459
4004
|
createDataSet('self', 2, 6000),
|
|
3460
4005
|
];
|
|
3461
4006
|
|
|
3462
|
-
parser.
|
|
4007
|
+
parser.resumeFromMessage(createResumeMessage(undefined, newDataSets));
|
|
3463
4008
|
|
|
3464
4009
|
expect(Object.keys(parser.dataSets)).to.have.lengthOf(2);
|
|
3465
4010
|
expect(parser.dataSets.main.leafCount).to.equal(8);
|
|
@@ -3481,7 +4026,7 @@ describe('HashTreeParser', () => {
|
|
|
3481
4026
|
{name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
|
|
3482
4027
|
];
|
|
3483
4028
|
|
|
3484
|
-
parser.
|
|
4029
|
+
parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
|
|
3485
4030
|
|
|
3486
4031
|
expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
|
|
3487
4032
|
expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
|
|
@@ -3495,7 +4040,7 @@ describe('HashTreeParser', () => {
|
|
|
3495
4040
|
const handleMessageStub = sinon.stub(parser, 'handleMessage');
|
|
3496
4041
|
|
|
3497
4042
|
const message = createResumeMessage();
|
|
3498
|
-
parser.
|
|
4043
|
+
parser.resumeFromMessage(message);
|
|
3499
4044
|
|
|
3500
4045
|
assert.calledOnceWithExactly(handleMessageStub, message, 'on resume');
|
|
3501
4046
|
});
|
|
@@ -3515,7 +4060,7 @@ describe('HashTreeParser', () => {
|
|
|
3515
4060
|
{name: 'atd-unmuted', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'},
|
|
3516
4061
|
];
|
|
3517
4062
|
|
|
3518
|
-
parser.
|
|
4063
|
+
parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
|
|
3519
4064
|
|
|
3520
4065
|
expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
|
|
3521
4066
|
expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
|
|
@@ -3523,6 +4068,67 @@ describe('HashTreeParser', () => {
|
|
|
3523
4068
|
});
|
|
3524
4069
|
});
|
|
3525
4070
|
|
|
4071
|
+
describe('#resumeFromApiResponse', () => {
|
|
4072
|
+
const exampleLocus = {
|
|
4073
|
+
participants: [],
|
|
4074
|
+
} as any;
|
|
4075
|
+
|
|
4076
|
+
it('should set state to active', async () => {
|
|
4077
|
+
const parser = createHashTreeParser();
|
|
4078
|
+
parser.stop();
|
|
4079
|
+
|
|
4080
|
+
expect(parser.state).to.equal('stopped');
|
|
4081
|
+
|
|
4082
|
+
sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
|
|
4083
|
+
|
|
4084
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4085
|
+
|
|
4086
|
+
expect(parser.state).to.equal('active');
|
|
4087
|
+
});
|
|
4088
|
+
|
|
4089
|
+
it('should reset dataSets to empty', async () => {
|
|
4090
|
+
const parser = createHashTreeParser();
|
|
4091
|
+
|
|
4092
|
+
expect(Object.keys(parser.dataSets).length).to.be.greaterThan(0);
|
|
4093
|
+
|
|
4094
|
+
parser.stop();
|
|
4095
|
+
|
|
4096
|
+
sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
|
|
4097
|
+
|
|
4098
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4099
|
+
|
|
4100
|
+
expect(parser.dataSets).to.deep.equal({});
|
|
4101
|
+
});
|
|
4102
|
+
|
|
4103
|
+
it('should call initializeFromGetLociResponse with the provided locus', async () => {
|
|
4104
|
+
const parser = createHashTreeParser();
|
|
4105
|
+
parser.stop();
|
|
4106
|
+
|
|
4107
|
+
const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
|
|
4108
|
+
|
|
4109
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4110
|
+
|
|
4111
|
+
assert.calledOnceWithExactly(initStub, exampleLocus);
|
|
4112
|
+
});
|
|
4113
|
+
|
|
4114
|
+
it('should propagate errors from initializeFromGetLociResponse', async () => {
|
|
4115
|
+
const parser = createHashTreeParser();
|
|
4116
|
+
parser.stop();
|
|
4117
|
+
|
|
4118
|
+
const error = new Error('initialization failed');
|
|
4119
|
+
const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').rejects(error);
|
|
4120
|
+
|
|
4121
|
+
let caughtError: Error | undefined;
|
|
4122
|
+
try {
|
|
4123
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4124
|
+
} catch (e) {
|
|
4125
|
+
caughtError = e;
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
expect(caughtError).to.equal(error);
|
|
4129
|
+
});
|
|
4130
|
+
});
|
|
4131
|
+
|
|
3526
4132
|
describe('#handleLocusUpdate when stopped', () => {
|
|
3527
4133
|
it('should return early without processing when parser is stopped', () => {
|
|
3528
4134
|
const parser = createHashTreeParser();
|
|
@@ -3557,4 +4163,343 @@ describe('HashTreeParser', () => {
|
|
|
3557
4163
|
assert.notCalled(callback);
|
|
3558
4164
|
});
|
|
3559
4165
|
});
|
|
4166
|
+
|
|
4167
|
+
describe('#syncAllDatasets', () => {
|
|
4168
|
+
it('should sync all datasets that have hash trees in priority order', async () => {
|
|
4169
|
+
const parser = createHashTreeParser();
|
|
4170
|
+
|
|
4171
|
+
// parser starts with main (leafCount=16) and self (leafCount=1) as visible datasets with hash trees
|
|
4172
|
+
// atd-unmuted has no hash tree (not visible)
|
|
4173
|
+
expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
|
|
4174
|
+
expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
|
|
4175
|
+
|
|
4176
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4177
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4178
|
+
|
|
4179
|
+
// Mock GET hashtree for main (leafCount > 1, so it does GET first)
|
|
4180
|
+
mockGetHashesFromLocusResponse(
|
|
4181
|
+
mainUrl,
|
|
4182
|
+
new Array(16).fill(EMPTY_HASH),
|
|
4183
|
+
createDataSet('main', 16, 1100)
|
|
4184
|
+
);
|
|
4185
|
+
|
|
4186
|
+
// Mock POST sync for main - return matching root hash so no further sync needed
|
|
4187
|
+
const mainSyncDataSet = createDataSet('main', 16, 1100);
|
|
4188
|
+
mainSyncDataSet.root = parser.dataSets.main.hashTree.getRootHash();
|
|
4189
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4190
|
+
dataSets: [mainSyncDataSet],
|
|
4191
|
+
visibleDataSetsUrl,
|
|
4192
|
+
locusUrl,
|
|
4193
|
+
locusStateElements: [],
|
|
4194
|
+
});
|
|
4195
|
+
|
|
4196
|
+
// Mock POST sync for self (leafCount=1, skips GET hashtree)
|
|
4197
|
+
const selfSyncDataSet = createDataSet('self', 1, 2100);
|
|
4198
|
+
selfSyncDataSet.root = parser.dataSets.self.hashTree.getRootHash();
|
|
4199
|
+
mockSendSyncRequestResponse(selfUrl, {
|
|
4200
|
+
dataSets: [selfSyncDataSet],
|
|
4201
|
+
visibleDataSetsUrl,
|
|
4202
|
+
locusUrl,
|
|
4203
|
+
locusStateElements: [],
|
|
4204
|
+
});
|
|
4205
|
+
|
|
4206
|
+
await parser.syncAllDatasets();
|
|
4207
|
+
|
|
4208
|
+
// Verify GET hashtree was called for main only (not self, because leafCount=1)
|
|
4209
|
+
assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
|
|
4210
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
|
|
4211
|
+
|
|
4212
|
+
// Verify POST sync was called for both
|
|
4213
|
+
assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
|
|
4214
|
+
assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
|
|
4215
|
+
|
|
4216
|
+
// Verify main was synced before self (priority order)
|
|
4217
|
+
const mainSyncCallIndex = webexRequest.args.findIndex(
|
|
4218
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
|
|
4219
|
+
);
|
|
4220
|
+
const selfSyncCallIndex = webexRequest.args.findIndex(
|
|
4221
|
+
(args) => args[0]?.method === 'POST' && args[0]?.uri === `${selfUrl}/sync`
|
|
4222
|
+
);
|
|
4223
|
+
expect(mainSyncCallIndex).to.be.lessThan(selfSyncCallIndex);
|
|
4224
|
+
|
|
4225
|
+
// Verify isSyncAllInProgress is reset
|
|
4226
|
+
expect(parser.isSyncAllInProgress).to.be.false;
|
|
4227
|
+
});
|
|
4228
|
+
|
|
4229
|
+
it('should return immediately when state is stopped', async () => {
|
|
4230
|
+
const parser = createHashTreeParser();
|
|
4231
|
+
parser.stop();
|
|
4232
|
+
|
|
4233
|
+
await parser.syncAllDatasets();
|
|
4234
|
+
|
|
4235
|
+
// No sync requests should have been made (only the initial sync from constructor)
|
|
4236
|
+
// Reset history to clear constructor calls then verify
|
|
4237
|
+
const callCountBefore = webexRequest.callCount;
|
|
4238
|
+
await parser.syncAllDatasets();
|
|
4239
|
+
assert.equal(webexRequest.callCount, callCountBefore);
|
|
4240
|
+
});
|
|
4241
|
+
|
|
4242
|
+
it('should guard against concurrent calls', async () => {
|
|
4243
|
+
const parser = createHashTreeParser();
|
|
4244
|
+
|
|
4245
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4246
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4247
|
+
|
|
4248
|
+
// Use a deferred promise for the main sync to control timing
|
|
4249
|
+
let resolveMainSync;
|
|
4250
|
+
webexRequest
|
|
4251
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4252
|
+
.returns(new Promise((resolve) => { resolveMainSync = resolve; }));
|
|
4253
|
+
|
|
4254
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4255
|
+
dataSets: [createDataSet('main', 16, 1100)],
|
|
4256
|
+
visibleDataSetsUrl,
|
|
4257
|
+
locusUrl,
|
|
4258
|
+
locusStateElements: [],
|
|
4259
|
+
});
|
|
4260
|
+
|
|
4261
|
+
mockSendSyncRequestResponse(selfUrl, {
|
|
4262
|
+
dataSets: [createDataSet('self', 1, 2100)],
|
|
4263
|
+
visibleDataSetsUrl,
|
|
4264
|
+
locusUrl,
|
|
4265
|
+
locusStateElements: [],
|
|
4266
|
+
});
|
|
4267
|
+
|
|
4268
|
+
// Start first call
|
|
4269
|
+
const promise1 = parser.syncAllDatasets();
|
|
4270
|
+
// Start second call while first is in progress
|
|
4271
|
+
const promise2 = parser.syncAllDatasets();
|
|
4272
|
+
|
|
4273
|
+
// Resolve the pending request
|
|
4274
|
+
resolveMainSync({
|
|
4275
|
+
body: {
|
|
4276
|
+
hashes: new Array(16).fill(EMPTY_HASH),
|
|
4277
|
+
dataSet: createDataSet('main', 16, 1100),
|
|
4278
|
+
},
|
|
4279
|
+
});
|
|
4280
|
+
|
|
4281
|
+
await promise1;
|
|
4282
|
+
await promise2;
|
|
4283
|
+
|
|
4284
|
+
// GET hashtree for main should only be called once (second syncAllDatasets returned immediately)
|
|
4285
|
+
const getHashtreeCalls = webexRequest.args.filter(
|
|
4286
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
|
|
4287
|
+
);
|
|
4288
|
+
expect(getHashtreeCalls).to.have.lengthOf(1);
|
|
4289
|
+
});
|
|
4290
|
+
|
|
4291
|
+
it('should skip datasets that do not have a hash tree', async () => {
|
|
4292
|
+
// Create parser with metadata that only has main and self as visible (not atd-unmuted)
|
|
4293
|
+
const metadataWithoutAtd = {
|
|
4294
|
+
...exampleMetadata,
|
|
4295
|
+
visibleDataSets: exampleMetadata.visibleDataSets.filter((ds) => ds.name !== 'atd-unmuted'),
|
|
4296
|
+
};
|
|
4297
|
+
const parser = createHashTreeParser(exampleInitialLocus, metadataWithoutAtd);
|
|
4298
|
+
|
|
4299
|
+
// atd-unmuted is in dataSets but has no hashTree (not visible)
|
|
4300
|
+
expect(parser.dataSets['atd-unmuted']).to.exist;
|
|
4301
|
+
expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
|
|
4302
|
+
|
|
4303
|
+
const atdUrl = parser.dataSets['atd-unmuted'].url;
|
|
4304
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4305
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4306
|
+
|
|
4307
|
+
mockGetHashesFromLocusResponse(
|
|
4308
|
+
mainUrl,
|
|
4309
|
+
new Array(16).fill(EMPTY_HASH),
|
|
4310
|
+
createDataSet('main', 16, 1100)
|
|
4311
|
+
);
|
|
4312
|
+
|
|
4313
|
+
const mainSyncDs = createDataSet('main', 16, 1100);
|
|
4314
|
+
mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
|
|
4315
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4316
|
+
dataSets: [mainSyncDs],
|
|
4317
|
+
visibleDataSetsUrl,
|
|
4318
|
+
locusUrl,
|
|
4319
|
+
locusStateElements: [],
|
|
4320
|
+
});
|
|
4321
|
+
|
|
4322
|
+
const selfSyncDs = createDataSet('self', 1, 2100);
|
|
4323
|
+
selfSyncDs.root = parser.dataSets.self.hashTree.getRootHash();
|
|
4324
|
+
mockSendSyncRequestResponse(selfUrl, {
|
|
4325
|
+
dataSets: [selfSyncDs],
|
|
4326
|
+
visibleDataSetsUrl,
|
|
4327
|
+
locusUrl,
|
|
4328
|
+
locusStateElements: [],
|
|
4329
|
+
});
|
|
4330
|
+
|
|
4331
|
+
await parser.syncAllDatasets();
|
|
4332
|
+
|
|
4333
|
+
// No requests should have been made for atd-unmuted
|
|
4334
|
+
assert.neverCalledWith(webexRequest, sinon.match({uri: sinon.match(atdUrl)}));
|
|
4335
|
+
});
|
|
4336
|
+
});
|
|
4337
|
+
|
|
4338
|
+
describe('#handleMessage sync queue', () => {
|
|
4339
|
+
it('should deduplicate: not sync the same dataset twice when enqueued multiple times', async () => {
|
|
4340
|
+
const parser = createHashTreeParser();
|
|
4341
|
+
|
|
4342
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4343
|
+
|
|
4344
|
+
// Setup mocks before triggering syncs
|
|
4345
|
+
mockGetHashesFromLocusResponse(
|
|
4346
|
+
mainUrl,
|
|
4347
|
+
new Array(16).fill(EMPTY_HASH),
|
|
4348
|
+
createDataSet('main', 16, 1101)
|
|
4349
|
+
);
|
|
4350
|
+
|
|
4351
|
+
const mainSyncDs = createDataSet('main', 16, 1101);
|
|
4352
|
+
mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
|
|
4353
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4354
|
+
dataSets: [mainSyncDs],
|
|
4355
|
+
visibleDataSetsUrl,
|
|
4356
|
+
locusUrl,
|
|
4357
|
+
locusStateElements: [],
|
|
4358
|
+
});
|
|
4359
|
+
|
|
4360
|
+
// Send two heartbeat messages (no locusStateElements) with different root hashes for main
|
|
4361
|
+
parser.handleMessage(createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'), 'first');
|
|
4362
|
+
parser.handleMessage(createHeartbeatMessage('main', 16, 1101, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2'), 'second');
|
|
4363
|
+
|
|
4364
|
+
// The second call resets the timer. After 1000ms, only one sync fires.
|
|
4365
|
+
await clock.tickAsync(1000);
|
|
4366
|
+
|
|
4367
|
+
// Only one GET hashtree call should have been made for main
|
|
4368
|
+
const getHashtreeCalls = webexRequest.args.filter(
|
|
4369
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
|
|
4370
|
+
);
|
|
4371
|
+
expect(getHashtreeCalls).to.have.lengthOf(1);
|
|
4372
|
+
});
|
|
4373
|
+
|
|
4374
|
+
it('should stop processing the sync queue when parser is stopped mid-queue', async () => {
|
|
4375
|
+
const parser = createHashTreeParser();
|
|
4376
|
+
|
|
4377
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4378
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4379
|
+
|
|
4380
|
+
// Mock main GET hashtree with a deferred promise so we can control when it resolves
|
|
4381
|
+
let resolveMainHashtree;
|
|
4382
|
+
webexRequest
|
|
4383
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4384
|
+
.callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
|
|
4385
|
+
|
|
4386
|
+
// Send a heartbeat message that triggers sync timers for both main and self
|
|
4387
|
+
parser.handleMessage(
|
|
4388
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4389
|
+
'trigger main sync'
|
|
4390
|
+
);
|
|
4391
|
+
parser.handleMessage(
|
|
4392
|
+
createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
|
|
4393
|
+
'trigger self sync'
|
|
4394
|
+
);
|
|
4395
|
+
|
|
4396
|
+
// Fire the timers - main sync starts (calls GET hashtree, which blocks)
|
|
4397
|
+
await clock.tickAsync(1000);
|
|
4398
|
+
|
|
4399
|
+
// Stop the parser while main sync is in progress
|
|
4400
|
+
parser.stop();
|
|
4401
|
+
|
|
4402
|
+
// Resolve the pending main GET request
|
|
4403
|
+
resolveMainHashtree({
|
|
4404
|
+
body: {
|
|
4405
|
+
hashes: new Array(16).fill(EMPTY_HASH),
|
|
4406
|
+
dataSet: createDataSet('main', 16, 1100),
|
|
4407
|
+
},
|
|
4408
|
+
});
|
|
4409
|
+
|
|
4410
|
+
await clock.tickAsync(0);
|
|
4411
|
+
|
|
4412
|
+
// Self sync should NOT have been triggered because parser was stopped
|
|
4413
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
|
|
4414
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
|
|
4415
|
+
});
|
|
4416
|
+
});
|
|
4417
|
+
|
|
4418
|
+
describe('#stop sync queue', () => {
|
|
4419
|
+
it('should clear the syncQueue when stopped so remaining queued items are not processed', async () => {
|
|
4420
|
+
const parser = createHashTreeParser();
|
|
4421
|
+
|
|
4422
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4423
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4424
|
+
|
|
4425
|
+
// Mock main GET hashtree with a deferred promise so we can control when it resolves
|
|
4426
|
+
let resolveMainHashtree;
|
|
4427
|
+
webexRequest
|
|
4428
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4429
|
+
.callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
|
|
4430
|
+
|
|
4431
|
+
// Enqueue syncs for both main and self by sending heartbeat messages
|
|
4432
|
+
parser.handleMessage(
|
|
4433
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4434
|
+
'trigger main sync'
|
|
4435
|
+
);
|
|
4436
|
+
parser.handleMessage(
|
|
4437
|
+
createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
|
|
4438
|
+
'trigger self sync'
|
|
4439
|
+
);
|
|
4440
|
+
|
|
4441
|
+
// Fire the timers - main sync starts and blocks on GET hashtree
|
|
4442
|
+
await clock.tickAsync(1000);
|
|
4443
|
+
|
|
4444
|
+
// Verify that self is still in the queue (main is being processed, self is waiting)
|
|
4445
|
+
// Now stop the parser - this should clear the syncQueue
|
|
4446
|
+
parser.stop();
|
|
4447
|
+
|
|
4448
|
+
// Resolve the pending main GET request so the in-flight sync can finish
|
|
4449
|
+
resolveMainHashtree({
|
|
4450
|
+
body: {
|
|
4451
|
+
hashes: new Array(16).fill(EMPTY_HASH),
|
|
4452
|
+
dataSet: createDataSet('main', 16, 1100),
|
|
4453
|
+
},
|
|
4454
|
+
});
|
|
4455
|
+
|
|
4456
|
+
await clock.tickAsync(0);
|
|
4457
|
+
|
|
4458
|
+
// Self should never have been synced because stop() cleared the queue
|
|
4459
|
+
const selfGetCalls = webexRequest.args.filter(
|
|
4460
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${selfUrl}/hashtree`
|
|
4461
|
+
);
|
|
4462
|
+
expect(selfGetCalls).to.have.lengthOf(0);
|
|
4463
|
+
});
|
|
4464
|
+
});
|
|
4465
|
+
|
|
4466
|
+
describe('#cleanUp', () => {
|
|
4467
|
+
it('should stop the parser, clear all timers and clear all dataSets', () => {
|
|
4468
|
+
const parser = createHashTreeParser();
|
|
4469
|
+
|
|
4470
|
+
// Send a message to set up sync timers via runSyncAlgorithm
|
|
4471
|
+
const message = {
|
|
4472
|
+
dataSets: [
|
|
4473
|
+
{
|
|
4474
|
+
...createDataSet('main', 16, 1100),
|
|
4475
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
|
|
4476
|
+
},
|
|
4477
|
+
],
|
|
4478
|
+
visibleDataSetsUrl,
|
|
4479
|
+
locusUrl,
|
|
4480
|
+
heartbeatIntervalMs: 5000,
|
|
4481
|
+
locusStateElements: [
|
|
4482
|
+
{
|
|
4483
|
+
htMeta: {
|
|
4484
|
+
elementId: {type: 'locus' as const, id: 0, version: 201},
|
|
4485
|
+
dataSetNames: ['main'],
|
|
4486
|
+
},
|
|
4487
|
+
data: {someData: 'value'},
|
|
4488
|
+
},
|
|
4489
|
+
],
|
|
4490
|
+
};
|
|
4491
|
+
|
|
4492
|
+
parser.handleMessage(message, 'setup timers');
|
|
4493
|
+
|
|
4494
|
+
// Verify timers were set by handleMessage
|
|
4495
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
4496
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
4497
|
+
|
|
4498
|
+
parser.cleanUp();
|
|
4499
|
+
|
|
4500
|
+
expect(parser.state).to.equal('stopped');
|
|
4501
|
+
expect(parser.visibleDataSets).to.deep.equal([]);
|
|
4502
|
+
expect(parser.dataSets).to.deep.equal({});
|
|
4503
|
+
});
|
|
4504
|
+
});
|
|
3560
4505
|
});
|