@webex/plugin-meetings 3.11.0-next.33 → 3.11.0-next.35

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.
@@ -1,5 +1,6 @@
1
1
  import HashTreeParser, {
2
2
  LocusInfoUpdateType,
3
+ MeetingEndedError,
3
4
  } from '@webex/plugin-meetings/src/hashTree/hashTreeParser';
4
5
  import HashTree from '@webex/plugin-meetings/src/hashTree/hashTree';
5
6
  import {expect} from '@webex/test-helper-chai';
@@ -634,6 +635,46 @@ describe('HashTreeParser', () => {
634
635
  // callback should not be called, because there are no updates
635
636
  assert.notCalled(callback);
636
637
  });
638
+
639
+ [404, 409].forEach((errorCode) => {
640
+ it(`emits MeetingEndedError if getting visible datasets returns ${errorCode}`, async () => {
641
+ const minimalInitialLocus = {
642
+ dataSets: [],
643
+ locus: null,
644
+ };
645
+
646
+ const parser = createHashTreeParser(minimalInitialLocus, null);
647
+
648
+ // Mock getAllVisibleDataSetsFromLocus to reject with the error code
649
+ const error: any = new Error(`Request failed with status ${errorCode}`);
650
+ error.statusCode = errorCode;
651
+ if (errorCode === 409) {
652
+ error.body = {errorCode: 2403004};
653
+ }
654
+ webexRequest
655
+ .withArgs(
656
+ sinon.match({
657
+ method: 'GET',
658
+ uri: visibleDataSetsUrl,
659
+ })
660
+ )
661
+ .rejects(error);
662
+
663
+ // initializeFromMessage should throw MeetingEndedError
664
+ let thrownError;
665
+ try {
666
+ await parser.initializeFromMessage({
667
+ dataSets: [],
668
+ visibleDataSetsUrl,
669
+ locusUrl,
670
+ });
671
+ } catch (e) {
672
+ thrownError = e;
673
+ }
674
+
675
+ expect(thrownError).to.be.instanceOf(MeetingEndedError);
676
+ });
677
+ });
637
678
  });
638
679
 
639
680
  describe('#initializeFromGetLociResponse', () => {
@@ -1181,7 +1222,7 @@ describe('HashTreeParser', () => {
1181
1222
  ],
1182
1223
  };
1183
1224
 
1184
- await parser.handleMessage(normalMessage, 'initial message');
1225
+ parser.handleMessage(normalMessage, 'initial message');
1185
1226
 
1186
1227
  // Verify the timer was set (the sync algorithm should have started)
1187
1228
  expect(parser.dataSets.main.timer).to.not.be.undefined;
@@ -1201,7 +1242,7 @@ describe('HashTreeParser', () => {
1201
1242
  'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' // still different from our hash
1202
1243
  );
1203
1244
 
1204
- await parser.handleMessage(heartbeatMessage, 'heartbeat message');
1245
+ parser.handleMessage(heartbeatMessage, 'heartbeat message');
1205
1246
 
1206
1247
  // Verify the timer was restarted (should still exist)
1207
1248
  expect(parser.dataSets.main.timer).to.not.be.undefined;
@@ -1328,7 +1369,7 @@ describe('HashTreeParser', () => {
1328
1369
  ],
1329
1370
  };
1330
1371
 
1331
- await parser.handleMessage(message, 'normal update');
1372
+ parser.handleMessage(message, 'normal update');
1332
1373
 
1333
1374
  // Verify updateItems was called on main hash tree
1334
1375
  assert.calledOnceWithExactly(mainUpdateItemsStub, [
@@ -1381,43 +1422,71 @@ describe('HashTreeParser', () => {
1381
1422
  });
1382
1423
  });
1383
1424
 
1384
- it('detects roster drop correctly', async () => {
1385
- const parser = createHashTreeParser();
1425
+ describe('handles sentinel messages correctly', () => {
1426
+ ['main', 'self', 'unjoined'].forEach((dataSetName) => {
1427
+ it('emits MEETING_ENDED for sentinel message with dataset ' + dataSetName, async () => {
1428
+ const parser = createHashTreeParser();
1429
+
1430
+ // Create a sentinel message: leafCount=1, root=EMPTY_HASH, version higher than current
1431
+ const sentinelMessage = createHeartbeatMessage(
1432
+ dataSetName,
1433
+ 1,
1434
+ parser.dataSets[dataSetName]?.version
1435
+ ? parser.dataSets[dataSetName].version + 1
1436
+ : 10000,
1437
+ EMPTY_HASH
1438
+ );
1439
+
1440
+ // If the dataset doesn't exist yet (e.g. 'unjoined'), create it
1441
+ if (!parser.dataSets[dataSetName]) {
1442
+ parser.dataSets[dataSetName] = {
1443
+ url: `https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/${dataSetName}`,
1444
+ name: dataSetName,
1445
+ version: 1,
1446
+ leafCount: 16,
1447
+ root: '0'.repeat(32),
1448
+ idleMs: 1000,
1449
+ backoff: {maxMs: 1000, exponent: 2},
1450
+ } as any;
1451
+ }
1386
1452
 
1387
- // Stub updateItems to return true (indicating the change was applied)
1388
- sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1453
+ parser.handleMessage(sentinelMessage, 'sentinel message');
1389
1454
 
1390
- // Send a roster drop message (SELF object with no data)
1391
- const rosterDropMessage = {
1392
- dataSets: [createDataSet('self', 1, 2101)],
1393
- visibleDataSetsUrl,
1394
- locusUrl,
1395
- locusStateElements: [
1396
- {
1397
- htMeta: {
1398
- elementId: {
1399
- type: 'self' as const,
1400
- id: 4,
1401
- version: 102,
1402
- },
1403
- dataSetNames: ['self'],
1404
- },
1405
- data: undefined, // No data - this indicates roster drop
1406
- },
1407
- ],
1408
- };
1409
-
1410
- await parser.handleMessage(rosterDropMessage, 'roster drop message');
1455
+ // Verify callback was called with MEETING_ENDED
1456
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1457
+ updatedObjects: undefined,
1458
+ });
1411
1459
 
1412
- // Verify callback was called with MEETING_ENDED
1413
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1414
- updatedObjects: undefined,
1460
+ // Verify that all timers were stopped
1461
+ Object.values(parser.dataSets).forEach((ds: any) => {
1462
+ assert.isUndefined(ds.timer);
1463
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1464
+ });
1465
+ });
1415
1466
  });
1416
1467
 
1417
- // Verify that all timers were stopped (timer should be undefined after roster drop)
1418
- assert.equal(parser.dataSets.self.timer, undefined);
1419
- assert.equal(parser.dataSets.main.timer, undefined);
1420
- assert.equal(parser.dataSets['atd-unmuted'].timer, undefined);
1468
+ it('emits MEETING_ENDED for sentinel message with unknown dataset', async () => {
1469
+ const parser = createHashTreeParser();
1470
+
1471
+ // 'unjoined' is a valid sentinel dataset name but is not tracked by the parser
1472
+ assert.isUndefined(parser.dataSets['unjoined']);
1473
+
1474
+ // Create a sentinel message for 'unjoined' dataset which the parser has never seen
1475
+ const sentinelMessage = createHeartbeatMessage('unjoined', 1, 10000, EMPTY_HASH);
1476
+
1477
+ parser.handleMessage(sentinelMessage, 'sentinel message');
1478
+
1479
+ // Verify callback was called with MEETING_ENDED
1480
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1481
+ updatedObjects: undefined,
1482
+ });
1483
+
1484
+ // Verify that all timers were stopped
1485
+ Object.values(parser.dataSets).forEach((ds: any) => {
1486
+ assert.isUndefined(ds.timer);
1487
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1488
+ });
1489
+ });
1421
1490
  });
1422
1491
 
1423
1492
  describe('sync algorithm', () => {
@@ -1448,7 +1517,7 @@ describe('HashTreeParser', () => {
1448
1517
  ],
1449
1518
  };
1450
1519
 
1451
- await parser.handleMessage(message, 'initial message');
1520
+ parser.handleMessage(message, 'initial message');
1452
1521
 
1453
1522
  // Verify callback was called with initial updates
1454
1523
  assert.calledOnce(callback);
@@ -1518,6 +1587,134 @@ describe('HashTreeParser', () => {
1518
1587
  ],
1519
1588
  });
1520
1589
  });
1590
+
1591
+ describe('emits MEETING_ENDED', () => {
1592
+ [404, 409].forEach((statusCode) => {
1593
+ it(`when /hashtree returns ${statusCode}`, async () => {
1594
+ const parser = createHashTreeParser();
1595
+
1596
+ // Send a message to trigger sync algorithm
1597
+ const message = {
1598
+ dataSets: [createDataSet('main', 16, 1100)],
1599
+ visibleDataSetsUrl,
1600
+ locusUrl,
1601
+ locusStateElements: [
1602
+ {
1603
+ htMeta: {
1604
+ elementId: {
1605
+ type: 'locus' as const,
1606
+ id: 0,
1607
+ version: 201,
1608
+ },
1609
+ dataSetNames: ['main'],
1610
+ },
1611
+ data: {info: {id: 'initial-update'}},
1612
+ },
1613
+ ],
1614
+ };
1615
+
1616
+ parser.handleMessage(message, 'initial message');
1617
+ callback.resetHistory();
1618
+
1619
+ const mainDataSetUrl = parser.dataSets.main.url;
1620
+
1621
+ // Mock getHashesFromLocus to reject with the sentinel error
1622
+ const error: any = new Error(`Request failed with status ${statusCode}`);
1623
+ error.statusCode = statusCode;
1624
+ if (statusCode === 409) {
1625
+ error.body = {errorCode: 2403004};
1626
+ }
1627
+ webexRequest
1628
+ .withArgs(
1629
+ sinon.match({
1630
+ method: 'GET',
1631
+ uri: `${mainDataSetUrl}/hashtree`,
1632
+ })
1633
+ )
1634
+ .rejects(error);
1635
+
1636
+ // Trigger sync by advancing time
1637
+ await clock.tickAsync(1000);
1638
+
1639
+ // Verify callback was called with MEETING_ENDED
1640
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1641
+ updatedObjects: undefined,
1642
+ });
1643
+
1644
+ // Verify all timers are stopped
1645
+ Object.values(parser.dataSets).forEach((ds: any) => {
1646
+ assert.isUndefined(ds.timer);
1647
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1648
+ });
1649
+ });
1650
+
1651
+ it(`when /sync returns ${statusCode}`, async () => {
1652
+ const parser = createHashTreeParser();
1653
+
1654
+ // Send a message to trigger sync algorithm
1655
+ const message = {
1656
+ dataSets: [createDataSet('main', 16, 1100)],
1657
+ visibleDataSetsUrl,
1658
+ locusUrl,
1659
+ locusStateElements: [
1660
+ {
1661
+ htMeta: {
1662
+ elementId: {
1663
+ type: 'locus' as const,
1664
+ id: 0,
1665
+ version: 201,
1666
+ },
1667
+ dataSetNames: ['main'],
1668
+ },
1669
+ data: {info: {id: 'initial-update'}},
1670
+ },
1671
+ ],
1672
+ };
1673
+
1674
+ parser.handleMessage(message, 'initial message');
1675
+ callback.resetHistory();
1676
+
1677
+ const mainDataSetUrl = parser.dataSets.main.url;
1678
+
1679
+ // Mock getHashesFromLocus to succeed
1680
+ mockGetHashesFromLocusResponse(
1681
+ mainDataSetUrl,
1682
+ new Array(16).fill('00000000000000000000000000000000'),
1683
+ createDataSet('main', 16, 1101)
1684
+ );
1685
+
1686
+ // Mock sendSyncRequestToLocus to reject with the sentinel error
1687
+ const error: any = new Error(`Request failed with status ${statusCode}`);
1688
+ error.statusCode = statusCode;
1689
+ if (statusCode === 409) {
1690
+ error.body = {errorCode: 2403004};
1691
+ }
1692
+ webexRequest
1693
+ .withArgs(
1694
+ sinon.match({
1695
+ method: 'POST',
1696
+ uri: `${mainDataSetUrl}/sync`,
1697
+ })
1698
+ )
1699
+ .rejects(error);
1700
+
1701
+ // Trigger sync by advancing time
1702
+ await clock.tickAsync(1000);
1703
+
1704
+ // Verify callback was called with MEETING_ENDED
1705
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1706
+ updatedObjects: undefined,
1707
+ });
1708
+
1709
+ // Verify all timers are stopped
1710
+ Object.values(parser.dataSets).forEach((ds: any) => {
1711
+ assert.isUndefined(ds.timer);
1712
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1713
+ });
1714
+ });
1715
+ });
1716
+ });
1717
+
1521
1718
  it('requests only mismatched hashes during sync', async () => {
1522
1719
  const parser = createHashTreeParser();
1523
1720
 
@@ -1563,7 +1760,7 @@ describe('HashTreeParser', () => {
1563
1760
  ],
1564
1761
  };
1565
1762
 
1566
- await parser.handleMessage(message, 'initial message');
1763
+ parser.handleMessage(message, 'initial message');
1567
1764
 
1568
1765
  callback.resetHistory();
1569
1766
 
@@ -1651,7 +1848,7 @@ describe('HashTreeParser', () => {
1651
1848
  ],
1652
1849
  };
1653
1850
 
1654
- await parser.handleMessage(message, 'message with self update');
1851
+ parser.handleMessage(message, 'message with self update');
1655
1852
 
1656
1853
  callback.resetHistory();
1657
1854
 
@@ -1735,7 +1932,7 @@ describe('HashTreeParser', () => {
1735
1932
  ],
1736
1933
  };
1737
1934
 
1738
- await parser.handleMessage(message, 'add visible dataset');
1935
+ parser.handleMessage(message, 'add visible dataset');
1739
1936
 
1740
1937
  // Verify that 'attendees' was added to visibleDataSets
1741
1938
  expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.true;
@@ -1860,11 +2057,82 @@ describe('HashTreeParser', () => {
1860
2057
  locusStateElements: [],
1861
2058
  });
1862
2059
 
1863
- await parser.handleMessage(message, 'add new dataset requiring async init');
2060
+ parser.handleMessage(message, 'add new dataset requiring async init');
1864
2061
 
1865
2062
  await checkAsyncDatasetInitialization(parser, newDataSet);
1866
2063
  });
1867
2064
 
2065
+ it('emits MEETING_ENDED if async init of a new visible dataset fails with 404', async () => {
2066
+ const parser = createHashTreeParser();
2067
+
2068
+ // Stub updateItems on self hash tree to return true
2069
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2070
+
2071
+ // Send a message with Metadata object that adds a new visible dataset
2072
+ const message = {
2073
+ dataSets: [createDataSet('self', 1, 2100)],
2074
+ visibleDataSetsUrl,
2075
+ locusUrl,
2076
+ locusStateElements: [
2077
+ {
2078
+ htMeta: {
2079
+ elementId: {
2080
+ type: 'metadata' as const,
2081
+ id: 5,
2082
+ version: 51,
2083
+ },
2084
+ dataSetNames: ['self'],
2085
+ },
2086
+ data: {
2087
+ visibleDataSets: [
2088
+ {
2089
+ name: 'main',
2090
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2091
+ },
2092
+ {
2093
+ name: 'self',
2094
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2095
+ },
2096
+ {
2097
+ name: 'atd-unmuted',
2098
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2099
+ },
2100
+ {
2101
+ name: 'new-dataset',
2102
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
2103
+ },
2104
+ ],
2105
+ },
2106
+ },
2107
+ ],
2108
+ };
2109
+
2110
+ // Mock getAllDataSetsMetadata to reject with 404
2111
+ const error: any = new Error('Request failed with status 404');
2112
+ error.statusCode = 404;
2113
+ webexRequest
2114
+ .withArgs(
2115
+ sinon.match({
2116
+ method: 'GET',
2117
+ uri: visibleDataSetsUrl,
2118
+ })
2119
+ )
2120
+ .rejects(error);
2121
+
2122
+ parser.handleMessage(message, 'add new dataset triggering 404');
2123
+
2124
+ // The first callback call is from parseMessage with the metadata update
2125
+ callback.resetHistory();
2126
+
2127
+ // Wait for the async initialization (queueMicrotask) to complete
2128
+ await clock.tickAsync(0);
2129
+
2130
+ // Verify callback was called with MEETING_ENDED
2131
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
2132
+ updatedObjects: undefined,
2133
+ });
2134
+ });
2135
+
1868
2136
  it('handles removal of visible data set', async () => {
1869
2137
  // Create a parser with visible datasets
1870
2138
  const parser = createHashTreeParser();
@@ -1913,7 +2181,7 @@ describe('HashTreeParser', () => {
1913
2181
  ],
1914
2182
  };
1915
2183
 
1916
- await parser.handleMessage(message, 'remove visible dataset');
2184
+ parser.handleMessage(message, 'remove visible dataset');
1917
2185
 
1918
2186
  // Verify that 'atd-unmuted' was removed from visibleDataSets
1919
2187
  expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
@@ -2014,7 +2282,7 @@ describe('HashTreeParser', () => {
2014
2282
  ],
2015
2283
  };
2016
2284
 
2017
- await parser.handleMessage(message, 'message with non-visible dataset');
2285
+ parser.handleMessage(message, 'message with non-visible dataset');
2018
2286
 
2019
2287
  // Verify that no hash tree was created for attendees
2020
2288
  assert.isUndefined(parser.dataSets.attendees.hashTree);
@@ -2042,7 +2310,7 @@ describe('HashTreeParser', () => {
2042
2310
  heartbeatIntervalMs,
2043
2311
  };
2044
2312
 
2045
- await parser.handleMessage(heartbeatMessage, 'initial heartbeat');
2313
+ parser.handleMessage(heartbeatMessage, 'initial heartbeat');
2046
2314
 
2047
2315
  // Verify only 'main' watchdog timer is set
2048
2316
  expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
@@ -2106,7 +2374,7 @@ describe('HashTreeParser', () => {
2106
2374
  heartbeatIntervalMs,
2107
2375
  };
2108
2376
 
2109
- await parser.handleMessage(heartbeatMessage, 'self heartbeat');
2377
+ parser.handleMessage(heartbeatMessage, 'self heartbeat');
2110
2378
 
2111
2379
  // Mock sync response for self
2112
2380
  mockSendSyncRequestResponse(parser.dataSets.self.url, null);
@@ -2153,7 +2421,7 @@ describe('HashTreeParser', () => {
2153
2421
  heartbeatIntervalMs,
2154
2422
  };
2155
2423
 
2156
- await parser.handleMessage(heartbeatMessage, 'multi-dataset heartbeat');
2424
+ parser.handleMessage(heartbeatMessage, 'multi-dataset heartbeat');
2157
2425
 
2158
2426
  // Watchdog timers should be set for both datasets in the message
2159
2427
  expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
@@ -2179,7 +2447,7 @@ describe('HashTreeParser', () => {
2179
2447
  heartbeatIntervalMs,
2180
2448
  };
2181
2449
 
2182
- await parser.handleMessage(heartbeat1, 'first heartbeat');
2450
+ parser.handleMessage(heartbeat1, 'first heartbeat');
2183
2451
 
2184
2452
  const firstTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2185
2453
  expect(firstTimer).to.not.be.undefined;
@@ -2200,7 +2468,7 @@ describe('HashTreeParser', () => {
2200
2468
  heartbeatIntervalMs,
2201
2469
  };
2202
2470
 
2203
- await parser.handleMessage(heartbeat2, 'second heartbeat');
2471
+ parser.handleMessage(heartbeat2, 'second heartbeat');
2204
2472
 
2205
2473
  const secondTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2206
2474
  expect(secondTimer).to.not.be.undefined;
@@ -2231,7 +2499,7 @@ describe('HashTreeParser', () => {
2231
2499
  heartbeatIntervalMs,
2232
2500
  };
2233
2501
 
2234
- await parser.handleMessage(heartbeat, 'initial heartbeat');
2502
+ parser.handleMessage(heartbeat, 'initial heartbeat');
2235
2503
 
2236
2504
  const firstTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2237
2505
  expect(firstTimer).to.not.be.undefined;
@@ -2259,7 +2527,7 @@ describe('HashTreeParser', () => {
2259
2527
  heartbeatIntervalMs,
2260
2528
  };
2261
2529
 
2262
- await parser.handleMessage(normalMessage, 'normal message');
2530
+ parser.handleMessage(normalMessage, 'normal message');
2263
2531
 
2264
2532
  const secondTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2265
2533
  expect(secondTimer).to.not.be.undefined;
@@ -2277,12 +2545,12 @@ describe('HashTreeParser', () => {
2277
2545
  parser.dataSets.main.hashTree.getRootHash()
2278
2546
  );
2279
2547
 
2280
- await parser.handleMessage(heartbeatMessage, 'heartbeat without interval');
2548
+ parser.handleMessage(heartbeatMessage, 'heartbeat without interval');
2281
2549
 
2282
2550
  expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
2283
2551
  });
2284
2552
 
2285
- it('stops all watchdog timers when meeting ends', async () => {
2553
+ it('stops all watchdog timers when meeting ends via sentinel message', async () => {
2286
2554
  const parser = createHashTreeParser();
2287
2555
  const heartbeatIntervalMs = 5000;
2288
2556
 
@@ -2304,34 +2572,22 @@ describe('HashTreeParser', () => {
2304
2572
  heartbeatIntervalMs,
2305
2573
  };
2306
2574
 
2307
- await parser.handleMessage(heartbeat, 'initial heartbeat');
2575
+ parser.handleMessage(heartbeat, 'initial heartbeat');
2308
2576
 
2309
2577
  expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2310
2578
  expect(parser.dataSets.self.heartbeatWatchdogTimer).to.not.be.undefined;
2311
2579
 
2312
- // Stub updateItems to return true for the roster drop detection
2313
- sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2314
-
2315
- // Send a roster drop message that triggers MEETING_ENDED
2316
- const rosterDropMessage = {
2317
- dataSets: [createDataSet('self', 1, 2101)],
2318
- visibleDataSetsUrl,
2319
- locusUrl,
2320
- locusStateElements: [
2321
- {
2322
- htMeta: {
2323
- elementId: {type: 'self' as const, id: 4, version: 102},
2324
- dataSetNames: ['self'],
2325
- },
2326
- data: undefined,
2327
- },
2328
- ],
2329
- heartbeatIntervalMs,
2330
- };
2580
+ // Send a sentinel END MEETING message
2581
+ const sentinelMessage = createHeartbeatMessage(
2582
+ 'main',
2583
+ 1,
2584
+ parser.dataSets.main.version + 1,
2585
+ EMPTY_HASH
2586
+ );
2331
2587
 
2332
- await parser.handleMessage(rosterDropMessage, 'roster drop');
2588
+ parser.handleMessage(sentinelMessage as any, 'sentinel message');
2333
2589
 
2334
- // All watchdog timers should have been stopped and NOT restarted
2590
+ // All watchdog timers should have been stopped
2335
2591
  expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
2336
2592
  expect(parser.dataSets.self.heartbeatWatchdogTimer).to.be.undefined;
2337
2593
  });
@@ -2389,7 +2645,7 @@ describe('HashTreeParser', () => {
2389
2645
  heartbeatIntervalMs,
2390
2646
  };
2391
2647
 
2392
- await parser.handleMessage(heartbeat, 'heartbeat');
2648
+ parser.handleMessage(heartbeat, 'heartbeat');
2393
2649
 
2394
2650
  // 'main' watchdog delay = 5000 + 1^2 * 500 = 5500ms
2395
2651
  // 'self' watchdog delay = 5000 + 1^3 * 2000 = 7000ms
@@ -2453,7 +2709,7 @@ describe('HashTreeParser', () => {
2453
2709
  heartbeatIntervalMs,
2454
2710
  };
2455
2711
 
2456
- await parser.handleMessage(heartbeatMessage, 'heartbeat with non-visible dataset');
2712
+ parser.handleMessage(heartbeatMessage, 'heartbeat with non-visible dataset');
2457
2713
 
2458
2714
  // Watchdog set for main (visible) but not for atd-active (no hash tree)
2459
2715
  expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
@@ -2464,7 +2720,7 @@ describe('HashTreeParser', () => {
2464
2720
 
2465
2721
  describe('#callLocusInfoUpdateCallback filtering', () => {
2466
2722
  // Helper to setup parser with initial objects and reset callback history
2467
- async function setupParserWithObjects(locusStateElements: any[]) {
2723
+ function setupParserWithObjects(locusStateElements: any[]) {
2468
2724
  const parser = createHashTreeParser();
2469
2725
 
2470
2726
  if (locusStateElements.length > 0) {
@@ -2486,15 +2742,15 @@ describe('HashTreeParser', () => {
2486
2742
  locusStateElements,
2487
2743
  };
2488
2744
 
2489
- await parser.handleMessage(setupMessage, 'setup');
2745
+ parser.handleMessage(setupMessage, 'setup');
2490
2746
  }
2491
2747
 
2492
2748
  callback.resetHistory();
2493
2749
  return parser;
2494
2750
  }
2495
2751
 
2496
- it('filters out updates when a dataset has a higher version', async () => {
2497
- const parser = await setupParserWithObjects([
2752
+ it('filters out updates when a dataset has a higher version', () => {
2753
+ const parser = setupParserWithObjects([
2498
2754
  {
2499
2755
  htMeta: {
2500
2756
  elementId: {type: 'locus' as const, id: 5, version: 100},
@@ -2520,14 +2776,14 @@ describe('HashTreeParser', () => {
2520
2776
  ],
2521
2777
  };
2522
2778
 
2523
- await parser.handleMessage(updateMessage, 'update with older version');
2779
+ parser.handleMessage(updateMessage, 'update with older version');
2524
2780
 
2525
2781
  // Callback should not be called because the update was filtered out
2526
2782
  assert.notCalled(callback);
2527
2783
  });
2528
2784
 
2529
- it('allows updates when version is newer than existing', async () => {
2530
- const parser = await setupParserWithObjects([
2785
+ it('allows updates when version is newer than existing', () => {
2786
+ const parser = setupParserWithObjects([
2531
2787
  {
2532
2788
  htMeta: {
2533
2789
  elementId: {type: 'locus' as const, id: 5, version: 100},
@@ -2553,7 +2809,7 @@ describe('HashTreeParser', () => {
2553
2809
  ],
2554
2810
  };
2555
2811
 
2556
- await parser.handleMessage(updateMessage, 'update with newer version');
2812
+ parser.handleMessage(updateMessage, 'update with newer version');
2557
2813
 
2558
2814
  // Callback should be called with the update
2559
2815
  assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
@@ -2569,8 +2825,8 @@ describe('HashTreeParser', () => {
2569
2825
  });
2570
2826
  });
2571
2827
 
2572
- it('filters out removal when object still exists in any dataset', async () => {
2573
- const parser = await setupParserWithObjects([
2828
+ it('filters out removal when object still exists in any dataset', () => {
2829
+ const parser = setupParserWithObjects([
2574
2830
  {
2575
2831
  htMeta: {
2576
2832
  elementId: {type: 'participant' as const, id: 10, version: 50},
@@ -2596,14 +2852,14 @@ describe('HashTreeParser', () => {
2596
2852
  ],
2597
2853
  };
2598
2854
 
2599
- await parser.handleMessage(removalMessage, 'removal from one dataset');
2855
+ parser.handleMessage(removalMessage, 'removal from one dataset');
2600
2856
 
2601
2857
  // Callback should not be called because object still exists in atd-unmuted
2602
2858
  assert.notCalled(callback);
2603
2859
  });
2604
2860
 
2605
- it('allows removal when object does not exist in any dataset', async () => {
2606
- const parser = await setupParserWithObjects([]);
2861
+ it('allows removal when object does not exist in any dataset', () => {
2862
+ const parser = setupParserWithObjects([]);
2607
2863
 
2608
2864
  // Stub updateItems to return true (simulating that the removal was "applied")
2609
2865
  sinon.stub(parser.dataSets.main.hashTree, 'updateItems').returns([true]);
@@ -2624,7 +2880,7 @@ describe('HashTreeParser', () => {
2624
2880
  ],
2625
2881
  };
2626
2882
 
2627
- await parser.handleMessage(removalMessage, 'removal of non-existent object');
2883
+ parser.handleMessage(removalMessage, 'removal of non-existent object');
2628
2884
 
2629
2885
  // Callback should be called with the removal
2630
2886
  assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
@@ -2640,11 +2896,11 @@ describe('HashTreeParser', () => {
2640
2896
  });
2641
2897
  });
2642
2898
 
2643
- it('filters out removal when object exists in another dataset with newer version', async () => {
2899
+ it('filters out removal when object exists in another dataset with newer version', () => {
2644
2900
  const parser = createHashTreeParser();
2645
2901
 
2646
2902
  // Setup: Add object to main with version 40
2647
- await parser.handleMessage(
2903
+ parser.handleMessage(
2648
2904
  {
2649
2905
  dataSets: [createDataSet('main', 16, 1100)],
2650
2906
  visibleDataSetsUrl,
@@ -2663,7 +2919,7 @@ describe('HashTreeParser', () => {
2663
2919
  );
2664
2920
 
2665
2921
  // Add object to atd-unmuted with version 50
2666
- await parser.handleMessage(
2922
+ parser.handleMessage(
2667
2923
  {
2668
2924
  dataSets: [createDataSet('atd-unmuted', 16, 3100)],
2669
2925
  visibleDataSetsUrl,
@@ -2698,14 +2954,14 @@ describe('HashTreeParser', () => {
2698
2954
  ],
2699
2955
  };
2700
2956
 
2701
- await parser.handleMessage(removalMessage, 'removal with older version');
2957
+ parser.handleMessage(removalMessage, 'removal with older version');
2702
2958
 
2703
2959
  // Callback should not be called because object still exists with newer version
2704
2960
  assert.notCalled(callback);
2705
2961
  });
2706
2962
 
2707
- it('filters mixed updates correctly - some pass, some filtered', async () => {
2708
- const parser = await setupParserWithObjects([
2963
+ it('filters mixed updates correctly - some pass, some filtered', () => {
2964
+ const parser = setupParserWithObjects([
2709
2965
  {
2710
2966
  htMeta: {
2711
2967
  elementId: {type: 'participant' as const, id: 1, version: 100},
@@ -2759,7 +3015,7 @@ describe('HashTreeParser', () => {
2759
3015
  ],
2760
3016
  };
2761
3017
 
2762
- await parser.handleMessage(mixedMessage, 'mixed updates');
3018
+ parser.handleMessage(mixedMessage, 'mixed updates');
2763
3019
 
2764
3020
  // Callback should be called with only the valid updates (participant 1 v110 and participant 3 v10)
2765
3021
  assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
@@ -2782,8 +3038,8 @@ describe('HashTreeParser', () => {
2782
3038
  });
2783
3039
  });
2784
3040
 
2785
- it('does not call callback when all updates are filtered out', async () => {
2786
- const parser = await setupParserWithObjects([
3041
+ it('does not call callback when all updates are filtered out', () => {
3042
+ const parser = setupParserWithObjects([
2787
3043
  {
2788
3044
  htMeta: {
2789
3045
  elementId: {type: 'locus' as const, id: 5, version: 100},
@@ -2816,17 +3072,17 @@ describe('HashTreeParser', () => {
2816
3072
  ],
2817
3073
  };
2818
3074
 
2819
- await parser.handleMessage(updateMessage, 'all filtered updates');
3075
+ parser.handleMessage(updateMessage, 'all filtered updates');
2820
3076
 
2821
3077
  // Callback should not be called at all
2822
3078
  assert.notCalled(callback);
2823
3079
  });
2824
3080
 
2825
- it('checks all visible datasets when filtering', async () => {
3081
+ it('checks all visible datasets when filtering', () => {
2826
3082
  const parser = createHashTreeParser();
2827
3083
 
2828
3084
  // Setup: Add same object to multiple datasets with different versions
2829
- await parser.handleMessage(
3085
+ parser.handleMessage(
2830
3086
  {
2831
3087
  dataSets: [createDataSet('main', 16, 1100)],
2832
3088
  visibleDataSetsUrl,
@@ -2844,7 +3100,7 @@ describe('HashTreeParser', () => {
2844
3100
  'setup main'
2845
3101
  );
2846
3102
 
2847
- await parser.handleMessage(
3103
+ parser.handleMessage(
2848
3104
  {
2849
3105
  dataSets: [createDataSet('self', 1, 2100)],
2850
3106
  visibleDataSetsUrl,
@@ -2862,7 +3118,7 @@ describe('HashTreeParser', () => {
2862
3118
  'setup self'
2863
3119
  );
2864
3120
 
2865
- await parser.handleMessage(
3121
+ parser.handleMessage(
2866
3122
  {
2867
3123
  dataSets: [createDataSet('atd-unmuted', 16, 3100)],
2868
3124
  visibleDataSetsUrl,
@@ -2897,14 +3153,14 @@ describe('HashTreeParser', () => {
2897
3153
  ],
2898
3154
  };
2899
3155
 
2900
- await parser.handleMessage(updateMessage, 'update with v115');
3156
+ parser.handleMessage(updateMessage, 'update with v115');
2901
3157
 
2902
3158
  // Should be filtered out because self dataset has version 120
2903
3159
  assert.notCalled(callback);
2904
3160
  });
2905
3161
 
2906
- it('does not call callback for empty locusStateElements', async () => {
2907
- const parser = await setupParserWithObjects([]);
3162
+ it('does not call callback for empty locusStateElements', () => {
3163
+ const parser = setupParserWithObjects([]);
2908
3164
 
2909
3165
  const emptyMessage = {
2910
3166
  dataSets: [createDataSet('main', 16, 1100)],
@@ -2913,13 +3169,13 @@ describe('HashTreeParser', () => {
2913
3169
  locusStateElements: [],
2914
3170
  };
2915
3171
 
2916
- await parser.handleMessage(emptyMessage, 'empty elements');
3172
+ parser.handleMessage(emptyMessage, 'empty elements');
2917
3173
 
2918
3174
  assert.notCalled(callback);
2919
3175
  });
2920
3176
 
2921
- it('always calls callback for MEETING_ENDED regardless of filtering', async () => {
2922
- const parser = await setupParserWithObjects([
3177
+ it('always calls callback for MEETING_ENDED regardless of filtering', () => {
3178
+ const parser = setupParserWithObjects([
2923
3179
  {
2924
3180
  htMeta: {
2925
3181
  elementId: {type: 'locus' as const, id: 0, version: 100},
@@ -2929,23 +3185,15 @@ describe('HashTreeParser', () => {
2929
3185
  },
2930
3186
  ]);
2931
3187
 
2932
- // Send roster drop message (SELF object with no data) to trigger MEETING_ENDED
2933
- const rosterDropMessage = {
2934
- dataSets: [createDataSet('self', 1, 2101)],
2935
- visibleDataSetsUrl,
2936
- locusUrl,
2937
- locusStateElements: [
2938
- {
2939
- htMeta: {
2940
- elementId: {type: 'self' as const, id: 4, version: 102},
2941
- dataSetNames: ['self'],
2942
- },
2943
- data: undefined, // roster drop triggers MEETING_ENDED
2944
- },
2945
- ],
2946
- };
3188
+ // Send a sentinel END MEETING message
3189
+ const sentinelMessage = createHeartbeatMessage(
3190
+ 'main',
3191
+ 1,
3192
+ parser.dataSets.main.version + 1,
3193
+ EMPTY_HASH
3194
+ );
2947
3195
 
2948
- await parser.handleMessage(rosterDropMessage, 'roster drop message');
3196
+ parser.handleMessage(sentinelMessage as any, 'sentinel message');
2949
3197
 
2950
3198
  // Callback should be called with MEETING_ENDED
2951
3199
  assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {