@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
@@ -1,19 +1,28 @@
1
1
  import 'jsdom-global/register';
2
2
  import SendSlotManager from '@webex/plugin-meetings/src/multistream/sendSlotManager';
3
- import { LocalStream, MediaType, MultistreamRoapMediaConnection } from "@webex/internal-media-core";
4
- import {expect} from '@webex/test-helper-chai';
3
+ import { LocalStream, MediaType, MultistreamRoapMediaConnection, MediaCodecMimeType } from "@webex/internal-media-core";
4
+ import {assert, expect} from '@webex/test-helper-chai';
5
5
  import sinon from 'sinon';
6
+ import Metrics from '@webex/plugin-meetings/src/metrics';
7
+ import BEHAVIORAL_METRICS from '@webex/plugin-meetings/src/metrics/constants';
6
8
 
7
9
  describe('SendSlotsManager', () => {
8
10
  let sendSlotsManager: SendSlotManager;
9
11
  const LoggerProxy = {
10
12
  logger: {
11
13
  info: sinon.stub(),
14
+ warn: sinon.stub(),
15
+ error: sinon.stub(),
12
16
  },
13
17
  };
14
18
 
15
19
  beforeEach(() => {
16
20
  sendSlotsManager = new SendSlotManager(LoggerProxy);
21
+ sinon.stub(Metrics, 'sendBehavioralMetric');
22
+ });
23
+
24
+ afterEach(() => {
25
+ sinon.restore();
17
26
  });
18
27
 
19
28
  describe('createSlot', () => {
@@ -29,13 +38,13 @@ describe('SendSlotsManager', () => {
29
38
  it('should create a slot for the given mediaType', () => {
30
39
  sendSlotsManager.createSlot(mediaConnection, mediaType);
31
40
 
32
- expect(mediaConnection.createSendSlot.calledWith(mediaType, true));
41
+ assert.calledWith(mediaConnection.createSendSlot, mediaType, true);
33
42
  });
34
43
 
35
44
  it('should create a slot for the given mediaType & active state', () => {
36
45
  sendSlotsManager.createSlot(mediaConnection, mediaType, false);
37
46
 
38
- expect(mediaConnection.createSendSlot.calledWith(mediaType, false));
47
+ assert.calledWith(mediaConnection.createSendSlot, mediaType, false);
39
48
  });
40
49
 
41
50
  it('should throw an error if a slot for the given mediaType already exists', () => {
@@ -86,14 +95,12 @@ describe('SendSlotsManager', () => {
86
95
 
87
96
  await sendSlotsManager.publishStream(mediaType, stream);
88
97
 
89
- expect(slot.publishStream.calledWith(stream));
98
+ assert.calledWith(slot.publishStream, stream);
90
99
  });
91
100
 
92
- it('should throw an error if a slot for the given mediaType does not exist', (done) => {
93
- sendSlotsManager.publishStream(mediaType, stream).catch((error) => {
94
- expect(error.message).to.equal(`Slot for ${mediaType} does not exist`);
95
- done();
96
- });
101
+ it('should throw an error if a slot for the given mediaType does not exist', async () => {
102
+ await expect(sendSlotsManager.publishStream(mediaType, stream))
103
+ .to.be.rejectedWith(`Slot for ${mediaType} does not exist`);
97
104
  });
98
105
  });
99
106
 
@@ -116,14 +123,12 @@ describe('SendSlotsManager', () => {
116
123
 
117
124
  await sendSlotsManager.unpublishStream(mediaType);
118
125
 
119
- expect(slot.unpublishStream.called);
126
+ assert.called(slot.unpublishStream);
120
127
  });
121
128
 
122
- it('should throw an error if a slot for the given mediaType does not exist',(done) => {
123
- sendSlotsManager.unpublishStream(mediaType).catch((error) => {
124
- expect(error.message).to.equal(`Slot for ${mediaType} does not exist`);
125
- done();
126
- });
129
+ it('should throw an error if a slot for the given mediaType does not exist', async () => {
130
+ await expect(sendSlotsManager.unpublishStream(mediaType))
131
+ .to.be.rejectedWith(`Slot for ${mediaType} does not exist`);
127
132
  });
128
133
  });
129
134
 
@@ -147,7 +152,7 @@ describe('SendSlotsManager', () => {
147
152
 
148
153
  await sendSlotsManager.setNamedMediaGroups(mediaType, groups);
149
154
 
150
- expect(slot.setNamedMediaGroups.calledWith(groups));
155
+ assert.calledWith(slot.setNamedMediaGroups, groups);
151
156
  });
152
157
 
153
158
  it('should throw an error if the given mediaType is not audio', () => {
@@ -169,16 +174,16 @@ describe('SendSlotsManager', () => {
169
174
  } as MultistreamRoapMediaConnection;
170
175
  });
171
176
 
172
- it('should set the active state of the sendSlot for the given mediaType', async () => {
177
+ it('should set the active state of the sendSlot for the given mediaType', () => {
173
178
  const slot = {
174
- setActive: sinon.stub().resolves(),
179
+ active: false,
175
180
  };
176
181
  mediaConnection.createSendSlot.returns(slot);
177
182
  sendSlotsManager.createSlot(mediaConnection, mediaType);
178
183
 
179
- await sendSlotsManager.setActive(mediaType,true);
184
+ sendSlotsManager.setActive(mediaType, true);
180
185
 
181
- expect(slot.setActive.called);
186
+ expect(slot.active).to.be.true;
182
187
  });
183
188
 
184
189
  it('should throw an error if a slot for the given mediaType does not exist', () => {
@@ -197,7 +202,7 @@ describe('SendSlotsManager', () => {
197
202
  } as MultistreamRoapMediaConnection;
198
203
  });
199
204
 
200
- it('should set the codec parameters of the sendSlot for the given mediaType', async () => {
205
+ it('should delegate to slot.setCodecParameters, log deprecation warning and send deprecation metric', async () => {
201
206
  const slot = {
202
207
  setCodecParameters: sinon.stub().resolves(),
203
208
  };
@@ -206,14 +211,17 @@ describe('SendSlotsManager', () => {
206
211
 
207
212
  await sendSlotsManager.setCodecParameters(mediaType, codecParameters);
208
213
 
209
- expect(slot.setCodecParameters.calledWith(codecParameters));
214
+ assert.calledWith(slot.setCodecParameters, codecParameters);
215
+ assert.called(LoggerProxy.logger.warn);
216
+ assert.calledWith(Metrics.sendBehavioralMetric as sinon.SinonStub,
217
+ BEHAVIORAL_METRICS.DEPRECATED_SET_CODEC_PARAMETERS_USED,
218
+ { mediaType, codecParameters }
219
+ );
210
220
  });
211
221
 
212
- it('should throw an error if a slot for the given mediaType does not exist', (done) => {
213
- sendSlotsManager.setCodecParameters(mediaType, codecParameters).catch((error) => {
214
- expect(error.message).to.equal(`Slot for ${mediaType} does not exist`);
215
- done();
216
- });
222
+ it('should throw an error if a slot for the given mediaType does not exist', async () => {
223
+ await expect(sendSlotsManager.setCodecParameters(mediaType, codecParameters))
224
+ .to.be.rejectedWith(`Slot for ${mediaType} does not exist`);
217
225
  });
218
226
  });
219
227
 
@@ -227,23 +235,114 @@ describe('SendSlotsManager', () => {
227
235
  } as MultistreamRoapMediaConnection;
228
236
  });
229
237
 
230
- it('should delete the codec parameters of the sendSlot for the given mediaType', async () => {
238
+ it('should delegate to slot.deleteCodecParameters, log deprecation warning and send deprecation metric', async () => {
231
239
  const slot = {
232
240
  deleteCodecParameters: sinon.stub().resolves(),
233
241
  };
234
242
  mediaConnection.createSendSlot.returns(slot);
235
243
  sendSlotsManager.createSlot(mediaConnection, mediaType);
236
244
 
237
- await sendSlotsManager.deleteCodecParameters(mediaType,[]);
245
+ await sendSlotsManager.deleteCodecParameters(mediaType, []);
246
+
247
+ assert.calledWith(slot.deleteCodecParameters, []);
248
+ assert.called(LoggerProxy.logger.warn);
249
+ assert.calledWith(Metrics.sendBehavioralMetric as sinon.SinonStub,
250
+ BEHAVIORAL_METRICS.DEPRECATED_DELETE_CODEC_PARAMETERS_USED,
251
+ { mediaType, parameters: [] }
252
+ );
253
+ });
254
+
255
+ it('should throw an error if a slot for the given mediaType does not exist', async () => {
256
+ await expect(sendSlotsManager.deleteCodecParameters(mediaType, []))
257
+ .to.be.rejectedWith(`Slot for ${mediaType} does not exist`);
258
+ });
259
+ });
260
+
261
+ describe('setCustomCodecParameters', () => {
262
+ let mediaConnection;
263
+ const mediaType = MediaType.AudioMain;
264
+ const codecMimeType = MediaCodecMimeType.OPUS;
265
+ const parameters = { maxaveragebitrate: '64000' };
266
+
267
+ beforeEach(() => {
268
+ mediaConnection = {
269
+ createSendSlot: sinon.stub(),
270
+ } as MultistreamRoapMediaConnection;
271
+ });
272
+
273
+ it('should set custom codec parameters on the sendSlot for the given mediaType and codec, log info and send metric', async () => {
274
+ const slot = {
275
+ setCustomCodecParameters: sinon.stub().resolves(),
276
+ };
277
+ mediaConnection.createSendSlot.returns(slot);
278
+ sendSlotsManager.createSlot(mediaConnection, mediaType);
279
+
280
+ await sendSlotsManager.setCustomCodecParameters(mediaType, codecMimeType, parameters);
281
+
282
+ assert.calledWith(slot.setCustomCodecParameters, codecMimeType, parameters);
283
+ assert.called(LoggerProxy.logger.info);
284
+ assert.calledWith(Metrics.sendBehavioralMetric as sinon.SinonStub,
285
+ BEHAVIORAL_METRICS.SET_CUSTOM_CODEC_PARAMETERS_USED,
286
+ { mediaType, codecMimeType, parameters }
287
+ );
288
+ });
289
+
290
+ it('should throw an error if a slot for the given mediaType does not exist', async () => {
291
+ await expect(sendSlotsManager.setCustomCodecParameters(mediaType, codecMimeType, parameters))
292
+ .to.be.rejectedWith(`Slot for ${mediaType} does not exist`);
293
+ });
294
+
295
+ it('should throw and log error when setCustomCodecParameters fails', async () => {
296
+ const error = new Error('codec parameter failure');
297
+ const slot = {
298
+ setCustomCodecParameters: sinon.stub().rejects(error),
299
+ };
300
+ mediaConnection.createSendSlot.returns(slot);
301
+ sendSlotsManager.createSlot(mediaConnection, mediaType);
302
+
303
+ await expect(sendSlotsManager.setCustomCodecParameters(mediaType, codecMimeType, parameters))
304
+ .to.be.rejectedWith('codec parameter failure');
305
+
306
+ assert.called(LoggerProxy.logger.error);
307
+ assert.calledWith(Metrics.sendBehavioralMetric as sinon.SinonStub,
308
+ BEHAVIORAL_METRICS.SET_CUSTOM_CODEC_PARAMETERS_USED,
309
+ { mediaType, codecMimeType, parameters }
310
+ );
311
+ });
312
+ });
313
+
314
+ describe('markCustomCodecParametersForDeletion', () => {
315
+ let mediaConnection;
316
+ const mediaType = MediaType.AudioMain;
317
+ const codecMimeType = MediaCodecMimeType.OPUS;
318
+ const parameters = ['maxaveragebitrate', 'maxplaybackrate'];
319
+
320
+ beforeEach(() => {
321
+ mediaConnection = {
322
+ createSendSlot: sinon.stub(),
323
+ } as MultistreamRoapMediaConnection;
324
+ });
325
+
326
+ it('should mark custom codec parameters for deletion on the sendSlot for the given mediaType and codec, log info and send metric', async () => {
327
+ const slot = {
328
+ markCustomCodecParametersForDeletion: sinon.stub().resolves(),
329
+ };
330
+ mediaConnection.createSendSlot.returns(slot);
331
+ sendSlotsManager.createSlot(mediaConnection, mediaType);
332
+
333
+ await sendSlotsManager.markCustomCodecParametersForDeletion(mediaType, codecMimeType, parameters);
238
334
 
239
- expect(slot.deleteCodecParameters.called);
335
+ assert.calledWith(slot.markCustomCodecParametersForDeletion, codecMimeType, parameters);
336
+ assert.called(LoggerProxy.logger.info);
337
+ assert.calledWith(Metrics.sendBehavioralMetric as sinon.SinonStub,
338
+ BEHAVIORAL_METRICS.MARK_CUSTOM_CODEC_PARAMETERS_FOR_DELETION_USED,
339
+ { mediaType, codecMimeType, parameters }
340
+ );
240
341
  });
241
342
 
242
- it('should throw an error if a slot for the given mediaType does not exist', (done) => {
243
- sendSlotsManager.deleteCodecParameters(mediaType,[]).catch((error) => {
244
- expect(error.message).to.equal(`Slot for ${mediaType} does not exist`);
245
- done();
246
- });
343
+ it('should throw an error if a slot for the given mediaType does not exist', async () => {
344
+ await expect(sendSlotsManager.markCustomCodecParametersForDeletion(mediaType, codecMimeType, parameters))
345
+ .to.be.rejectedWith(`Slot for ${mediaType} does not exist`);
247
346
  });
248
347
  });
249
348
 
@@ -33,6 +33,7 @@ describe('plugin-meetings', () => {
33
33
  webex.internal.llm = {
34
34
  getDatachannelToken: sinon.stub().returns(undefined),
35
35
  setDatachannelToken: sinon.stub(),
36
+ isDataChannelTokenEnabled: sinon.stub().resolves(false),
36
37
  isConnected: sinon.stub().returns(false),
37
38
  disconnectLLM: sinon.stub().resolves(),
38
39
  off: sinon.stub(),
@@ -210,6 +211,26 @@ describe('plugin-meetings', () => {
210
211
  meeting.processRelayEvent
211
212
  );
212
213
  });
214
+
215
+ it('removes a pending online listener if one exists', async () => {
216
+ const listener = sinon.stub();
217
+ webinar._pendingOnlineListener = listener;
218
+
219
+ await webinar.cleanupPSDataChannel();
220
+
221
+ assert.calledWith(webex.internal.llm.off, 'online', listener);
222
+ assert.isNull(webinar._pendingOnlineListener);
223
+ });
224
+
225
+ it('skips online listener removal when none is pending', async () => {
226
+ webinar._pendingOnlineListener = null;
227
+
228
+ await webinar.cleanupPSDataChannel();
229
+
230
+ // 'off' should only be called for the relay event, not for 'online'
231
+ const onlineOffCalls = webex.internal.llm.off.args.filter(([event]) => event === 'online');
232
+ assert.equal(onlineOffCalls.length, 0);
233
+ });
213
234
  });
214
235
 
215
236
  describe('#updatePSDataChannel', () => {
@@ -224,12 +245,22 @@ describe('plugin-meetings', () => {
224
245
  locusInfo: {
225
246
  url: 'locus-url',
226
247
  info: {practiceSessionDatachannelUrl: 'dc-url'},
227
- self: {practiceSessionDatachannelToken: 'ps-token'},
228
248
  },
229
249
  };
230
250
 
231
251
  webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
232
252
 
253
+ // Default session is connected by default; practice session is not
254
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
255
+ return sessionId !== LLM_PRACTICE_SESSION;
256
+ });
257
+
258
+ // Token is pre-saved into LLM by saveDataChannelToken
259
+ webex.internal.llm.getDatachannelToken = sinon.stub().callsFake((tokenType) => {
260
+ if (tokenType === DataChannelTokenType.PracticeSession) return 'ps-token';
261
+ return undefined;
262
+ });
263
+
233
264
  // Ensure connect path is eligible
234
265
  webinar.selfIsPanelist = true;
235
266
  webinar.practiceSessionEnabled = true;
@@ -237,6 +268,65 @@ describe('plugin-meetings', () => {
237
268
  webex.internal.voicea.updateSubchannelSubscriptions = sinon.stub();
238
269
  });
239
270
 
271
+ it('refreshes practice-session token before register when cached token is missing', async () => {
272
+ webex.internal.llm.isDataChannelTokenEnabled.resolves(true);
273
+ webex.internal.llm.getDatachannelToken = sinon.stub().callsFake((tokenType) => {
274
+ if (tokenType === DataChannelTokenType.PracticeSession) return undefined;
275
+
276
+ return undefined;
277
+ });
278
+ meeting.refreshDataChannelToken = sinon.stub().resolves({
279
+ body: {
280
+ datachannelToken: 'ps-token-from-refresh',
281
+ dataChannelTokenType: DataChannelTokenType.PracticeSession,
282
+ },
283
+ });
284
+
285
+ await webinar.updatePSDataChannel();
286
+
287
+ assert.calledOnceWithExactly(meeting.refreshDataChannelToken);
288
+ assert.calledWithExactly(
289
+ webex.internal.llm.setDatachannelToken,
290
+ 'ps-token-from-refresh',
291
+ DataChannelTokenType.PracticeSession
292
+ );
293
+ assert.calledWith(
294
+ webex.internal.llm.registerAndConnect,
295
+ 'locus-url',
296
+ 'dc-url',
297
+ 'ps-token-from-refresh',
298
+ LLM_PRACTICE_SESSION
299
+ );
300
+ });
301
+
302
+ it('does not reconnect if practice-session eligibility changes during async token refresh', async () => {
303
+ webex.internal.llm.isDataChannelTokenEnabled.resolves(true);
304
+ webex.internal.llm.getDatachannelToken = sinon.stub().returns(undefined);
305
+
306
+ let resolveRefresh;
307
+ meeting.refreshDataChannelToken = sinon.stub().returns(
308
+ new Promise((resolve) => {
309
+ resolveRefresh = resolve;
310
+ })
311
+ );
312
+
313
+ const updatePromise = webinar.updatePSDataChannel();
314
+
315
+ webinar.practiceSessionEnabled = false;
316
+
317
+ resolveRefresh({
318
+ body: {
319
+ datachannelToken: 'stale-ps-token',
320
+ dataChannelTokenType: DataChannelTokenType.PracticeSession,
321
+ },
322
+ });
323
+
324
+ const result = await updatePromise;
325
+
326
+ assert.isUndefined(result);
327
+ assert.notCalled(webex.internal.llm.registerAndConnect);
328
+ });
329
+
240
330
  it('no-ops when practice session join eligibility is false', async () => {
241
331
  webinar.practiceSessionEnabled = false;
242
332
  const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
@@ -284,11 +374,6 @@ describe('plugin-meetings', () => {
284
374
  it('connects when eligible', async () => {
285
375
  const result = await webinar.updatePSDataChannel();
286
376
 
287
- assert.calledOnceWithExactly(
288
- webex.internal.llm.setDatachannelToken,
289
- 'ps-token',
290
- DataChannelTokenType.PracticeSession
291
- );
292
377
  assert.calledOnce(webex.internal.llm.registerAndConnect);
293
378
  assert.calledWith(
294
379
  webex.internal.llm.registerAndConnect,
@@ -301,8 +386,11 @@ describe('plugin-meetings', () => {
301
386
  assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
302
387
  });
303
388
 
304
- it('uses cached token when available', async () => {
305
- webex.internal.llm.getDatachannelToken.returns('cached-token');
389
+ it('uses token from LLM', async () => {
390
+ webex.internal.llm.getDatachannelToken = sinon.stub().callsFake((tokenType) => {
391
+ if (tokenType === DataChannelTokenType.PracticeSession) return 'cached-token';
392
+ return undefined;
393
+ });
306
394
 
307
395
  await webinar.updatePSDataChannel();
308
396
 
@@ -360,6 +448,101 @@ describe('plugin-meetings', () => {
360
448
 
361
449
  assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
362
450
  });
451
+
452
+ it('defers connect when default session is not yet connected', async () => {
453
+ // Default session is not connected initially
454
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
455
+
456
+ const result = await webinar.updatePSDataChannel();
457
+
458
+ // Should return undefined immediately (deferred)
459
+ assert.isUndefined(result);
460
+ // Should register an 'online' listener but NOT call registerAndConnect yet
461
+ assert.calledWith(webex.internal.llm.on, 'online', sinon.match.func);
462
+ assert.notCalled(webex.internal.llm.registerAndConnect);
463
+ // Should store the pending listener
464
+ assert.isNotNull(webinar._pendingOnlineListener);
465
+ });
466
+
467
+ it('does not register duplicate online listeners on repeated calls', async () => {
468
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
469
+
470
+ await webinar.updatePSDataChannel();
471
+ await webinar.updatePSDataChannel();
472
+ await webinar.updatePSDataChannel();
473
+
474
+ // Only one 'online' listener should have been registered
475
+ const onlineCalls = webex.internal.llm.on.args.filter(([event]) => event === 'online');
476
+ assert.equal(onlineCalls.length, 1, 'should register exactly one online listener');
477
+ });
478
+
479
+ it('re-invokes updatePSDataChannel when default session comes online', async () => {
480
+ // Default session is not connected initially
481
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
482
+
483
+ const updatePSDataChannelSpy = sinon.spy(webinar, 'updatePSDataChannel');
484
+
485
+ // First call defers
486
+ await webinar.updatePSDataChannel();
487
+
488
+ // Capture the 'online' listener
489
+ const onlineCall = webex.internal.llm.on.args.find(([event]) => event === 'online');
490
+ assert.isDefined(onlineCall, 'should have registered an online listener');
491
+
492
+ // Now simulate default session coming online
493
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
494
+ return sessionId !== LLM_PRACTICE_SESSION;
495
+ });
496
+
497
+ // Fire the captured listener
498
+ onlineCall[1]();
499
+
500
+ // The listener should have cleared itself, removed itself, and re-called updatePSDataChannel
501
+ assert.isNull(webinar._pendingOnlineListener);
502
+ assert.calledWith(webex.internal.llm.off, 'online', sinon.match.func);
503
+ assert.equal(updatePSDataChannelSpy.callCount, 2);
504
+ });
505
+
506
+ it('does not reconnect with stale data if demoted before default session comes online', async () => {
507
+ // Default session is not connected initially
508
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
509
+
510
+ await webinar.updatePSDataChannel();
511
+
512
+ // Capture the 'online' listener
513
+ const onlineCall = webex.internal.llm.on.args.find(([event]) => event === 'online');
514
+ assert.isDefined(onlineCall);
515
+
516
+ // Simulate demotion while waiting
517
+ webinar.selfIsPanelist = false;
518
+
519
+ // Now default session comes online
520
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
521
+ return sessionId !== LLM_PRACTICE_SESSION;
522
+ });
523
+
524
+ // Fire the listener — re-invokes updatePSDataChannel which will see isPracticeSession = false
525
+ onlineCall[1]();
526
+
527
+ // Should NOT have called registerAndConnect since the user is no longer eligible
528
+ assert.notCalled(webex.internal.llm.registerAndConnect);
529
+ });
530
+
531
+ it('proceeds immediately when default session is already connected', async () => {
532
+ // Default session already connected, practice session not
533
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
534
+ return sessionId !== LLM_PRACTICE_SESSION;
535
+ });
536
+
537
+ const result = await webinar.updatePSDataChannel();
538
+
539
+ // The 'online' listener is registered then immediately removed since default session is already connected
540
+ assert.calledWith(webex.internal.llm.on, 'online', sinon.match.func);
541
+ assert.calledWith(webex.internal.llm.off, 'online', sinon.match.func);
542
+ assert.isNull(webinar._pendingOnlineListener);
543
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
544
+ assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
545
+ });
363
546
  });
364
547
 
365
548
  describe('#updateStatusByRole', () => {
@@ -369,6 +552,7 @@ describe('plugin-meetings', () => {
369
552
  webinar.webex.meetings = {
370
553
  getMeetingByType: sinon.stub().returns({
371
554
  id: 'meeting-id',
555
+ isJoined: sinon.stub().returns(false),
372
556
  updateLLMConnection: sinon.stub(),
373
557
  shareStatus: SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE,
374
558
  locusInfo: {
@@ -423,6 +607,7 @@ describe('plugin-meetings', () => {
423
607
  webinar.webex.meetings = {
424
608
  getMeetingByType: sinon.stub().returns({
425
609
  id: 'meeting-id',
610
+ isJoined: sinon.stub().returns(false),
426
611
  updateLLMConnection: sinon.stub(),
427
612
  shareStatus: SHARE_STATUS.REMOTE_SHARE_ACTIVE,
428
613
  locusInfo: {