@webex/plugin-meetings 3.12.0-next.9 → 3.12.0-next.91
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.
- package/AGENTS.md +9 -0
- package/dist/aiEnableRequest/index.js +15 -2
- package/dist/aiEnableRequest/index.js.map +1 -1
- package/dist/breakouts/breakout.js +8 -3
- package/dist/breakouts/breakout.js.map +1 -1
- package/dist/breakouts/index.js +26 -2
- package/dist/breakouts/index.js.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/constants.js +35 -9
- package/dist/constants.js.map +1 -1
- package/dist/controls-options-manager/constants.js +11 -1
- package/dist/controls-options-manager/constants.js.map +1 -1
- package/dist/controls-options-manager/index.js +67 -29
- package/dist/controls-options-manager/index.js.map +1 -1
- package/dist/controls-options-manager/util.js +91 -0
- package/dist/controls-options-manager/util.js.map +1 -1
- package/dist/hashTree/constants.js +13 -1
- package/dist/hashTree/constants.js.map +1 -1
- package/dist/hashTree/hashTreeParser.js +880 -382
- package/dist/hashTree/hashTreeParser.js.map +1 -1
- package/dist/hashTree/utils.js +42 -0
- package/dist/hashTree/utils.js.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/interceptors/dataChannelAuthToken.js +75 -15
- package/dist/interceptors/dataChannelAuthToken.js.map +1 -1
- package/dist/interceptors/locusRetry.js +23 -8
- package/dist/interceptors/locusRetry.js.map +1 -1
- package/dist/interpretation/index.js +10 -1
- package/dist/interpretation/index.js.map +1 -1
- package/dist/interpretation/interpretation.types.js +7 -0
- package/dist/interpretation/interpretation.types.js.map +1 -0
- package/dist/interpretation/siLanguage.js +1 -1
- package/dist/locus-info/controlsUtils.js +4 -1
- package/dist/locus-info/controlsUtils.js.map +1 -1
- package/dist/locus-info/index.js +303 -88
- package/dist/locus-info/index.js.map +1 -1
- package/dist/locus-info/infoUtils.js +36 -5
- package/dist/locus-info/infoUtils.js.map +1 -1
- package/dist/locus-info/types.js +19 -0
- package/dist/locus-info/types.js.map +1 -1
- package/dist/media/index.js +3 -1
- package/dist/media/index.js.map +1 -1
- package/dist/media/properties.js +1 -0
- package/dist/media/properties.js.map +1 -1
- package/dist/meeting/in-meeting-actions.js +5 -1
- package/dist/meeting/in-meeting-actions.js.map +1 -1
- package/dist/meeting/index.js +1233 -793
- package/dist/meeting/index.js.map +1 -1
- package/dist/meeting/locusMediaRequest.js +229 -82
- package/dist/meeting/locusMediaRequest.js.map +1 -1
- package/dist/meeting/muteState.js +10 -1
- package/dist/meeting/muteState.js.map +1 -1
- package/dist/meeting/request.js +5 -2
- package/dist/meeting/request.js.map +1 -1
- package/dist/meeting/util.js +35 -2
- package/dist/meeting/util.js.map +1 -1
- package/dist/meeting-info/meeting-info-v2.js +2 -2
- package/dist/meeting-info/meeting-info-v2.js.map +1 -1
- package/dist/meetings/index.js +231 -118
- package/dist/meetings/index.js.map +1 -1
- package/dist/meetings/meetings.types.js +6 -1
- package/dist/meetings/meetings.types.js.map +1 -1
- package/dist/meetings/request.js +39 -0
- package/dist/meetings/request.js.map +1 -1
- package/dist/meetings/util.js +79 -5
- package/dist/meetings/util.js.map +1 -1
- package/dist/member/index.js +10 -0
- package/dist/member/index.js.map +1 -1
- package/dist/member/types.js.map +1 -1
- package/dist/member/util.js +30 -0
- package/dist/member/util.js.map +1 -1
- package/dist/members/index.js +101 -43
- package/dist/members/index.js.map +1 -1
- package/dist/members/util.js +8 -1
- package/dist/members/util.js.map +1 -1
- package/dist/metrics/constants.js +5 -1
- package/dist/metrics/constants.js.map +1 -1
- package/dist/multistream/codec/constants.js +63 -0
- package/dist/multistream/codec/constants.js.map +1 -0
- package/dist/multistream/mediaRequestManager.js +62 -15
- package/dist/multistream/mediaRequestManager.js.map +1 -1
- package/dist/multistream/receiveSlot.js +9 -0
- package/dist/multistream/receiveSlot.js.map +1 -1
- package/dist/reachability/index.js +10 -13
- package/dist/reachability/index.js.map +1 -1
- package/dist/reachability/reachability.types.js +2 -0
- package/dist/reachability/reachability.types.js.map +1 -1
- package/dist/reachability/util.js +45 -0
- package/dist/reachability/util.js.map +1 -1
- package/dist/reactions/reactions.type.js.map +1 -1
- package/dist/recording-controller/index.js +1 -3
- package/dist/recording-controller/index.js.map +1 -1
- package/dist/types/config.d.ts +2 -2
- package/dist/types/constants.d.ts +12 -1
- package/dist/types/controls-options-manager/constants.d.ts +6 -1
- package/dist/types/controls-options-manager/index.d.ts +16 -0
- package/dist/types/hashTree/constants.d.ts +2 -0
- package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
- package/dist/types/hashTree/utils.d.ts +18 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/interceptors/locusRetry.d.ts +4 -4
- package/dist/types/interpretation/interpretation.types.d.ts +10 -0
- package/dist/types/locus-info/index.d.ts +50 -6
- package/dist/types/locus-info/infoUtils.d.ts +8 -0
- package/dist/types/locus-info/types.d.ts +21 -1
- package/dist/types/media/properties.d.ts +1 -0
- package/dist/types/meeting/in-meeting-actions.d.ts +4 -0
- package/dist/types/meeting/index.d.ts +94 -7
- package/dist/types/meeting/locusMediaRequest.d.ts +12 -0
- package/dist/types/meeting/request.d.ts +1 -0
- package/dist/types/meeting/util.d.ts +9 -0
- package/dist/types/meetings/index.d.ts +30 -18
- package/dist/types/meetings/meetings.types.d.ts +15 -0
- package/dist/types/meetings/request.d.ts +14 -0
- package/dist/types/member/index.d.ts +1 -0
- package/dist/types/member/types.d.ts +1 -0
- package/dist/types/member/util.d.ts +9 -0
- package/dist/types/members/index.d.ts +15 -2
- package/dist/types/members/util.d.ts +1 -5
- package/dist/types/metrics/constants.d.ts +4 -0
- package/dist/types/multistream/codec/constants.d.ts +7 -0
- package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
- package/dist/types/reachability/index.d.ts +1 -1
- package/dist/types/reachability/reachability.types.d.ts +7 -0
- package/dist/types/reachability/util.d.ts +17 -0
- package/dist/types/reactions/reactions.type.d.ts +3 -0
- package/dist/webinar/index.js +305 -159
- package/dist/webinar/index.js.map +1 -1
- package/package.json +22 -22
- package/src/aiEnableRequest/index.ts +16 -0
- package/src/breakouts/breakout.ts +3 -1
- package/src/breakouts/index.ts +31 -0
- package/src/config.ts +2 -2
- package/src/constants.ts +20 -3
- package/src/controls-options-manager/constants.ts +14 -1
- package/src/controls-options-manager/index.ts +87 -28
- package/src/controls-options-manager/util.ts +81 -1
- package/src/hashTree/constants.ts +16 -0
- package/src/hashTree/hashTreeParser.ts +580 -196
- package/src/hashTree/utils.ts +36 -0
- package/src/index.ts +6 -0
- package/src/interceptors/dataChannelAuthToken.ts +88 -12
- package/src/interceptors/locusRetry.ts +25 -4
- package/src/interpretation/index.ts +27 -9
- package/src/interpretation/interpretation.types.ts +11 -0
- package/src/locus-info/controlsUtils.ts +3 -1
- package/src/locus-info/index.ts +304 -100
- package/src/locus-info/infoUtils.ts +41 -6
- package/src/locus-info/types.ts +25 -1
- package/src/media/index.ts +3 -0
- package/src/media/properties.ts +1 -0
- package/src/meeting/in-meeting-actions.ts +8 -0
- package/src/meeting/index.ts +494 -67
- package/src/meeting/locusMediaRequest.ts +106 -6
- package/src/meeting/muteState.ts +10 -1
- package/src/meeting/request.ts +11 -0
- package/src/meeting/util.ts +47 -5
- package/src/meeting-info/meeting-info-v2.ts +4 -2
- package/src/meetings/index.ts +133 -78
- package/src/meetings/meetings.types.ts +19 -0
- package/src/meetings/request.ts +43 -0
- package/src/meetings/util.ts +97 -1
- package/src/member/index.ts +10 -0
- package/src/member/types.ts +1 -0
- package/src/member/util.ts +29 -0
- package/src/members/index.ts +70 -7
- package/src/members/util.ts +12 -4
- package/src/metrics/constants.ts +4 -0
- package/src/multistream/codec/constants.ts +58 -0
- package/src/multistream/mediaRequestManager.ts +119 -28
- package/src/multistream/receiveSlot.ts +18 -0
- package/src/reachability/index.ts +19 -15
- package/src/reachability/reachability.types.ts +11 -0
- package/src/reachability/util.ts +36 -0
- package/src/reactions/reactions.type.ts +3 -0
- package/src/recording-controller/index.ts +1 -2
- package/src/webinar/index.ts +214 -36
- package/test/unit/spec/aiEnableRequest/index.ts +86 -0
- package/test/unit/spec/breakouts/breakout.ts +9 -3
- package/test/unit/spec/breakouts/index.ts +49 -0
- package/test/unit/spec/controls-options-manager/index.js +244 -29
- package/test/unit/spec/controls-options-manager/util.js +165 -0
- package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
- package/test/unit/spec/hashTree/utils.ts +125 -1
- package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
- package/test/unit/spec/interceptors/locusRetry.ts +205 -4
- package/test/unit/spec/interpretation/index.ts +26 -4
- package/test/unit/spec/locus-info/controlsUtils.js +172 -57
- package/test/unit/spec/locus-info/index.js +499 -81
- package/test/unit/spec/locus-info/infoUtils.js +184 -1
- package/test/unit/spec/media/index.ts +31 -0
- package/test/unit/spec/meeting/in-meeting-actions.ts +4 -0
- package/test/unit/spec/meeting/index.js +1615 -42
- package/test/unit/spec/meeting/locusMediaRequest.ts +302 -4
- package/test/unit/spec/meeting/muteState.js +81 -0
- package/test/unit/spec/meeting/request.js +12 -0
- package/test/unit/spec/meeting/utils.js +157 -1
- package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
- package/test/unit/spec/meetings/index.js +363 -36
- package/test/unit/spec/meetings/request.js +141 -0
- package/test/unit/spec/meetings/utils.js +189 -0
- package/test/unit/spec/member/index.js +7 -0
- package/test/unit/spec/member/util.js +89 -0
- package/test/unit/spec/members/index.js +216 -0
- package/test/unit/spec/members/request.js +37 -1
- package/test/unit/spec/members/utils.js +63 -0
- package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
- package/test/unit/spec/reachability/index.ts +59 -46
- package/test/unit/spec/reachability/util.ts +31 -0
- package/test/unit/spec/recording-controller/index.js +9 -8
- package/test/unit/spec/webinar/index.ts +329 -28
|
@@ -34,6 +34,9 @@ import {
|
|
|
34
34
|
ONLINE,
|
|
35
35
|
OFFLINE,
|
|
36
36
|
ROAP_OFFER_ANSWER_EXCHANGE_TIMEOUT,
|
|
37
|
+
LOCUS_LLM_EVENT,
|
|
38
|
+
LLM_PRACTICE_SESSION,
|
|
39
|
+
RECORDING_STATE,
|
|
37
40
|
} from '@webex/plugin-meetings/src/constants';
|
|
38
41
|
import {
|
|
39
42
|
ConnectionState,
|
|
@@ -266,6 +269,20 @@ describe('plugin-meetings', () => {
|
|
|
266
269
|
stopReachability: sinon.stub(),
|
|
267
270
|
isSubnetReachable: sinon.stub().returns(true),
|
|
268
271
|
};
|
|
272
|
+
webex.internal.llm.resolveSessionOwnership = sinon
|
|
273
|
+
.stub()
|
|
274
|
+
.callsFake((ownerMeetingId, sessionId) => {
|
|
275
|
+
const currentOwner = webex.internal.llm.getOwnerMeetingId
|
|
276
|
+
? webex.internal.llm.getOwnerMeetingId(sessionId)
|
|
277
|
+
: undefined;
|
|
278
|
+
const canAssertOwnership = !!ownerMeetingId;
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
currentOwner,
|
|
282
|
+
canAssertOwnership,
|
|
283
|
+
isOwner: !currentOwner || !canAssertOwnership || currentOwner === ownerMeetingId,
|
|
284
|
+
};
|
|
285
|
+
});
|
|
269
286
|
webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
|
|
270
287
|
webex.internal.llm.on = sinon.stub();
|
|
271
288
|
webex.internal.voicea.announce = sinon.stub();
|
|
@@ -417,6 +434,160 @@ describe('plugin-meetings', () => {
|
|
|
417
434
|
assert.instanceOf(meeting.mediaRequestManagers.screenShareVideo, MediaRequestManager);
|
|
418
435
|
});
|
|
419
436
|
|
|
437
|
+
it('getIngressPayloadType on webrtcMediaConnection is invoked for H264 when sending multistream video requests', () => {
|
|
438
|
+
const getIngressPayloadType = sinon.stub().returns(97);
|
|
439
|
+
|
|
440
|
+
meeting.isMultistream = true;
|
|
441
|
+
meeting.mediaProperties.webrtcMediaConnection = {
|
|
442
|
+
getIngressPayloadType,
|
|
443
|
+
requestMedia: sinon.stub(),
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const fakeReceiveSlot = {
|
|
447
|
+
on: sinon.stub(),
|
|
448
|
+
off: sinon.stub(),
|
|
449
|
+
sourceState: 'live',
|
|
450
|
+
mediaType: MediaType.VideoMain,
|
|
451
|
+
wcmeReceiveSlot: {id: 'fake-wcme-slot'},
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
meeting.mediaRequestManagers.video.addRequest(
|
|
455
|
+
{
|
|
456
|
+
policyInfo: {
|
|
457
|
+
policy: 'receiver-selected',
|
|
458
|
+
csi: 42,
|
|
459
|
+
},
|
|
460
|
+
receiveSlots: [fakeReceiveSlot],
|
|
461
|
+
codecInfo: {
|
|
462
|
+
codec: 'h264',
|
|
463
|
+
maxFs: 3600,
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
true
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
assert.calledOnceWithExactly(
|
|
470
|
+
getIngressPayloadType,
|
|
471
|
+
MediaType.VideoMain,
|
|
472
|
+
MediaCodecMimeType.H264
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('getIngressPayloadType on webrtcMediaConnection is invoked for H264 and AV1 for slides video when AV1 slides support is enabled', () => {
|
|
477
|
+
const localWebex = new MockWebex({
|
|
478
|
+
children: {
|
|
479
|
+
meetings: Meetings,
|
|
480
|
+
credentials: Credentials,
|
|
481
|
+
support: Support,
|
|
482
|
+
llm: LLM,
|
|
483
|
+
mercury: Mercury,
|
|
484
|
+
},
|
|
485
|
+
config: {
|
|
486
|
+
credentials: {
|
|
487
|
+
client_id: 'mock-client-id',
|
|
488
|
+
},
|
|
489
|
+
meetings: {
|
|
490
|
+
reconnection: {
|
|
491
|
+
enabled: false,
|
|
492
|
+
},
|
|
493
|
+
mediaSettings: {},
|
|
494
|
+
metrics: {},
|
|
495
|
+
stats: {},
|
|
496
|
+
experimental: {enableUnifiedMeetings: true},
|
|
497
|
+
degradationPreferences: {maxMacroblocksLimit: 8192},
|
|
498
|
+
enableAv1SlidesSupport: true,
|
|
499
|
+
},
|
|
500
|
+
metrics: {
|
|
501
|
+
type: ['behavioral'],
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
localWebex.internal.newMetrics.callDiagnosticMetrics.clearErrorCache = sinon.stub();
|
|
507
|
+
localWebex.internal.newMetrics.callDiagnosticMetrics.clearEventLimitsForCorrelationId =
|
|
508
|
+
sinon.stub();
|
|
509
|
+
localWebex.internal.support.submitLogs = sinon.stub().returns(Promise.resolve());
|
|
510
|
+
localWebex.internal.services = {get: sinon.stub().returns('locus-url')};
|
|
511
|
+
localWebex.credentials.getOrgId = sinon.stub().returns('fake-org-id');
|
|
512
|
+
localWebex.internal.metrics.submitClientMetrics = sinon.stub().returns(Promise.resolve());
|
|
513
|
+
localWebex.meetings.uploadLogs = sinon.stub().returns(Promise.resolve());
|
|
514
|
+
localWebex.meetings.reachability = {
|
|
515
|
+
isAnyPublicClusterReachable: sinon.stub().resolves(true),
|
|
516
|
+
getReachabilityResults: sinon.stub().resolves(undefined),
|
|
517
|
+
getReachabilityMetrics: sinon.stub().resolves({}),
|
|
518
|
+
stopReachability: sinon.stub(),
|
|
519
|
+
isSubnetReachable: sinon.stub().returns(true),
|
|
520
|
+
};
|
|
521
|
+
localWebex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
|
|
522
|
+
localWebex.internal.llm.on = sinon.stub();
|
|
523
|
+
localWebex.internal.voicea.announce = sinon.stub();
|
|
524
|
+
localWebex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies(
|
|
525
|
+
{},
|
|
526
|
+
{parent: localWebex}
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
Metrics.initialSetup(localWebex);
|
|
530
|
+
|
|
531
|
+
const localMeeting = new Meeting(
|
|
532
|
+
{
|
|
533
|
+
userId: uuid1,
|
|
534
|
+
resource: uuid2,
|
|
535
|
+
deviceUrl: uuid3,
|
|
536
|
+
locus: {url: url1},
|
|
537
|
+
destination: testDestination,
|
|
538
|
+
destinationType: DESTINATION_TYPE.MEETING_ID,
|
|
539
|
+
correlationId,
|
|
540
|
+
selfId: uuid1,
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
parent: localWebex,
|
|
544
|
+
}
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const getIngressPayloadType = sinon.stub().callsFake((_mediaType, codecMimeType) => {
|
|
548
|
+
if (codecMimeType === MediaCodecMimeType.H264) {
|
|
549
|
+
return 97;
|
|
550
|
+
}
|
|
551
|
+
if (codecMimeType === MediaCodecMimeType.AV1) {
|
|
552
|
+
return 98;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return undefined;
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
localMeeting.isMultistream = true;
|
|
559
|
+
localMeeting.mediaProperties.webrtcMediaConnection = {
|
|
560
|
+
getIngressPayloadType,
|
|
561
|
+
requestMedia: sinon.stub(),
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const fakeReceiveSlot = {
|
|
565
|
+
on: sinon.stub(),
|
|
566
|
+
off: sinon.stub(),
|
|
567
|
+
sourceState: 'live',
|
|
568
|
+
mediaType: MediaType.VideoSlides,
|
|
569
|
+
wcmeReceiveSlot: {id: 'fake-wcme-slides-slot'},
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
localMeeting.mediaRequestManagers.screenShareVideo.addRequest(
|
|
573
|
+
{
|
|
574
|
+
policyInfo: {
|
|
575
|
+
policy: 'receiver-selected',
|
|
576
|
+
csi: 42,
|
|
577
|
+
},
|
|
578
|
+
receiveSlots: [fakeReceiveSlot],
|
|
579
|
+
codecInfo: {
|
|
580
|
+
codec: 'h264',
|
|
581
|
+
maxFs: 3600,
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
true
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
assert.calledWith(getIngressPayloadType, MediaType.VideoSlides, MediaCodecMimeType.H264);
|
|
588
|
+
assert.calledWith(getIngressPayloadType, MediaType.VideoSlides, MediaCodecMimeType.AV1);
|
|
589
|
+
});
|
|
590
|
+
|
|
420
591
|
it('uses meeting id as correlation id if not provided in constructor', () => {
|
|
421
592
|
const newMeeting = new Meeting(
|
|
422
593
|
{
|
|
@@ -712,6 +883,67 @@ describe('plugin-meetings', () => {
|
|
|
712
883
|
assert.deepEqual(args, [[uuid1], locusUrls]);
|
|
713
884
|
});
|
|
714
885
|
});
|
|
886
|
+
describe('#transfer', () => {
|
|
887
|
+
it('should have #transfer', () => {
|
|
888
|
+
assert.exists(meeting.transfer);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it('should transfer host using the current locus when the target member is in the current session', async () => {
|
|
892
|
+
sinon.stub(meeting.members.membersCollection, 'get').withArgs(uuid2).returns({isInMeeting: true});
|
|
893
|
+
meeting.members.transferHostToMember = sinon.stub().resolves(test1);
|
|
894
|
+
|
|
895
|
+
await meeting.transfer(uuid2, false);
|
|
896
|
+
|
|
897
|
+
assert.calledOnceWithExactly(meeting.members.transferHostToMember, uuid2, false);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it('should transfer host using breakout locus when the target member is joined in a breakout session', async () => {
|
|
901
|
+
sinon.stub(meeting.members.membersCollection, 'get').withArgs(uuid2).returns({isInMeeting: false});
|
|
902
|
+
meeting.members.transferHostToMember = sinon.stub().resolves(test1);
|
|
903
|
+
meeting.breakouts.breakouts = {
|
|
904
|
+
models: [
|
|
905
|
+
{
|
|
906
|
+
members: {
|
|
907
|
+
membersCollection: {
|
|
908
|
+
get: sinon.stub().withArgs(uuid2).returns({isInMeeting: true}),
|
|
909
|
+
},
|
|
910
|
+
},
|
|
911
|
+
breakoutRosterLocus: {url: 'https://example.com/breakout-locus'},
|
|
912
|
+
},
|
|
913
|
+
],
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
await meeting.transfer(uuid2, false);
|
|
917
|
+
|
|
918
|
+
assert.calledOnceWithExactly(
|
|
919
|
+
meeting.members.transferHostToMember,
|
|
920
|
+
uuid2,
|
|
921
|
+
false,
|
|
922
|
+
'https://example.com/breakout-locus'
|
|
923
|
+
);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('should not use breakout locus when the breakout member is not in meeting', async () => {
|
|
927
|
+
sinon.stub(meeting.members.membersCollection, 'get').withArgs(uuid2).returns({isInMeeting: false});
|
|
928
|
+
meeting.members.transferHostToMember = sinon.stub().resolves(test1);
|
|
929
|
+
meeting.breakouts.breakouts = {
|
|
930
|
+
models: [
|
|
931
|
+
{
|
|
932
|
+
members: {
|
|
933
|
+
membersCollection: {
|
|
934
|
+
get: sinon.stub().withArgs(uuid2).returns({isInMeeting: false}),
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
breakoutRosterLocus: {url: 'https://example.com/breakout-locus'},
|
|
938
|
+
},
|
|
939
|
+
],
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
await meeting.transfer(uuid2, false);
|
|
943
|
+
|
|
944
|
+
assert.calledOnceWithExactly(meeting.members.transferHostToMember, uuid2, false, undefined);
|
|
945
|
+
});
|
|
946
|
+
});
|
|
715
947
|
describe('#getMembers', () => {
|
|
716
948
|
it('should have #getMembers', () => {
|
|
717
949
|
assert.exists(meeting.getMembers);
|
|
@@ -792,7 +1024,7 @@ describe('plugin-meetings', () => {
|
|
|
792
1024
|
|
|
793
1025
|
// resets joinWithMediaRetryInfo
|
|
794
1026
|
assert.deepEqual(meeting.joinWithMediaRetryInfo, {
|
|
795
|
-
|
|
1027
|
+
retryCount: 0,
|
|
796
1028
|
prevJoinResponse: undefined,
|
|
797
1029
|
});
|
|
798
1030
|
});
|
|
@@ -864,7 +1096,12 @@ describe('plugin-meetings', () => {
|
|
|
864
1096
|
meeting.join = sinon.stub().returns(Promise.reject(error));
|
|
865
1097
|
meeting.locusUrl = null; // when join fails, we end up with null locusUrl
|
|
866
1098
|
|
|
867
|
-
await assert.isRejected(
|
|
1099
|
+
const thrownError = await assert.isRejected(
|
|
1100
|
+
meeting.joinWithMedia({mediaOptions: {allowMediaInLobby: true}})
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
// should throw the first attempt's error
|
|
1104
|
+
assert.equal(thrownError, error);
|
|
868
1105
|
|
|
869
1106
|
assert.calledTwice(abortTurnDiscoveryStub);
|
|
870
1107
|
|
|
@@ -902,11 +1139,79 @@ describe('plugin-meetings', () => {
|
|
|
902
1139
|
|
|
903
1140
|
// resets joinWithMediaRetryInfo
|
|
904
1141
|
assert.deepEqual(meeting.joinWithMediaRetryInfo, {
|
|
905
|
-
|
|
1142
|
+
retryCount: 0,
|
|
1143
|
+
prevJoinResponse: undefined,
|
|
1144
|
+
firstError: undefined,
|
|
1145
|
+
prevError: undefined,
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it('should re-join on retry when join() fails on first attempt, and throw the first error if join fails again', async () => {
|
|
1150
|
+
const firstJoinError = new Error('first join error');
|
|
1151
|
+
const secondJoinError = new Error('second join error');
|
|
1152
|
+
|
|
1153
|
+
meeting.join = sinon
|
|
1154
|
+
.stub()
|
|
1155
|
+
.onFirstCall()
|
|
1156
|
+
.rejects(firstJoinError)
|
|
1157
|
+
.onSecondCall()
|
|
1158
|
+
.rejects(secondJoinError);
|
|
1159
|
+
meeting.locusUrl = null; // join never succeeds
|
|
1160
|
+
|
|
1161
|
+
const thrownError = await assert.isRejected(
|
|
1162
|
+
meeting.joinWithMedia({joinOptions, mediaOptions})
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
// join() should be called twice — once for the first attempt, once for the re-join
|
|
1166
|
+
assert.calledTwice(meeting.join);
|
|
1167
|
+
// TURN discovery should be attempted twice (once per join)
|
|
1168
|
+
assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
|
|
1169
|
+
|
|
1170
|
+
// should throw the first attempt's error, not the second
|
|
1171
|
+
assert.equal(thrownError, firstJoinError);
|
|
1172
|
+
|
|
1173
|
+
assert.calledTwice(Metrics.sendBehavioralMetric);
|
|
1174
|
+
assert.calledWith(
|
|
1175
|
+
Metrics.sendBehavioralMetric.firstCall,
|
|
1176
|
+
BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
|
|
1177
|
+
sinon.match({reason: firstJoinError.message, isRetry: false}),
|
|
1178
|
+
sinon.match.any
|
|
1179
|
+
);
|
|
1180
|
+
assert.calledWith(
|
|
1181
|
+
Metrics.sendBehavioralMetric.secondCall,
|
|
1182
|
+
BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
|
|
1183
|
+
sinon.match({reason: secondJoinError.message, isRetry: true}),
|
|
1184
|
+
sinon.match.any
|
|
1185
|
+
);
|
|
1186
|
+
|
|
1187
|
+
assert.deepEqual(meeting.joinWithMediaRetryInfo, {
|
|
1188
|
+
retryCount: 0,
|
|
906
1189
|
prevJoinResponse: undefined,
|
|
1190
|
+
firstError: undefined,
|
|
1191
|
+
prevError: undefined,
|
|
907
1192
|
});
|
|
908
1193
|
});
|
|
909
1194
|
|
|
1195
|
+
it('should NOT call join() again on retry when join() succeeded but addMediaInternal() failed', async () => {
|
|
1196
|
+
const addMediaError = new Error('addMedia error');
|
|
1197
|
+
|
|
1198
|
+
meeting.addMediaInternal = sinon
|
|
1199
|
+
.stub()
|
|
1200
|
+
.onFirstCall()
|
|
1201
|
+
.rejects(addMediaError)
|
|
1202
|
+
.onSecondCall()
|
|
1203
|
+
.resolves(test4);
|
|
1204
|
+
|
|
1205
|
+
const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1206
|
+
|
|
1207
|
+
assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
|
|
1208
|
+
|
|
1209
|
+
// join() should only be called once — the successful join result is reused on retry
|
|
1210
|
+
assert.calledOnce(meeting.join);
|
|
1211
|
+
// TURN discovery request is only generated once (on the first attempt alongside join)
|
|
1212
|
+
assert.calledOnce(generateTurnDiscoveryRequestMessageStub);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
910
1215
|
it('should resolve if join() fails the first time but succeeds the second time', async () => {
|
|
911
1216
|
const error = new Error('fake');
|
|
912
1217
|
meeting.join = sinon
|
|
@@ -948,7 +1253,7 @@ describe('plugin-meetings', () => {
|
|
|
948
1253
|
|
|
949
1254
|
// resets joinWithMediaRetryInfo
|
|
950
1255
|
assert.deepEqual(meeting.joinWithMediaRetryInfo, {
|
|
951
|
-
|
|
1256
|
+
retryCount: 0,
|
|
952
1257
|
prevJoinResponse: undefined,
|
|
953
1258
|
});
|
|
954
1259
|
});
|
|
@@ -1017,6 +1322,127 @@ describe('plugin-meetings', () => {
|
|
|
1017
1322
|
);
|
|
1018
1323
|
});
|
|
1019
1324
|
|
|
1325
|
+
it('should throw the first attempt error when retry also fails with a different error', async () => {
|
|
1326
|
+
const firstError = new Error('first attempt error');
|
|
1327
|
+
const secondError = new Error('second attempt error');
|
|
1328
|
+
|
|
1329
|
+
const addMediaInternalResults = [];
|
|
1330
|
+
meeting.addMediaInternal = sinon.stub().callsFake(() => {
|
|
1331
|
+
const defer = new Defer();
|
|
1332
|
+
addMediaInternalResults.push(defer);
|
|
1333
|
+
return defer.promise;
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1337
|
+
|
|
1338
|
+
const result = meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1339
|
+
|
|
1340
|
+
await testUtils.flushPromises();
|
|
1341
|
+
|
|
1342
|
+
// 1st attempt fails
|
|
1343
|
+
addMediaInternalResults[0].reject(firstError);
|
|
1344
|
+
await testUtils.flushPromises();
|
|
1345
|
+
|
|
1346
|
+
// leave() should NOT be called after the 1st attempt (intermediate retry)
|
|
1347
|
+
assert.notCalled(leaveStub);
|
|
1348
|
+
|
|
1349
|
+
// 2nd (final) attempt fails
|
|
1350
|
+
addMediaInternalResults[1].reject(secondError);
|
|
1351
|
+
const thrownError = await assert.isRejected(result);
|
|
1352
|
+
|
|
1353
|
+
// should throw the first error, not the second
|
|
1354
|
+
assert.equal(thrownError, firstError);
|
|
1355
|
+
|
|
1356
|
+
// leave() should only be called after the last (2nd) attempt
|
|
1357
|
+
assert.calledOnce(leaveStub);
|
|
1358
|
+
|
|
1359
|
+
assert.calledTwice(Metrics.sendBehavioralMetric);
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
it('should throw the UserNotJoinedError as firstError when it occurs on the 1st attempt and 2nd attempt fails differently', async () => {
|
|
1363
|
+
const userNotJoinedError = new UserNotJoinedError();
|
|
1364
|
+
const secondError = new Error('second attempt error');
|
|
1365
|
+
|
|
1366
|
+
const addMediaInternalResults = [];
|
|
1367
|
+
meeting.addMediaInternal = sinon.stub().callsFake(() => {
|
|
1368
|
+
const defer = new Defer();
|
|
1369
|
+
addMediaInternalResults.push(defer);
|
|
1370
|
+
return defer.promise;
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1374
|
+
|
|
1375
|
+
const result = meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1376
|
+
|
|
1377
|
+
await testUtils.flushPromises();
|
|
1378
|
+
|
|
1379
|
+
// 1st attempt fails with UserNotJoinedError — triggers a re-join
|
|
1380
|
+
addMediaInternalResults[0].reject(userNotJoinedError);
|
|
1381
|
+
await testUtils.flushPromises();
|
|
1382
|
+
|
|
1383
|
+
// leave() should NOT be called after the 1st attempt (intermediate retry)
|
|
1384
|
+
assert.notCalled(leaveStub);
|
|
1385
|
+
|
|
1386
|
+
// 2nd (final) attempt fails
|
|
1387
|
+
addMediaInternalResults[1].reject(secondError);
|
|
1388
|
+
const thrownError = await assert.isRejected(result);
|
|
1389
|
+
|
|
1390
|
+
// should throw the first (UserNotJoinedError), not the second
|
|
1391
|
+
assert.equal(thrownError, userNotJoinedError);
|
|
1392
|
+
|
|
1393
|
+
// join() called twice: original + re-join triggered by UserNotJoinedError in prevError
|
|
1394
|
+
assert.calledTwice(meeting.join);
|
|
1395
|
+
// leave() should only be called after the last (2nd) attempt
|
|
1396
|
+
assert.calledOnce(leaveStub);
|
|
1397
|
+
assert.calledTwice(Metrics.sendBehavioralMetric);
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it('should throw the first UserNotJoinedError when it occurs on both 1st and 2nd attempts and a 3rd attempt also fails', async () => {
|
|
1401
|
+
const firstUserNotJoinedError = new UserNotJoinedError('first');
|
|
1402
|
+
const secondUserNotJoinedError = new UserNotJoinedError('second');
|
|
1403
|
+
const thirdError = new Error('third attempt error');
|
|
1404
|
+
|
|
1405
|
+
const addMediaInternalResults = [];
|
|
1406
|
+
meeting.addMediaInternal = sinon.stub().callsFake(() => {
|
|
1407
|
+
const defer = new Defer();
|
|
1408
|
+
addMediaInternalResults.push(defer);
|
|
1409
|
+
return defer.promise;
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1413
|
+
|
|
1414
|
+
const result = meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1415
|
+
|
|
1416
|
+
await testUtils.flushPromises();
|
|
1417
|
+
|
|
1418
|
+
// 1st attempt fails with UserNotJoinedError — triggers a re-join
|
|
1419
|
+
addMediaInternalResults[0].reject(firstUserNotJoinedError);
|
|
1420
|
+
await testUtils.flushPromises();
|
|
1421
|
+
|
|
1422
|
+
// leave() should NOT be called after the 1st attempt (intermediate retry)
|
|
1423
|
+
assert.notCalled(leaveStub);
|
|
1424
|
+
|
|
1425
|
+
// 2nd attempt fails with UserNotJoinedError again — triggers another re-join
|
|
1426
|
+
addMediaInternalResults[1].reject(secondUserNotJoinedError);
|
|
1427
|
+
await testUtils.flushPromises();
|
|
1428
|
+
|
|
1429
|
+
// leave() should NOT be called after the 2nd attempt (intermediate retry)
|
|
1430
|
+
assert.notCalled(leaveStub);
|
|
1431
|
+
|
|
1432
|
+
// 3rd (final) attempt fails
|
|
1433
|
+
addMediaInternalResults[2].reject(thirdError);
|
|
1434
|
+
const thrownError = await assert.isRejected(result);
|
|
1435
|
+
|
|
1436
|
+
// should throw the very first error across all 3 attempts
|
|
1437
|
+
assert.equal(thrownError, firstUserNotJoinedError);
|
|
1438
|
+
|
|
1439
|
+
// join() called 3 times: original + 2 re-joins triggered by prevError being UserNotJoinedError
|
|
1440
|
+
assert.calledThrice(meeting.join);
|
|
1441
|
+
// leave() should only be called after the last (3rd) attempt
|
|
1442
|
+
assert.calledOnce(leaveStub);
|
|
1443
|
+
assert.calledThrice(Metrics.sendBehavioralMetric);
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1020
1446
|
it('should call leave() if addMediaInternal() fails with a browser media error (TypeError)', async () => {
|
|
1021
1447
|
const addMediaError = new Error('fake addMedia error');
|
|
1022
1448
|
addMediaError.name = 'TypeError'; // This makes it a browser media error
|
|
@@ -1264,7 +1690,8 @@ describe('plugin-meetings', () => {
|
|
|
1264
1690
|
|
|
1265
1691
|
await testUtils.flushPromises();
|
|
1266
1692
|
|
|
1267
|
-
// check the callback works correctly on the 2nd attempt
|
|
1693
|
+
// check the callback works correctly on the 2nd attempt:
|
|
1694
|
+
// retryCount=1 with a non-UserNotJoinedError is terminal, so icePhase must be JOIN_MEETING_FINAL
|
|
1268
1695
|
assert.equal(icePhaseCallbacks.length, 2);
|
|
1269
1696
|
assert.equal(icePhaseCallbacks[1](), 'JOIN_MEETING_FINAL');
|
|
1270
1697
|
|
|
@@ -1274,6 +1701,118 @@ describe('plugin-meetings', () => {
|
|
|
1274
1701
|
await assert.isRejected(result);
|
|
1275
1702
|
});
|
|
1276
1703
|
|
|
1704
|
+
it('should allow an additional retry when UserNotJoinedError occurs and return JOIN_MEETING_FINAL on the 3rd attempt', async () => {
|
|
1705
|
+
const genericError = new Error('generic error');
|
|
1706
|
+
const userNotJoinedError = new UserNotJoinedError();
|
|
1707
|
+
const thirdError = new Error('third error');
|
|
1708
|
+
|
|
1709
|
+
const icePhaseCallbacks = [];
|
|
1710
|
+
const addMediaInternalResults = [];
|
|
1711
|
+
|
|
1712
|
+
meeting.addMediaInternal = sinon
|
|
1713
|
+
.stub()
|
|
1714
|
+
.callsFake((icePhaseCallback) => {
|
|
1715
|
+
const defer = new Defer();
|
|
1716
|
+
|
|
1717
|
+
icePhaseCallbacks.push(icePhaseCallback);
|
|
1718
|
+
addMediaInternalResults.push(defer);
|
|
1719
|
+
return defer.promise;
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1723
|
+
|
|
1724
|
+
const result = meeting.joinWithMedia({
|
|
1725
|
+
joinOptions,
|
|
1726
|
+
mediaOptions,
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
await testUtils.flushPromises();
|
|
1730
|
+
|
|
1731
|
+
// 1st attempt: retryCount=0 → JOIN_MEETING_RETRY
|
|
1732
|
+
assert.equal(icePhaseCallbacks.length, 1);
|
|
1733
|
+
assert.equal(icePhaseCallbacks[0](), 'JOIN_MEETING_RETRY');
|
|
1734
|
+
addMediaInternalResults[0].reject(genericError);
|
|
1735
|
+
|
|
1736
|
+
await testUtils.flushPromises();
|
|
1737
|
+
|
|
1738
|
+
// leave() should NOT be called after the 1st attempt (intermediate retry)
|
|
1739
|
+
assert.notCalled(leaveStub);
|
|
1740
|
+
|
|
1741
|
+
// 2nd attempt: retryCount=1 → JOIN_MEETING_FINAL
|
|
1742
|
+
// (In real usage this callback is never invoked when UserNotJoinedError is thrown,
|
|
1743
|
+
// because UserNotJoinedError is thrown before waitForMediaConnectionConnected() is reached.)
|
|
1744
|
+
assert.equal(icePhaseCallbacks.length, 2);
|
|
1745
|
+
assert.equal(icePhaseCallbacks[1](), 'JOIN_MEETING_FINAL');
|
|
1746
|
+
addMediaInternalResults[1].reject(userNotJoinedError);
|
|
1747
|
+
|
|
1748
|
+
await testUtils.flushPromises();
|
|
1749
|
+
|
|
1750
|
+
// leave() should NOT be called after the 2nd attempt (intermediate retry)
|
|
1751
|
+
assert.notCalled(leaveStub);
|
|
1752
|
+
|
|
1753
|
+
// 3rd attempt: retryCount=2 → JOIN_MEETING_FINAL
|
|
1754
|
+
assert.equal(icePhaseCallbacks.length, 3);
|
|
1755
|
+
assert.equal(icePhaseCallbacks[2](), 'JOIN_MEETING_FINAL');
|
|
1756
|
+
addMediaInternalResults[2].reject(thirdError);
|
|
1757
|
+
|
|
1758
|
+
const thrownError = await assert.isRejected(result);
|
|
1759
|
+
|
|
1760
|
+
// should throw the first error
|
|
1761
|
+
assert.equal(thrownError, genericError);
|
|
1762
|
+
|
|
1763
|
+
// leave() should only be called once, after the last (3rd) attempt
|
|
1764
|
+
assert.calledOnce(leaveStub);
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
it('should re-join when retrying after a UserNotJoinedError', async () => {
|
|
1768
|
+
const userNotJoinedError = new UserNotJoinedError();
|
|
1769
|
+
|
|
1770
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1771
|
+
|
|
1772
|
+
meeting.addMediaInternal = sinon
|
|
1773
|
+
.stub()
|
|
1774
|
+
.onFirstCall()
|
|
1775
|
+
.rejects(userNotJoinedError)
|
|
1776
|
+
.onSecondCall()
|
|
1777
|
+
.resolves(test4);
|
|
1778
|
+
|
|
1779
|
+
const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1780
|
+
|
|
1781
|
+
assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
|
|
1782
|
+
|
|
1783
|
+
// join() should be called twice — once for the first attempt, once for the re-join after UserNotJoinedError
|
|
1784
|
+
assert.calledTwice(meeting.join);
|
|
1785
|
+
// TURN discovery should be attempted twice (once per join)
|
|
1786
|
+
assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
|
|
1787
|
+
// leave() should never be called when retrying after UserNotJoinedError
|
|
1788
|
+
assert.notCalled(leaveStub);
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
it('should re-join when isUserInLeftState returns true on retry', async () => {
|
|
1792
|
+
const addMediaError = new Error('addMedia error');
|
|
1793
|
+
|
|
1794
|
+
sinon.stub(MeetingUtil, 'isUserInLeftState').returns(true);
|
|
1795
|
+
|
|
1796
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1797
|
+
|
|
1798
|
+
meeting.addMediaInternal = sinon
|
|
1799
|
+
.stub()
|
|
1800
|
+
.onFirstCall()
|
|
1801
|
+
.rejects(addMediaError)
|
|
1802
|
+
.onSecondCall()
|
|
1803
|
+
.resolves(test4);
|
|
1804
|
+
|
|
1805
|
+
const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1806
|
+
|
|
1807
|
+
assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
|
|
1808
|
+
|
|
1809
|
+
// join() should be called twice — once for the first attempt, once because the user is in left state
|
|
1810
|
+
assert.calledTwice(meeting.join);
|
|
1811
|
+
assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
|
|
1812
|
+
// leave() should never be called when retrying after isUserInLeftState returns true
|
|
1813
|
+
assert.notCalled(leaveStub);
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1277
1816
|
[
|
|
1278
1817
|
{
|
|
1279
1818
|
errorName: 'SdpOfferCreationError',
|
|
@@ -1977,6 +2516,113 @@ describe('plugin-meetings', () => {
|
|
|
1977
2516
|
fakeProcessedReaction
|
|
1978
2517
|
);
|
|
1979
2518
|
});
|
|
2519
|
+
|
|
2520
|
+
[
|
|
2521
|
+
{
|
|
2522
|
+
title: 'should skip a reaction when the default relay route does not match the LLM binding',
|
|
2523
|
+
isPracticeSessionConnected: false,
|
|
2524
|
+
route: 'wrong-default-route',
|
|
2525
|
+
defaultBinding: 'default-route',
|
|
2526
|
+
practiceBinding: 'practice-route',
|
|
2527
|
+
shouldProcess: false,
|
|
2528
|
+
expectedSessionLabel: 'default session',
|
|
2529
|
+
},
|
|
2530
|
+
{
|
|
2531
|
+
title: 'should process a reaction when the default relay route matches the LLM binding',
|
|
2532
|
+
isPracticeSessionConnected: false,
|
|
2533
|
+
route: 'default-route',
|
|
2534
|
+
defaultBinding: 'default-route',
|
|
2535
|
+
practiceBinding: 'practice-route',
|
|
2536
|
+
shouldProcess: true,
|
|
2537
|
+
},
|
|
2538
|
+
{
|
|
2539
|
+
title:
|
|
2540
|
+
'should process a reaction when the practice-session relay route matches the practice-session LLM binding',
|
|
2541
|
+
isPracticeSessionConnected: true,
|
|
2542
|
+
route: 'practice-route',
|
|
2543
|
+
defaultBinding: 'default-route',
|
|
2544
|
+
practiceBinding: 'practice-route',
|
|
2545
|
+
shouldProcess: true,
|
|
2546
|
+
},
|
|
2547
|
+
{
|
|
2548
|
+
title:
|
|
2549
|
+
'should skip a reaction when the practice-session relay route does not match the practice-session LLM binding',
|
|
2550
|
+
isPracticeSessionConnected: true,
|
|
2551
|
+
route: 'default-route',
|
|
2552
|
+
defaultBinding: 'default-route',
|
|
2553
|
+
practiceBinding: 'practice-route',
|
|
2554
|
+
shouldProcess: false,
|
|
2555
|
+
expectedSessionLabel: 'practice session',
|
|
2556
|
+
},
|
|
2557
|
+
].forEach(
|
|
2558
|
+
({
|
|
2559
|
+
title,
|
|
2560
|
+
isPracticeSessionConnected,
|
|
2561
|
+
route,
|
|
2562
|
+
defaultBinding,
|
|
2563
|
+
practiceBinding,
|
|
2564
|
+
shouldProcess,
|
|
2565
|
+
expectedSessionLabel,
|
|
2566
|
+
}) => {
|
|
2567
|
+
it(title, () => {
|
|
2568
|
+
meeting.isReactionsSupported = sinon.stub().returns(true);
|
|
2569
|
+
meeting.config.receiveReactions = true;
|
|
2570
|
+
const fakeSendersName = 'Fake reactors name';
|
|
2571
|
+
meeting.members.membersCollection.get = sinon.stub().returns({name: fakeSendersName});
|
|
2572
|
+
webex.internal.llm.isConnected = sinon.stub().callsFake((llmSessionId) => {
|
|
2573
|
+
return llmSessionId === LLM_PRACTICE_SESSION && isPracticeSessionConnected;
|
|
2574
|
+
});
|
|
2575
|
+
webex.internal.llm.getBinding = sinon.stub().callsFake((llmSessionId) => {
|
|
2576
|
+
if (llmSessionId === LLM_PRACTICE_SESSION) {
|
|
2577
|
+
return practiceBinding;
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
return defaultBinding;
|
|
2581
|
+
});
|
|
2582
|
+
const fakeReactionPayload = {
|
|
2583
|
+
type: 'fake_type',
|
|
2584
|
+
codepoints: 'fake_codepoints',
|
|
2585
|
+
shortcodes: 'fake_shortcodes',
|
|
2586
|
+
};
|
|
2587
|
+
const fakeSenderPayload = {
|
|
2588
|
+
participantId: 'fake_participant_id',
|
|
2589
|
+
};
|
|
2590
|
+
const fakeRelayEvent = {
|
|
2591
|
+
headers: {route},
|
|
2592
|
+
data: {
|
|
2593
|
+
relayType: REACTION_RELAY_TYPES.REACTION,
|
|
2594
|
+
reaction: fakeReactionPayload,
|
|
2595
|
+
sender: fakeSenderPayload,
|
|
2596
|
+
},
|
|
2597
|
+
};
|
|
2598
|
+
const fakeProcessedReaction = {
|
|
2599
|
+
reaction: fakeReactionPayload,
|
|
2600
|
+
sender: {
|
|
2601
|
+
id: fakeSenderPayload.participantId,
|
|
2602
|
+
name: fakeSendersName,
|
|
2603
|
+
},
|
|
2604
|
+
};
|
|
2605
|
+
|
|
2606
|
+
TriggerProxy.trigger.resetHistory();
|
|
2607
|
+
meeting.processRelayEvent(fakeRelayEvent);
|
|
2608
|
+
|
|
2609
|
+
if (shouldProcess) {
|
|
2610
|
+
assert.calledWith(
|
|
2611
|
+
TriggerProxy.trigger,
|
|
2612
|
+
sinon.match.instanceOf(Meeting),
|
|
2613
|
+
{
|
|
2614
|
+
file: 'meeting/index',
|
|
2615
|
+
function: 'join',
|
|
2616
|
+
},
|
|
2617
|
+
EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
|
|
2618
|
+
fakeProcessedReaction
|
|
2619
|
+
);
|
|
2620
|
+
} else {
|
|
2621
|
+
assert.notCalled(TriggerProxy.trigger);
|
|
2622
|
+
}
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
);
|
|
1980
2626
|
});
|
|
1981
2627
|
|
|
1982
2628
|
describe('#handleLLMOnline', () => {
|
|
@@ -2016,6 +2662,14 @@ describe('plugin-meetings', () => {
|
|
|
2016
2662
|
|
|
2017
2663
|
assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
|
|
2018
2664
|
});
|
|
2665
|
+
|
|
2666
|
+
it('calls syncAllHashTreeDatasets on locusInfo', () => {
|
|
2667
|
+
sinon.stub(meeting.locusInfo, 'syncAllHashTreeDatasets').resolves();
|
|
2668
|
+
|
|
2669
|
+
meeting.handleLLMOnline();
|
|
2670
|
+
|
|
2671
|
+
assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true});
|
|
2672
|
+
});
|
|
2019
2673
|
});
|
|
2020
2674
|
|
|
2021
2675
|
describe('#join', () => {
|
|
@@ -2096,6 +2750,8 @@ describe('plugin-meetings', () => {
|
|
|
2096
2750
|
regionCode: 'EU',
|
|
2097
2751
|
},
|
|
2098
2752
|
preferTranscoding: !enableMultistream,
|
|
2753
|
+
getCurrentSelfUrl: sinon.match.func,
|
|
2754
|
+
waitForSelfUrlChange: sinon.match.func,
|
|
2099
2755
|
},
|
|
2100
2756
|
{
|
|
2101
2757
|
parent: meeting.webex,
|
|
@@ -4534,6 +5190,297 @@ describe('plugin-meetings', () => {
|
|
|
4534
5190
|
},
|
|
4535
5191
|
});
|
|
4536
5192
|
});
|
|
5193
|
+
|
|
5194
|
+
describe('handles STATS_UPDATE event for SRTP cipher detection', () => {
|
|
5195
|
+
it('emits MEETING_SRTP_CIPHER_UPDATED event when srtpCipher is found in transport stats', async () => {
|
|
5196
|
+
const fakeStats = new Map([
|
|
5197
|
+
[
|
|
5198
|
+
'transport-1',
|
|
5199
|
+
{
|
|
5200
|
+
type: 'transport',
|
|
5201
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
5202
|
+
dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
|
|
5203
|
+
},
|
|
5204
|
+
],
|
|
5205
|
+
[
|
|
5206
|
+
'outbound-rtp-1',
|
|
5207
|
+
{
|
|
5208
|
+
type: 'outbound-rtp',
|
|
5209
|
+
ssrc: 12345,
|
|
5210
|
+
},
|
|
5211
|
+
],
|
|
5212
|
+
]);
|
|
5213
|
+
|
|
5214
|
+
statsAnalyzerStub.emit(
|
|
5215
|
+
{file: 'test', function: 'test'},
|
|
5216
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5217
|
+
{stats: fakeStats}
|
|
5218
|
+
);
|
|
5219
|
+
|
|
5220
|
+
assert.calledWith(
|
|
5221
|
+
TriggerProxy.trigger,
|
|
5222
|
+
sinon.match.instanceOf(Meeting),
|
|
5223
|
+
{
|
|
5224
|
+
file: 'meeting/index',
|
|
5225
|
+
function: 'setupStatsAnalyzerEventHandlers',
|
|
5226
|
+
},
|
|
5227
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5228
|
+
{srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
|
|
5229
|
+
);
|
|
5230
|
+
|
|
5231
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5232
|
+
});
|
|
5233
|
+
|
|
5234
|
+
it('updates meeting.mediaProperties.srtpCipher when cipher changes', async () => {
|
|
5235
|
+
const firstStats = new Map([
|
|
5236
|
+
[
|
|
5237
|
+
'transport-1',
|
|
5238
|
+
{
|
|
5239
|
+
type: 'transport',
|
|
5240
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
5241
|
+
},
|
|
5242
|
+
],
|
|
5243
|
+
]);
|
|
5244
|
+
|
|
5245
|
+
statsAnalyzerStub.emit(
|
|
5246
|
+
{file: 'test', function: 'test'},
|
|
5247
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5248
|
+
{stats: firstStats}
|
|
5249
|
+
);
|
|
5250
|
+
|
|
5251
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5252
|
+
|
|
5253
|
+
const secondStats = new Map([
|
|
5254
|
+
[
|
|
5255
|
+
'transport-1',
|
|
5256
|
+
{
|
|
5257
|
+
type: 'transport',
|
|
5258
|
+
srtpCipher: 'AEAD_AES_256_GCM',
|
|
5259
|
+
},
|
|
5260
|
+
],
|
|
5261
|
+
]);
|
|
5262
|
+
|
|
5263
|
+
TriggerProxy.trigger.resetHistory();
|
|
5264
|
+
|
|
5265
|
+
statsAnalyzerStub.emit(
|
|
5266
|
+
{file: 'test', function: 'test'},
|
|
5267
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5268
|
+
{stats: secondStats}
|
|
5269
|
+
);
|
|
5270
|
+
|
|
5271
|
+
assert.calledWith(
|
|
5272
|
+
TriggerProxy.trigger,
|
|
5273
|
+
sinon.match.instanceOf(Meeting),
|
|
5274
|
+
{
|
|
5275
|
+
file: 'meeting/index',
|
|
5276
|
+
function: 'setupStatsAnalyzerEventHandlers',
|
|
5277
|
+
},
|
|
5278
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5279
|
+
{srtpCipher: 'AEAD_AES_256_GCM'}
|
|
5280
|
+
);
|
|
5281
|
+
|
|
5282
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AEAD_AES_256_GCM');
|
|
5283
|
+
});
|
|
5284
|
+
|
|
5285
|
+
it('does not emit event when srtpCipher has not changed', async () => {
|
|
5286
|
+
const firstStats = new Map([
|
|
5287
|
+
[
|
|
5288
|
+
'transport-1',
|
|
5289
|
+
{
|
|
5290
|
+
type: 'transport',
|
|
5291
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
5292
|
+
},
|
|
5293
|
+
],
|
|
5294
|
+
]);
|
|
5295
|
+
|
|
5296
|
+
statsAnalyzerStub.emit(
|
|
5297
|
+
{file: 'test', function: 'test'},
|
|
5298
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5299
|
+
{stats: firstStats}
|
|
5300
|
+
);
|
|
5301
|
+
|
|
5302
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5303
|
+
|
|
5304
|
+
TriggerProxy.trigger.resetHistory();
|
|
5305
|
+
|
|
5306
|
+
// Emit same cipher again
|
|
5307
|
+
statsAnalyzerStub.emit(
|
|
5308
|
+
{file: 'test', function: 'test'},
|
|
5309
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5310
|
+
{stats: firstStats}
|
|
5311
|
+
);
|
|
5312
|
+
|
|
5313
|
+
// Should not trigger event again
|
|
5314
|
+
assert.neverCalledWith(
|
|
5315
|
+
TriggerProxy.trigger,
|
|
5316
|
+
sinon.match.instanceOf(Meeting),
|
|
5317
|
+
sinon.match.any,
|
|
5318
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5319
|
+
sinon.match.any
|
|
5320
|
+
);
|
|
5321
|
+
|
|
5322
|
+
// Cipher should remain the same
|
|
5323
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5324
|
+
});
|
|
5325
|
+
|
|
5326
|
+
it('does not emit event when stats contain no transport with srtpCipher', async () => {
|
|
5327
|
+
const fakeStats = new Map([
|
|
5328
|
+
[
|
|
5329
|
+
'outbound-rtp-1',
|
|
5330
|
+
{
|
|
5331
|
+
type: 'outbound-rtp',
|
|
5332
|
+
ssrc: 12345,
|
|
5333
|
+
},
|
|
5334
|
+
],
|
|
5335
|
+
[
|
|
5336
|
+
'inbound-rtp-1',
|
|
5337
|
+
{
|
|
5338
|
+
type: 'inbound-rtp',
|
|
5339
|
+
ssrc: 67890,
|
|
5340
|
+
},
|
|
5341
|
+
],
|
|
5342
|
+
]);
|
|
5343
|
+
|
|
5344
|
+
statsAnalyzerStub.emit(
|
|
5345
|
+
{file: 'test', function: 'test'},
|
|
5346
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5347
|
+
{stats: fakeStats}
|
|
5348
|
+
);
|
|
5349
|
+
|
|
5350
|
+
assert.neverCalledWith(
|
|
5351
|
+
TriggerProxy.trigger,
|
|
5352
|
+
sinon.match.instanceOf(Meeting),
|
|
5353
|
+
sinon.match.any,
|
|
5354
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5355
|
+
sinon.match.any
|
|
5356
|
+
);
|
|
5357
|
+
|
|
5358
|
+
assert.isUndefined(meeting.mediaProperties.srtpCipher);
|
|
5359
|
+
});
|
|
5360
|
+
|
|
5361
|
+
it('does not emit event when transport stat has no srtpCipher property', async () => {
|
|
5362
|
+
const fakeStats = new Map([
|
|
5363
|
+
[
|
|
5364
|
+
'transport-1',
|
|
5365
|
+
{
|
|
5366
|
+
type: 'transport',
|
|
5367
|
+
dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
|
|
5368
|
+
// no srtpCipher property
|
|
5369
|
+
},
|
|
5370
|
+
],
|
|
5371
|
+
]);
|
|
5372
|
+
|
|
5373
|
+
statsAnalyzerStub.emit(
|
|
5374
|
+
{file: 'test', function: 'test'},
|
|
5375
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5376
|
+
{stats: fakeStats}
|
|
5377
|
+
);
|
|
5378
|
+
|
|
5379
|
+
assert.neverCalledWith(
|
|
5380
|
+
TriggerProxy.trigger,
|
|
5381
|
+
sinon.match.instanceOf(Meeting),
|
|
5382
|
+
sinon.match.any,
|
|
5383
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5384
|
+
sinon.match.any
|
|
5385
|
+
);
|
|
5386
|
+
|
|
5387
|
+
assert.isUndefined(meeting.mediaProperties.srtpCipher);
|
|
5388
|
+
});
|
|
5389
|
+
|
|
5390
|
+
it('uses first transport with srtpCipher when multiple transports exist', async () => {
|
|
5391
|
+
const fakeStats = new Map([
|
|
5392
|
+
[
|
|
5393
|
+
'transport-1',
|
|
5394
|
+
{
|
|
5395
|
+
type: 'transport',
|
|
5396
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
5397
|
+
},
|
|
5398
|
+
],
|
|
5399
|
+
[
|
|
5400
|
+
'transport-2',
|
|
5401
|
+
{
|
|
5402
|
+
type: 'transport',
|
|
5403
|
+
srtpCipher: 'AEAD_AES_256_GCM',
|
|
5404
|
+
},
|
|
5405
|
+
],
|
|
5406
|
+
[
|
|
5407
|
+
'outbound-rtp-1',
|
|
5408
|
+
{
|
|
5409
|
+
type: 'outbound-rtp',
|
|
5410
|
+
ssrc: 12345,
|
|
5411
|
+
},
|
|
5412
|
+
],
|
|
5413
|
+
]);
|
|
5414
|
+
|
|
5415
|
+
statsAnalyzerStub.emit(
|
|
5416
|
+
{file: 'test', function: 'test'},
|
|
5417
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5418
|
+
{stats: fakeStats}
|
|
5419
|
+
);
|
|
5420
|
+
|
|
5421
|
+
assert.calledWith(
|
|
5422
|
+
TriggerProxy.trigger,
|
|
5423
|
+
sinon.match.instanceOf(Meeting),
|
|
5424
|
+
{
|
|
5425
|
+
file: 'meeting/index',
|
|
5426
|
+
function: 'setupStatsAnalyzerEventHandlers',
|
|
5427
|
+
},
|
|
5428
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5429
|
+
{srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
|
|
5430
|
+
);
|
|
5431
|
+
|
|
5432
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5433
|
+
});
|
|
5434
|
+
|
|
5435
|
+
it('handles empty stats map without errors', async () => {
|
|
5436
|
+
const emptyStats = new Map();
|
|
5437
|
+
|
|
5438
|
+
statsAnalyzerStub.emit(
|
|
5439
|
+
{file: 'test', function: 'test'},
|
|
5440
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5441
|
+
{stats: emptyStats}
|
|
5442
|
+
);
|
|
5443
|
+
|
|
5444
|
+
assert.neverCalledWith(
|
|
5445
|
+
TriggerProxy.trigger,
|
|
5446
|
+
sinon.match.instanceOf(Meeting),
|
|
5447
|
+
sinon.match.any,
|
|
5448
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5449
|
+
sinon.match.any
|
|
5450
|
+
);
|
|
5451
|
+
|
|
5452
|
+
assert.isUndefined(meeting.mediaProperties.srtpCipher);
|
|
5453
|
+
});
|
|
5454
|
+
|
|
5455
|
+
it('logs cipher change when cipher is updated', async () => {
|
|
5456
|
+
const loggerSpy = sinon.spy(LoggerProxy.logger, 'info');
|
|
5457
|
+
|
|
5458
|
+
meeting.mediaProperties.srtpCipher = 'AES_CM_128_HMAC_SHA1_80';
|
|
5459
|
+
|
|
5460
|
+
const newStats = new Map([
|
|
5461
|
+
[
|
|
5462
|
+
'transport-1',
|
|
5463
|
+
{
|
|
5464
|
+
type: 'transport',
|
|
5465
|
+
srtpCipher: 'AEAD_AES_256_GCM',
|
|
5466
|
+
},
|
|
5467
|
+
],
|
|
5468
|
+
]);
|
|
5469
|
+
|
|
5470
|
+
statsAnalyzerStub.emit(
|
|
5471
|
+
{file: 'test', function: 'test'},
|
|
5472
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5473
|
+
{stats: newStats}
|
|
5474
|
+
);
|
|
5475
|
+
|
|
5476
|
+
assert.calledWithMatch(
|
|
5477
|
+
loggerSpy,
|
|
5478
|
+
sinon.match(/SRTP cipher changed from AES_CM_128_HMAC_SHA1_80 to AEAD_AES_256_GCM/)
|
|
5479
|
+
);
|
|
5480
|
+
|
|
5481
|
+
loggerSpy.restore();
|
|
5482
|
+
});
|
|
5483
|
+
});
|
|
4537
5484
|
});
|
|
4538
5485
|
|
|
4539
5486
|
describe('handles StatsMonitor events', () => {
|
|
@@ -6429,6 +7376,9 @@ describe('plugin-meetings', () => {
|
|
|
6429
7376
|
|
|
6430
7377
|
meeting.annotation.deregisterEvents = sinon.stub();
|
|
6431
7378
|
webex.internal.llm.off = sinon.stub();
|
|
7379
|
+
webex.internal.mercury.off = sinon.stub();
|
|
7380
|
+
meeting.mercuryOnlineHandler = sinon.stub();
|
|
7381
|
+
meeting.mercuryOfflineHandler = sinon.stub();
|
|
6432
7382
|
|
|
6433
7383
|
// A meeting needs to be joined to leave
|
|
6434
7384
|
meeting.meetingState = 'ACTIVE';
|
|
@@ -6452,6 +7402,67 @@ describe('plugin-meetings', () => {
|
|
|
6452
7402
|
assert.calledOnce(meeting.clearMeetingData);
|
|
6453
7403
|
});
|
|
6454
7404
|
|
|
7405
|
+
it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /leave', async () => {
|
|
7406
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
7407
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
7408
|
+
|
|
7409
|
+
await meeting.leave();
|
|
7410
|
+
|
|
7411
|
+
// All llm/mercury consumers (direct listeners, voicea transcription,
|
|
7412
|
+
// annotation) must be detached before the /leave request so that
|
|
7413
|
+
// in-flight events do not trigger unnecessary Locus syncs
|
|
7414
|
+
// (per Locus team recommendation).
|
|
7415
|
+
assert.callOrder(
|
|
7416
|
+
webex.internal.llm.off,
|
|
7417
|
+
webex.internal.mercury.off,
|
|
7418
|
+
meeting.stopTranscription,
|
|
7419
|
+
meeting.annotation.deregisterEvents,
|
|
7420
|
+
meeting.meetingRequest.leaveMeeting
|
|
7421
|
+
);
|
|
7422
|
+
assert.calledWithExactly(
|
|
7423
|
+
webex.internal.llm.off,
|
|
7424
|
+
'event:relay.event',
|
|
7425
|
+
meeting.processRelayEvent
|
|
7426
|
+
);
|
|
7427
|
+
assert.calledWithExactly(
|
|
7428
|
+
webex.internal.llm.off,
|
|
7429
|
+
LOCUS_LLM_EVENT,
|
|
7430
|
+
meeting.processLocusLLMEvent
|
|
7431
|
+
);
|
|
7432
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
7433
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
7434
|
+
assert.isUndefined(meeting.mercuryOnlineHandler);
|
|
7435
|
+
assert.isUndefined(meeting.mercuryOfflineHandler);
|
|
7436
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
7437
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
7438
|
+
assert.isUndefined(meeting.transcription);
|
|
7439
|
+
});
|
|
7440
|
+
|
|
7441
|
+
it('tears down llm/mercury/transcription/annotation even when /leave rejects', async () => {
|
|
7442
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
7443
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
7444
|
+
meeting.meetingRequest.leaveMeeting = sinon
|
|
7445
|
+
.stub()
|
|
7446
|
+
.returns(Promise.reject(new Error('leave failed')));
|
|
7447
|
+
|
|
7448
|
+
await meeting.leave().catch(() => {});
|
|
7449
|
+
|
|
7450
|
+
assert.calledWithExactly(
|
|
7451
|
+
webex.internal.llm.off,
|
|
7452
|
+
'event:relay.event',
|
|
7453
|
+
meeting.processRelayEvent
|
|
7454
|
+
);
|
|
7455
|
+
assert.calledWithExactly(
|
|
7456
|
+
webex.internal.llm.off,
|
|
7457
|
+
LOCUS_LLM_EVENT,
|
|
7458
|
+
meeting.processLocusLLMEvent
|
|
7459
|
+
);
|
|
7460
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
7461
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
7462
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
7463
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
7464
|
+
});
|
|
7465
|
+
|
|
6455
7466
|
it('should reset call diagnostic latencies correctly', async () => {
|
|
6456
7467
|
const leave = meeting.leave();
|
|
6457
7468
|
|
|
@@ -8459,6 +9470,9 @@ describe('plugin-meetings', () => {
|
|
|
8459
9470
|
|
|
8460
9471
|
meeting.annotation.deregisterEvents = sinon.stub();
|
|
8461
9472
|
webex.internal.llm.off = sinon.stub();
|
|
9473
|
+
webex.internal.mercury.off = sinon.stub();
|
|
9474
|
+
meeting.mercuryOnlineHandler = sinon.stub();
|
|
9475
|
+
meeting.mercuryOfflineHandler = sinon.stub();
|
|
8462
9476
|
|
|
8463
9477
|
// A meeting needs to be joined to end
|
|
8464
9478
|
meeting.meetingState = 'ACTIVE';
|
|
@@ -8481,6 +9495,66 @@ describe('plugin-meetings', () => {
|
|
|
8481
9495
|
assert.calledOnce(meeting?.unsetPeerConnections);
|
|
8482
9496
|
assert.calledOnce(meeting?.clearMeetingData);
|
|
8483
9497
|
});
|
|
9498
|
+
|
|
9499
|
+
it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /end', async () => {
|
|
9500
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
9501
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
9502
|
+
|
|
9503
|
+
await meeting.endMeetingForAll();
|
|
9504
|
+
|
|
9505
|
+
// All llm/mercury consumers (direct listeners, voicea transcription,
|
|
9506
|
+
// annotation) must be detached before the /end request so that
|
|
9507
|
+
// in-flight events do not trigger unnecessary Locus syncs
|
|
9508
|
+
// (per Locus team recommendation).
|
|
9509
|
+
assert.callOrder(
|
|
9510
|
+
webex.internal.llm.off,
|
|
9511
|
+
webex.internal.mercury.off,
|
|
9512
|
+
meeting.stopTranscription,
|
|
9513
|
+
meeting.annotation.deregisterEvents,
|
|
9514
|
+
meeting.meetingRequest.endMeetingForAll
|
|
9515
|
+
);
|
|
9516
|
+
assert.calledWithExactly(
|
|
9517
|
+
webex.internal.llm.off,
|
|
9518
|
+
'event:relay.event',
|
|
9519
|
+
meeting.processRelayEvent
|
|
9520
|
+
);
|
|
9521
|
+
assert.calledWithExactly(
|
|
9522
|
+
webex.internal.llm.off,
|
|
9523
|
+
LOCUS_LLM_EVENT,
|
|
9524
|
+
meeting.processLocusLLMEvent
|
|
9525
|
+
);
|
|
9526
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
9527
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
9528
|
+
assert.isUndefined(meeting.mercuryOnlineHandler);
|
|
9529
|
+
assert.isUndefined(meeting.mercuryOfflineHandler);
|
|
9530
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
9531
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
9532
|
+
});
|
|
9533
|
+
|
|
9534
|
+
it('tears down llm/mercury/transcription/annotation even when /end rejects', async () => {
|
|
9535
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
9536
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
9537
|
+
meeting.meetingRequest.endMeetingForAll = sinon
|
|
9538
|
+
.stub()
|
|
9539
|
+
.returns(Promise.reject(new Error('end failed')));
|
|
9540
|
+
|
|
9541
|
+
await meeting.endMeetingForAll().catch(() => {});
|
|
9542
|
+
|
|
9543
|
+
assert.calledWithExactly(
|
|
9544
|
+
webex.internal.llm.off,
|
|
9545
|
+
'event:relay.event',
|
|
9546
|
+
meeting.processRelayEvent
|
|
9547
|
+
);
|
|
9548
|
+
assert.calledWithExactly(
|
|
9549
|
+
webex.internal.llm.off,
|
|
9550
|
+
LOCUS_LLM_EVENT,
|
|
9551
|
+
meeting.processLocusLLMEvent
|
|
9552
|
+
);
|
|
9553
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
9554
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
9555
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
9556
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
9557
|
+
});
|
|
8484
9558
|
});
|
|
8485
9559
|
|
|
8486
9560
|
describe('#moveTo', () => {
|
|
@@ -10417,7 +11491,7 @@ describe('plugin-meetings', () => {
|
|
|
10417
11491
|
);
|
|
10418
11492
|
done();
|
|
10419
11493
|
});
|
|
10420
|
-
it('listens to the self admitted guest event
|
|
11494
|
+
it('listens to the self admitted guest event and waits for token prefetch before reconnecting LLM', async () => {
|
|
10421
11495
|
meeting.stopKeepAlive = sinon.stub();
|
|
10422
11496
|
meeting.updateLLMConnection = sinon.stub();
|
|
10423
11497
|
let resolvePrefetch;
|
|
@@ -10443,7 +11517,7 @@ describe('plugin-meetings', () => {
|
|
|
10443
11517
|
'meeting:self:guestAdmitted',
|
|
10444
11518
|
{payload: test1}
|
|
10445
11519
|
);
|
|
10446
|
-
assert.
|
|
11520
|
+
assert.notCalled(meeting.updateLLMConnection);
|
|
10447
11521
|
assert.calledOnceWithExactly(meeting.rtcMetrics.sendNextMetrics);
|
|
10448
11522
|
|
|
10449
11523
|
assert.calledOnceWithExactly(
|
|
@@ -10456,6 +11530,7 @@ describe('plugin-meetings', () => {
|
|
|
10456
11530
|
|
|
10457
11531
|
resolvePrefetch(false);
|
|
10458
11532
|
await Promise.resolve();
|
|
11533
|
+
await Promise.resolve();
|
|
10459
11534
|
|
|
10460
11535
|
assert.calledOnce(meeting.updateLLMConnection);
|
|
10461
11536
|
});
|
|
@@ -10971,6 +12046,92 @@ describe('plugin-meetings', () => {
|
|
|
10971
12046
|
);
|
|
10972
12047
|
});
|
|
10973
12048
|
|
|
12049
|
+
const recordingTestCases = [
|
|
12050
|
+
{
|
|
12051
|
+
description: 'triggers MEETING_STARTED_RECORDING when state is RECORDING',
|
|
12052
|
+
state: RECORDING_STATE.RECORDING,
|
|
12053
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
|
|
12054
|
+
expectedRecordingState: RECORDING_STATE.RECORDING,
|
|
12055
|
+
},
|
|
12056
|
+
{
|
|
12057
|
+
description: 'triggers MEETING_STOPPED_RECORDING when state is IDLE',
|
|
12058
|
+
state: RECORDING_STATE.IDLE,
|
|
12059
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_STOPPED_RECORDING,
|
|
12060
|
+
expectedRecordingState: RECORDING_STATE.IDLE,
|
|
12061
|
+
},
|
|
12062
|
+
{
|
|
12063
|
+
description: 'triggers MEETING_PAUSED_RECORDING when state is PAUSED',
|
|
12064
|
+
state: RECORDING_STATE.PAUSED,
|
|
12065
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_PAUSED_RECORDING,
|
|
12066
|
+
expectedRecordingState: RECORDING_STATE.PAUSED,
|
|
12067
|
+
},
|
|
12068
|
+
{
|
|
12069
|
+
description:
|
|
12070
|
+
'triggers MEETING_RESUMED_RECORDING and sets state to RECORDING when state is RESUMED',
|
|
12071
|
+
state: RECORDING_STATE.RESUMED,
|
|
12072
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_RESUMED_RECORDING,
|
|
12073
|
+
expectedRecordingState: RECORDING_STATE.RECORDING,
|
|
12074
|
+
},
|
|
12075
|
+
];
|
|
12076
|
+
|
|
12077
|
+
recordingTestCases.forEach(({description, state, expectedEvent, expectedRecordingState}) => {
|
|
12078
|
+
it(`listens to CONTROLS_RECORDING_UPDATED - ${description}`, async () => {
|
|
12079
|
+
const modifiedBy = 'user-id-123';
|
|
12080
|
+
const lastModified = '2026-01-01T00:00:00Z';
|
|
12081
|
+
|
|
12082
|
+
await meeting.locusInfo.emitScoped(
|
|
12083
|
+
{function: 'test', file: 'test'},
|
|
12084
|
+
LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
|
|
12085
|
+
{state, modifiedBy, lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined}
|
|
12086
|
+
);
|
|
12087
|
+
|
|
12088
|
+
assert.deepEqual(meeting.recording, {
|
|
12089
|
+
state: expectedRecordingState,
|
|
12090
|
+
modifiedBy,
|
|
12091
|
+
lastModified,
|
|
12092
|
+
modifiedByServiceAppName: undefined,
|
|
12093
|
+
modifiedByServiceAppId: undefined,
|
|
12094
|
+
});
|
|
12095
|
+
|
|
12096
|
+
assert.calledWith(
|
|
12097
|
+
TriggerProxy.trigger,
|
|
12098
|
+
meeting,
|
|
12099
|
+
{file: 'meeting/index', function: 'setupLocusControlsListener'},
|
|
12100
|
+
expectedEvent,
|
|
12101
|
+
meeting.recording
|
|
12102
|
+
);
|
|
12103
|
+
});
|
|
12104
|
+
});
|
|
12105
|
+
|
|
12106
|
+
it('listens to CONTROLS_RECORDING_UPDATED and includes modifiedByServiceAppName and modifiedByServiceAppId when present', async () => {
|
|
12107
|
+
const modifiedBy = 'user-id-123';
|
|
12108
|
+
const lastModified = '2026-01-01T00:00:00Z';
|
|
12109
|
+
const modifiedByServiceAppName = 'My Bot';
|
|
12110
|
+
const modifiedByServiceAppId = 'app-id-123';
|
|
12111
|
+
|
|
12112
|
+
await meeting.locusInfo.emitScoped(
|
|
12113
|
+
{function: 'test', file: 'test'},
|
|
12114
|
+
LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
|
|
12115
|
+
{state: RECORDING_STATE.RECORDING, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}
|
|
12116
|
+
);
|
|
12117
|
+
|
|
12118
|
+
assert.deepEqual(meeting.recording, {
|
|
12119
|
+
state: RECORDING_STATE.RECORDING,
|
|
12120
|
+
modifiedBy,
|
|
12121
|
+
lastModified,
|
|
12122
|
+
modifiedByServiceAppName,
|
|
12123
|
+
modifiedByServiceAppId,
|
|
12124
|
+
});
|
|
12125
|
+
|
|
12126
|
+
assert.calledWith(
|
|
12127
|
+
TriggerProxy.trigger,
|
|
12128
|
+
meeting,
|
|
12129
|
+
{file: 'meeting/index', function: 'setupLocusControlsListener'},
|
|
12130
|
+
EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
|
|
12131
|
+
meeting.recording
|
|
12132
|
+
);
|
|
12133
|
+
});
|
|
12134
|
+
|
|
10974
12135
|
it('listens to the locus interpretation update event', () => {
|
|
10975
12136
|
const interpretation = {
|
|
10976
12137
|
siLanguages: [{languageCode: 20, languageName: 'en'}],
|
|
@@ -11024,6 +12185,7 @@ describe('plugin-meetings', () => {
|
|
|
11024
12185
|
meeting.annotation.locusUrlUpdate = sinon.stub();
|
|
11025
12186
|
meeting.simultaneousInterpretation.locusUrlUpdate = sinon.stub();
|
|
11026
12187
|
meeting.webinar.locusUrlUpdate = sinon.stub();
|
|
12188
|
+
meeting.aiEnableRequest.locusUrlUpdate = sinon.stub();
|
|
11027
12189
|
|
|
11028
12190
|
meeting.locusInfo.emit(
|
|
11029
12191
|
{function: 'test', file: 'test'},
|
|
@@ -11038,6 +12200,7 @@ describe('plugin-meetings', () => {
|
|
|
11038
12200
|
assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, false);
|
|
11039
12201
|
assert.calledWith(meeting.simultaneousInterpretation.locusUrlUpdate, newLocusUrl);
|
|
11040
12202
|
assert.calledWith(meeting.webinar.locusUrlUpdate, newLocusUrl);
|
|
12203
|
+
assert.calledWith(meeting.aiEnableRequest.locusUrlUpdate, newLocusUrl);
|
|
11041
12204
|
assert.equal(meeting.locusUrl, newLocusUrl);
|
|
11042
12205
|
assert(meeting.locusId, '12345');
|
|
11043
12206
|
|
|
@@ -11353,6 +12516,109 @@ describe('plugin-meetings', () => {
|
|
|
11353
12516
|
});
|
|
11354
12517
|
});
|
|
11355
12518
|
|
|
12519
|
+
describe('#finalizeMeetingAfterInitialLocusSetup', () => {
|
|
12520
|
+
it('refreshes destination from synced locus when destination type is LOCUS_ID', () => {
|
|
12521
|
+
const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12522
|
+
|
|
12523
|
+
meeting.destinationType = DESTINATION_TYPE.LOCUS_ID;
|
|
12524
|
+
meeting.destination = {info: {topic: 'old'}};
|
|
12525
|
+
|
|
12526
|
+
meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
|
|
12527
|
+
|
|
12528
|
+
assert.equal(meeting.destination, syncedLocus);
|
|
12529
|
+
});
|
|
12530
|
+
|
|
12531
|
+
it('does not refresh destination when destination type is not LOCUS_ID', () => {
|
|
12532
|
+
const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12533
|
+
const originalDestination = {destination: 'original-destination'};
|
|
12534
|
+
|
|
12535
|
+
meeting.destinationType = DESTINATION_TYPE.CONVERSATION_URL;
|
|
12536
|
+
meeting.destination = originalDestination;
|
|
12537
|
+
|
|
12538
|
+
meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
|
|
12539
|
+
|
|
12540
|
+
assert.equal(meeting.destination, originalDestination);
|
|
12541
|
+
});
|
|
12542
|
+
|
|
12543
|
+
it('fetches meeting info when meetingInfo is empty and destination has info', () => {
|
|
12544
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12545
|
+
|
|
12546
|
+
meeting.meetingInfo = {};
|
|
12547
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12548
|
+
|
|
12549
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12550
|
+
|
|
12551
|
+
assert.calledOnceWithExactly(fetchMeetingInfoStub, {});
|
|
12552
|
+
});
|
|
12553
|
+
|
|
12554
|
+
it('does not fetch meeting info when destination has no info', () => {
|
|
12555
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12556
|
+
|
|
12557
|
+
meeting.meetingInfo = {};
|
|
12558
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123'};
|
|
12559
|
+
|
|
12560
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12561
|
+
|
|
12562
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12563
|
+
});
|
|
12564
|
+
|
|
12565
|
+
it('does not fetch meeting info when meetingInfo is already populated', () => {
|
|
12566
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12567
|
+
|
|
12568
|
+
meeting.meetingInfo = {meetingJoinUrl: 'https://example.com/join/abc'};
|
|
12569
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12570
|
+
|
|
12571
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12572
|
+
|
|
12573
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12574
|
+
});
|
|
12575
|
+
|
|
12576
|
+
it('does not fetch meeting info when delayed fetch timer is already scheduled', () => {
|
|
12577
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12578
|
+
|
|
12579
|
+
meeting.meetingInfo = {};
|
|
12580
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12581
|
+
meeting.fetchMeetingInfoTimeoutId = 42;
|
|
12582
|
+
|
|
12583
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12584
|
+
|
|
12585
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12586
|
+
});
|
|
12587
|
+
|
|
12588
|
+
['CALL', 'SIP_BRIDGE', 'SPACE_SHARE'].forEach((fullStateType) => {
|
|
12589
|
+
it(`does not fetch meeting info when destination is a 1:1 call (fullState.type ${fullStateType})`, () => {
|
|
12590
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12591
|
+
|
|
12592
|
+
meeting.meetingInfo = {};
|
|
12593
|
+
meeting.destination = {
|
|
12594
|
+
url: 'https://locus.example.com/locus/123',
|
|
12595
|
+
info: {topic: 'x'},
|
|
12596
|
+
};
|
|
12597
|
+
|
|
12598
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({fullState: {type: fullStateType}});
|
|
12599
|
+
|
|
12600
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12601
|
+
});
|
|
12602
|
+
});
|
|
12603
|
+
|
|
12604
|
+
it('swallows async fetchMeetingInfo errors and logs info', async () => {
|
|
12605
|
+
const error = new Error('fetch failed');
|
|
12606
|
+
|
|
12607
|
+
meeting.meetingInfo = {};
|
|
12608
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12609
|
+
sinon.stub(meeting, 'fetchMeetingInfo').returns(Promise.reject(error));
|
|
12610
|
+
const loggerInfoStub = sinon.stub(LoggerProxy.logger, 'info');
|
|
12611
|
+
|
|
12612
|
+
await meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12613
|
+
|
|
12614
|
+
assert.calledOnce(loggerInfoStub);
|
|
12615
|
+
assert.match(
|
|
12616
|
+
loggerInfoStub.firstCall.args[0],
|
|
12617
|
+
/Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: fetch failed/
|
|
12618
|
+
);
|
|
12619
|
+
});
|
|
12620
|
+
});
|
|
12621
|
+
|
|
11356
12622
|
describe('#emailInput', () => {
|
|
11357
12623
|
it('should set the email input', () => {
|
|
11358
12624
|
assert.notOk(meeting.emailInput);
|
|
@@ -11955,6 +13221,7 @@ describe('plugin-meetings', () => {
|
|
|
11955
13221
|
let showAutoEndMeetingWarningSpy;
|
|
11956
13222
|
let canAttendeeRequestAiAssistantEnabledSpy;
|
|
11957
13223
|
let attendeeRequestAiAssistantDeclinedAllSpy;
|
|
13224
|
+
let isAnonymizeDisplayNamesEnabledSpy;
|
|
11958
13225
|
// Due to import tree issues, hasHints must be stubed within the scope of the `it`.
|
|
11959
13226
|
|
|
11960
13227
|
beforeEach(() => {
|
|
@@ -12003,6 +13270,10 @@ describe('plugin-meetings', () => {
|
|
|
12003
13270
|
MeetingUtil,
|
|
12004
13271
|
'attendeeRequestAiAssistantDeclinedAll'
|
|
12005
13272
|
);
|
|
13273
|
+
isAnonymizeDisplayNamesEnabledSpy = sinon.spy(
|
|
13274
|
+
MeetingUtil,
|
|
13275
|
+
'isAnonymizeDisplayNamesEnabled'
|
|
13276
|
+
);
|
|
12006
13277
|
});
|
|
12007
13278
|
|
|
12008
13279
|
afterEach(() => {
|
|
@@ -12011,6 +13282,7 @@ describe('plugin-meetings', () => {
|
|
|
12011
13282
|
showAutoEndMeetingWarningSpy.restore();
|
|
12012
13283
|
canAttendeeRequestAiAssistantEnabledSpy.restore();
|
|
12013
13284
|
attendeeRequestAiAssistantDeclinedAllSpy.restore();
|
|
13285
|
+
isAnonymizeDisplayNamesEnabledSpy.restore();
|
|
12014
13286
|
});
|
|
12015
13287
|
|
|
12016
13288
|
forEach(
|
|
@@ -12568,6 +13840,7 @@ describe('plugin-meetings', () => {
|
|
|
12568
13840
|
meeting.roles
|
|
12569
13841
|
);
|
|
12570
13842
|
assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
|
|
13843
|
+
assert.calledWith(isAnonymizeDisplayNamesEnabledSpy, userDisplayHints);
|
|
12571
13844
|
|
|
12572
13845
|
assert.calledWith(ControlsOptionsUtil.hasHints, {
|
|
12573
13846
|
requiredHints: [DISPLAY_HINTS.MUTE_ALL],
|
|
@@ -12782,6 +14055,10 @@ describe('plugin-meetings', () => {
|
|
|
12782
14055
|
describe('#saveDataChannelToken', () => {
|
|
12783
14056
|
beforeEach(() => {
|
|
12784
14057
|
webex.internal.llm.setDatachannelToken = sinon.stub();
|
|
14058
|
+
webex.internal.llm.resolveSessionOwnership = sinon
|
|
14059
|
+
.stub()
|
|
14060
|
+
.returns({currentOwner: undefined, isOwner: true});
|
|
14061
|
+
webex.internal.llm.isConnected = sinon.stub().returns(false);
|
|
12785
14062
|
});
|
|
12786
14063
|
|
|
12787
14064
|
it('saves datachannelToken into LLM as Default', () => {
|
|
@@ -12794,7 +14071,8 @@ describe('plugin-meetings', () => {
|
|
|
12794
14071
|
assert.calledWithExactly(
|
|
12795
14072
|
webex.internal.llm.setDatachannelToken,
|
|
12796
14073
|
'default-token',
|
|
12797
|
-
'llm-default-session'
|
|
14074
|
+
'llm-default-session',
|
|
14075
|
+
meeting.id
|
|
12798
14076
|
);
|
|
12799
14077
|
});
|
|
12800
14078
|
|
|
@@ -12808,7 +14086,8 @@ describe('plugin-meetings', () => {
|
|
|
12808
14086
|
assert.calledWithExactly(
|
|
12809
14087
|
webex.internal.llm.setDatachannelToken,
|
|
12810
14088
|
'ps-token',
|
|
12811
|
-
'llm-practice-session'
|
|
14089
|
+
'llm-practice-session',
|
|
14090
|
+
meeting.id
|
|
12812
14091
|
);
|
|
12813
14092
|
});
|
|
12814
14093
|
|
|
@@ -12826,12 +14105,14 @@ describe('plugin-meetings', () => {
|
|
|
12826
14105
|
assert.calledWithExactly(
|
|
12827
14106
|
webex.internal.llm.setDatachannelToken,
|
|
12828
14107
|
'default-token',
|
|
12829
|
-
'llm-default-session'
|
|
14108
|
+
'llm-default-session',
|
|
14109
|
+
meeting.id
|
|
12830
14110
|
);
|
|
12831
14111
|
assert.calledWithExactly(
|
|
12832
14112
|
webex.internal.llm.setDatachannelToken,
|
|
12833
14113
|
'ps-token',
|
|
12834
|
-
'llm-practice-session'
|
|
14114
|
+
'llm-practice-session',
|
|
14115
|
+
meeting.id
|
|
12835
14116
|
);
|
|
12836
14117
|
});
|
|
12837
14118
|
|
|
@@ -12852,17 +14133,42 @@ describe('plugin-meetings', () => {
|
|
|
12852
14133
|
|
|
12853
14134
|
assert.notCalled(webex.internal.llm.setDatachannelToken);
|
|
12854
14135
|
});
|
|
14136
|
+
|
|
14137
|
+
it('writes token with meeting id as owner', () => {
|
|
14138
|
+
meeting.saveDataChannelToken({
|
|
14139
|
+
locus: {
|
|
14140
|
+
self: {datachannelToken: 'default-token'},
|
|
14141
|
+
},
|
|
14142
|
+
});
|
|
14143
|
+
|
|
14144
|
+
assert.calledOnceWithExactly(
|
|
14145
|
+
webex.internal.llm.setDatachannelToken,
|
|
14146
|
+
'default-token',
|
|
14147
|
+
'llm-default-session',
|
|
14148
|
+
meeting.id
|
|
14149
|
+
);
|
|
14150
|
+
});
|
|
12855
14151
|
});
|
|
12856
14152
|
|
|
12857
14153
|
describe('#clearDataChannelToken', () => {
|
|
12858
14154
|
beforeEach(() => {
|
|
12859
|
-
webex.internal.llm.
|
|
14155
|
+
webex.internal.llm.clearDatachannelToken = sinon.stub();
|
|
12860
14156
|
});
|
|
12861
14157
|
|
|
12862
|
-
it('
|
|
14158
|
+
it('delegates default and practice token clears to llm with meeting ownership id', () => {
|
|
12863
14159
|
meeting.clearDataChannelToken();
|
|
12864
14160
|
|
|
12865
|
-
assert.
|
|
14161
|
+
assert.calledWithExactly(
|
|
14162
|
+
webex.internal.llm.clearDatachannelToken,
|
|
14163
|
+
'llm-default-session',
|
|
14164
|
+
meeting.id
|
|
14165
|
+
);
|
|
14166
|
+
assert.calledWithExactly(
|
|
14167
|
+
webex.internal.llm.clearDatachannelToken,
|
|
14168
|
+
'llm-practice-session',
|
|
14169
|
+
meeting.id
|
|
14170
|
+
);
|
|
14171
|
+
assert.callCount(webex.internal.llm.clearDatachannelToken, 2);
|
|
12866
14172
|
});
|
|
12867
14173
|
});
|
|
12868
14174
|
|
|
@@ -12872,6 +14178,7 @@ describe('plugin-meetings', () => {
|
|
|
12872
14178
|
webex.internal.llm.getLocusUrl = sinon.stub();
|
|
12873
14179
|
webex.internal.llm.getDatachannelUrl = sinon.stub();
|
|
12874
14180
|
webex.internal.llm.registerAndConnect = sinon.stub().resolves('something');
|
|
14181
|
+
webex.internal.llm.setRefreshHandler = sinon.stub();
|
|
12875
14182
|
webex.internal.llm.disconnectLLM = sinon.stub().resolves();
|
|
12876
14183
|
webex.internal.llm.on = sinon.stub();
|
|
12877
14184
|
webex.internal.llm.off = sinon.stub();
|
|
@@ -12889,7 +14196,7 @@ describe('plugin-meetings', () => {
|
|
|
12889
14196
|
meeting.joinedWith = {state: 'any other state'};
|
|
12890
14197
|
webex.internal.llm.getLocusUrl.returns('a url');
|
|
12891
14198
|
|
|
12892
|
-
meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14199
|
+
meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
12893
14200
|
|
|
12894
14201
|
const result = await meeting.updateLLMConnection();
|
|
12895
14202
|
|
|
@@ -12901,6 +14208,7 @@ describe('plugin-meetings', () => {
|
|
|
12901
14208
|
it('returns undefined if llm is already connected and the locus url is unchanged', async () => {
|
|
12902
14209
|
meeting.joinedWith = {state: 'JOINED'};
|
|
12903
14210
|
meeting.locusInfo = {
|
|
14211
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
12904
14212
|
url: 'a url',
|
|
12905
14213
|
info: {datachannelUrl: 'a datachannel url'},
|
|
12906
14214
|
};
|
|
@@ -12937,7 +14245,7 @@ describe('plugin-meetings', () => {
|
|
|
12937
14245
|
});
|
|
12938
14246
|
it('connects if not already connected', async () => {
|
|
12939
14247
|
meeting.joinedWith = {state: 'JOINED'};
|
|
12940
|
-
meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14248
|
+
meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
12941
14249
|
|
|
12942
14250
|
const result = await meeting.updateLLMConnection();
|
|
12943
14251
|
|
|
@@ -12948,7 +14256,14 @@ describe('plugin-meetings', () => {
|
|
|
12948
14256
|
'a datachannel url',
|
|
12949
14257
|
undefined
|
|
12950
14258
|
);
|
|
14259
|
+
assert.calledOnceWithExactly(
|
|
14260
|
+
webex.internal.llm.setRefreshHandler,
|
|
14261
|
+
sinon.match.func,
|
|
14262
|
+
'llm-default-session',
|
|
14263
|
+
meeting.id
|
|
14264
|
+
);
|
|
12951
14265
|
assert.equal(result, 'something');
|
|
14266
|
+
assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true});
|
|
12952
14267
|
});
|
|
12953
14268
|
it('disconnects if the locus url has changed', async () => {
|
|
12954
14269
|
meeting.joinedWith = {state: 'JOINED'};
|
|
@@ -12957,6 +14272,7 @@ describe('plugin-meetings', () => {
|
|
|
12957
14272
|
webex.internal.llm.getLocusUrl.returns('a url');
|
|
12958
14273
|
|
|
12959
14274
|
meeting.locusInfo = {
|
|
14275
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
12960
14276
|
url: 'a different url',
|
|
12961
14277
|
info: {datachannelUrl: 'a datachannel url'},
|
|
12962
14278
|
self: {},
|
|
@@ -12967,7 +14283,7 @@ describe('plugin-meetings', () => {
|
|
|
12967
14283
|
assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
|
|
12968
14284
|
code: 3050,
|
|
12969
14285
|
reason: 'done (permanent)',
|
|
12970
|
-
});
|
|
14286
|
+
}, 'llm-default-session', meeting.id);
|
|
12971
14287
|
|
|
12972
14288
|
assert.calledWithExactly(
|
|
12973
14289
|
webex.internal.llm.registerAndConnect,
|
|
@@ -13010,6 +14326,7 @@ describe('plugin-meetings', () => {
|
|
|
13010
14326
|
webex.internal.llm.getLocusUrl.returns('a url');
|
|
13011
14327
|
|
|
13012
14328
|
meeting.locusInfo = {
|
|
14329
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13013
14330
|
url: 'a url',
|
|
13014
14331
|
info: {datachannelUrl: 'a different datachannel url'},
|
|
13015
14332
|
self: {},
|
|
@@ -13020,7 +14337,7 @@ describe('plugin-meetings', () => {
|
|
|
13020
14337
|
assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
|
|
13021
14338
|
code: 3050,
|
|
13022
14339
|
reason: 'done (permanent)',
|
|
13023
|
-
});
|
|
14340
|
+
}, 'llm-default-session', meeting.id);
|
|
13024
14341
|
|
|
13025
14342
|
assert.calledWithExactly(
|
|
13026
14343
|
webex.internal.llm.registerAndConnect,
|
|
@@ -13061,14 +14378,14 @@ describe('plugin-meetings', () => {
|
|
|
13061
14378
|
webex.internal.llm.isConnected.returns(true);
|
|
13062
14379
|
webex.internal.llm.getLocusUrl.returns('a url');
|
|
13063
14380
|
|
|
13064
|
-
meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14381
|
+
meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
13065
14382
|
|
|
13066
14383
|
const result = await meeting.updateLLMConnection();
|
|
13067
14384
|
|
|
13068
14385
|
assert.calledWith(webex.internal.llm.disconnectLLM, {
|
|
13069
14386
|
code: 3050,
|
|
13070
14387
|
reason: 'done (permanent)',
|
|
13071
|
-
});
|
|
14388
|
+
}, 'llm-default-session', meeting.id);
|
|
13072
14389
|
assert.notCalled(webex.internal.llm.registerAndConnect);
|
|
13073
14390
|
assert.equal(result, undefined);
|
|
13074
14391
|
assert.isFalse(
|
|
@@ -13084,6 +14401,7 @@ describe('plugin-meetings', () => {
|
|
|
13084
14401
|
webex.internal.llm.disconnectLLM.rejects(disconnectError);
|
|
13085
14402
|
|
|
13086
14403
|
meeting.locusInfo = {
|
|
14404
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13087
14405
|
url: 'a different url',
|
|
13088
14406
|
info: {datachannelUrl: 'a datachannel url'},
|
|
13089
14407
|
self: {},
|
|
@@ -13115,6 +14433,7 @@ describe('plugin-meetings', () => {
|
|
|
13115
14433
|
it('still need connect main session data channel when PS started', async () => {
|
|
13116
14434
|
meeting.joinedWith = {state: 'JOINED'};
|
|
13117
14435
|
meeting.locusInfo = {
|
|
14436
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13118
14437
|
url: 'a url',
|
|
13119
14438
|
info: {
|
|
13120
14439
|
datachannelUrl: 'a datachannel url',
|
|
@@ -13135,11 +14454,14 @@ describe('plugin-meetings', () => {
|
|
|
13135
14454
|
it('passes dataChannelToken from LLM to registerAndConnect', async () => {
|
|
13136
14455
|
meeting.joinedWith = {state: 'JOINED'};
|
|
13137
14456
|
meeting.locusInfo = {
|
|
14457
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13138
14458
|
url: 'a url',
|
|
13139
14459
|
info: {datachannelUrl: 'a datachannel url'},
|
|
13140
14460
|
};
|
|
13141
14461
|
|
|
13142
|
-
webex.internal.llm.getDatachannelToken
|
|
14462
|
+
webex.internal.llm.getDatachannelToken
|
|
14463
|
+
.withArgs('llm-default-session', meeting.id)
|
|
14464
|
+
.returns('token-123');
|
|
13143
14465
|
|
|
13144
14466
|
await meeting.updateLLMConnection();
|
|
13145
14467
|
|
|
@@ -13154,6 +14476,7 @@ describe('plugin-meetings', () => {
|
|
|
13154
14476
|
it('passes undefined token when LLM has no token stored', async () => {
|
|
13155
14477
|
meeting.joinedWith = {state: 'JOINED'};
|
|
13156
14478
|
meeting.locusInfo = {
|
|
14479
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13157
14480
|
url: 'a url',
|
|
13158
14481
|
info: {datachannelUrl: 'a datachannel url'},
|
|
13159
14482
|
};
|
|
@@ -13175,6 +14498,7 @@ describe('plugin-meetings', () => {
|
|
|
13175
14498
|
it('does not pass token when data channel with jwt token is disabled', async () => {
|
|
13176
14499
|
meeting.joinedWith = {state: 'JOINED'};
|
|
13177
14500
|
meeting.locusInfo = {
|
|
14501
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13178
14502
|
url: 'a url',
|
|
13179
14503
|
info: {datachannelUrl: 'a datachannel url'},
|
|
13180
14504
|
};
|
|
@@ -13193,6 +14517,197 @@ describe('plugin-meetings', () => {
|
|
|
13193
14517
|
assert.notCalled(webex.internal.llm.setDatachannelToken);
|
|
13194
14518
|
});
|
|
13195
14519
|
|
|
14520
|
+
describe('ownership tag', () => {
|
|
14521
|
+
beforeEach(() => {
|
|
14522
|
+
// Make the owner stub dynamic so setOwnerMeetingId() writes
|
|
14523
|
+
// propagate back to getOwnerMeetingId() reads. This mirrors the
|
|
14524
|
+
// real LLM singleton behavior so the finally-block release in
|
|
14525
|
+
// cleanupLLMConneciton is reflected in subsequent reads.
|
|
14526
|
+
webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
|
|
14527
|
+
webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
|
|
14528
|
+
webex.internal.llm.getOwnerMeetingId.returns(id);
|
|
14529
|
+
});
|
|
14530
|
+
});
|
|
14531
|
+
|
|
14532
|
+
it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
|
|
14533
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14534
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14535
|
+
webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
|
|
14536
|
+
// Locus/datachannel URL mismatch is the *normal* case when
|
|
14537
|
+
// another meeting owns the live socket -- each meeting has its
|
|
14538
|
+
// own locus URL. URL mismatch must NOT trigger a reclaim,
|
|
14539
|
+
// because doing so would tear down the owning meeting's healthy
|
|
14540
|
+
// LLM socket and break its data channel.
|
|
14541
|
+
webex.internal.llm.getLocusUrl.returns('owner-locus-url');
|
|
14542
|
+
webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
|
|
14543
|
+
meeting.locusInfo = {
|
|
14544
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
14545
|
+
url: 'a different url',
|
|
14546
|
+
info: {datachannelUrl: 'a different datachannel url'},
|
|
14547
|
+
self: {},
|
|
14548
|
+
};
|
|
14549
|
+
|
|
14550
|
+
const result = await meeting.updateLLMConnection();
|
|
14551
|
+
|
|
14552
|
+
assert.equal(result, undefined);
|
|
14553
|
+
assert.notCalled(webex.internal.llm.disconnectLLM);
|
|
14554
|
+
assert.notCalled(webex.internal.llm.registerAndConnect);
|
|
14555
|
+
assert.notCalled(webex.internal.llm.setOwnerMeetingId);
|
|
14556
|
+
assert.notCalled(meeting.startLLMHealthCheckTimer);
|
|
14557
|
+
});
|
|
14558
|
+
|
|
14559
|
+
|
|
14560
|
+
it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
|
|
14561
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14562
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14563
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14564
|
+
webex.internal.llm.getLocusUrl.returns('a url');
|
|
14565
|
+
webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
|
|
14566
|
+
webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
|
|
14567
|
+
meeting.locusInfo = {
|
|
14568
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
14569
|
+
url: 'a different url',
|
|
14570
|
+
info: {datachannelUrl: 'a datachannel url'},
|
|
14571
|
+
self: {},
|
|
14572
|
+
};
|
|
14573
|
+
|
|
14574
|
+
try {
|
|
14575
|
+
await meeting.updateLLMConnection();
|
|
14576
|
+
} catch (e) {
|
|
14577
|
+
/* updateLLMConnection may reject when cleanup throws */
|
|
14578
|
+
}
|
|
14579
|
+
|
|
14580
|
+
// The owner-eligible finally branch must release the tag so a
|
|
14581
|
+
// subsequent reconnect attempt from any meeting is not blocked.
|
|
14582
|
+
assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
|
|
14583
|
+
});
|
|
14584
|
+
|
|
14585
|
+
it('does not clear owner tag when ownership changes during cleanup disconnect await', async () => {
|
|
14586
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14587
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14588
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14589
|
+
webex.internal.llm.getLocusUrl.returns('a url');
|
|
14590
|
+
webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
|
|
14591
|
+
webex.internal.llm.disconnectLLM.callsFake(async () => {
|
|
14592
|
+
webex.internal.llm.getOwnerMeetingId.returns('new-owner-id');
|
|
14593
|
+
throw new Error('disconnect failed');
|
|
14594
|
+
});
|
|
14595
|
+
meeting.locusInfo = {
|
|
14596
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
14597
|
+
url: 'a different url',
|
|
14598
|
+
info: {datachannelUrl: 'a datachannel url'},
|
|
14599
|
+
self: {},
|
|
14600
|
+
};
|
|
14601
|
+
|
|
14602
|
+
try {
|
|
14603
|
+
await meeting.updateLLMConnection();
|
|
14604
|
+
} catch (e) {
|
|
14605
|
+
/* updateLLMConnection may reject when cleanup throws */
|
|
14606
|
+
}
|
|
14607
|
+
|
|
14608
|
+
assert.notCalled(webex.internal.llm.setOwnerMeetingId);
|
|
14609
|
+
assert.equal(webex.internal.llm.getOwnerMeetingId(), 'new-owner-id');
|
|
14610
|
+
});
|
|
14611
|
+
|
|
14612
|
+
it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
|
|
14613
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14614
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14615
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14616
|
+
webex.internal.llm.getLocusUrl.returns('a url');
|
|
14617
|
+
webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
|
|
14618
|
+
meeting.locusInfo = {
|
|
14619
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
14620
|
+
url: 'a different url',
|
|
14621
|
+
info: {datachannelUrl: 'a datachannel url'},
|
|
14622
|
+
self: {},
|
|
14623
|
+
};
|
|
14624
|
+
|
|
14625
|
+
await meeting.updateLLMConnection();
|
|
14626
|
+
|
|
14627
|
+
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
14628
|
+
code: 3050,
|
|
14629
|
+
reason: 'done (permanent)',
|
|
14630
|
+
}, 'llm-default-session', meeting.id);
|
|
14631
|
+
assert.calledWithExactly(
|
|
14632
|
+
webex.internal.llm.registerAndConnect,
|
|
14633
|
+
'a different url',
|
|
14634
|
+
'a datachannel url',
|
|
14635
|
+
undefined
|
|
14636
|
+
);
|
|
14637
|
+
// setOwnerMeetingId is called twice: first with undefined in
|
|
14638
|
+
// cleanupLLMConneciton's finally block (so a failed disconnect
|
|
14639
|
+
// cannot leave a stale owner), then with this meeting's id
|
|
14640
|
+
// after registerAndConnect resolves.
|
|
14641
|
+
assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
|
|
14642
|
+
assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
|
|
14643
|
+
assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
|
|
14644
|
+
});
|
|
14645
|
+
|
|
14646
|
+
it('claims ownership after successful registerAndConnect on initial connect', async () => {
|
|
14647
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14648
|
+
webex.internal.llm.isConnected.returns(false);
|
|
14649
|
+
webex.internal.llm.getOwnerMeetingId.returns(undefined);
|
|
14650
|
+
meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14651
|
+
|
|
14652
|
+
await meeting.updateLLMConnection();
|
|
14653
|
+
|
|
14654
|
+
assert.calledOnce(webex.internal.llm.registerAndConnect);
|
|
14655
|
+
assert.calledOnceWithExactly(
|
|
14656
|
+
webex.internal.llm.setRefreshHandler,
|
|
14657
|
+
sinon.match.func,
|
|
14658
|
+
'llm-default-session',
|
|
14659
|
+
meeting.id
|
|
14660
|
+
);
|
|
14661
|
+
assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
|
|
14662
|
+
});
|
|
14663
|
+
|
|
14664
|
+
it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
|
|
14665
|
+
// Defensive path: if the LLM reports not-connected but an old
|
|
14666
|
+
// ownerId is still present (e.g. race before a successful
|
|
14667
|
+
// connections.delete), this meeting can still claim a fresh
|
|
14668
|
+
// connection.
|
|
14669
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14670
|
+
webex.internal.llm.isConnected.returns(false);
|
|
14671
|
+
webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
|
|
14672
|
+
webex.internal.llm.getDatachannelToken.onFirstCall().returns(undefined);
|
|
14673
|
+
webex.internal.llm.getDatachannelToken.onSecondCall().returns('recovered-token');
|
|
14674
|
+
meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14675
|
+
|
|
14676
|
+
await meeting.updateLLMConnection();
|
|
14677
|
+
|
|
14678
|
+
assert.calledTwice(webex.internal.llm.getDatachannelToken);
|
|
14679
|
+
assert.calledWithExactly(
|
|
14680
|
+
webex.internal.llm.getDatachannelToken.firstCall,
|
|
14681
|
+
'llm-default-session',
|
|
14682
|
+
meeting.id
|
|
14683
|
+
);
|
|
14684
|
+
assert.calledWithExactly(
|
|
14685
|
+
webex.internal.llm.getDatachannelToken.secondCall,
|
|
14686
|
+
'llm-default-session'
|
|
14687
|
+
);
|
|
14688
|
+
assert.calledOnceWithExactly(
|
|
14689
|
+
webex.internal.llm.registerAndConnect,
|
|
14690
|
+
'a url',
|
|
14691
|
+
'a datachannel url',
|
|
14692
|
+
'recovered-token'
|
|
14693
|
+
);
|
|
14694
|
+
assert.calledWithExactly(
|
|
14695
|
+
webex.internal.llm.setRefreshHandler.firstCall,
|
|
14696
|
+
sinon.match.func,
|
|
14697
|
+
'llm-default-session',
|
|
14698
|
+
undefined
|
|
14699
|
+
);
|
|
14700
|
+
assert.calledTwice(webex.internal.llm.setRefreshHandler);
|
|
14701
|
+
assert.calledWithExactly(
|
|
14702
|
+
webex.internal.llm.setRefreshHandler.secondCall,
|
|
14703
|
+
sinon.match.func,
|
|
14704
|
+
'llm-default-session',
|
|
14705
|
+
meeting.id
|
|
14706
|
+
);
|
|
14707
|
+
assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
|
|
14708
|
+
});
|
|
14709
|
+
});
|
|
14710
|
+
|
|
13196
14711
|
describe('#clearMeetingData', () => {
|
|
13197
14712
|
beforeEach(() => {
|
|
13198
14713
|
webex.internal.llm.isConnected = sinon.stub().returns(true);
|
|
@@ -13211,7 +14726,7 @@ describe('plugin-meetings', () => {
|
|
|
13211
14726
|
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
13212
14727
|
code: 3050,
|
|
13213
14728
|
reason: 'done (permanent)',
|
|
13214
|
-
});
|
|
14729
|
+
}, 'llm-default-session', meeting.id);
|
|
13215
14730
|
assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
|
|
13216
14731
|
assert.calledWithExactly(
|
|
13217
14732
|
webex.internal.llm.off,
|
|
@@ -13224,10 +14739,13 @@ describe('plugin-meetings', () => {
|
|
|
13224
14739
|
meeting.processLocusLLMEvent
|
|
13225
14740
|
);
|
|
13226
14741
|
assert.calledOnce(meeting.clearLLMHealthCheckTimer);
|
|
13227
|
-
assert.calledOnce(meeting.stopTranscription);
|
|
13228
|
-
assert.isUndefined(meeting.transcription);
|
|
13229
14742
|
assert.calledOnce(meeting.clearDataChannelToken);
|
|
13230
|
-
|
|
14743
|
+
// stopTranscription and annotation.deregisterEvents are not
|
|
14744
|
+
// called here: they run in stopListeningForMeetingEvents()
|
|
14745
|
+
// before /leave to avoid double-emitting
|
|
14746
|
+
// MEETING_STOPPED_RECEIVING_TRANSCRIPTION.
|
|
14747
|
+
assert.notCalled(meeting.stopTranscription);
|
|
14748
|
+
assert.notCalled(meeting.annotation.deregisterEvents);
|
|
13231
14749
|
});
|
|
13232
14750
|
it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
|
|
13233
14751
|
webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
|
|
@@ -13246,19 +14764,65 @@ describe('plugin-meetings', () => {
|
|
|
13246
14764
|
meeting.processLocusLLMEvent
|
|
13247
14765
|
);
|
|
13248
14766
|
assert.calledOnce(meeting.clearLLMHealthCheckTimer);
|
|
13249
|
-
assert.calledOnce(meeting.stopTranscription);
|
|
13250
|
-
assert.isUndefined(meeting.transcription);
|
|
13251
14767
|
assert.calledOnce(meeting.clearDataChannelToken);
|
|
13252
|
-
assert.
|
|
14768
|
+
assert.notCalled(meeting.stopTranscription);
|
|
14769
|
+
assert.notCalled(meeting.annotation.deregisterEvents);
|
|
13253
14770
|
});
|
|
13254
|
-
it('always calls stopTranscription even when transcription is undefined', async () => {
|
|
13255
|
-
meeting.transcription = undefined;
|
|
13256
14771
|
|
|
13257
|
-
|
|
14772
|
+
describe('ownership tag', () => {
|
|
14773
|
+
beforeEach(() => {
|
|
14774
|
+
webex.internal.llm.getOwnerMeetingId = sinon.stub();
|
|
14775
|
+
});
|
|
13258
14776
|
|
|
13259
|
-
|
|
13260
|
-
|
|
13261
|
-
|
|
14777
|
+
it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
|
|
14778
|
+
webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
|
|
14779
|
+
|
|
14780
|
+
await meeting.clearMeetingData();
|
|
14781
|
+
|
|
14782
|
+
assert.notCalled(webex.internal.llm.disconnectLLM);
|
|
14783
|
+
// clearDataChannelToken is always delegated; llm enforces
|
|
14784
|
+
// ownership and no-ops for non-owners internally.
|
|
14785
|
+
assert.calledOnce(meeting.clearDataChannelToken);
|
|
14786
|
+
// Listeners owned by *this* Meeting instance must still be
|
|
14787
|
+
// removed so a leaving subordinate meeting stops receiving
|
|
14788
|
+
// relay/locus events from the shared singleton.
|
|
14789
|
+
assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
|
|
14790
|
+
assert.calledWithExactly(
|
|
14791
|
+
webex.internal.llm.off,
|
|
14792
|
+
'event:relay.event',
|
|
14793
|
+
meeting.processRelayEvent
|
|
14794
|
+
);
|
|
14795
|
+
assert.calledWithExactly(
|
|
14796
|
+
webex.internal.llm.off,
|
|
14797
|
+
'event:locus.state_message',
|
|
14798
|
+
meeting.processLocusLLMEvent
|
|
14799
|
+
);
|
|
14800
|
+
assert.calledOnce(meeting.clearLLMHealthCheckTimer);
|
|
14801
|
+
});
|
|
14802
|
+
|
|
14803
|
+
it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
|
|
14804
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14805
|
+
|
|
14806
|
+
await meeting.clearMeetingData();
|
|
14807
|
+
|
|
14808
|
+
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
14809
|
+
code: 3050,
|
|
14810
|
+
reason: 'done (permanent)',
|
|
14811
|
+
}, 'llm-default-session', meeting.id);
|
|
14812
|
+
assert.calledOnce(meeting.clearDataChannelToken);
|
|
14813
|
+
});
|
|
14814
|
+
|
|
14815
|
+
it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
|
|
14816
|
+
webex.internal.llm.getOwnerMeetingId.returns(undefined);
|
|
14817
|
+
|
|
14818
|
+
await meeting.clearMeetingData();
|
|
14819
|
+
|
|
14820
|
+
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
14821
|
+
code: 3050,
|
|
14822
|
+
reason: 'done (permanent)',
|
|
14823
|
+
}, 'llm-default-session', meeting.id);
|
|
14824
|
+
assert.calledOnce(meeting.clearDataChannelToken);
|
|
14825
|
+
});
|
|
13262
14826
|
});
|
|
13263
14827
|
});
|
|
13264
14828
|
});
|
|
@@ -15012,16 +16576,25 @@ describe('plugin-meetings', () => {
|
|
|
15012
16576
|
assert.notCalled(meeting.meetingRequest.sendReaction);
|
|
15013
16577
|
});
|
|
15014
16578
|
|
|
15015
|
-
it('should
|
|
16579
|
+
it('should send a custom reaction type not in the known list', async () => {
|
|
15016
16580
|
meeting.locusInfo.controls = {reactions: {reactionChannelUrl: 'Fake URL'}};
|
|
15017
16581
|
|
|
15018
|
-
|
|
15019
|
-
meeting.sendReaction('invalid_reaction', 'light'),
|
|
15020
|
-
Error,
|
|
15021
|
-
'invalid_reaction is not a valid reaction.'
|
|
15022
|
-
);
|
|
16582
|
+
const reactionPromise = meeting.sendReaction('custom_reaction', 'light');
|
|
15023
16583
|
|
|
15024
|
-
assert.
|
|
16584
|
+
assert.exists(reactionPromise.then);
|
|
16585
|
+
await reactionPromise;
|
|
16586
|
+
assert.calledOnceWithExactly(meeting.meetingRequest.sendReaction, {
|
|
16587
|
+
reactionChannelUrl: 'Fake URL',
|
|
16588
|
+
reaction: {
|
|
16589
|
+
type: 'custom_reaction',
|
|
16590
|
+
tone: {
|
|
16591
|
+
type: 'light_skin_tone',
|
|
16592
|
+
codepoints: '1F3FB',
|
|
16593
|
+
shortcodes: ':skin-tone-2:',
|
|
16594
|
+
},
|
|
16595
|
+
},
|
|
16596
|
+
participantId: meeting.members.selfId,
|
|
16597
|
+
});
|
|
15025
16598
|
});
|
|
15026
16599
|
|
|
15027
16600
|
it('should send a reaction with default skin tone if provided skinToneType is invalid ', async () => {
|
|
@@ -16045,4 +17618,4 @@ describe('plugin-meetings', () => {
|
|
|
16045
17618
|
assert.calledOnceWithExactly(meeting.meetingRequest.cancelSipCallOut, participantId);
|
|
16046
17619
|
});
|
|
16047
17620
|
});
|
|
16048
|
-
});
|
|
17621
|
+
});
|