@webex/plugin-meetings 3.11.0-next.23 → 3.11.0-next.25

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.
@@ -532,6 +532,46 @@ describe('HashTreeParser', () => {
532
532
  });
533
533
  });
534
534
  });
535
+
536
+ it('handles sync response that has locusStateElements undefined', async () => {
537
+ const minimalInitialLocus = {
538
+ dataSets: [],
539
+ locus: null,
540
+ };
541
+
542
+ const parser = createHashTreeParser(minimalInitialLocus, null);
543
+
544
+ const mainDataSet = createDataSet('main', 16, 1100);
545
+
546
+ // Mock getAllVisibleDataSetsFromLocus to return the main dataset
547
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet]);
548
+
549
+ // Mock the sync response to have locusStateElements: undefined
550
+ // This is what sendInitializationSyncRequestToLocus will receive and pass to parseMessage
551
+ mockSyncRequest(webexRequest, mainDataSet.url, {
552
+ dataSets: [mainDataSet],
553
+ visibleDataSetsUrl,
554
+ locusUrl,
555
+ locusStateElements: undefined,
556
+ });
557
+
558
+ // Trigger sendInitializationSyncRequestToLocus via initializeFromMessage
559
+ await parser.initializeFromMessage({
560
+ dataSets: [],
561
+ visibleDataSetsUrl,
562
+ locusUrl,
563
+ });
564
+
565
+ // Verify the hash tree was created for main dataset
566
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
567
+
568
+ // updateItems should NOT have been called because locusStateElements is undefined
569
+ const mainUpdateItemsStub = sinon.spy(parser.dataSets.main.hashTree, 'updateItems');
570
+ assert.notCalled(mainUpdateItemsStub);
571
+
572
+ // callback should not be called, because there are no updates
573
+ assert.notCalled(callback);
574
+ });
535
575
  });
536
576
 
537
577
  describe('#initializeFromGetLociResponse', () => {
@@ -855,10 +895,6 @@ describe('HashTreeParser', () => {
855
895
  // Verify putItem was called on self hash tree with metadata
856
896
  assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 51});
857
897
 
858
- console.log(
859
- 'callback calls',
860
- callback.getCalls().map((call) => JSON.stringify(call.args, null, 2))
861
- );
862
898
  // Verify callback was called with metadata object and removed dataset objects
863
899
  assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
864
900
  updatedObjects: [
@@ -1860,6 +1896,443 @@ describe('HashTreeParser', () => {
1860
1896
  assert.notCalled(callback);
1861
1897
  });
1862
1898
  });
1899
+
1900
+ describe('heartbeat watchdog', () => {
1901
+ it('initiates sync immediately only for the specific data set whose heartbeat watchdog fires', async () => {
1902
+ const parser = createHashTreeParser();
1903
+ const heartbeatIntervalMs = 5000;
1904
+
1905
+ // Send initial heartbeat message for 'main' only
1906
+ const heartbeatMessage = {
1907
+ dataSets: [
1908
+ {
1909
+ ...createDataSet('main', 16, 1100),
1910
+ root: parser.dataSets.main.hashTree.getRootHash(),
1911
+ },
1912
+ ],
1913
+ visibleDataSetsUrl,
1914
+ locusUrl,
1915
+ heartbeatIntervalMs,
1916
+ };
1917
+
1918
+ await parser.handleMessage(heartbeatMessage, 'initial heartbeat');
1919
+
1920
+ // Verify only 'main' watchdog timer is set
1921
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
1922
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.be.undefined;
1923
+ expect(parser.dataSets['atd-unmuted'].heartbeatWatchdogTimer).to.be.undefined;
1924
+
1925
+ // Mock responses for performSync (GET hashtree then POST sync for leafCount > 1)
1926
+ const mainDataSetUrl = parser.dataSets.main.url;
1927
+ mockGetHashesFromLocusResponse(
1928
+ mainDataSetUrl,
1929
+ new Array(16).fill('00000000000000000000000000000000'),
1930
+ createDataSet('main', 16, 1101)
1931
+ );
1932
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
1933
+
1934
+ // Advance time past heartbeatIntervalMs + backoff (Math.random returns 0, so backoff = 0)
1935
+ // performSync is called immediately when the watchdog fires - no additional delay
1936
+ await clock.tickAsync(heartbeatIntervalMs);
1937
+
1938
+ // Verify sync request was sent immediately for 'main' (GET hashtree + POST sync)
1939
+ assert.calledWith(
1940
+ webexRequest,
1941
+ sinon.match({
1942
+ method: 'GET',
1943
+ uri: `${mainDataSetUrl}/hashtree`,
1944
+ })
1945
+ );
1946
+
1947
+ // Verify no sync requests were sent for other datasets
1948
+ assert.neverCalledWith(
1949
+ webexRequest,
1950
+ sinon.match({
1951
+ method: 'POST',
1952
+ uri: `${parser.dataSets.self.url}/sync`,
1953
+ })
1954
+ );
1955
+ assert.neverCalledWith(
1956
+ webexRequest,
1957
+ sinon.match({
1958
+ method: 'GET',
1959
+ uri: `${parser.dataSets['atd-unmuted'].url}/hashtree`,
1960
+ })
1961
+ );
1962
+ });
1963
+
1964
+ it('calls POST sync directly for leafCount === 1 data sets', async () => {
1965
+ const parser = createHashTreeParser();
1966
+ const heartbeatIntervalMs = 5000;
1967
+
1968
+ // Send heartbeat for 'self' (leafCount === 1)
1969
+ const heartbeatMessage = {
1970
+ dataSets: [
1971
+ {
1972
+ ...createDataSet('self', 1, 2100),
1973
+ url: parser.dataSets.self.url,
1974
+ root: parser.dataSets.self.hashTree.getRootHash(),
1975
+ },
1976
+ ],
1977
+ visibleDataSetsUrl,
1978
+ locusUrl,
1979
+ heartbeatIntervalMs,
1980
+ };
1981
+
1982
+ await parser.handleMessage(heartbeatMessage, 'self heartbeat');
1983
+
1984
+ // Mock sync response for self
1985
+ mockSendSyncRequestResponse(parser.dataSets.self.url, null);
1986
+
1987
+ // Advance time past watchdog delay
1988
+ await clock.tickAsync(heartbeatIntervalMs);
1989
+
1990
+ // For leafCount === 1, performSync skips GET hashtree and goes straight to POST sync
1991
+ assert.neverCalledWith(
1992
+ webexRequest,
1993
+ sinon.match({
1994
+ method: 'GET',
1995
+ uri: `${parser.dataSets.self.url}/hashtree`,
1996
+ })
1997
+ );
1998
+ assert.calledWith(
1999
+ webexRequest,
2000
+ sinon.match({
2001
+ method: 'POST',
2002
+ uri: `${parser.dataSets.self.url}/sync`,
2003
+ })
2004
+ );
2005
+ });
2006
+
2007
+ it('sets watchdog timers for each data set in the message', async () => {
2008
+ const parser = createHashTreeParser();
2009
+ const heartbeatIntervalMs = 5000;
2010
+
2011
+ // Send heartbeat with multiple datasets
2012
+ const heartbeatMessage = {
2013
+ dataSets: [
2014
+ {
2015
+ ...createDataSet('main', 16, 1100),
2016
+ root: parser.dataSets.main.hashTree.getRootHash(),
2017
+ },
2018
+ {
2019
+ ...createDataSet('self', 1, 2100),
2020
+ url: parser.dataSets.self.url,
2021
+ root: parser.dataSets.self.hashTree.getRootHash(),
2022
+ },
2023
+ ],
2024
+ visibleDataSetsUrl,
2025
+ locusUrl,
2026
+ heartbeatIntervalMs,
2027
+ };
2028
+
2029
+ await parser.handleMessage(heartbeatMessage, 'multi-dataset heartbeat');
2030
+
2031
+ // Watchdog timers should be set for both datasets in the message
2032
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2033
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.not.be.undefined;
2034
+ // But not for datasets not in the message
2035
+ expect(parser.dataSets['atd-unmuted'].heartbeatWatchdogTimer).to.be.undefined;
2036
+ });
2037
+
2038
+ it('resets the watchdog timer for a specific data set when a new heartbeat for it is received', async () => {
2039
+ const parser = createHashTreeParser();
2040
+ const heartbeatIntervalMs = 5000;
2041
+
2042
+ // Send first heartbeat for 'main'
2043
+ const heartbeat1 = {
2044
+ dataSets: [
2045
+ {
2046
+ ...createDataSet('main', 16, 1100),
2047
+ root: parser.dataSets.main.hashTree.getRootHash(),
2048
+ },
2049
+ ],
2050
+ visibleDataSetsUrl,
2051
+ locusUrl,
2052
+ heartbeatIntervalMs,
2053
+ };
2054
+
2055
+ await parser.handleMessage(heartbeat1, 'first heartbeat');
2056
+
2057
+ const firstTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2058
+ expect(firstTimer).to.not.be.undefined;
2059
+
2060
+ // Advance time to just before the watchdog would fire
2061
+ clock.tick(4000);
2062
+
2063
+ // Send second heartbeat for 'main' - this should reset the watchdog
2064
+ const heartbeat2 = {
2065
+ dataSets: [
2066
+ {
2067
+ ...createDataSet('main', 16, 1101),
2068
+ root: parser.dataSets.main.hashTree.getRootHash(),
2069
+ },
2070
+ ],
2071
+ visibleDataSetsUrl,
2072
+ locusUrl,
2073
+ heartbeatIntervalMs,
2074
+ };
2075
+
2076
+ await parser.handleMessage(heartbeat2, 'second heartbeat');
2077
+
2078
+ const secondTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2079
+ expect(secondTimer).to.not.be.undefined;
2080
+ expect(secondTimer).to.not.equal(firstTimer);
2081
+
2082
+ // Advance another 4000ms (total 8000ms from start, but only 4000ms since last heartbeat)
2083
+ // The watchdog should NOT fire yet
2084
+ await clock.tickAsync(4000);
2085
+
2086
+ // No sync requests should have been sent
2087
+ assert.notCalled(webexRequest);
2088
+ });
2089
+
2090
+ it('resets the watchdog timer when a normal message (with locusStateElements) is received', async () => {
2091
+ const parser = createHashTreeParser();
2092
+ const heartbeatIntervalMs = 5000;
2093
+
2094
+ // Send initial heartbeat to start the watchdog for 'main'
2095
+ const heartbeat = {
2096
+ dataSets: [
2097
+ {
2098
+ ...createDataSet('main', 16, 1100),
2099
+ root: parser.dataSets.main.hashTree.getRootHash(),
2100
+ },
2101
+ ],
2102
+ visibleDataSetsUrl,
2103
+ locusUrl,
2104
+ heartbeatIntervalMs,
2105
+ };
2106
+
2107
+ await parser.handleMessage(heartbeat, 'initial heartbeat');
2108
+
2109
+ const firstTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2110
+ expect(firstTimer).to.not.be.undefined;
2111
+
2112
+ // Advance time partially
2113
+ clock.tick(3000);
2114
+
2115
+ // Stub updateItems so the normal message is processed
2116
+ sinon.stub(parser.dataSets.main.hashTree, 'updateItems').returns([true]);
2117
+
2118
+ // Send a normal message (with locusStateElements) for 'main' - should also reset watchdog
2119
+ const normalMessage = {
2120
+ dataSets: [createDataSet('main', 16, 1101)],
2121
+ visibleDataSetsUrl,
2122
+ locusUrl,
2123
+ locusStateElements: [
2124
+ {
2125
+ htMeta: {
2126
+ elementId: {type: 'locus' as const, id: 0, version: 201},
2127
+ dataSetNames: ['main'],
2128
+ },
2129
+ data: {someData: 'value'},
2130
+ },
2131
+ ],
2132
+ heartbeatIntervalMs,
2133
+ };
2134
+
2135
+ await parser.handleMessage(normalMessage, 'normal message');
2136
+
2137
+ const secondTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2138
+ expect(secondTimer).to.not.be.undefined;
2139
+ expect(secondTimer).to.not.equal(firstTimer);
2140
+ });
2141
+
2142
+ it('does not set the watchdog timer when heartbeatIntervalMs is not set', async () => {
2143
+ const parser = createHashTreeParser();
2144
+
2145
+ // Send a heartbeat message without heartbeatIntervalMs
2146
+ const heartbeatMessage = createHeartbeatMessage(
2147
+ 'main',
2148
+ 16,
2149
+ 1100,
2150
+ parser.dataSets.main.hashTree.getRootHash()
2151
+ );
2152
+
2153
+ await parser.handleMessage(heartbeatMessage, 'heartbeat without interval');
2154
+
2155
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
2156
+ });
2157
+
2158
+ it('stops all watchdog timers when meeting ends', async () => {
2159
+ const parser = createHashTreeParser();
2160
+ const heartbeatIntervalMs = 5000;
2161
+
2162
+ // Send heartbeat for multiple datasets
2163
+ const heartbeat = {
2164
+ dataSets: [
2165
+ {
2166
+ ...createDataSet('main', 16, 1100),
2167
+ root: parser.dataSets.main.hashTree.getRootHash(),
2168
+ },
2169
+ {
2170
+ ...createDataSet('self', 1, 2100),
2171
+ url: parser.dataSets.self.url,
2172
+ root: parser.dataSets.self.hashTree.getRootHash(),
2173
+ },
2174
+ ],
2175
+ visibleDataSetsUrl,
2176
+ locusUrl,
2177
+ heartbeatIntervalMs,
2178
+ };
2179
+
2180
+ await parser.handleMessage(heartbeat, 'initial heartbeat');
2181
+
2182
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2183
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.not.be.undefined;
2184
+
2185
+ // Stub updateItems to return true for the roster drop detection
2186
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2187
+
2188
+ // Send a roster drop message that triggers MEETING_ENDED
2189
+ const rosterDropMessage = {
2190
+ dataSets: [createDataSet('self', 1, 2101)],
2191
+ visibleDataSetsUrl,
2192
+ locusUrl,
2193
+ locusStateElements: [
2194
+ {
2195
+ htMeta: {
2196
+ elementId: {type: 'self' as const, id: 4, version: 102},
2197
+ dataSetNames: ['self'],
2198
+ },
2199
+ data: undefined,
2200
+ },
2201
+ ],
2202
+ heartbeatIntervalMs,
2203
+ };
2204
+
2205
+ await parser.handleMessage(rosterDropMessage, 'roster drop');
2206
+
2207
+ // All watchdog timers should have been stopped and NOT restarted
2208
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
2209
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.be.undefined;
2210
+ });
2211
+
2212
+ it("uses each data set's own backoff for its watchdog delay", async () => {
2213
+ // Create a parser where datasets have different backoff configs
2214
+ const initialLocus = {
2215
+ dataSets: [
2216
+ {
2217
+ ...createDataSet('main', 16, 1000),
2218
+ backoff: {maxMs: 500, exponent: 2},
2219
+ },
2220
+ {
2221
+ ...createDataSet('self', 1, 2000),
2222
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2223
+ backoff: {maxMs: 2000, exponent: 3},
2224
+ },
2225
+ ],
2226
+ locus: {
2227
+ ...exampleInitialLocus.locus,
2228
+ },
2229
+ };
2230
+
2231
+ const metadata = {
2232
+ ...exampleMetadata,
2233
+ visibleDataSets: [
2234
+ {name: 'main', url: initialLocus.dataSets[0].url},
2235
+ {name: 'self', url: initialLocus.dataSets[1].url},
2236
+ ],
2237
+ };
2238
+
2239
+ const parser = createHashTreeParser(initialLocus, metadata);
2240
+ const heartbeatIntervalMs = 5000;
2241
+
2242
+ // Set Math.random to return 1 so that backoff = 1^exponent * maxMs = maxMs
2243
+ mathRandomStub.returns(1);
2244
+
2245
+ // Send heartbeat for both datasets
2246
+ const heartbeat = {
2247
+ dataSets: [
2248
+ {
2249
+ ...createDataSet('main', 16, 1100),
2250
+ backoff: {maxMs: 500, exponent: 2},
2251
+ root: parser.dataSets.main.hashTree.getRootHash(),
2252
+ },
2253
+ {
2254
+ ...createDataSet('self', 1, 2100),
2255
+ url: parser.dataSets.self.url,
2256
+ backoff: {maxMs: 2000, exponent: 3},
2257
+ root: parser.dataSets.self.hashTree.getRootHash(),
2258
+ },
2259
+ ],
2260
+ visibleDataSetsUrl,
2261
+ locusUrl,
2262
+ heartbeatIntervalMs,
2263
+ };
2264
+
2265
+ await parser.handleMessage(heartbeat, 'heartbeat');
2266
+
2267
+ // 'main' watchdog delay = 5000 + 1^2 * 500 = 5500ms
2268
+ // 'self' watchdog delay = 5000 + 1^3 * 2000 = 7000ms
2269
+
2270
+ // Mock sync responses
2271
+ mockGetHashesFromLocusResponse(
2272
+ parser.dataSets.main.url,
2273
+ new Array(16).fill('00000000000000000000000000000000'),
2274
+ createDataSet('main', 16, 1101)
2275
+ );
2276
+ mockSendSyncRequestResponse(parser.dataSets.main.url, null);
2277
+ mockSendSyncRequestResponse(parser.dataSets.self.url, null);
2278
+
2279
+ // At 5499ms, neither watchdog should have fired
2280
+ await clock.tickAsync(5499);
2281
+ assert.notCalled(webexRequest);
2282
+
2283
+ // At 5500ms, 'main' watchdog fires and performSync runs immediately
2284
+ await clock.tickAsync(1);
2285
+
2286
+ // main sync should have triggered immediately (GET hashtree + POST sync)
2287
+ assert.calledWith(
2288
+ webexRequest,
2289
+ sinon.match({
2290
+ method: 'GET',
2291
+ uri: `${parser.dataSets.main.url}/hashtree`,
2292
+ })
2293
+ );
2294
+
2295
+ webexRequest.resetHistory();
2296
+
2297
+ // At 7000ms, 'self' watchdog fires and performSync runs immediately
2298
+ await clock.tickAsync(1500);
2299
+
2300
+ // self sync should have also triggered (POST sync only, leafCount === 1)
2301
+ assert.calledWith(
2302
+ webexRequest,
2303
+ sinon.match({
2304
+ method: 'POST',
2305
+ uri: `${parser.dataSets.self.url}/sync`,
2306
+ })
2307
+ );
2308
+ });
2309
+
2310
+ it('does not set watchdog for data sets without a hash tree', async () => {
2311
+ const parser = createHashTreeParser();
2312
+ const heartbeatIntervalMs = 5000;
2313
+
2314
+ // 'atd-active' is in the initial locus but is not visible (no hash tree)
2315
+ // Send heartbeat mentioning a non-visible dataset
2316
+ const heartbeatMessage = {
2317
+ dataSets: [
2318
+ {
2319
+ ...createDataSet('main', 16, 1100),
2320
+ root: parser.dataSets.main.hashTree.getRootHash(),
2321
+ },
2322
+ createDataSet('atd-active', 16, 4000),
2323
+ ],
2324
+ visibleDataSetsUrl,
2325
+ locusUrl,
2326
+ heartbeatIntervalMs,
2327
+ };
2328
+
2329
+ await parser.handleMessage(heartbeatMessage, 'heartbeat with non-visible dataset');
2330
+
2331
+ // Watchdog set for main (visible) but not for atd-active (no hash tree)
2332
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2333
+ expect(parser.dataSets['atd-active']?.heartbeatWatchdogTimer).to.be.undefined;
2334
+ });
2335
+ });
1863
2336
  });
1864
2337
 
1865
2338
  describe('#callLocusInfoUpdateCallback filtering', () => {