@webex/plugin-meetings 3.12.0-next.64 → 3.12.0-next.66

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.
@@ -5,7 +5,9 @@ import MockWebex from '@webex/test-helper-mock-webex';
5
5
  import uuid from 'uuid';
6
6
  import sinon from 'sinon';
7
7
  import {DataChannelTokenType} from '@webex/internal-plugin-llm';
8
- import {LLM_PRACTICE_SESSION, SHARE_STATUS} from '@webex/plugin-meetings/src/constants';
8
+ import {LLM_PRACTICE_SESSION, LOCUS_LLM_EVENT, SHARE_STATUS} from '@webex/plugin-meetings/src/constants';
9
+
10
+ const PRACTICE_SESSION_KEY = LLM_PRACTICE_SESSION || DataChannelTokenType.PracticeSession;
9
11
 
10
12
  describe('plugin-meetings', () => {
11
13
  describe('Webinar', () => {
@@ -33,6 +35,17 @@ describe('plugin-meetings', () => {
33
35
  webex.internal.llm = {
34
36
  getDatachannelToken: sinon.stub().returns(undefined),
35
37
  setDatachannelToken: sinon.stub(),
38
+ setRefreshHandler: sinon.stub(),
39
+ getOwnerMeetingId: sinon.stub().returns(undefined),
40
+ resolveSessionOwnership: sinon.stub().callsFake((ownerMeetingId, sessionId) => {
41
+ const currentOwner = webex.internal.llm.getOwnerMeetingId(sessionId);
42
+
43
+ return {
44
+ currentOwner,
45
+ isOwner: !currentOwner || !ownerMeetingId || currentOwner === ownerMeetingId,
46
+ };
47
+ }),
48
+ setOwnerMeetingId: sinon.stub(),
36
49
  isDataChannelTokenEnabled: sinon.stub().resolves(false),
37
50
  isConnected: sinon.stub().returns(false),
38
51
  disconnectLLM: sinon.stub().resolves(),
@@ -226,8 +239,9 @@ describe('plugin-meetings', () => {
226
239
  let relayListener;
227
240
 
228
241
  beforeEach(() => {
242
+ webinar.meetingId = 'meeting-id';
229
243
  relayListener = sinon.stub();
230
- webinar._practiceSessionRelayListener = relayListener;
244
+ webinar.llmListeners = {relay: relayListener, locusLLM: null};
231
245
  });
232
246
 
233
247
  it('disconnects the practice session channel and removes the tracked relay listener', async () => {
@@ -236,27 +250,99 @@ describe('plugin-meetings', () => {
236
250
  assert.calledOnceWithExactly(
237
251
  webex.internal.llm.disconnectLLM,
238
252
  {code: 3050, reason: 'done (permanent)'},
239
- LLM_PRACTICE_SESSION
253
+ PRACTICE_SESSION_KEY,
254
+ webinar.meetingId
255
+ );
256
+ assert.calledWithExactly(
257
+ webex.internal.llm.off,
258
+ `event:relay.event:${PRACTICE_SESSION_KEY}`,
259
+ relayListener
260
+ );
261
+ assert.isNull(webinar.llmListeners.relay);
262
+ });
263
+
264
+ it('skips disconnect when practice-session owner is another meeting', async () => {
265
+ webex.internal.llm.getOwnerMeetingId.returns('other-meeting-id');
266
+ webex.internal.llm.disconnectLLM.resolves(false);
267
+
268
+ await webinar.cleanupPSDataChannel();
269
+
270
+ assert.calledOnceWithExactly(
271
+ webex.internal.llm.disconnectLLM,
272
+ {code: 3050, reason: 'done (permanent)'},
273
+ PRACTICE_SESSION_KEY,
274
+ webinar.meetingId
240
275
  );
276
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
241
277
  assert.calledOnceWithExactly(
242
278
  webex.internal.llm.off,
243
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
279
+ `event:relay.event:${PRACTICE_SESSION_KEY}`,
244
280
  relayListener
245
281
  );
246
- assert.isNull(webinar._practiceSessionRelayListener);
247
282
  });
248
283
 
249
284
  it('skips relay listener removal when no listener has been tracked', async () => {
250
- webinar._practiceSessionRelayListener = null;
285
+ webinar.llmListeners.relay = null;
251
286
 
252
287
  await webinar.cleanupPSDataChannel();
253
288
 
254
289
  const relayOffCalls = webex.internal.llm.off.args.filter(
255
- ([event]) => event === `event:relay.event:${LLM_PRACTICE_SESSION}`
290
+ ([event]) => event === `event:relay.event:${PRACTICE_SESSION_KEY}`
256
291
  );
257
292
  assert.equal(relayOffCalls.length, 0);
258
293
  });
259
294
 
295
+ it('removes tracked relay listener even when disconnect throws', async () => {
296
+ const disconnectError = new Error('disconnect failed');
297
+ webex.internal.llm.disconnectLLM.rejects(disconnectError);
298
+
299
+ let caughtError;
300
+
301
+ try {
302
+ await webinar.cleanupPSDataChannel();
303
+ } catch (error) {
304
+ caughtError = error;
305
+ }
306
+
307
+ assert.equal(caughtError, disconnectError);
308
+ assert.calledOnceWithExactly(
309
+ webex.internal.llm.setOwnerMeetingId,
310
+ undefined,
311
+ PRACTICE_SESSION_KEY
312
+ );
313
+ assert.calledOnceWithExactly(
314
+ webex.internal.llm.off,
315
+ `event:relay.event:${PRACTICE_SESSION_KEY}`,
316
+ relayListener
317
+ );
318
+ assert.notOk(webinar._practiceSessionRelayListener);
319
+ });
320
+
321
+ it('disconnects and removes the tracked locusLLM listener', async () => {
322
+ const locusLLMListener = sinon.stub();
323
+ webinar.llmListeners.locusLLM = locusLLMListener;
324
+
325
+ await webinar.cleanupPSDataChannel();
326
+
327
+ assert.calledWithExactly(
328
+ webex.internal.llm.off,
329
+ `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`,
330
+ locusLLMListener
331
+ );
332
+ assert.isNull(webinar.llmListeners.locusLLM);
333
+ });
334
+
335
+ it('skips locusLLM listener removal when no listener has been tracked', async () => {
336
+ webinar.llmListeners.locusLLM = null;
337
+
338
+ await webinar.cleanupPSDataChannel();
339
+
340
+ const locusLLMOffCalls = webex.internal.llm.off.args.filter(
341
+ ([event]) => event === `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`
342
+ );
343
+ assert.equal(locusLLMOffCalls.length, 0);
344
+ });
345
+
260
346
  it('does not consult the meeting collection during cleanup', async () => {
261
347
  webex.meetings.getMeetingByType = sinon.stub();
262
348
 
@@ -289,13 +375,18 @@ describe('plugin-meetings', () => {
289
375
  describe('#updatePSDataChannel', () => {
290
376
  let meeting;
291
377
  let processRelayEvent;
378
+ let processLocusLLMEvent;
292
379
 
293
380
  beforeEach(() => {
381
+ webinar.meetingId = 'meeting-id';
294
382
  processRelayEvent = sinon.stub();
383
+ processLocusLLMEvent = sinon.stub();
295
384
  meeting = {
385
+ id: 'meeting-id',
296
386
  locusUrl: 'locusUrl',
297
387
  isJoined: sinon.stub().returns(true),
298
388
  processRelayEvent,
389
+ processLocusLLMEvent,
299
390
  locusInfo: {
300
391
  url: 'locus-url',
301
392
  info: {practiceSessionDatachannelUrl: 'dc-url'},
@@ -306,7 +397,7 @@ describe('plugin-meetings', () => {
306
397
 
307
398
  // Default session is connected by default; practice session is not
308
399
  webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
309
- return sessionId !== LLM_PRACTICE_SESSION;
400
+ return sessionId !== PRACTICE_SESSION_KEY;
310
401
  });
311
402
 
312
403
  // Token is pre-saved into LLM by saveDataChannelToken
@@ -342,14 +433,15 @@ describe('plugin-meetings', () => {
342
433
  assert.calledWithExactly(
343
434
  webex.internal.llm.setDatachannelToken,
344
435
  'ps-token-from-refresh',
345
- DataChannelTokenType.PracticeSession
436
+ DataChannelTokenType.PracticeSession,
437
+ 'meeting-id'
346
438
  );
347
439
  assert.calledWith(
348
440
  webex.internal.llm.registerAndConnect,
349
441
  'locus-url',
350
442
  'dc-url',
351
443
  'ps-token-from-refresh',
352
- LLM_PRACTICE_SESSION
444
+ PRACTICE_SESSION_KEY
353
445
  );
354
446
  });
355
447
 
@@ -409,6 +501,8 @@ describe('plugin-meetings', () => {
409
501
  const result = await webinar.updatePSDataChannel();
410
502
 
411
503
  assert.isUndefined(result);
504
+ assert.notCalled(webex.internal.llm.setRefreshHandler);
505
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
412
506
  assert.notCalled(webex.internal.llm.registerAndConnect);
413
507
  });
414
508
 
@@ -429,12 +523,17 @@ describe('plugin-meetings', () => {
429
523
  const result = await webinar.updatePSDataChannel();
430
524
 
431
525
  assert.calledOnce(webex.internal.llm.registerAndConnect);
526
+ assert.calledWithExactly(
527
+ webex.internal.llm.setOwnerMeetingId,
528
+ 'meeting-id',
529
+ PRACTICE_SESSION_KEY
530
+ );
432
531
  assert.calledWith(
433
532
  webex.internal.llm.registerAndConnect,
434
533
  'locus-url',
435
534
  'dc-url',
436
535
  'ps-token',
437
- LLM_PRACTICE_SESSION
536
+ PRACTICE_SESSION_KEY
438
537
  );
439
538
  assert.calledOnceWithExactly(webex.internal.voicea.announce);
440
539
  assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
@@ -450,7 +549,8 @@ describe('plugin-meetings', () => {
450
549
 
451
550
  assert.calledWithExactly(
452
551
  webex.internal.llm.getDatachannelToken,
453
- DataChannelTokenType.PracticeSession
552
+ DataChannelTokenType.PracticeSession,
553
+ webinar.meetingId
454
554
  );
455
555
  assert.notCalled(webex.internal.llm.setDatachannelToken);
456
556
  assert.calledWith(
@@ -458,7 +558,7 @@ describe('plugin-meetings', () => {
458
558
  'locus-url',
459
559
  'dc-url',
460
560
  'cached-token',
461
- LLM_PRACTICE_SESSION
561
+ PRACTICE_SESSION_KEY
462
562
  );
463
563
  });
464
564
 
@@ -476,26 +576,51 @@ describe('plugin-meetings', () => {
476
576
  await webinar.updatePSDataChannel();
477
577
 
478
578
  // Stores the exact listener reference for deterministic cleanup
479
- assert.equal(webinar._practiceSessionRelayListener, processRelayEvent);
579
+ assert.equal(webinar.llmListeners.relay, processRelayEvent);
480
580
  assert.calledWith(
481
581
  webex.internal.llm.on,
482
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
582
+ `event:relay.event:${PRACTICE_SESSION_KEY}`,
483
583
  processRelayEvent
484
584
  );
485
585
  });
486
586
 
487
587
  it('removes a previously tracked relay listener before re-binding on reconnect', async () => {
488
588
  const previousListener = sinon.stub();
489
- webinar._practiceSessionRelayListener = previousListener;
589
+ webinar.llmListeners = {relay: previousListener, locusLLM: null};
490
590
 
491
591
  await webinar.updatePSDataChannel();
492
592
 
493
593
  assert.calledWith(
494
594
  webex.internal.llm.off,
495
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
595
+ `event:relay.event:${PRACTICE_SESSION_KEY}`,
496
596
  previousListener
497
597
  );
498
- assert.equal(webinar._practiceSessionRelayListener, processRelayEvent);
598
+ assert.equal(webinar.llmListeners.relay, processRelayEvent);
599
+ });
600
+
601
+ it('tracks and binds the locusLLM listener after successful connect', async () => {
602
+ await webinar.updatePSDataChannel();
603
+
604
+ assert.equal(webinar.llmListeners.locusLLM, processLocusLLMEvent);
605
+ assert.calledWith(
606
+ webex.internal.llm.on,
607
+ `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`,
608
+ processLocusLLMEvent
609
+ );
610
+ });
611
+
612
+ it('removes a previously tracked locusLLM listener before re-binding on reconnect', async () => {
613
+ const previousListener = sinon.stub();
614
+ webinar.llmListeners = {relay: null, locusLLM: previousListener};
615
+
616
+ await webinar.updatePSDataChannel();
617
+
618
+ assert.calledWith(
619
+ webex.internal.llm.off,
620
+ `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`,
621
+ previousListener
622
+ );
623
+ assert.equal(webinar.llmListeners.locusLLM, processLocusLLMEvent);
499
624
  });
500
625
 
501
626
  it('subscribes to transcription when caption intent is enabled', async () => {
@@ -525,6 +650,8 @@ describe('plugin-meetings', () => {
525
650
  // Should register an 'online' listener but NOT call registerAndConnect yet
526
651
  assert.calledWith(webex.internal.llm.on, 'online', sinon.match.func);
527
652
  assert.notCalled(webex.internal.llm.registerAndConnect);
653
+ assert.notCalled(webex.internal.llm.setRefreshHandler);
654
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
528
655
  // Should store the pending listener
529
656
  assert.isNotNull(webinar._pendingOnlineListener);
530
657
  });
@@ -556,7 +683,7 @@ describe('plugin-meetings', () => {
556
683
 
557
684
  // Now simulate default session coming online
558
685
  webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
559
- return sessionId !== LLM_PRACTICE_SESSION;
686
+ return sessionId !== PRACTICE_SESSION_KEY;
560
687
  });
561
688
 
562
689
  // Fire the captured listener
@@ -583,7 +710,7 @@ describe('plugin-meetings', () => {
583
710
 
584
711
  // Now default session comes online
585
712
  webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
586
- return sessionId !== LLM_PRACTICE_SESSION;
713
+ return sessionId !== PRACTICE_SESSION_KEY;
587
714
  });
588
715
 
589
716
  // Fire the listener — re-invokes updatePSDataChannel which will see isPracticeSession = false
@@ -596,7 +723,7 @@ describe('plugin-meetings', () => {
596
723
  it('proceeds immediately when default session is already connected', async () => {
597
724
  // Default session already connected, practice session not
598
725
  webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
599
- return sessionId !== LLM_PRACTICE_SESSION;
726
+ return sessionId !== PRACTICE_SESSION_KEY;
600
727
  });
601
728
 
602
729
  const result = await webinar.updatePSDataChannel();
@@ -608,6 +735,115 @@ describe('plugin-meetings', () => {
608
735
  assert.calledOnce(webex.internal.llm.registerAndConnect);
609
736
  assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
610
737
  });
738
+
739
+ it('does not override practice refresh handler or reconnect when owned by another meeting', async () => {
740
+ webex.internal.llm.getOwnerMeetingId.returns('other-meeting-id');
741
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
742
+ return sessionId !== undefined;
743
+ });
744
+
745
+ const result = await webinar.updatePSDataChannel();
746
+
747
+ assert.isUndefined(result);
748
+ assert.notCalled(webex.internal.llm.setRefreshHandler);
749
+ assert.notCalled(webex.internal.llm.registerAndConnect);
750
+ });
751
+
752
+ it('does not reconnect when practice session is disconnected but owned by another meeting', async () => {
753
+ webex.internal.llm.getOwnerMeetingId.returns('other-meeting-id');
754
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
755
+
756
+ const result = await webinar.updatePSDataChannel();
757
+
758
+ assert.isUndefined(result);
759
+ assert.notCalled(webex.internal.llm.setRefreshHandler);
760
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
761
+ assert.notCalled(webex.internal.llm.registerAndConnect);
762
+ });
763
+
764
+ it('does not write owner or connect if ownership changes before pre-connect owner write', async () => {
765
+ let ownerMeetingId = 'meeting-id';
766
+
767
+ webex.internal.llm.getOwnerMeetingId.callsFake(() => ownerMeetingId);
768
+ webex.internal.llm.isDataChannelTokenEnabled.resolves(true);
769
+ webex.internal.llm.getDatachannelToken = sinon.stub().returns(undefined);
770
+
771
+ let resolveRefresh;
772
+ meeting.refreshDataChannelToken = sinon.stub().returns(
773
+ new Promise((resolve) => {
774
+ resolveRefresh = resolve;
775
+ })
776
+ );
777
+
778
+ const updatePromise = webinar.updatePSDataChannel();
779
+
780
+ ownerMeetingId = 'other-meeting-id';
781
+ resolveRefresh({
782
+ body: {
783
+ datachannelToken: 'ps-token-from-refresh',
784
+ dataChannelTokenType: DataChannelTokenType.PracticeSession,
785
+ },
786
+ });
787
+
788
+ const result = await updatePromise;
789
+
790
+ assert.isUndefined(result);
791
+ assert.notCalled(webex.internal.llm.setRefreshHandler);
792
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
793
+ assert.notCalled(webex.internal.llm.registerAndConnect);
794
+ });
795
+
796
+ it('does not overwrite owner after connect when ownership changed during registerAndConnect', async () => {
797
+ let ownerMeetingId = 'meeting-id';
798
+
799
+ webex.internal.llm.getOwnerMeetingId.callsFake(() => ownerMeetingId);
800
+ webex.internal.llm.registerAndConnect = sinon.stub().callsFake(async () => {
801
+ ownerMeetingId = 'other-meeting-id';
802
+
803
+ return 'REGISTER_AND_CONNECT_RESULT';
804
+ });
805
+
806
+ const result = await webinar.updatePSDataChannel();
807
+
808
+ assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
809
+ assert.calledOnce(webex.internal.llm.setOwnerMeetingId);
810
+ assert.calledWithExactly(
811
+ webex.internal.llm.setOwnerMeetingId,
812
+ 'meeting-id',
813
+ PRACTICE_SESSION_KEY
814
+ );
815
+ });
816
+
817
+ it('clears pre-claimed owner when registerAndConnect rejects', async () => {
818
+ const registerError = new Error('register failed');
819
+ let ownerMeetingId = 'meeting-id';
820
+
821
+ webex.internal.llm.getOwnerMeetingId.callsFake(() => ownerMeetingId);
822
+ webex.internal.llm.setOwnerMeetingId.callsFake((id) => {
823
+ ownerMeetingId = id;
824
+ });
825
+ webex.internal.llm.registerAndConnect = sinon.stub().rejects(registerError);
826
+
827
+ try {
828
+ await webinar.updatePSDataChannel();
829
+ assert.fail('Expected updatePSDataChannel to reject when registerAndConnect fails');
830
+ } catch (error) {
831
+ assert.equal(error, registerError);
832
+ }
833
+
834
+ assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
835
+ assert.calledWithExactly(
836
+ webex.internal.llm.setOwnerMeetingId.firstCall,
837
+ 'meeting-id',
838
+ PRACTICE_SESSION_KEY
839
+ );
840
+ assert.calledWithExactly(
841
+ webex.internal.llm.setOwnerMeetingId.secondCall,
842
+ undefined,
843
+ PRACTICE_SESSION_KEY
844
+ );
845
+ assert.isUndefined(ownerMeetingId);
846
+ });
611
847
  });
612
848
 
613
849
  describe('#updateStatusByRole', () => {