@webex/plugin-meetings 3.12.0-next.1 → 3.12.0-next.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/aiEnableRequest/index.js +1 -1
  2. package/dist/breakouts/breakout.js +1 -1
  3. package/dist/breakouts/index.js +1 -1
  4. package/dist/hashTree/constants.js +10 -1
  5. package/dist/hashTree/constants.js.map +1 -1
  6. package/dist/hashTree/hashTreeParser.js +56 -31
  7. package/dist/hashTree/hashTreeParser.js.map +1 -1
  8. package/dist/hashTree/utils.js +22 -0
  9. package/dist/hashTree/utils.js.map +1 -1
  10. package/dist/interpretation/index.js +1 -1
  11. package/dist/interpretation/siLanguage.js +1 -1
  12. package/dist/locus-info/index.js +38 -14
  13. package/dist/locus-info/index.js.map +1 -1
  14. package/dist/meeting/index.js +427 -323
  15. package/dist/meeting/index.js.map +1 -1
  16. package/dist/meeting/util.js +1 -0
  17. package/dist/meeting/util.js.map +1 -1
  18. package/dist/metrics/constants.js +5 -1
  19. package/dist/metrics/constants.js.map +1 -1
  20. package/dist/multistream/sendSlotManager.js +116 -2
  21. package/dist/multistream/sendSlotManager.js.map +1 -1
  22. package/dist/types/hashTree/constants.d.ts +1 -0
  23. package/dist/types/hashTree/hashTreeParser.d.ts +12 -2
  24. package/dist/types/hashTree/utils.d.ts +11 -0
  25. package/dist/types/locus-info/index.d.ts +8 -3
  26. package/dist/types/meeting/index.d.ts +24 -1
  27. package/dist/types/metrics/constants.d.ts +4 -0
  28. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  29. package/dist/webinar/index.js +325 -220
  30. package/dist/webinar/index.js.map +1 -1
  31. package/package.json +15 -15
  32. package/src/hashTree/constants.ts +9 -0
  33. package/src/hashTree/hashTreeParser.ts +60 -36
  34. package/src/hashTree/utils.ts +17 -0
  35. package/src/locus-info/index.ts +48 -24
  36. package/src/meeting/index.ts +165 -57
  37. package/src/meeting/util.ts +1 -0
  38. package/src/metrics/constants.ts +5 -0
  39. package/src/multistream/sendSlotManager.ts +97 -3
  40. package/src/webinar/index.ts +120 -18
  41. package/test/unit/spec/hashTree/hashTreeParser.ts +295 -30
  42. package/test/unit/spec/hashTree/utils.ts +88 -1
  43. package/test/unit/spec/locus-info/index.js +47 -22
  44. package/test/unit/spec/meeting/index.js +179 -48
  45. package/test/unit/spec/meeting/utils.js +4 -0
  46. package/test/unit/spec/meetings/index.js +3 -3
  47. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  48. package/test/unit/spec/webinar/index.ts +193 -8
@@ -131,6 +131,12 @@ const Webinar = WebexPlugin.extend({
131
131
  * @returns {Promise<void>}
132
132
  */
133
133
  async cleanupPSDataChannel() {
134
+ if (this._pendingOnlineListener) {
135
+ // @ts-ignore - Fix type
136
+ this.webex.internal.llm.off('online', this._pendingOnlineListener);
137
+ this._pendingOnlineListener = null;
138
+ }
139
+
134
140
  const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
135
141
 
136
142
  // @ts-ignore - Fix type
@@ -148,12 +154,63 @@ const Webinar = WebexPlugin.extend({
148
154
  );
149
155
  },
150
156
 
157
+ /**
158
+ * Ensures practice-session token exists before registering the practice LLM channel.
159
+ * @param {object} meeting
160
+ * @returns {Promise<string|undefined>}
161
+ */
162
+ async ensurePracticeSessionDatachannelToken(meeting) {
163
+ // @ts-ignore
164
+ const isDataChannelTokenEnabled = await this.webex.internal.llm.isDataChannelTokenEnabled();
165
+
166
+ if (!isDataChannelTokenEnabled) {
167
+ return undefined;
168
+ }
169
+
170
+ // @ts-ignore
171
+ const cachedToken = this.webex.internal.llm.getDatachannelToken(
172
+ DataChannelTokenType.PracticeSession
173
+ );
174
+
175
+ if (cachedToken) {
176
+ return cachedToken;
177
+ }
178
+
179
+ try {
180
+ const refreshResponse = await meeting.refreshDataChannelToken();
181
+ const {datachannelToken, dataChannelTokenType} = refreshResponse?.body ?? {};
182
+
183
+ if (!datachannelToken) {
184
+ return undefined;
185
+ }
186
+
187
+ // @ts-ignore
188
+ this.webex.internal.llm.setDatachannelToken(
189
+ datachannelToken,
190
+ dataChannelTokenType || DataChannelTokenType.PracticeSession
191
+ );
192
+
193
+ return datachannelToken;
194
+ } catch (error) {
195
+ LoggerProxy.logger.warn(
196
+ `Webinar:index#ensurePracticeSessionDatachannelToken --> failed to proactively refresh practice-session token: ${
197
+ error?.message || String(error)
198
+ }`
199
+ );
200
+
201
+ return undefined;
202
+ }
203
+ },
204
+
151
205
  /**
152
206
  * Connects to low latency mercury and reconnects if the address has changed
153
207
  * It will also disconnect if called when the meeting has ended
154
208
  * @returns {Promise}
155
209
  */
156
210
  async updatePSDataChannel() {
211
+ this._updatePSDataChannelSequence = (this._updatePSDataChannelSequence || 0) + 1;
212
+ const invocationSequence = this._updatePSDataChannelSequence;
213
+
157
214
  const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
158
215
  const isPracticeSession = meeting?.isJoined() && this.isJoinPracticeSessionDataChannel();
159
216
 
@@ -164,29 +221,16 @@ const Webinar = WebexPlugin.extend({
164
221
  }
165
222
 
166
223
  // @ts-ignore - Fix type
167
- const {
168
- url = undefined,
169
- info: {practiceSessionDatachannelUrl = undefined} = {},
170
- self: {practiceSessionDatachannelToken = undefined} = {},
171
- } = meeting?.locusInfo || {};
224
+ const {url = undefined, info: {practiceSessionDatachannelUrl = undefined} = {}} =
225
+ meeting?.locusInfo || {};
172
226
 
173
227
  // @ts-ignore
174
- const currentToken = this.webex.internal.llm.getDatachannelToken(
228
+ let practiceSessionDatachannelToken = this.webex.internal.llm.getDatachannelToken(
175
229
  DataChannelTokenType.PracticeSession
176
230
  );
177
231
 
178
- const finalToken = currentToken ?? practiceSessionDatachannelToken;
179
-
180
232
  const isCaptionBoxOn = this.webex.internal.voicea.getIsCaptionBoxOn();
181
233
 
182
- if (!currentToken && practiceSessionDatachannelToken) {
183
- // @ts-ignore
184
- this.webex.internal.llm.setDatachannelToken(
185
- practiceSessionDatachannelToken,
186
- DataChannelTokenType.PracticeSession
187
- );
188
- }
189
-
190
234
  if (!practiceSessionDatachannelUrl) {
191
235
  return undefined;
192
236
  }
@@ -205,9 +249,68 @@ const Webinar = WebexPlugin.extend({
205
249
  await this.cleanupPSDataChannel();
206
250
  }
207
251
 
252
+ // Ensure the default session data channel is connected before connecting the practice session.
253
+ // Subscribe before checking isConnected() to avoid a race where the 'online' event fires
254
+ // between the check and the subscription — Mercury does not replay missed events.
255
+ if (!this._pendingOnlineListener) {
256
+ const onDefaultSessionConnected = () => {
257
+ this._pendingOnlineListener = null;
258
+ // @ts-ignore - Fix type
259
+ this.webex.internal.llm.off('online', onDefaultSessionConnected);
260
+ this.updatePSDataChannel();
261
+ };
262
+ this._pendingOnlineListener = onDefaultSessionConnected;
263
+ // @ts-ignore - Fix type
264
+ this.webex.internal.llm.on('online', onDefaultSessionConnected);
265
+ }
266
+
267
+ // @ts-ignore - Fix type
268
+ if (!this.webex.internal.llm.isConnected()) {
269
+ LoggerProxy.logger.info(
270
+ 'Webinar:index#updatePSDataChannel --> default session not yet connected, deferring practice session connect.'
271
+ );
272
+
273
+ return undefined;
274
+ }
275
+
276
+ // Default session is already connected — cancel the pending listener and proceed
277
+ if (this._pendingOnlineListener) {
278
+ // @ts-ignore - Fix type
279
+ this.webex.internal.llm.off('online', this._pendingOnlineListener);
280
+ this._pendingOnlineListener = null;
281
+ }
282
+
283
+ const refreshedPracticeSessionToken = await this.ensurePracticeSessionDatachannelToken(meeting);
284
+
285
+ const latestPracticeSessionDatachannelUrl = get(
286
+ meeting,
287
+ 'locusInfo.info.practiceSessionDatachannelUrl'
288
+ );
289
+ const isStillPracticeSession = meeting?.isJoined() && this.isJoinPracticeSessionDataChannel();
290
+
291
+ // Skip stale invocations after async refresh to avoid reconnecting a session
292
+ // that was already updated/cleaned by a newer state transition.
293
+ if (
294
+ invocationSequence !== this._updatePSDataChannelSequence ||
295
+ !isStillPracticeSession ||
296
+ !latestPracticeSessionDatachannelUrl ||
297
+ latestPracticeSessionDatachannelUrl !== practiceSessionDatachannelUrl
298
+ ) {
299
+ return undefined;
300
+ }
301
+
302
+ if (refreshedPracticeSessionToken) {
303
+ practiceSessionDatachannelToken = refreshedPracticeSessionToken;
304
+ }
305
+
208
306
  // @ts-ignore - Fix type
209
307
  return this.webex.internal.llm
210
- .registerAndConnect(url, practiceSessionDatachannelUrl, finalToken, LLM_PRACTICE_SESSION)
308
+ .registerAndConnect(
309
+ url,
310
+ practiceSessionDatachannelUrl,
311
+ practiceSessionDatachannelToken,
312
+ LLM_PRACTICE_SESSION
313
+ )
211
314
  .then((registerAndConnectResult) => {
212
315
  // @ts-ignore - Fix type
213
316
  this.webex.internal.llm.off(
@@ -366,7 +469,6 @@ const Webinar = WebexPlugin.extend({
366
469
 
367
470
  /**
368
471
  * view all webcast attendees
369
- * @param {string} queryString
370
472
  * @returns {Promise}
371
473
  */
372
474
  async viewAllWebcastAttendees() {
@@ -553,7 +553,7 @@ describe('HashTreeParser', () => {
553
553
  );
554
554
 
555
555
  // Verify callback was called with OBJECTS_UPDATED and correct updatedObjects list
556
- assert.calledWith(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
556
+ assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
557
557
  updatedObjects: [
558
558
  {
559
559
  htMeta: {
@@ -596,6 +596,41 @@ describe('HashTreeParser', () => {
596
596
  });
597
597
  });
598
598
 
599
+ it('initializes "main" before "self" regardless of order from Locus', async () => {
600
+ const parser = createHashTreeParser({dataSets: [], locus: null}, null);
601
+
602
+ // Locus returns datasets in non-priority order: atd-active, main, self
603
+ const atdActiveDataSet = createDataSet('atd-active', 4, 500);
604
+ const mainDataSet = createDataSet('main', 16, 1100);
605
+ const selfDataSet = createDataSet('self', 1, 2100);
606
+
607
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
608
+ atdActiveDataSet,
609
+ mainDataSet,
610
+ selfDataSet,
611
+ ]);
612
+
613
+ mockSyncRequest(webexRequest, selfDataSet.url);
614
+ mockSyncRequest(webexRequest, mainDataSet.url);
615
+ mockSyncRequest(webexRequest, atdActiveDataSet.url);
616
+
617
+ await parser.initializeFromMessage({
618
+ dataSets: [],
619
+ visibleDataSetsUrl,
620
+ locusUrl,
621
+ });
622
+
623
+ // Verify sync requests were sent in priority order: main, self, then atd-active
624
+ const syncCalls = webexRequest
625
+ .getCalls()
626
+ .filter((call) => call.args[0]?.method === 'POST' && call.args[0]?.uri?.endsWith('/sync'));
627
+
628
+ expect(syncCalls).to.have.lengthOf(3);
629
+ expect(syncCalls[0].args[0].uri).to.equal(`${mainDataSet.url}/sync`);
630
+ expect(syncCalls[1].args[0].uri).to.equal(`${selfDataSet.url}/sync`);
631
+ expect(syncCalls[2].args[0].uri).to.equal(`${atdActiveDataSet.url}/sync`);
632
+ });
633
+
599
634
  it('handles sync response that has locusStateElements undefined', async () => {
600
635
  const minimalInitialLocus = {
601
636
  dataSets: [],
@@ -788,7 +823,7 @@ describe('HashTreeParser', () => {
788
823
  expect(parser.dataSets.self.version).to.equal(2100);
789
824
  expect(parser.dataSets['atd-unmuted'].version).to.equal(3100);
790
825
 
791
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
826
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
792
827
  updatedObjects: [
793
828
  {
794
829
  htMeta: {
@@ -861,6 +896,116 @@ describe('HashTreeParser', () => {
861
896
  });
862
897
  });
863
898
 
899
+ it('handles updates to control entries correctly', () => {
900
+ const parser = createHashTreeParser();
901
+
902
+ const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
903
+
904
+ // Create a locus update with new htMeta information for some things
905
+ const locusUpdate = {
906
+ dataSets: [
907
+ createDataSet('main', 16, 1100),
908
+ ],
909
+ locus: {
910
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
911
+ htMeta: {
912
+ elementId: {
913
+ type: 'locus',
914
+ id: 0,
915
+ version: 200, // same version
916
+ },
917
+ dataSetNames: ['main'],
918
+ },
919
+ participants: [],
920
+ controls: {
921
+ lock: {
922
+ locked: true,
923
+ htMeta: {
924
+ elementId: {
925
+ type: 'ControlEntry',
926
+ id: 10100,
927
+ version: 100,
928
+ },
929
+ dataSetNames: ['main'],
930
+ },
931
+ },
932
+ stream: {
933
+ streaming: true,
934
+ htMeta: {
935
+ elementId: {
936
+ type: 'ControlEntry',
937
+ id: 10101,
938
+ version: 100,
939
+ },
940
+ dataSetNames: ['main'],
941
+ },
942
+ }
943
+ }
944
+ },
945
+ };
946
+
947
+ // Call handleLocusUpdate
948
+ parser.handleLocusUpdate(locusUpdate);
949
+
950
+ // Verify putItems was called on main hash tree with correct data
951
+ assert.calledOnceWithExactly(mainPutItemsSpy, [
952
+ {type: 'locus', id: 0, version: 200},
953
+ {type: 'ControlEntry', id: 10100, version: 100},
954
+ {type: 'ControlEntry', id: 10101, version: 100}
955
+ ]);
956
+
957
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
958
+ updatedObjects: [
959
+ {
960
+ htMeta: {
961
+ elementId: {
962
+ type: 'ControlEntry',
963
+ id: 10100,
964
+ version: 100,
965
+ },
966
+ dataSetNames: ['main'],
967
+ },
968
+ data: {
969
+ lock: {
970
+ locked: true,
971
+ htMeta: {
972
+ elementId: {
973
+ type: 'ControlEntry',
974
+ id: 10100,
975
+ version: 100,
976
+ },
977
+ dataSetNames: ['main'],
978
+ },
979
+ },
980
+ },
981
+ },
982
+ {
983
+ htMeta: {
984
+ elementId: {
985
+ type: 'ControlEntry',
986
+ id: 10101,
987
+ version: 100,
988
+ },
989
+ dataSetNames: ['main'],
990
+ },
991
+ data: {
992
+ stream: {
993
+ streaming: true,
994
+ htMeta: {
995
+ elementId: {
996
+ type: 'ControlEntry',
997
+ id: 10101,
998
+ version: 100,
999
+ },
1000
+ dataSetNames: ['main'],
1001
+ },
1002
+ },
1003
+ },
1004
+ }
1005
+ ],
1006
+ });
1007
+ });
1008
+
864
1009
  it('handles unknown datasets gracefully', () => {
865
1010
  const parser = createHashTreeParser();
866
1011
 
@@ -899,7 +1044,7 @@ describe('HashTreeParser', () => {
899
1044
  assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 201}]);
900
1045
 
901
1046
  // Verify callback was called only for known dataset
902
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1047
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
903
1048
  updatedObjects: [
904
1049
  {
905
1050
  htMeta: {
@@ -999,7 +1144,7 @@ describe('HashTreeParser', () => {
999
1144
  assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 51});
1000
1145
 
1001
1146
  // Verify callback was called with metadata object and removed dataset objects
1002
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1147
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1003
1148
  updatedObjects: [
1004
1149
  // updated metadata object:
1005
1150
  {
@@ -1160,7 +1305,7 @@ describe('HashTreeParser', () => {
1160
1305
  assert.notCalled(atdUnmutedPutItemsSpy);
1161
1306
 
1162
1307
  // Verify callback was called with the updated object
1163
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1308
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1164
1309
  updatedObjects: [
1165
1310
  {
1166
1311
  htMeta: {
@@ -1388,7 +1533,7 @@ describe('HashTreeParser', () => {
1388
1533
  ]);
1389
1534
 
1390
1535
  // Verify callback was called with OBJECTS_UPDATED and all updated objects
1391
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1536
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1392
1537
  updatedObjects: [
1393
1538
  {
1394
1539
  htMeta: {
@@ -1453,9 +1598,7 @@ describe('HashTreeParser', () => {
1453
1598
  parser.handleMessage(sentinelMessage, 'sentinel message');
1454
1599
 
1455
1600
  // Verify callback was called with MEETING_ENDED
1456
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1457
- updatedObjects: undefined,
1458
- });
1601
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1459
1602
 
1460
1603
  // Verify that all timers were stopped
1461
1604
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1477,9 +1620,7 @@ describe('HashTreeParser', () => {
1477
1620
  parser.handleMessage(sentinelMessage, 'sentinel message');
1478
1621
 
1479
1622
  // Verify callback was called with MEETING_ENDED
1480
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1481
- updatedObjects: undefined,
1482
- });
1623
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1483
1624
 
1484
1625
  // Verify that all timers were stopped
1485
1626
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1575,7 +1716,7 @@ describe('HashTreeParser', () => {
1575
1716
  );
1576
1717
 
1577
1718
  // Verify that callback was called with synced objects
1578
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1719
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1579
1720
  updatedObjects: [
1580
1721
  {
1581
1722
  htMeta: {
@@ -1637,9 +1778,7 @@ describe('HashTreeParser', () => {
1637
1778
  await clock.tickAsync(1000);
1638
1779
 
1639
1780
  // Verify callback was called with MEETING_ENDED
1640
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1641
- updatedObjects: undefined,
1642
- });
1781
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1643
1782
 
1644
1783
  // Verify all timers are stopped
1645
1784
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1702,9 +1841,7 @@ describe('HashTreeParser', () => {
1702
1841
  await clock.tickAsync(1000);
1703
1842
 
1704
1843
  // Verify callback was called with MEETING_ENDED
1705
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1706
- updatedObjects: undefined,
1707
- });
1844
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1708
1845
 
1709
1846
  // Verify all timers are stopped
1710
1847
  Object.values(parser.dataSets).forEach((ds: any) => {
@@ -1942,7 +2079,7 @@ describe('HashTreeParser', () => {
1942
2079
  assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
1943
2080
 
1944
2081
  // 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, {
2082
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1946
2083
  updatedObjects: [
1947
2084
  {
1948
2085
  htMeta: {
@@ -2062,6 +2199,98 @@ describe('HashTreeParser', () => {
2062
2199
  await checkAsyncDatasetInitialization(parser, newDataSet);
2063
2200
  });
2064
2201
 
2202
+ it('initializes new visible data sets in priority order', async () => {
2203
+ // Create a parser that only has "self" as visible (no "main")
2204
+ const initialLocusWithoutMain = {
2205
+ dataSets: [createDataSet('self', 1, 2000)],
2206
+ locus: {
2207
+ ...exampleInitialLocus.locus,
2208
+ },
2209
+ };
2210
+ const metadataWithoutMain = {
2211
+ ...exampleMetadata,
2212
+ visibleDataSets: [
2213
+ {
2214
+ name: 'self',
2215
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2216
+ },
2217
+ ],
2218
+ };
2219
+ const parser = createHashTreeParser(initialLocusWithoutMain, metadataWithoutMain);
2220
+
2221
+ // Verify "main" is not visible initially
2222
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.false;
2223
+
2224
+ // Stub updateItems on self hash tree to return true
2225
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
2226
+
2227
+ // Send a message that adds "main" and "atd-active" as new visible datasets.
2228
+ // Neither has info in dataSets, so both require async initialization.
2229
+ const newMainDataSet = createDataSet('main', 16, 6000);
2230
+ const newAtdActiveDataSet = createDataSet('atd-active', 4, 7000);
2231
+
2232
+ const message = {
2233
+ dataSets: [createDataSet('self', 1, 2100)],
2234
+ visibleDataSetsUrl,
2235
+ locusUrl,
2236
+ locusStateElements: [
2237
+ {
2238
+ htMeta: {
2239
+ elementId: {
2240
+ type: 'metadata' as const,
2241
+ id: 5,
2242
+ version: 51,
2243
+ },
2244
+ dataSetNames: ['self'],
2245
+ },
2246
+ data: {
2247
+ visibleDataSets: [
2248
+ {
2249
+ name: 'self',
2250
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2251
+ },
2252
+ // listed in non-priority order: atd-active before main
2253
+ {name: 'atd-active', url: newAtdActiveDataSet.url},
2254
+ {name: 'main', url: newMainDataSet.url},
2255
+ ],
2256
+ },
2257
+ },
2258
+ ],
2259
+ };
2260
+
2261
+ // Mock getAllVisibleDataSetsFromLocus to return both new datasets (in non-priority order)
2262
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
2263
+ newAtdActiveDataSet,
2264
+ newMainDataSet,
2265
+ ]);
2266
+ mockSyncRequest(webexRequest, newMainDataSet.url);
2267
+ mockSyncRequest(webexRequest, newAtdActiveDataSet.url);
2268
+
2269
+ parser.handleMessage(message, 'add main and atd-active datasets');
2270
+
2271
+ // Wait for the async initialization (queueMicrotask) to complete
2272
+ await clock.tickAsync(0);
2273
+
2274
+ // Verify both datasets are initialized
2275
+ expect(parser.dataSets.main?.hashTree).to.exist;
2276
+ expect(parser.dataSets['atd-active']?.hashTree).to.exist;
2277
+
2278
+ // Verify sync requests were sent in priority order: "main" before "atd-active",
2279
+ // even though atd-active was listed first in both the message and the Locus response
2280
+ const syncCalls = webexRequest
2281
+ .getCalls()
2282
+ .filter(
2283
+ (call) =>
2284
+ call.args[0]?.method === 'POST' &&
2285
+ call.args[0]?.uri?.endsWith('/sync') &&
2286
+ (call.args[0]?.uri?.includes('/main/') || call.args[0]?.uri?.includes('/atd-active/'))
2287
+ );
2288
+
2289
+ expect(syncCalls).to.have.lengthOf(2);
2290
+ expect(syncCalls[0].args[0].uri).to.equal(`${newMainDataSet.url}/sync`);
2291
+ expect(syncCalls[1].args[0].uri).to.equal(`${newAtdActiveDataSet.url}/sync`);
2292
+ });
2293
+
2065
2294
  it('emits MEETING_ENDED if async init of a new visible dataset fails with 404', async () => {
2066
2295
  const parser = createHashTreeParser();
2067
2296
 
@@ -2128,9 +2357,7 @@ describe('HashTreeParser', () => {
2128
2357
  await clock.tickAsync(0);
2129
2358
 
2130
2359
  // Verify callback was called with MEETING_ENDED
2131
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
2132
- updatedObjects: undefined,
2133
- });
2360
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
2134
2361
  });
2135
2362
 
2136
2363
  it('handles removal of visible data set', async () => {
@@ -2193,7 +2420,7 @@ describe('HashTreeParser', () => {
2193
2420
  assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
2194
2421
 
2195
2422
  // 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, {
2423
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2197
2424
  updatedObjects: [
2198
2425
  {
2199
2426
  htMeta: {
@@ -2812,7 +3039,7 @@ describe('HashTreeParser', () => {
2812
3039
  parser.handleMessage(updateMessage, 'update with newer version');
2813
3040
 
2814
3041
  // Callback should be called with the update
2815
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3042
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2816
3043
  updatedObjects: [
2817
3044
  {
2818
3045
  htMeta: {
@@ -2883,7 +3110,7 @@ describe('HashTreeParser', () => {
2883
3110
  parser.handleMessage(removalMessage, 'removal of non-existent object');
2884
3111
 
2885
3112
  // Callback should be called with the removal
2886
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3113
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
2887
3114
  updatedObjects: [
2888
3115
  {
2889
3116
  htMeta: {
@@ -3018,7 +3245,7 @@ describe('HashTreeParser', () => {
3018
3245
  parser.handleMessage(mixedMessage, 'mixed updates');
3019
3246
 
3020
3247
  // Callback should be called with only the valid updates (participant 1 v110 and participant 3 v10)
3021
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3248
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
3022
3249
  updatedObjects: [
3023
3250
  {
3024
3251
  htMeta: {
@@ -3196,9 +3423,7 @@ describe('HashTreeParser', () => {
3196
3423
  parser.handleMessage(sentinelMessage as any, 'sentinel message');
3197
3424
 
3198
3425
  // Callback should be called with MEETING_ENDED
3199
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
3200
- updatedObjects: undefined,
3201
- });
3426
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
3202
3427
  });
3203
3428
  });
3204
3429
 
@@ -3557,4 +3782,44 @@ describe('HashTreeParser', () => {
3557
3782
  assert.notCalled(callback);
3558
3783
  });
3559
3784
  });
3785
+
3786
+ describe('#cleanUp', () => {
3787
+ it('should stop the parser, clear all timers and clear all dataSets', () => {
3788
+ const parser = createHashTreeParser();
3789
+
3790
+ // Send a message to set up sync timers via runSyncAlgorithm
3791
+ const message = {
3792
+ dataSets: [
3793
+ {
3794
+ ...createDataSet('main', 16, 1100),
3795
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
3796
+ },
3797
+ ],
3798
+ visibleDataSetsUrl,
3799
+ locusUrl,
3800
+ heartbeatIntervalMs: 5000,
3801
+ locusStateElements: [
3802
+ {
3803
+ htMeta: {
3804
+ elementId: {type: 'locus' as const, id: 0, version: 201},
3805
+ dataSetNames: ['main'],
3806
+ },
3807
+ data: {someData: 'value'},
3808
+ },
3809
+ ],
3810
+ };
3811
+
3812
+ parser.handleMessage(message, 'setup timers');
3813
+
3814
+ // Verify timers were set by handleMessage
3815
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
3816
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
3817
+
3818
+ parser.cleanUp();
3819
+
3820
+ expect(parser.state).to.equal('stopped');
3821
+ expect(parser.visibleDataSets).to.deep.equal([]);
3822
+ expect(parser.dataSets).to.deep.equal({});
3823
+ });
3824
+ });
3560
3825
  });