@webex/plugin-meetings 3.12.0-next.8 → 3.12.0-next.80
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 -0
- package/dist/config.js.map +1 -1
- package/dist/constants.js +34 -8
- 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 +1111 -724
- package/dist/meeting/index.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 +29 -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 -78
- 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 +3 -0
- package/dist/member/util.js.map +1 -1
- package/dist/metrics/constants.js +4 -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/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 -0
- 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 +80 -6
- 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 -2
- 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 +1 -0
- package/dist/types/metrics/constants.d.ts +3 -0
- package/dist/types/multistream/codec/constants.d.ts +7 -0
- package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
- 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 -0
- package/src/constants.ts +16 -2
- 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 +441 -65
- package/src/meeting/muteState.ts +10 -1
- package/src/meeting/request.ts +11 -0
- package/src/meeting/util.ts +33 -2
- package/src/meeting-info/meeting-info-v2.ts +4 -2
- package/src/meetings/index.ts +134 -44
- 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 +3 -0
- package/src/metrics/constants.ts +3 -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/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 +87 -0
- 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 +1552 -42
- 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 +82 -0
- package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
- package/test/unit/spec/meetings/index.js +363 -10
- 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 +24 -0
- package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
- 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
|
{
|
|
@@ -792,7 +963,7 @@ describe('plugin-meetings', () => {
|
|
|
792
963
|
|
|
793
964
|
// resets joinWithMediaRetryInfo
|
|
794
965
|
assert.deepEqual(meeting.joinWithMediaRetryInfo, {
|
|
795
|
-
|
|
966
|
+
retryCount: 0,
|
|
796
967
|
prevJoinResponse: undefined,
|
|
797
968
|
});
|
|
798
969
|
});
|
|
@@ -864,7 +1035,12 @@ describe('plugin-meetings', () => {
|
|
|
864
1035
|
meeting.join = sinon.stub().returns(Promise.reject(error));
|
|
865
1036
|
meeting.locusUrl = null; // when join fails, we end up with null locusUrl
|
|
866
1037
|
|
|
867
|
-
await assert.isRejected(
|
|
1038
|
+
const thrownError = await assert.isRejected(
|
|
1039
|
+
meeting.joinWithMedia({mediaOptions: {allowMediaInLobby: true}})
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
// should throw the first attempt's error
|
|
1043
|
+
assert.equal(thrownError, error);
|
|
868
1044
|
|
|
869
1045
|
assert.calledTwice(abortTurnDiscoveryStub);
|
|
870
1046
|
|
|
@@ -902,11 +1078,79 @@ describe('plugin-meetings', () => {
|
|
|
902
1078
|
|
|
903
1079
|
// resets joinWithMediaRetryInfo
|
|
904
1080
|
assert.deepEqual(meeting.joinWithMediaRetryInfo, {
|
|
905
|
-
|
|
1081
|
+
retryCount: 0,
|
|
1082
|
+
prevJoinResponse: undefined,
|
|
1083
|
+
firstError: undefined,
|
|
1084
|
+
prevError: undefined,
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
it('should re-join on retry when join() fails on first attempt, and throw the first error if join fails again', async () => {
|
|
1089
|
+
const firstJoinError = new Error('first join error');
|
|
1090
|
+
const secondJoinError = new Error('second join error');
|
|
1091
|
+
|
|
1092
|
+
meeting.join = sinon
|
|
1093
|
+
.stub()
|
|
1094
|
+
.onFirstCall()
|
|
1095
|
+
.rejects(firstJoinError)
|
|
1096
|
+
.onSecondCall()
|
|
1097
|
+
.rejects(secondJoinError);
|
|
1098
|
+
meeting.locusUrl = null; // join never succeeds
|
|
1099
|
+
|
|
1100
|
+
const thrownError = await assert.isRejected(
|
|
1101
|
+
meeting.joinWithMedia({joinOptions, mediaOptions})
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
// join() should be called twice — once for the first attempt, once for the re-join
|
|
1105
|
+
assert.calledTwice(meeting.join);
|
|
1106
|
+
// TURN discovery should be attempted twice (once per join)
|
|
1107
|
+
assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
|
|
1108
|
+
|
|
1109
|
+
// should throw the first attempt's error, not the second
|
|
1110
|
+
assert.equal(thrownError, firstJoinError);
|
|
1111
|
+
|
|
1112
|
+
assert.calledTwice(Metrics.sendBehavioralMetric);
|
|
1113
|
+
assert.calledWith(
|
|
1114
|
+
Metrics.sendBehavioralMetric.firstCall,
|
|
1115
|
+
BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
|
|
1116
|
+
sinon.match({reason: firstJoinError.message, isRetry: false}),
|
|
1117
|
+
sinon.match.any
|
|
1118
|
+
);
|
|
1119
|
+
assert.calledWith(
|
|
1120
|
+
Metrics.sendBehavioralMetric.secondCall,
|
|
1121
|
+
BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
|
|
1122
|
+
sinon.match({reason: secondJoinError.message, isRetry: true}),
|
|
1123
|
+
sinon.match.any
|
|
1124
|
+
);
|
|
1125
|
+
|
|
1126
|
+
assert.deepEqual(meeting.joinWithMediaRetryInfo, {
|
|
1127
|
+
retryCount: 0,
|
|
906
1128
|
prevJoinResponse: undefined,
|
|
1129
|
+
firstError: undefined,
|
|
1130
|
+
prevError: undefined,
|
|
907
1131
|
});
|
|
908
1132
|
});
|
|
909
1133
|
|
|
1134
|
+
it('should NOT call join() again on retry when join() succeeded but addMediaInternal() failed', async () => {
|
|
1135
|
+
const addMediaError = new Error('addMedia error');
|
|
1136
|
+
|
|
1137
|
+
meeting.addMediaInternal = sinon
|
|
1138
|
+
.stub()
|
|
1139
|
+
.onFirstCall()
|
|
1140
|
+
.rejects(addMediaError)
|
|
1141
|
+
.onSecondCall()
|
|
1142
|
+
.resolves(test4);
|
|
1143
|
+
|
|
1144
|
+
const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1145
|
+
|
|
1146
|
+
assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
|
|
1147
|
+
|
|
1148
|
+
// join() should only be called once — the successful join result is reused on retry
|
|
1149
|
+
assert.calledOnce(meeting.join);
|
|
1150
|
+
// TURN discovery request is only generated once (on the first attempt alongside join)
|
|
1151
|
+
assert.calledOnce(generateTurnDiscoveryRequestMessageStub);
|
|
1152
|
+
});
|
|
1153
|
+
|
|
910
1154
|
it('should resolve if join() fails the first time but succeeds the second time', async () => {
|
|
911
1155
|
const error = new Error('fake');
|
|
912
1156
|
meeting.join = sinon
|
|
@@ -948,7 +1192,7 @@ describe('plugin-meetings', () => {
|
|
|
948
1192
|
|
|
949
1193
|
// resets joinWithMediaRetryInfo
|
|
950
1194
|
assert.deepEqual(meeting.joinWithMediaRetryInfo, {
|
|
951
|
-
|
|
1195
|
+
retryCount: 0,
|
|
952
1196
|
prevJoinResponse: undefined,
|
|
953
1197
|
});
|
|
954
1198
|
});
|
|
@@ -1017,6 +1261,127 @@ describe('plugin-meetings', () => {
|
|
|
1017
1261
|
);
|
|
1018
1262
|
});
|
|
1019
1263
|
|
|
1264
|
+
it('should throw the first attempt error when retry also fails with a different error', async () => {
|
|
1265
|
+
const firstError = new Error('first attempt error');
|
|
1266
|
+
const secondError = new Error('second attempt error');
|
|
1267
|
+
|
|
1268
|
+
const addMediaInternalResults = [];
|
|
1269
|
+
meeting.addMediaInternal = sinon.stub().callsFake(() => {
|
|
1270
|
+
const defer = new Defer();
|
|
1271
|
+
addMediaInternalResults.push(defer);
|
|
1272
|
+
return defer.promise;
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1276
|
+
|
|
1277
|
+
const result = meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1278
|
+
|
|
1279
|
+
await testUtils.flushPromises();
|
|
1280
|
+
|
|
1281
|
+
// 1st attempt fails
|
|
1282
|
+
addMediaInternalResults[0].reject(firstError);
|
|
1283
|
+
await testUtils.flushPromises();
|
|
1284
|
+
|
|
1285
|
+
// leave() should NOT be called after the 1st attempt (intermediate retry)
|
|
1286
|
+
assert.notCalled(leaveStub);
|
|
1287
|
+
|
|
1288
|
+
// 2nd (final) attempt fails
|
|
1289
|
+
addMediaInternalResults[1].reject(secondError);
|
|
1290
|
+
const thrownError = await assert.isRejected(result);
|
|
1291
|
+
|
|
1292
|
+
// should throw the first error, not the second
|
|
1293
|
+
assert.equal(thrownError, firstError);
|
|
1294
|
+
|
|
1295
|
+
// leave() should only be called after the last (2nd) attempt
|
|
1296
|
+
assert.calledOnce(leaveStub);
|
|
1297
|
+
|
|
1298
|
+
assert.calledTwice(Metrics.sendBehavioralMetric);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
it('should throw the UserNotJoinedError as firstError when it occurs on the 1st attempt and 2nd attempt fails differently', async () => {
|
|
1302
|
+
const userNotJoinedError = new UserNotJoinedError();
|
|
1303
|
+
const secondError = new Error('second attempt error');
|
|
1304
|
+
|
|
1305
|
+
const addMediaInternalResults = [];
|
|
1306
|
+
meeting.addMediaInternal = sinon.stub().callsFake(() => {
|
|
1307
|
+
const defer = new Defer();
|
|
1308
|
+
addMediaInternalResults.push(defer);
|
|
1309
|
+
return defer.promise;
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1313
|
+
|
|
1314
|
+
const result = meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1315
|
+
|
|
1316
|
+
await testUtils.flushPromises();
|
|
1317
|
+
|
|
1318
|
+
// 1st attempt fails with UserNotJoinedError — triggers a re-join
|
|
1319
|
+
addMediaInternalResults[0].reject(userNotJoinedError);
|
|
1320
|
+
await testUtils.flushPromises();
|
|
1321
|
+
|
|
1322
|
+
// leave() should NOT be called after the 1st attempt (intermediate retry)
|
|
1323
|
+
assert.notCalled(leaveStub);
|
|
1324
|
+
|
|
1325
|
+
// 2nd (final) attempt fails
|
|
1326
|
+
addMediaInternalResults[1].reject(secondError);
|
|
1327
|
+
const thrownError = await assert.isRejected(result);
|
|
1328
|
+
|
|
1329
|
+
// should throw the first (UserNotJoinedError), not the second
|
|
1330
|
+
assert.equal(thrownError, userNotJoinedError);
|
|
1331
|
+
|
|
1332
|
+
// join() called twice: original + re-join triggered by UserNotJoinedError in prevError
|
|
1333
|
+
assert.calledTwice(meeting.join);
|
|
1334
|
+
// leave() should only be called after the last (2nd) attempt
|
|
1335
|
+
assert.calledOnce(leaveStub);
|
|
1336
|
+
assert.calledTwice(Metrics.sendBehavioralMetric);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
it('should throw the first UserNotJoinedError when it occurs on both 1st and 2nd attempts and a 3rd attempt also fails', async () => {
|
|
1340
|
+
const firstUserNotJoinedError = new UserNotJoinedError('first');
|
|
1341
|
+
const secondUserNotJoinedError = new UserNotJoinedError('second');
|
|
1342
|
+
const thirdError = new Error('third attempt error');
|
|
1343
|
+
|
|
1344
|
+
const addMediaInternalResults = [];
|
|
1345
|
+
meeting.addMediaInternal = sinon.stub().callsFake(() => {
|
|
1346
|
+
const defer = new Defer();
|
|
1347
|
+
addMediaInternalResults.push(defer);
|
|
1348
|
+
return defer.promise;
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1352
|
+
|
|
1353
|
+
const result = meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1354
|
+
|
|
1355
|
+
await testUtils.flushPromises();
|
|
1356
|
+
|
|
1357
|
+
// 1st attempt fails with UserNotJoinedError — triggers a re-join
|
|
1358
|
+
addMediaInternalResults[0].reject(firstUserNotJoinedError);
|
|
1359
|
+
await testUtils.flushPromises();
|
|
1360
|
+
|
|
1361
|
+
// leave() should NOT be called after the 1st attempt (intermediate retry)
|
|
1362
|
+
assert.notCalled(leaveStub);
|
|
1363
|
+
|
|
1364
|
+
// 2nd attempt fails with UserNotJoinedError again — triggers another re-join
|
|
1365
|
+
addMediaInternalResults[1].reject(secondUserNotJoinedError);
|
|
1366
|
+
await testUtils.flushPromises();
|
|
1367
|
+
|
|
1368
|
+
// leave() should NOT be called after the 2nd attempt (intermediate retry)
|
|
1369
|
+
assert.notCalled(leaveStub);
|
|
1370
|
+
|
|
1371
|
+
// 3rd (final) attempt fails
|
|
1372
|
+
addMediaInternalResults[2].reject(thirdError);
|
|
1373
|
+
const thrownError = await assert.isRejected(result);
|
|
1374
|
+
|
|
1375
|
+
// should throw the very first error across all 3 attempts
|
|
1376
|
+
assert.equal(thrownError, firstUserNotJoinedError);
|
|
1377
|
+
|
|
1378
|
+
// join() called 3 times: original + 2 re-joins triggered by prevError being UserNotJoinedError
|
|
1379
|
+
assert.calledThrice(meeting.join);
|
|
1380
|
+
// leave() should only be called after the last (3rd) attempt
|
|
1381
|
+
assert.calledOnce(leaveStub);
|
|
1382
|
+
assert.calledThrice(Metrics.sendBehavioralMetric);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1020
1385
|
it('should call leave() if addMediaInternal() fails with a browser media error (TypeError)', async () => {
|
|
1021
1386
|
const addMediaError = new Error('fake addMedia error');
|
|
1022
1387
|
addMediaError.name = 'TypeError'; // This makes it a browser media error
|
|
@@ -1264,7 +1629,8 @@ describe('plugin-meetings', () => {
|
|
|
1264
1629
|
|
|
1265
1630
|
await testUtils.flushPromises();
|
|
1266
1631
|
|
|
1267
|
-
// check the callback works correctly on the 2nd attempt
|
|
1632
|
+
// check the callback works correctly on the 2nd attempt:
|
|
1633
|
+
// retryCount=1 with a non-UserNotJoinedError is terminal, so icePhase must be JOIN_MEETING_FINAL
|
|
1268
1634
|
assert.equal(icePhaseCallbacks.length, 2);
|
|
1269
1635
|
assert.equal(icePhaseCallbacks[1](), 'JOIN_MEETING_FINAL');
|
|
1270
1636
|
|
|
@@ -1274,6 +1640,118 @@ describe('plugin-meetings', () => {
|
|
|
1274
1640
|
await assert.isRejected(result);
|
|
1275
1641
|
});
|
|
1276
1642
|
|
|
1643
|
+
it('should allow an additional retry when UserNotJoinedError occurs and return JOIN_MEETING_FINAL on the 3rd attempt', async () => {
|
|
1644
|
+
const genericError = new Error('generic error');
|
|
1645
|
+
const userNotJoinedError = new UserNotJoinedError();
|
|
1646
|
+
const thirdError = new Error('third error');
|
|
1647
|
+
|
|
1648
|
+
const icePhaseCallbacks = [];
|
|
1649
|
+
const addMediaInternalResults = [];
|
|
1650
|
+
|
|
1651
|
+
meeting.addMediaInternal = sinon
|
|
1652
|
+
.stub()
|
|
1653
|
+
.callsFake((icePhaseCallback) => {
|
|
1654
|
+
const defer = new Defer();
|
|
1655
|
+
|
|
1656
|
+
icePhaseCallbacks.push(icePhaseCallback);
|
|
1657
|
+
addMediaInternalResults.push(defer);
|
|
1658
|
+
return defer.promise;
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1662
|
+
|
|
1663
|
+
const result = meeting.joinWithMedia({
|
|
1664
|
+
joinOptions,
|
|
1665
|
+
mediaOptions,
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
await testUtils.flushPromises();
|
|
1669
|
+
|
|
1670
|
+
// 1st attempt: retryCount=0 → JOIN_MEETING_RETRY
|
|
1671
|
+
assert.equal(icePhaseCallbacks.length, 1);
|
|
1672
|
+
assert.equal(icePhaseCallbacks[0](), 'JOIN_MEETING_RETRY');
|
|
1673
|
+
addMediaInternalResults[0].reject(genericError);
|
|
1674
|
+
|
|
1675
|
+
await testUtils.flushPromises();
|
|
1676
|
+
|
|
1677
|
+
// leave() should NOT be called after the 1st attempt (intermediate retry)
|
|
1678
|
+
assert.notCalled(leaveStub);
|
|
1679
|
+
|
|
1680
|
+
// 2nd attempt: retryCount=1 → JOIN_MEETING_FINAL
|
|
1681
|
+
// (In real usage this callback is never invoked when UserNotJoinedError is thrown,
|
|
1682
|
+
// because UserNotJoinedError is thrown before waitForMediaConnectionConnected() is reached.)
|
|
1683
|
+
assert.equal(icePhaseCallbacks.length, 2);
|
|
1684
|
+
assert.equal(icePhaseCallbacks[1](), 'JOIN_MEETING_FINAL');
|
|
1685
|
+
addMediaInternalResults[1].reject(userNotJoinedError);
|
|
1686
|
+
|
|
1687
|
+
await testUtils.flushPromises();
|
|
1688
|
+
|
|
1689
|
+
// leave() should NOT be called after the 2nd attempt (intermediate retry)
|
|
1690
|
+
assert.notCalled(leaveStub);
|
|
1691
|
+
|
|
1692
|
+
// 3rd attempt: retryCount=2 → JOIN_MEETING_FINAL
|
|
1693
|
+
assert.equal(icePhaseCallbacks.length, 3);
|
|
1694
|
+
assert.equal(icePhaseCallbacks[2](), 'JOIN_MEETING_FINAL');
|
|
1695
|
+
addMediaInternalResults[2].reject(thirdError);
|
|
1696
|
+
|
|
1697
|
+
const thrownError = await assert.isRejected(result);
|
|
1698
|
+
|
|
1699
|
+
// should throw the first error
|
|
1700
|
+
assert.equal(thrownError, genericError);
|
|
1701
|
+
|
|
1702
|
+
// leave() should only be called once, after the last (3rd) attempt
|
|
1703
|
+
assert.calledOnce(leaveStub);
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
it('should re-join when retrying after a UserNotJoinedError', async () => {
|
|
1707
|
+
const userNotJoinedError = new UserNotJoinedError();
|
|
1708
|
+
|
|
1709
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1710
|
+
|
|
1711
|
+
meeting.addMediaInternal = sinon
|
|
1712
|
+
.stub()
|
|
1713
|
+
.onFirstCall()
|
|
1714
|
+
.rejects(userNotJoinedError)
|
|
1715
|
+
.onSecondCall()
|
|
1716
|
+
.resolves(test4);
|
|
1717
|
+
|
|
1718
|
+
const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1719
|
+
|
|
1720
|
+
assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
|
|
1721
|
+
|
|
1722
|
+
// join() should be called twice — once for the first attempt, once for the re-join after UserNotJoinedError
|
|
1723
|
+
assert.calledTwice(meeting.join);
|
|
1724
|
+
// TURN discovery should be attempted twice (once per join)
|
|
1725
|
+
assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
|
|
1726
|
+
// leave() should never be called when retrying after UserNotJoinedError
|
|
1727
|
+
assert.notCalled(leaveStub);
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
it('should re-join when isUserInLeftState returns true on retry', async () => {
|
|
1731
|
+
const addMediaError = new Error('addMedia error');
|
|
1732
|
+
|
|
1733
|
+
sinon.stub(MeetingUtil, 'isUserInLeftState').returns(true);
|
|
1734
|
+
|
|
1735
|
+
const leaveStub = sinon.stub(meeting, 'leave').resolves();
|
|
1736
|
+
|
|
1737
|
+
meeting.addMediaInternal = sinon
|
|
1738
|
+
.stub()
|
|
1739
|
+
.onFirstCall()
|
|
1740
|
+
.rejects(addMediaError)
|
|
1741
|
+
.onSecondCall()
|
|
1742
|
+
.resolves(test4);
|
|
1743
|
+
|
|
1744
|
+
const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
|
|
1745
|
+
|
|
1746
|
+
assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
|
|
1747
|
+
|
|
1748
|
+
// join() should be called twice — once for the first attempt, once because the user is in left state
|
|
1749
|
+
assert.calledTwice(meeting.join);
|
|
1750
|
+
assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
|
|
1751
|
+
// leave() should never be called when retrying after isUserInLeftState returns true
|
|
1752
|
+
assert.notCalled(leaveStub);
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1277
1755
|
[
|
|
1278
1756
|
{
|
|
1279
1757
|
errorName: 'SdpOfferCreationError',
|
|
@@ -1977,6 +2455,113 @@ describe('plugin-meetings', () => {
|
|
|
1977
2455
|
fakeProcessedReaction
|
|
1978
2456
|
);
|
|
1979
2457
|
});
|
|
2458
|
+
|
|
2459
|
+
[
|
|
2460
|
+
{
|
|
2461
|
+
title: 'should skip a reaction when the default relay route does not match the LLM binding',
|
|
2462
|
+
isPracticeSessionConnected: false,
|
|
2463
|
+
route: 'wrong-default-route',
|
|
2464
|
+
defaultBinding: 'default-route',
|
|
2465
|
+
practiceBinding: 'practice-route',
|
|
2466
|
+
shouldProcess: false,
|
|
2467
|
+
expectedSessionLabel: 'default session',
|
|
2468
|
+
},
|
|
2469
|
+
{
|
|
2470
|
+
title: 'should process a reaction when the default relay route matches the LLM binding',
|
|
2471
|
+
isPracticeSessionConnected: false,
|
|
2472
|
+
route: 'default-route',
|
|
2473
|
+
defaultBinding: 'default-route',
|
|
2474
|
+
practiceBinding: 'practice-route',
|
|
2475
|
+
shouldProcess: true,
|
|
2476
|
+
},
|
|
2477
|
+
{
|
|
2478
|
+
title:
|
|
2479
|
+
'should process a reaction when the practice-session relay route matches the practice-session LLM binding',
|
|
2480
|
+
isPracticeSessionConnected: true,
|
|
2481
|
+
route: 'practice-route',
|
|
2482
|
+
defaultBinding: 'default-route',
|
|
2483
|
+
practiceBinding: 'practice-route',
|
|
2484
|
+
shouldProcess: true,
|
|
2485
|
+
},
|
|
2486
|
+
{
|
|
2487
|
+
title:
|
|
2488
|
+
'should skip a reaction when the practice-session relay route does not match the practice-session LLM binding',
|
|
2489
|
+
isPracticeSessionConnected: true,
|
|
2490
|
+
route: 'default-route',
|
|
2491
|
+
defaultBinding: 'default-route',
|
|
2492
|
+
practiceBinding: 'practice-route',
|
|
2493
|
+
shouldProcess: false,
|
|
2494
|
+
expectedSessionLabel: 'practice session',
|
|
2495
|
+
},
|
|
2496
|
+
].forEach(
|
|
2497
|
+
({
|
|
2498
|
+
title,
|
|
2499
|
+
isPracticeSessionConnected,
|
|
2500
|
+
route,
|
|
2501
|
+
defaultBinding,
|
|
2502
|
+
practiceBinding,
|
|
2503
|
+
shouldProcess,
|
|
2504
|
+
expectedSessionLabel,
|
|
2505
|
+
}) => {
|
|
2506
|
+
it(title, () => {
|
|
2507
|
+
meeting.isReactionsSupported = sinon.stub().returns(true);
|
|
2508
|
+
meeting.config.receiveReactions = true;
|
|
2509
|
+
const fakeSendersName = 'Fake reactors name';
|
|
2510
|
+
meeting.members.membersCollection.get = sinon.stub().returns({name: fakeSendersName});
|
|
2511
|
+
webex.internal.llm.isConnected = sinon.stub().callsFake((llmSessionId) => {
|
|
2512
|
+
return llmSessionId === LLM_PRACTICE_SESSION && isPracticeSessionConnected;
|
|
2513
|
+
});
|
|
2514
|
+
webex.internal.llm.getBinding = sinon.stub().callsFake((llmSessionId) => {
|
|
2515
|
+
if (llmSessionId === LLM_PRACTICE_SESSION) {
|
|
2516
|
+
return practiceBinding;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
return defaultBinding;
|
|
2520
|
+
});
|
|
2521
|
+
const fakeReactionPayload = {
|
|
2522
|
+
type: 'fake_type',
|
|
2523
|
+
codepoints: 'fake_codepoints',
|
|
2524
|
+
shortcodes: 'fake_shortcodes',
|
|
2525
|
+
};
|
|
2526
|
+
const fakeSenderPayload = {
|
|
2527
|
+
participantId: 'fake_participant_id',
|
|
2528
|
+
};
|
|
2529
|
+
const fakeRelayEvent = {
|
|
2530
|
+
headers: {route},
|
|
2531
|
+
data: {
|
|
2532
|
+
relayType: REACTION_RELAY_TYPES.REACTION,
|
|
2533
|
+
reaction: fakeReactionPayload,
|
|
2534
|
+
sender: fakeSenderPayload,
|
|
2535
|
+
},
|
|
2536
|
+
};
|
|
2537
|
+
const fakeProcessedReaction = {
|
|
2538
|
+
reaction: fakeReactionPayload,
|
|
2539
|
+
sender: {
|
|
2540
|
+
id: fakeSenderPayload.participantId,
|
|
2541
|
+
name: fakeSendersName,
|
|
2542
|
+
},
|
|
2543
|
+
};
|
|
2544
|
+
|
|
2545
|
+
TriggerProxy.trigger.resetHistory();
|
|
2546
|
+
meeting.processRelayEvent(fakeRelayEvent);
|
|
2547
|
+
|
|
2548
|
+
if (shouldProcess) {
|
|
2549
|
+
assert.calledWith(
|
|
2550
|
+
TriggerProxy.trigger,
|
|
2551
|
+
sinon.match.instanceOf(Meeting),
|
|
2552
|
+
{
|
|
2553
|
+
file: 'meeting/index',
|
|
2554
|
+
function: 'join',
|
|
2555
|
+
},
|
|
2556
|
+
EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
|
|
2557
|
+
fakeProcessedReaction
|
|
2558
|
+
);
|
|
2559
|
+
} else {
|
|
2560
|
+
assert.notCalled(TriggerProxy.trigger);
|
|
2561
|
+
}
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
);
|
|
1980
2565
|
});
|
|
1981
2566
|
|
|
1982
2567
|
describe('#handleLLMOnline', () => {
|
|
@@ -2016,6 +2601,14 @@ describe('plugin-meetings', () => {
|
|
|
2016
2601
|
|
|
2017
2602
|
assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
|
|
2018
2603
|
});
|
|
2604
|
+
|
|
2605
|
+
it('calls syncAllHashTreeDatasets on locusInfo', () => {
|
|
2606
|
+
sinon.stub(meeting.locusInfo, 'syncAllHashTreeDatasets').resolves();
|
|
2607
|
+
|
|
2608
|
+
meeting.handleLLMOnline();
|
|
2609
|
+
|
|
2610
|
+
assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true});
|
|
2611
|
+
});
|
|
2019
2612
|
});
|
|
2020
2613
|
|
|
2021
2614
|
describe('#join', () => {
|
|
@@ -4534,6 +5127,297 @@ describe('plugin-meetings', () => {
|
|
|
4534
5127
|
},
|
|
4535
5128
|
});
|
|
4536
5129
|
});
|
|
5130
|
+
|
|
5131
|
+
describe('handles STATS_UPDATE event for SRTP cipher detection', () => {
|
|
5132
|
+
it('emits MEETING_SRTP_CIPHER_UPDATED event when srtpCipher is found in transport stats', async () => {
|
|
5133
|
+
const fakeStats = new Map([
|
|
5134
|
+
[
|
|
5135
|
+
'transport-1',
|
|
5136
|
+
{
|
|
5137
|
+
type: 'transport',
|
|
5138
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
5139
|
+
dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
|
|
5140
|
+
},
|
|
5141
|
+
],
|
|
5142
|
+
[
|
|
5143
|
+
'outbound-rtp-1',
|
|
5144
|
+
{
|
|
5145
|
+
type: 'outbound-rtp',
|
|
5146
|
+
ssrc: 12345,
|
|
5147
|
+
},
|
|
5148
|
+
],
|
|
5149
|
+
]);
|
|
5150
|
+
|
|
5151
|
+
statsAnalyzerStub.emit(
|
|
5152
|
+
{file: 'test', function: 'test'},
|
|
5153
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5154
|
+
{stats: fakeStats}
|
|
5155
|
+
);
|
|
5156
|
+
|
|
5157
|
+
assert.calledWith(
|
|
5158
|
+
TriggerProxy.trigger,
|
|
5159
|
+
sinon.match.instanceOf(Meeting),
|
|
5160
|
+
{
|
|
5161
|
+
file: 'meeting/index',
|
|
5162
|
+
function: 'setupStatsAnalyzerEventHandlers',
|
|
5163
|
+
},
|
|
5164
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5165
|
+
{srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
|
|
5166
|
+
);
|
|
5167
|
+
|
|
5168
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5169
|
+
});
|
|
5170
|
+
|
|
5171
|
+
it('updates meeting.mediaProperties.srtpCipher when cipher changes', async () => {
|
|
5172
|
+
const firstStats = new Map([
|
|
5173
|
+
[
|
|
5174
|
+
'transport-1',
|
|
5175
|
+
{
|
|
5176
|
+
type: 'transport',
|
|
5177
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
5178
|
+
},
|
|
5179
|
+
],
|
|
5180
|
+
]);
|
|
5181
|
+
|
|
5182
|
+
statsAnalyzerStub.emit(
|
|
5183
|
+
{file: 'test', function: 'test'},
|
|
5184
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5185
|
+
{stats: firstStats}
|
|
5186
|
+
);
|
|
5187
|
+
|
|
5188
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5189
|
+
|
|
5190
|
+
const secondStats = new Map([
|
|
5191
|
+
[
|
|
5192
|
+
'transport-1',
|
|
5193
|
+
{
|
|
5194
|
+
type: 'transport',
|
|
5195
|
+
srtpCipher: 'AEAD_AES_256_GCM',
|
|
5196
|
+
},
|
|
5197
|
+
],
|
|
5198
|
+
]);
|
|
5199
|
+
|
|
5200
|
+
TriggerProxy.trigger.resetHistory();
|
|
5201
|
+
|
|
5202
|
+
statsAnalyzerStub.emit(
|
|
5203
|
+
{file: 'test', function: 'test'},
|
|
5204
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5205
|
+
{stats: secondStats}
|
|
5206
|
+
);
|
|
5207
|
+
|
|
5208
|
+
assert.calledWith(
|
|
5209
|
+
TriggerProxy.trigger,
|
|
5210
|
+
sinon.match.instanceOf(Meeting),
|
|
5211
|
+
{
|
|
5212
|
+
file: 'meeting/index',
|
|
5213
|
+
function: 'setupStatsAnalyzerEventHandlers',
|
|
5214
|
+
},
|
|
5215
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5216
|
+
{srtpCipher: 'AEAD_AES_256_GCM'}
|
|
5217
|
+
);
|
|
5218
|
+
|
|
5219
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AEAD_AES_256_GCM');
|
|
5220
|
+
});
|
|
5221
|
+
|
|
5222
|
+
it('does not emit event when srtpCipher has not changed', async () => {
|
|
5223
|
+
const firstStats = new Map([
|
|
5224
|
+
[
|
|
5225
|
+
'transport-1',
|
|
5226
|
+
{
|
|
5227
|
+
type: 'transport',
|
|
5228
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
5229
|
+
},
|
|
5230
|
+
],
|
|
5231
|
+
]);
|
|
5232
|
+
|
|
5233
|
+
statsAnalyzerStub.emit(
|
|
5234
|
+
{file: 'test', function: 'test'},
|
|
5235
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5236
|
+
{stats: firstStats}
|
|
5237
|
+
);
|
|
5238
|
+
|
|
5239
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5240
|
+
|
|
5241
|
+
TriggerProxy.trigger.resetHistory();
|
|
5242
|
+
|
|
5243
|
+
// Emit same cipher again
|
|
5244
|
+
statsAnalyzerStub.emit(
|
|
5245
|
+
{file: 'test', function: 'test'},
|
|
5246
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5247
|
+
{stats: firstStats}
|
|
5248
|
+
);
|
|
5249
|
+
|
|
5250
|
+
// Should not trigger event again
|
|
5251
|
+
assert.neverCalledWith(
|
|
5252
|
+
TriggerProxy.trigger,
|
|
5253
|
+
sinon.match.instanceOf(Meeting),
|
|
5254
|
+
sinon.match.any,
|
|
5255
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5256
|
+
sinon.match.any
|
|
5257
|
+
);
|
|
5258
|
+
|
|
5259
|
+
// Cipher should remain the same
|
|
5260
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5261
|
+
});
|
|
5262
|
+
|
|
5263
|
+
it('does not emit event when stats contain no transport with srtpCipher', async () => {
|
|
5264
|
+
const fakeStats = new Map([
|
|
5265
|
+
[
|
|
5266
|
+
'outbound-rtp-1',
|
|
5267
|
+
{
|
|
5268
|
+
type: 'outbound-rtp',
|
|
5269
|
+
ssrc: 12345,
|
|
5270
|
+
},
|
|
5271
|
+
],
|
|
5272
|
+
[
|
|
5273
|
+
'inbound-rtp-1',
|
|
5274
|
+
{
|
|
5275
|
+
type: 'inbound-rtp',
|
|
5276
|
+
ssrc: 67890,
|
|
5277
|
+
},
|
|
5278
|
+
],
|
|
5279
|
+
]);
|
|
5280
|
+
|
|
5281
|
+
statsAnalyzerStub.emit(
|
|
5282
|
+
{file: 'test', function: 'test'},
|
|
5283
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5284
|
+
{stats: fakeStats}
|
|
5285
|
+
);
|
|
5286
|
+
|
|
5287
|
+
assert.neverCalledWith(
|
|
5288
|
+
TriggerProxy.trigger,
|
|
5289
|
+
sinon.match.instanceOf(Meeting),
|
|
5290
|
+
sinon.match.any,
|
|
5291
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5292
|
+
sinon.match.any
|
|
5293
|
+
);
|
|
5294
|
+
|
|
5295
|
+
assert.isUndefined(meeting.mediaProperties.srtpCipher);
|
|
5296
|
+
});
|
|
5297
|
+
|
|
5298
|
+
it('does not emit event when transport stat has no srtpCipher property', async () => {
|
|
5299
|
+
const fakeStats = new Map([
|
|
5300
|
+
[
|
|
5301
|
+
'transport-1',
|
|
5302
|
+
{
|
|
5303
|
+
type: 'transport',
|
|
5304
|
+
dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
|
|
5305
|
+
// no srtpCipher property
|
|
5306
|
+
},
|
|
5307
|
+
],
|
|
5308
|
+
]);
|
|
5309
|
+
|
|
5310
|
+
statsAnalyzerStub.emit(
|
|
5311
|
+
{file: 'test', function: 'test'},
|
|
5312
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5313
|
+
{stats: fakeStats}
|
|
5314
|
+
);
|
|
5315
|
+
|
|
5316
|
+
assert.neverCalledWith(
|
|
5317
|
+
TriggerProxy.trigger,
|
|
5318
|
+
sinon.match.instanceOf(Meeting),
|
|
5319
|
+
sinon.match.any,
|
|
5320
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5321
|
+
sinon.match.any
|
|
5322
|
+
);
|
|
5323
|
+
|
|
5324
|
+
assert.isUndefined(meeting.mediaProperties.srtpCipher);
|
|
5325
|
+
});
|
|
5326
|
+
|
|
5327
|
+
it('uses first transport with srtpCipher when multiple transports exist', async () => {
|
|
5328
|
+
const fakeStats = new Map([
|
|
5329
|
+
[
|
|
5330
|
+
'transport-1',
|
|
5331
|
+
{
|
|
5332
|
+
type: 'transport',
|
|
5333
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
5334
|
+
},
|
|
5335
|
+
],
|
|
5336
|
+
[
|
|
5337
|
+
'transport-2',
|
|
5338
|
+
{
|
|
5339
|
+
type: 'transport',
|
|
5340
|
+
srtpCipher: 'AEAD_AES_256_GCM',
|
|
5341
|
+
},
|
|
5342
|
+
],
|
|
5343
|
+
[
|
|
5344
|
+
'outbound-rtp-1',
|
|
5345
|
+
{
|
|
5346
|
+
type: 'outbound-rtp',
|
|
5347
|
+
ssrc: 12345,
|
|
5348
|
+
},
|
|
5349
|
+
],
|
|
5350
|
+
]);
|
|
5351
|
+
|
|
5352
|
+
statsAnalyzerStub.emit(
|
|
5353
|
+
{file: 'test', function: 'test'},
|
|
5354
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5355
|
+
{stats: fakeStats}
|
|
5356
|
+
);
|
|
5357
|
+
|
|
5358
|
+
assert.calledWith(
|
|
5359
|
+
TriggerProxy.trigger,
|
|
5360
|
+
sinon.match.instanceOf(Meeting),
|
|
5361
|
+
{
|
|
5362
|
+
file: 'meeting/index',
|
|
5363
|
+
function: 'setupStatsAnalyzerEventHandlers',
|
|
5364
|
+
},
|
|
5365
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5366
|
+
{srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
|
|
5367
|
+
);
|
|
5368
|
+
|
|
5369
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5370
|
+
});
|
|
5371
|
+
|
|
5372
|
+
it('handles empty stats map without errors', async () => {
|
|
5373
|
+
const emptyStats = new Map();
|
|
5374
|
+
|
|
5375
|
+
statsAnalyzerStub.emit(
|
|
5376
|
+
{file: 'test', function: 'test'},
|
|
5377
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5378
|
+
{stats: emptyStats}
|
|
5379
|
+
);
|
|
5380
|
+
|
|
5381
|
+
assert.neverCalledWith(
|
|
5382
|
+
TriggerProxy.trigger,
|
|
5383
|
+
sinon.match.instanceOf(Meeting),
|
|
5384
|
+
sinon.match.any,
|
|
5385
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5386
|
+
sinon.match.any
|
|
5387
|
+
);
|
|
5388
|
+
|
|
5389
|
+
assert.isUndefined(meeting.mediaProperties.srtpCipher);
|
|
5390
|
+
});
|
|
5391
|
+
|
|
5392
|
+
it('logs cipher change when cipher is updated', async () => {
|
|
5393
|
+
const loggerSpy = sinon.spy(LoggerProxy.logger, 'info');
|
|
5394
|
+
|
|
5395
|
+
meeting.mediaProperties.srtpCipher = 'AES_CM_128_HMAC_SHA1_80';
|
|
5396
|
+
|
|
5397
|
+
const newStats = new Map([
|
|
5398
|
+
[
|
|
5399
|
+
'transport-1',
|
|
5400
|
+
{
|
|
5401
|
+
type: 'transport',
|
|
5402
|
+
srtpCipher: 'AEAD_AES_256_GCM',
|
|
5403
|
+
},
|
|
5404
|
+
],
|
|
5405
|
+
]);
|
|
5406
|
+
|
|
5407
|
+
statsAnalyzerStub.emit(
|
|
5408
|
+
{file: 'test', function: 'test'},
|
|
5409
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5410
|
+
{stats: newStats}
|
|
5411
|
+
);
|
|
5412
|
+
|
|
5413
|
+
assert.calledWithMatch(
|
|
5414
|
+
loggerSpy,
|
|
5415
|
+
sinon.match(/SRTP cipher changed from AES_CM_128_HMAC_SHA1_80 to AEAD_AES_256_GCM/)
|
|
5416
|
+
);
|
|
5417
|
+
|
|
5418
|
+
loggerSpy.restore();
|
|
5419
|
+
});
|
|
5420
|
+
});
|
|
4537
5421
|
});
|
|
4538
5422
|
|
|
4539
5423
|
describe('handles StatsMonitor events', () => {
|
|
@@ -6429,6 +7313,9 @@ describe('plugin-meetings', () => {
|
|
|
6429
7313
|
|
|
6430
7314
|
meeting.annotation.deregisterEvents = sinon.stub();
|
|
6431
7315
|
webex.internal.llm.off = sinon.stub();
|
|
7316
|
+
webex.internal.mercury.off = sinon.stub();
|
|
7317
|
+
meeting.mercuryOnlineHandler = sinon.stub();
|
|
7318
|
+
meeting.mercuryOfflineHandler = sinon.stub();
|
|
6432
7319
|
|
|
6433
7320
|
// A meeting needs to be joined to leave
|
|
6434
7321
|
meeting.meetingState = 'ACTIVE';
|
|
@@ -6452,6 +7339,67 @@ describe('plugin-meetings', () => {
|
|
|
6452
7339
|
assert.calledOnce(meeting.clearMeetingData);
|
|
6453
7340
|
});
|
|
6454
7341
|
|
|
7342
|
+
it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /leave', async () => {
|
|
7343
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
7344
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
7345
|
+
|
|
7346
|
+
await meeting.leave();
|
|
7347
|
+
|
|
7348
|
+
// All llm/mercury consumers (direct listeners, voicea transcription,
|
|
7349
|
+
// annotation) must be detached before the /leave request so that
|
|
7350
|
+
// in-flight events do not trigger unnecessary Locus syncs
|
|
7351
|
+
// (per Locus team recommendation).
|
|
7352
|
+
assert.callOrder(
|
|
7353
|
+
webex.internal.llm.off,
|
|
7354
|
+
webex.internal.mercury.off,
|
|
7355
|
+
meeting.stopTranscription,
|
|
7356
|
+
meeting.annotation.deregisterEvents,
|
|
7357
|
+
meeting.meetingRequest.leaveMeeting
|
|
7358
|
+
);
|
|
7359
|
+
assert.calledWithExactly(
|
|
7360
|
+
webex.internal.llm.off,
|
|
7361
|
+
'event:relay.event',
|
|
7362
|
+
meeting.processRelayEvent
|
|
7363
|
+
);
|
|
7364
|
+
assert.calledWithExactly(
|
|
7365
|
+
webex.internal.llm.off,
|
|
7366
|
+
LOCUS_LLM_EVENT,
|
|
7367
|
+
meeting.processLocusLLMEvent
|
|
7368
|
+
);
|
|
7369
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
7370
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
7371
|
+
assert.isUndefined(meeting.mercuryOnlineHandler);
|
|
7372
|
+
assert.isUndefined(meeting.mercuryOfflineHandler);
|
|
7373
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
7374
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
7375
|
+
assert.isUndefined(meeting.transcription);
|
|
7376
|
+
});
|
|
7377
|
+
|
|
7378
|
+
it('tears down llm/mercury/transcription/annotation even when /leave rejects', async () => {
|
|
7379
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
7380
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
7381
|
+
meeting.meetingRequest.leaveMeeting = sinon
|
|
7382
|
+
.stub()
|
|
7383
|
+
.returns(Promise.reject(new Error('leave failed')));
|
|
7384
|
+
|
|
7385
|
+
await meeting.leave().catch(() => {});
|
|
7386
|
+
|
|
7387
|
+
assert.calledWithExactly(
|
|
7388
|
+
webex.internal.llm.off,
|
|
7389
|
+
'event:relay.event',
|
|
7390
|
+
meeting.processRelayEvent
|
|
7391
|
+
);
|
|
7392
|
+
assert.calledWithExactly(
|
|
7393
|
+
webex.internal.llm.off,
|
|
7394
|
+
LOCUS_LLM_EVENT,
|
|
7395
|
+
meeting.processLocusLLMEvent
|
|
7396
|
+
);
|
|
7397
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
7398
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
7399
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
7400
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
7401
|
+
});
|
|
7402
|
+
|
|
6455
7403
|
it('should reset call diagnostic latencies correctly', async () => {
|
|
6456
7404
|
const leave = meeting.leave();
|
|
6457
7405
|
|
|
@@ -8459,6 +9407,9 @@ describe('plugin-meetings', () => {
|
|
|
8459
9407
|
|
|
8460
9408
|
meeting.annotation.deregisterEvents = sinon.stub();
|
|
8461
9409
|
webex.internal.llm.off = sinon.stub();
|
|
9410
|
+
webex.internal.mercury.off = sinon.stub();
|
|
9411
|
+
meeting.mercuryOnlineHandler = sinon.stub();
|
|
9412
|
+
meeting.mercuryOfflineHandler = sinon.stub();
|
|
8462
9413
|
|
|
8463
9414
|
// A meeting needs to be joined to end
|
|
8464
9415
|
meeting.meetingState = 'ACTIVE';
|
|
@@ -8481,6 +9432,66 @@ describe('plugin-meetings', () => {
|
|
|
8481
9432
|
assert.calledOnce(meeting?.unsetPeerConnections);
|
|
8482
9433
|
assert.calledOnce(meeting?.clearMeetingData);
|
|
8483
9434
|
});
|
|
9435
|
+
|
|
9436
|
+
it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /end', async () => {
|
|
9437
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
9438
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
9439
|
+
|
|
9440
|
+
await meeting.endMeetingForAll();
|
|
9441
|
+
|
|
9442
|
+
// All llm/mercury consumers (direct listeners, voicea transcription,
|
|
9443
|
+
// annotation) must be detached before the /end request so that
|
|
9444
|
+
// in-flight events do not trigger unnecessary Locus syncs
|
|
9445
|
+
// (per Locus team recommendation).
|
|
9446
|
+
assert.callOrder(
|
|
9447
|
+
webex.internal.llm.off,
|
|
9448
|
+
webex.internal.mercury.off,
|
|
9449
|
+
meeting.stopTranscription,
|
|
9450
|
+
meeting.annotation.deregisterEvents,
|
|
9451
|
+
meeting.meetingRequest.endMeetingForAll
|
|
9452
|
+
);
|
|
9453
|
+
assert.calledWithExactly(
|
|
9454
|
+
webex.internal.llm.off,
|
|
9455
|
+
'event:relay.event',
|
|
9456
|
+
meeting.processRelayEvent
|
|
9457
|
+
);
|
|
9458
|
+
assert.calledWithExactly(
|
|
9459
|
+
webex.internal.llm.off,
|
|
9460
|
+
LOCUS_LLM_EVENT,
|
|
9461
|
+
meeting.processLocusLLMEvent
|
|
9462
|
+
);
|
|
9463
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
9464
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
9465
|
+
assert.isUndefined(meeting.mercuryOnlineHandler);
|
|
9466
|
+
assert.isUndefined(meeting.mercuryOfflineHandler);
|
|
9467
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
9468
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
9469
|
+
});
|
|
9470
|
+
|
|
9471
|
+
it('tears down llm/mercury/transcription/annotation even when /end rejects', async () => {
|
|
9472
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
9473
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
9474
|
+
meeting.meetingRequest.endMeetingForAll = sinon
|
|
9475
|
+
.stub()
|
|
9476
|
+
.returns(Promise.reject(new Error('end failed')));
|
|
9477
|
+
|
|
9478
|
+
await meeting.endMeetingForAll().catch(() => {});
|
|
9479
|
+
|
|
9480
|
+
assert.calledWithExactly(
|
|
9481
|
+
webex.internal.llm.off,
|
|
9482
|
+
'event:relay.event',
|
|
9483
|
+
meeting.processRelayEvent
|
|
9484
|
+
);
|
|
9485
|
+
assert.calledWithExactly(
|
|
9486
|
+
webex.internal.llm.off,
|
|
9487
|
+
LOCUS_LLM_EVENT,
|
|
9488
|
+
meeting.processLocusLLMEvent
|
|
9489
|
+
);
|
|
9490
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
9491
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
9492
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
9493
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
9494
|
+
});
|
|
8484
9495
|
});
|
|
8485
9496
|
|
|
8486
9497
|
describe('#moveTo', () => {
|
|
@@ -10417,7 +11428,7 @@ describe('plugin-meetings', () => {
|
|
|
10417
11428
|
);
|
|
10418
11429
|
done();
|
|
10419
11430
|
});
|
|
10420
|
-
it('listens to the self admitted guest event
|
|
11431
|
+
it('listens to the self admitted guest event and waits for token prefetch before reconnecting LLM', async () => {
|
|
10421
11432
|
meeting.stopKeepAlive = sinon.stub();
|
|
10422
11433
|
meeting.updateLLMConnection = sinon.stub();
|
|
10423
11434
|
let resolvePrefetch;
|
|
@@ -10443,7 +11454,7 @@ describe('plugin-meetings', () => {
|
|
|
10443
11454
|
'meeting:self:guestAdmitted',
|
|
10444
11455
|
{payload: test1}
|
|
10445
11456
|
);
|
|
10446
|
-
assert.
|
|
11457
|
+
assert.notCalled(meeting.updateLLMConnection);
|
|
10447
11458
|
assert.calledOnceWithExactly(meeting.rtcMetrics.sendNextMetrics);
|
|
10448
11459
|
|
|
10449
11460
|
assert.calledOnceWithExactly(
|
|
@@ -10456,6 +11467,7 @@ describe('plugin-meetings', () => {
|
|
|
10456
11467
|
|
|
10457
11468
|
resolvePrefetch(false);
|
|
10458
11469
|
await Promise.resolve();
|
|
11470
|
+
await Promise.resolve();
|
|
10459
11471
|
|
|
10460
11472
|
assert.calledOnce(meeting.updateLLMConnection);
|
|
10461
11473
|
});
|
|
@@ -10971,6 +11983,92 @@ describe('plugin-meetings', () => {
|
|
|
10971
11983
|
);
|
|
10972
11984
|
});
|
|
10973
11985
|
|
|
11986
|
+
const recordingTestCases = [
|
|
11987
|
+
{
|
|
11988
|
+
description: 'triggers MEETING_STARTED_RECORDING when state is RECORDING',
|
|
11989
|
+
state: RECORDING_STATE.RECORDING,
|
|
11990
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
|
|
11991
|
+
expectedRecordingState: RECORDING_STATE.RECORDING,
|
|
11992
|
+
},
|
|
11993
|
+
{
|
|
11994
|
+
description: 'triggers MEETING_STOPPED_RECORDING when state is IDLE',
|
|
11995
|
+
state: RECORDING_STATE.IDLE,
|
|
11996
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_STOPPED_RECORDING,
|
|
11997
|
+
expectedRecordingState: RECORDING_STATE.IDLE,
|
|
11998
|
+
},
|
|
11999
|
+
{
|
|
12000
|
+
description: 'triggers MEETING_PAUSED_RECORDING when state is PAUSED',
|
|
12001
|
+
state: RECORDING_STATE.PAUSED,
|
|
12002
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_PAUSED_RECORDING,
|
|
12003
|
+
expectedRecordingState: RECORDING_STATE.PAUSED,
|
|
12004
|
+
},
|
|
12005
|
+
{
|
|
12006
|
+
description:
|
|
12007
|
+
'triggers MEETING_RESUMED_RECORDING and sets state to RECORDING when state is RESUMED',
|
|
12008
|
+
state: RECORDING_STATE.RESUMED,
|
|
12009
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_RESUMED_RECORDING,
|
|
12010
|
+
expectedRecordingState: RECORDING_STATE.RECORDING,
|
|
12011
|
+
},
|
|
12012
|
+
];
|
|
12013
|
+
|
|
12014
|
+
recordingTestCases.forEach(({description, state, expectedEvent, expectedRecordingState}) => {
|
|
12015
|
+
it(`listens to CONTROLS_RECORDING_UPDATED - ${description}`, async () => {
|
|
12016
|
+
const modifiedBy = 'user-id-123';
|
|
12017
|
+
const lastModified = '2026-01-01T00:00:00Z';
|
|
12018
|
+
|
|
12019
|
+
await meeting.locusInfo.emitScoped(
|
|
12020
|
+
{function: 'test', file: 'test'},
|
|
12021
|
+
LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
|
|
12022
|
+
{state, modifiedBy, lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined}
|
|
12023
|
+
);
|
|
12024
|
+
|
|
12025
|
+
assert.deepEqual(meeting.recording, {
|
|
12026
|
+
state: expectedRecordingState,
|
|
12027
|
+
modifiedBy,
|
|
12028
|
+
lastModified,
|
|
12029
|
+
modifiedByServiceAppName: undefined,
|
|
12030
|
+
modifiedByServiceAppId: undefined,
|
|
12031
|
+
});
|
|
12032
|
+
|
|
12033
|
+
assert.calledWith(
|
|
12034
|
+
TriggerProxy.trigger,
|
|
12035
|
+
meeting,
|
|
12036
|
+
{file: 'meeting/index', function: 'setupLocusControlsListener'},
|
|
12037
|
+
expectedEvent,
|
|
12038
|
+
meeting.recording
|
|
12039
|
+
);
|
|
12040
|
+
});
|
|
12041
|
+
});
|
|
12042
|
+
|
|
12043
|
+
it('listens to CONTROLS_RECORDING_UPDATED and includes modifiedByServiceAppName and modifiedByServiceAppId when present', async () => {
|
|
12044
|
+
const modifiedBy = 'user-id-123';
|
|
12045
|
+
const lastModified = '2026-01-01T00:00:00Z';
|
|
12046
|
+
const modifiedByServiceAppName = 'My Bot';
|
|
12047
|
+
const modifiedByServiceAppId = 'app-id-123';
|
|
12048
|
+
|
|
12049
|
+
await meeting.locusInfo.emitScoped(
|
|
12050
|
+
{function: 'test', file: 'test'},
|
|
12051
|
+
LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
|
|
12052
|
+
{state: RECORDING_STATE.RECORDING, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}
|
|
12053
|
+
);
|
|
12054
|
+
|
|
12055
|
+
assert.deepEqual(meeting.recording, {
|
|
12056
|
+
state: RECORDING_STATE.RECORDING,
|
|
12057
|
+
modifiedBy,
|
|
12058
|
+
lastModified,
|
|
12059
|
+
modifiedByServiceAppName,
|
|
12060
|
+
modifiedByServiceAppId,
|
|
12061
|
+
});
|
|
12062
|
+
|
|
12063
|
+
assert.calledWith(
|
|
12064
|
+
TriggerProxy.trigger,
|
|
12065
|
+
meeting,
|
|
12066
|
+
{file: 'meeting/index', function: 'setupLocusControlsListener'},
|
|
12067
|
+
EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
|
|
12068
|
+
meeting.recording
|
|
12069
|
+
);
|
|
12070
|
+
});
|
|
12071
|
+
|
|
10974
12072
|
it('listens to the locus interpretation update event', () => {
|
|
10975
12073
|
const interpretation = {
|
|
10976
12074
|
siLanguages: [{languageCode: 20, languageName: 'en'}],
|
|
@@ -11024,6 +12122,7 @@ describe('plugin-meetings', () => {
|
|
|
11024
12122
|
meeting.annotation.locusUrlUpdate = sinon.stub();
|
|
11025
12123
|
meeting.simultaneousInterpretation.locusUrlUpdate = sinon.stub();
|
|
11026
12124
|
meeting.webinar.locusUrlUpdate = sinon.stub();
|
|
12125
|
+
meeting.aiEnableRequest.locusUrlUpdate = sinon.stub();
|
|
11027
12126
|
|
|
11028
12127
|
meeting.locusInfo.emit(
|
|
11029
12128
|
{function: 'test', file: 'test'},
|
|
@@ -11038,6 +12137,7 @@ describe('plugin-meetings', () => {
|
|
|
11038
12137
|
assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, false);
|
|
11039
12138
|
assert.calledWith(meeting.simultaneousInterpretation.locusUrlUpdate, newLocusUrl);
|
|
11040
12139
|
assert.calledWith(meeting.webinar.locusUrlUpdate, newLocusUrl);
|
|
12140
|
+
assert.calledWith(meeting.aiEnableRequest.locusUrlUpdate, newLocusUrl);
|
|
11041
12141
|
assert.equal(meeting.locusUrl, newLocusUrl);
|
|
11042
12142
|
assert(meeting.locusId, '12345');
|
|
11043
12143
|
|
|
@@ -11353,6 +12453,109 @@ describe('plugin-meetings', () => {
|
|
|
11353
12453
|
});
|
|
11354
12454
|
});
|
|
11355
12455
|
|
|
12456
|
+
describe('#finalizeMeetingAfterInitialLocusSetup', () => {
|
|
12457
|
+
it('refreshes destination from synced locus when destination type is LOCUS_ID', () => {
|
|
12458
|
+
const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12459
|
+
|
|
12460
|
+
meeting.destinationType = DESTINATION_TYPE.LOCUS_ID;
|
|
12461
|
+
meeting.destination = {info: {topic: 'old'}};
|
|
12462
|
+
|
|
12463
|
+
meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
|
|
12464
|
+
|
|
12465
|
+
assert.equal(meeting.destination, syncedLocus);
|
|
12466
|
+
});
|
|
12467
|
+
|
|
12468
|
+
it('does not refresh destination when destination type is not LOCUS_ID', () => {
|
|
12469
|
+
const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12470
|
+
const originalDestination = {destination: 'original-destination'};
|
|
12471
|
+
|
|
12472
|
+
meeting.destinationType = DESTINATION_TYPE.CONVERSATION_URL;
|
|
12473
|
+
meeting.destination = originalDestination;
|
|
12474
|
+
|
|
12475
|
+
meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
|
|
12476
|
+
|
|
12477
|
+
assert.equal(meeting.destination, originalDestination);
|
|
12478
|
+
});
|
|
12479
|
+
|
|
12480
|
+
it('fetches meeting info when meetingInfo is empty and destination has info', () => {
|
|
12481
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12482
|
+
|
|
12483
|
+
meeting.meetingInfo = {};
|
|
12484
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12485
|
+
|
|
12486
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12487
|
+
|
|
12488
|
+
assert.calledOnceWithExactly(fetchMeetingInfoStub, {});
|
|
12489
|
+
});
|
|
12490
|
+
|
|
12491
|
+
it('does not fetch meeting info when destination has no info', () => {
|
|
12492
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12493
|
+
|
|
12494
|
+
meeting.meetingInfo = {};
|
|
12495
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123'};
|
|
12496
|
+
|
|
12497
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12498
|
+
|
|
12499
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12500
|
+
});
|
|
12501
|
+
|
|
12502
|
+
it('does not fetch meeting info when meetingInfo is already populated', () => {
|
|
12503
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12504
|
+
|
|
12505
|
+
meeting.meetingInfo = {meetingJoinUrl: 'https://example.com/join/abc'};
|
|
12506
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12507
|
+
|
|
12508
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12509
|
+
|
|
12510
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12511
|
+
});
|
|
12512
|
+
|
|
12513
|
+
it('does not fetch meeting info when delayed fetch timer is already scheduled', () => {
|
|
12514
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12515
|
+
|
|
12516
|
+
meeting.meetingInfo = {};
|
|
12517
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12518
|
+
meeting.fetchMeetingInfoTimeoutId = 42;
|
|
12519
|
+
|
|
12520
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12521
|
+
|
|
12522
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12523
|
+
});
|
|
12524
|
+
|
|
12525
|
+
['CALL', 'SIP_BRIDGE', 'SPACE_SHARE'].forEach((fullStateType) => {
|
|
12526
|
+
it(`does not fetch meeting info when destination is a 1:1 call (fullState.type ${fullStateType})`, () => {
|
|
12527
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12528
|
+
|
|
12529
|
+
meeting.meetingInfo = {};
|
|
12530
|
+
meeting.destination = {
|
|
12531
|
+
url: 'https://locus.example.com/locus/123',
|
|
12532
|
+
info: {topic: 'x'},
|
|
12533
|
+
};
|
|
12534
|
+
|
|
12535
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({fullState: {type: fullStateType}});
|
|
12536
|
+
|
|
12537
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12538
|
+
});
|
|
12539
|
+
});
|
|
12540
|
+
|
|
12541
|
+
it('swallows async fetchMeetingInfo errors and logs info', async () => {
|
|
12542
|
+
const error = new Error('fetch failed');
|
|
12543
|
+
|
|
12544
|
+
meeting.meetingInfo = {};
|
|
12545
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12546
|
+
sinon.stub(meeting, 'fetchMeetingInfo').returns(Promise.reject(error));
|
|
12547
|
+
const loggerInfoStub = sinon.stub(LoggerProxy.logger, 'info');
|
|
12548
|
+
|
|
12549
|
+
await meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12550
|
+
|
|
12551
|
+
assert.calledOnce(loggerInfoStub);
|
|
12552
|
+
assert.match(
|
|
12553
|
+
loggerInfoStub.firstCall.args[0],
|
|
12554
|
+
/Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: fetch failed/
|
|
12555
|
+
);
|
|
12556
|
+
});
|
|
12557
|
+
});
|
|
12558
|
+
|
|
11356
12559
|
describe('#emailInput', () => {
|
|
11357
12560
|
it('should set the email input', () => {
|
|
11358
12561
|
assert.notOk(meeting.emailInput);
|
|
@@ -11955,6 +13158,7 @@ describe('plugin-meetings', () => {
|
|
|
11955
13158
|
let showAutoEndMeetingWarningSpy;
|
|
11956
13159
|
let canAttendeeRequestAiAssistantEnabledSpy;
|
|
11957
13160
|
let attendeeRequestAiAssistantDeclinedAllSpy;
|
|
13161
|
+
let isAnonymizeDisplayNamesEnabledSpy;
|
|
11958
13162
|
// Due to import tree issues, hasHints must be stubed within the scope of the `it`.
|
|
11959
13163
|
|
|
11960
13164
|
beforeEach(() => {
|
|
@@ -12003,6 +13207,10 @@ describe('plugin-meetings', () => {
|
|
|
12003
13207
|
MeetingUtil,
|
|
12004
13208
|
'attendeeRequestAiAssistantDeclinedAll'
|
|
12005
13209
|
);
|
|
13210
|
+
isAnonymizeDisplayNamesEnabledSpy = sinon.spy(
|
|
13211
|
+
MeetingUtil,
|
|
13212
|
+
'isAnonymizeDisplayNamesEnabled'
|
|
13213
|
+
);
|
|
12006
13214
|
});
|
|
12007
13215
|
|
|
12008
13216
|
afterEach(() => {
|
|
@@ -12011,6 +13219,7 @@ describe('plugin-meetings', () => {
|
|
|
12011
13219
|
showAutoEndMeetingWarningSpy.restore();
|
|
12012
13220
|
canAttendeeRequestAiAssistantEnabledSpy.restore();
|
|
12013
13221
|
attendeeRequestAiAssistantDeclinedAllSpy.restore();
|
|
13222
|
+
isAnonymizeDisplayNamesEnabledSpy.restore();
|
|
12014
13223
|
});
|
|
12015
13224
|
|
|
12016
13225
|
forEach(
|
|
@@ -12568,6 +13777,7 @@ describe('plugin-meetings', () => {
|
|
|
12568
13777
|
meeting.roles
|
|
12569
13778
|
);
|
|
12570
13779
|
assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
|
|
13780
|
+
assert.calledWith(isAnonymizeDisplayNamesEnabledSpy, userDisplayHints);
|
|
12571
13781
|
|
|
12572
13782
|
assert.calledWith(ControlsOptionsUtil.hasHints, {
|
|
12573
13783
|
requiredHints: [DISPLAY_HINTS.MUTE_ALL],
|
|
@@ -12782,6 +13992,10 @@ describe('plugin-meetings', () => {
|
|
|
12782
13992
|
describe('#saveDataChannelToken', () => {
|
|
12783
13993
|
beforeEach(() => {
|
|
12784
13994
|
webex.internal.llm.setDatachannelToken = sinon.stub();
|
|
13995
|
+
webex.internal.llm.resolveSessionOwnership = sinon
|
|
13996
|
+
.stub()
|
|
13997
|
+
.returns({currentOwner: undefined, isOwner: true});
|
|
13998
|
+
webex.internal.llm.isConnected = sinon.stub().returns(false);
|
|
12785
13999
|
});
|
|
12786
14000
|
|
|
12787
14001
|
it('saves datachannelToken into LLM as Default', () => {
|
|
@@ -12794,7 +14008,8 @@ describe('plugin-meetings', () => {
|
|
|
12794
14008
|
assert.calledWithExactly(
|
|
12795
14009
|
webex.internal.llm.setDatachannelToken,
|
|
12796
14010
|
'default-token',
|
|
12797
|
-
'llm-default-session'
|
|
14011
|
+
'llm-default-session',
|
|
14012
|
+
meeting.id
|
|
12798
14013
|
);
|
|
12799
14014
|
});
|
|
12800
14015
|
|
|
@@ -12808,7 +14023,8 @@ describe('plugin-meetings', () => {
|
|
|
12808
14023
|
assert.calledWithExactly(
|
|
12809
14024
|
webex.internal.llm.setDatachannelToken,
|
|
12810
14025
|
'ps-token',
|
|
12811
|
-
'llm-practice-session'
|
|
14026
|
+
'llm-practice-session',
|
|
14027
|
+
meeting.id
|
|
12812
14028
|
);
|
|
12813
14029
|
});
|
|
12814
14030
|
|
|
@@ -12826,12 +14042,14 @@ describe('plugin-meetings', () => {
|
|
|
12826
14042
|
assert.calledWithExactly(
|
|
12827
14043
|
webex.internal.llm.setDatachannelToken,
|
|
12828
14044
|
'default-token',
|
|
12829
|
-
'llm-default-session'
|
|
14045
|
+
'llm-default-session',
|
|
14046
|
+
meeting.id
|
|
12830
14047
|
);
|
|
12831
14048
|
assert.calledWithExactly(
|
|
12832
14049
|
webex.internal.llm.setDatachannelToken,
|
|
12833
14050
|
'ps-token',
|
|
12834
|
-
'llm-practice-session'
|
|
14051
|
+
'llm-practice-session',
|
|
14052
|
+
meeting.id
|
|
12835
14053
|
);
|
|
12836
14054
|
});
|
|
12837
14055
|
|
|
@@ -12852,17 +14070,42 @@ describe('plugin-meetings', () => {
|
|
|
12852
14070
|
|
|
12853
14071
|
assert.notCalled(webex.internal.llm.setDatachannelToken);
|
|
12854
14072
|
});
|
|
14073
|
+
|
|
14074
|
+
it('writes token with meeting id as owner', () => {
|
|
14075
|
+
meeting.saveDataChannelToken({
|
|
14076
|
+
locus: {
|
|
14077
|
+
self: {datachannelToken: 'default-token'},
|
|
14078
|
+
},
|
|
14079
|
+
});
|
|
14080
|
+
|
|
14081
|
+
assert.calledOnceWithExactly(
|
|
14082
|
+
webex.internal.llm.setDatachannelToken,
|
|
14083
|
+
'default-token',
|
|
14084
|
+
'llm-default-session',
|
|
14085
|
+
meeting.id
|
|
14086
|
+
);
|
|
14087
|
+
});
|
|
12855
14088
|
});
|
|
12856
14089
|
|
|
12857
14090
|
describe('#clearDataChannelToken', () => {
|
|
12858
14091
|
beforeEach(() => {
|
|
12859
|
-
webex.internal.llm.
|
|
14092
|
+
webex.internal.llm.clearDatachannelToken = sinon.stub();
|
|
12860
14093
|
});
|
|
12861
14094
|
|
|
12862
|
-
it('
|
|
14095
|
+
it('delegates default and practice token clears to llm with meeting ownership id', () => {
|
|
12863
14096
|
meeting.clearDataChannelToken();
|
|
12864
14097
|
|
|
12865
|
-
assert.
|
|
14098
|
+
assert.calledWithExactly(
|
|
14099
|
+
webex.internal.llm.clearDatachannelToken,
|
|
14100
|
+
'llm-default-session',
|
|
14101
|
+
meeting.id
|
|
14102
|
+
);
|
|
14103
|
+
assert.calledWithExactly(
|
|
14104
|
+
webex.internal.llm.clearDatachannelToken,
|
|
14105
|
+
'llm-practice-session',
|
|
14106
|
+
meeting.id
|
|
14107
|
+
);
|
|
14108
|
+
assert.callCount(webex.internal.llm.clearDatachannelToken, 2);
|
|
12866
14109
|
});
|
|
12867
14110
|
});
|
|
12868
14111
|
|
|
@@ -12872,6 +14115,7 @@ describe('plugin-meetings', () => {
|
|
|
12872
14115
|
webex.internal.llm.getLocusUrl = sinon.stub();
|
|
12873
14116
|
webex.internal.llm.getDatachannelUrl = sinon.stub();
|
|
12874
14117
|
webex.internal.llm.registerAndConnect = sinon.stub().resolves('something');
|
|
14118
|
+
webex.internal.llm.setRefreshHandler = sinon.stub();
|
|
12875
14119
|
webex.internal.llm.disconnectLLM = sinon.stub().resolves();
|
|
12876
14120
|
webex.internal.llm.on = sinon.stub();
|
|
12877
14121
|
webex.internal.llm.off = sinon.stub();
|
|
@@ -12889,7 +14133,7 @@ describe('plugin-meetings', () => {
|
|
|
12889
14133
|
meeting.joinedWith = {state: 'any other state'};
|
|
12890
14134
|
webex.internal.llm.getLocusUrl.returns('a url');
|
|
12891
14135
|
|
|
12892
|
-
meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14136
|
+
meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
12893
14137
|
|
|
12894
14138
|
const result = await meeting.updateLLMConnection();
|
|
12895
14139
|
|
|
@@ -12901,6 +14145,7 @@ describe('plugin-meetings', () => {
|
|
|
12901
14145
|
it('returns undefined if llm is already connected and the locus url is unchanged', async () => {
|
|
12902
14146
|
meeting.joinedWith = {state: 'JOINED'};
|
|
12903
14147
|
meeting.locusInfo = {
|
|
14148
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
12904
14149
|
url: 'a url',
|
|
12905
14150
|
info: {datachannelUrl: 'a datachannel url'},
|
|
12906
14151
|
};
|
|
@@ -12937,7 +14182,7 @@ describe('plugin-meetings', () => {
|
|
|
12937
14182
|
});
|
|
12938
14183
|
it('connects if not already connected', async () => {
|
|
12939
14184
|
meeting.joinedWith = {state: 'JOINED'};
|
|
12940
|
-
meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14185
|
+
meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
12941
14186
|
|
|
12942
14187
|
const result = await meeting.updateLLMConnection();
|
|
12943
14188
|
|
|
@@ -12948,7 +14193,14 @@ describe('plugin-meetings', () => {
|
|
|
12948
14193
|
'a datachannel url',
|
|
12949
14194
|
undefined
|
|
12950
14195
|
);
|
|
14196
|
+
assert.calledOnceWithExactly(
|
|
14197
|
+
webex.internal.llm.setRefreshHandler,
|
|
14198
|
+
sinon.match.func,
|
|
14199
|
+
'llm-default-session',
|
|
14200
|
+
meeting.id
|
|
14201
|
+
);
|
|
12951
14202
|
assert.equal(result, 'something');
|
|
14203
|
+
assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true});
|
|
12952
14204
|
});
|
|
12953
14205
|
it('disconnects if the locus url has changed', async () => {
|
|
12954
14206
|
meeting.joinedWith = {state: 'JOINED'};
|
|
@@ -12957,6 +14209,7 @@ describe('plugin-meetings', () => {
|
|
|
12957
14209
|
webex.internal.llm.getLocusUrl.returns('a url');
|
|
12958
14210
|
|
|
12959
14211
|
meeting.locusInfo = {
|
|
14212
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
12960
14213
|
url: 'a different url',
|
|
12961
14214
|
info: {datachannelUrl: 'a datachannel url'},
|
|
12962
14215
|
self: {},
|
|
@@ -12967,7 +14220,7 @@ describe('plugin-meetings', () => {
|
|
|
12967
14220
|
assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
|
|
12968
14221
|
code: 3050,
|
|
12969
14222
|
reason: 'done (permanent)',
|
|
12970
|
-
});
|
|
14223
|
+
}, 'llm-default-session', meeting.id);
|
|
12971
14224
|
|
|
12972
14225
|
assert.calledWithExactly(
|
|
12973
14226
|
webex.internal.llm.registerAndConnect,
|
|
@@ -13010,6 +14263,7 @@ describe('plugin-meetings', () => {
|
|
|
13010
14263
|
webex.internal.llm.getLocusUrl.returns('a url');
|
|
13011
14264
|
|
|
13012
14265
|
meeting.locusInfo = {
|
|
14266
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13013
14267
|
url: 'a url',
|
|
13014
14268
|
info: {datachannelUrl: 'a different datachannel url'},
|
|
13015
14269
|
self: {},
|
|
@@ -13020,7 +14274,7 @@ describe('plugin-meetings', () => {
|
|
|
13020
14274
|
assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
|
|
13021
14275
|
code: 3050,
|
|
13022
14276
|
reason: 'done (permanent)',
|
|
13023
|
-
});
|
|
14277
|
+
}, 'llm-default-session', meeting.id);
|
|
13024
14278
|
|
|
13025
14279
|
assert.calledWithExactly(
|
|
13026
14280
|
webex.internal.llm.registerAndConnect,
|
|
@@ -13061,14 +14315,14 @@ describe('plugin-meetings', () => {
|
|
|
13061
14315
|
webex.internal.llm.isConnected.returns(true);
|
|
13062
14316
|
webex.internal.llm.getLocusUrl.returns('a url');
|
|
13063
14317
|
|
|
13064
|
-
meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14318
|
+
meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
13065
14319
|
|
|
13066
14320
|
const result = await meeting.updateLLMConnection();
|
|
13067
14321
|
|
|
13068
14322
|
assert.calledWith(webex.internal.llm.disconnectLLM, {
|
|
13069
14323
|
code: 3050,
|
|
13070
14324
|
reason: 'done (permanent)',
|
|
13071
|
-
});
|
|
14325
|
+
}, 'llm-default-session', meeting.id);
|
|
13072
14326
|
assert.notCalled(webex.internal.llm.registerAndConnect);
|
|
13073
14327
|
assert.equal(result, undefined);
|
|
13074
14328
|
assert.isFalse(
|
|
@@ -13084,6 +14338,7 @@ describe('plugin-meetings', () => {
|
|
|
13084
14338
|
webex.internal.llm.disconnectLLM.rejects(disconnectError);
|
|
13085
14339
|
|
|
13086
14340
|
meeting.locusInfo = {
|
|
14341
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13087
14342
|
url: 'a different url',
|
|
13088
14343
|
info: {datachannelUrl: 'a datachannel url'},
|
|
13089
14344
|
self: {},
|
|
@@ -13115,6 +14370,7 @@ describe('plugin-meetings', () => {
|
|
|
13115
14370
|
it('still need connect main session data channel when PS started', async () => {
|
|
13116
14371
|
meeting.joinedWith = {state: 'JOINED'};
|
|
13117
14372
|
meeting.locusInfo = {
|
|
14373
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13118
14374
|
url: 'a url',
|
|
13119
14375
|
info: {
|
|
13120
14376
|
datachannelUrl: 'a datachannel url',
|
|
@@ -13135,11 +14391,14 @@ describe('plugin-meetings', () => {
|
|
|
13135
14391
|
it('passes dataChannelToken from LLM to registerAndConnect', async () => {
|
|
13136
14392
|
meeting.joinedWith = {state: 'JOINED'};
|
|
13137
14393
|
meeting.locusInfo = {
|
|
14394
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13138
14395
|
url: 'a url',
|
|
13139
14396
|
info: {datachannelUrl: 'a datachannel url'},
|
|
13140
14397
|
};
|
|
13141
14398
|
|
|
13142
|
-
webex.internal.llm.getDatachannelToken
|
|
14399
|
+
webex.internal.llm.getDatachannelToken
|
|
14400
|
+
.withArgs('llm-default-session', meeting.id)
|
|
14401
|
+
.returns('token-123');
|
|
13143
14402
|
|
|
13144
14403
|
await meeting.updateLLMConnection();
|
|
13145
14404
|
|
|
@@ -13154,6 +14413,7 @@ describe('plugin-meetings', () => {
|
|
|
13154
14413
|
it('passes undefined token when LLM has no token stored', async () => {
|
|
13155
14414
|
meeting.joinedWith = {state: 'JOINED'};
|
|
13156
14415
|
meeting.locusInfo = {
|
|
14416
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13157
14417
|
url: 'a url',
|
|
13158
14418
|
info: {datachannelUrl: 'a datachannel url'},
|
|
13159
14419
|
};
|
|
@@ -13175,6 +14435,7 @@ describe('plugin-meetings', () => {
|
|
|
13175
14435
|
it('does not pass token when data channel with jwt token is disabled', async () => {
|
|
13176
14436
|
meeting.joinedWith = {state: 'JOINED'};
|
|
13177
14437
|
meeting.locusInfo = {
|
|
14438
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
13178
14439
|
url: 'a url',
|
|
13179
14440
|
info: {datachannelUrl: 'a datachannel url'},
|
|
13180
14441
|
};
|
|
@@ -13193,6 +14454,197 @@ describe('plugin-meetings', () => {
|
|
|
13193
14454
|
assert.notCalled(webex.internal.llm.setDatachannelToken);
|
|
13194
14455
|
});
|
|
13195
14456
|
|
|
14457
|
+
describe('ownership tag', () => {
|
|
14458
|
+
beforeEach(() => {
|
|
14459
|
+
// Make the owner stub dynamic so setOwnerMeetingId() writes
|
|
14460
|
+
// propagate back to getOwnerMeetingId() reads. This mirrors the
|
|
14461
|
+
// real LLM singleton behavior so the finally-block release in
|
|
14462
|
+
// cleanupLLMConneciton is reflected in subsequent reads.
|
|
14463
|
+
webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
|
|
14464
|
+
webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
|
|
14465
|
+
webex.internal.llm.getOwnerMeetingId.returns(id);
|
|
14466
|
+
});
|
|
14467
|
+
});
|
|
14468
|
+
|
|
14469
|
+
it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
|
|
14470
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14471
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14472
|
+
webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
|
|
14473
|
+
// Locus/datachannel URL mismatch is the *normal* case when
|
|
14474
|
+
// another meeting owns the live socket -- each meeting has its
|
|
14475
|
+
// own locus URL. URL mismatch must NOT trigger a reclaim,
|
|
14476
|
+
// because doing so would tear down the owning meeting's healthy
|
|
14477
|
+
// LLM socket and break its data channel.
|
|
14478
|
+
webex.internal.llm.getLocusUrl.returns('owner-locus-url');
|
|
14479
|
+
webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
|
|
14480
|
+
meeting.locusInfo = {
|
|
14481
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
14482
|
+
url: 'a different url',
|
|
14483
|
+
info: {datachannelUrl: 'a different datachannel url'},
|
|
14484
|
+
self: {},
|
|
14485
|
+
};
|
|
14486
|
+
|
|
14487
|
+
const result = await meeting.updateLLMConnection();
|
|
14488
|
+
|
|
14489
|
+
assert.equal(result, undefined);
|
|
14490
|
+
assert.notCalled(webex.internal.llm.disconnectLLM);
|
|
14491
|
+
assert.notCalled(webex.internal.llm.registerAndConnect);
|
|
14492
|
+
assert.notCalled(webex.internal.llm.setOwnerMeetingId);
|
|
14493
|
+
assert.notCalled(meeting.startLLMHealthCheckTimer);
|
|
14494
|
+
});
|
|
14495
|
+
|
|
14496
|
+
|
|
14497
|
+
it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
|
|
14498
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14499
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14500
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14501
|
+
webex.internal.llm.getLocusUrl.returns('a url');
|
|
14502
|
+
webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
|
|
14503
|
+
webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
|
|
14504
|
+
meeting.locusInfo = {
|
|
14505
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
14506
|
+
url: 'a different url',
|
|
14507
|
+
info: {datachannelUrl: 'a datachannel url'},
|
|
14508
|
+
self: {},
|
|
14509
|
+
};
|
|
14510
|
+
|
|
14511
|
+
try {
|
|
14512
|
+
await meeting.updateLLMConnection();
|
|
14513
|
+
} catch (e) {
|
|
14514
|
+
/* updateLLMConnection may reject when cleanup throws */
|
|
14515
|
+
}
|
|
14516
|
+
|
|
14517
|
+
// The owner-eligible finally branch must release the tag so a
|
|
14518
|
+
// subsequent reconnect attempt from any meeting is not blocked.
|
|
14519
|
+
assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
|
|
14520
|
+
});
|
|
14521
|
+
|
|
14522
|
+
it('does not clear owner tag when ownership changes during cleanup disconnect await', async () => {
|
|
14523
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14524
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14525
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14526
|
+
webex.internal.llm.getLocusUrl.returns('a url');
|
|
14527
|
+
webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
|
|
14528
|
+
webex.internal.llm.disconnectLLM.callsFake(async () => {
|
|
14529
|
+
webex.internal.llm.getOwnerMeetingId.returns('new-owner-id');
|
|
14530
|
+
throw new Error('disconnect failed');
|
|
14531
|
+
});
|
|
14532
|
+
meeting.locusInfo = {
|
|
14533
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
14534
|
+
url: 'a different url',
|
|
14535
|
+
info: {datachannelUrl: 'a datachannel url'},
|
|
14536
|
+
self: {},
|
|
14537
|
+
};
|
|
14538
|
+
|
|
14539
|
+
try {
|
|
14540
|
+
await meeting.updateLLMConnection();
|
|
14541
|
+
} catch (e) {
|
|
14542
|
+
/* updateLLMConnection may reject when cleanup throws */
|
|
14543
|
+
}
|
|
14544
|
+
|
|
14545
|
+
assert.notCalled(webex.internal.llm.setOwnerMeetingId);
|
|
14546
|
+
assert.equal(webex.internal.llm.getOwnerMeetingId(), 'new-owner-id');
|
|
14547
|
+
});
|
|
14548
|
+
|
|
14549
|
+
it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
|
|
14550
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14551
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14552
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14553
|
+
webex.internal.llm.getLocusUrl.returns('a url');
|
|
14554
|
+
webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
|
|
14555
|
+
meeting.locusInfo = {
|
|
14556
|
+
syncAllHashTreeDatasets: sinon.stub().resolves(),
|
|
14557
|
+
url: 'a different url',
|
|
14558
|
+
info: {datachannelUrl: 'a datachannel url'},
|
|
14559
|
+
self: {},
|
|
14560
|
+
};
|
|
14561
|
+
|
|
14562
|
+
await meeting.updateLLMConnection();
|
|
14563
|
+
|
|
14564
|
+
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
14565
|
+
code: 3050,
|
|
14566
|
+
reason: 'done (permanent)',
|
|
14567
|
+
}, 'llm-default-session', meeting.id);
|
|
14568
|
+
assert.calledWithExactly(
|
|
14569
|
+
webex.internal.llm.registerAndConnect,
|
|
14570
|
+
'a different url',
|
|
14571
|
+
'a datachannel url',
|
|
14572
|
+
undefined
|
|
14573
|
+
);
|
|
14574
|
+
// setOwnerMeetingId is called twice: first with undefined in
|
|
14575
|
+
// cleanupLLMConneciton's finally block (so a failed disconnect
|
|
14576
|
+
// cannot leave a stale owner), then with this meeting's id
|
|
14577
|
+
// after registerAndConnect resolves.
|
|
14578
|
+
assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
|
|
14579
|
+
assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
|
|
14580
|
+
assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
|
|
14581
|
+
});
|
|
14582
|
+
|
|
14583
|
+
it('claims ownership after successful registerAndConnect on initial connect', async () => {
|
|
14584
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14585
|
+
webex.internal.llm.isConnected.returns(false);
|
|
14586
|
+
webex.internal.llm.getOwnerMeetingId.returns(undefined);
|
|
14587
|
+
meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14588
|
+
|
|
14589
|
+
await meeting.updateLLMConnection();
|
|
14590
|
+
|
|
14591
|
+
assert.calledOnce(webex.internal.llm.registerAndConnect);
|
|
14592
|
+
assert.calledOnceWithExactly(
|
|
14593
|
+
webex.internal.llm.setRefreshHandler,
|
|
14594
|
+
sinon.match.func,
|
|
14595
|
+
'llm-default-session',
|
|
14596
|
+
meeting.id
|
|
14597
|
+
);
|
|
14598
|
+
assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
|
|
14599
|
+
});
|
|
14600
|
+
|
|
14601
|
+
it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
|
|
14602
|
+
// Defensive path: if the LLM reports not-connected but an old
|
|
14603
|
+
// ownerId is still present (e.g. race before a successful
|
|
14604
|
+
// connections.delete), this meeting can still claim a fresh
|
|
14605
|
+
// connection.
|
|
14606
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14607
|
+
webex.internal.llm.isConnected.returns(false);
|
|
14608
|
+
webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
|
|
14609
|
+
webex.internal.llm.getDatachannelToken.onFirstCall().returns(undefined);
|
|
14610
|
+
webex.internal.llm.getDatachannelToken.onSecondCall().returns('recovered-token');
|
|
14611
|
+
meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14612
|
+
|
|
14613
|
+
await meeting.updateLLMConnection();
|
|
14614
|
+
|
|
14615
|
+
assert.calledTwice(webex.internal.llm.getDatachannelToken);
|
|
14616
|
+
assert.calledWithExactly(
|
|
14617
|
+
webex.internal.llm.getDatachannelToken.firstCall,
|
|
14618
|
+
'llm-default-session',
|
|
14619
|
+
meeting.id
|
|
14620
|
+
);
|
|
14621
|
+
assert.calledWithExactly(
|
|
14622
|
+
webex.internal.llm.getDatachannelToken.secondCall,
|
|
14623
|
+
'llm-default-session'
|
|
14624
|
+
);
|
|
14625
|
+
assert.calledOnceWithExactly(
|
|
14626
|
+
webex.internal.llm.registerAndConnect,
|
|
14627
|
+
'a url',
|
|
14628
|
+
'a datachannel url',
|
|
14629
|
+
'recovered-token'
|
|
14630
|
+
);
|
|
14631
|
+
assert.calledWithExactly(
|
|
14632
|
+
webex.internal.llm.setRefreshHandler.firstCall,
|
|
14633
|
+
sinon.match.func,
|
|
14634
|
+
'llm-default-session',
|
|
14635
|
+
undefined
|
|
14636
|
+
);
|
|
14637
|
+
assert.calledTwice(webex.internal.llm.setRefreshHandler);
|
|
14638
|
+
assert.calledWithExactly(
|
|
14639
|
+
webex.internal.llm.setRefreshHandler.secondCall,
|
|
14640
|
+
sinon.match.func,
|
|
14641
|
+
'llm-default-session',
|
|
14642
|
+
meeting.id
|
|
14643
|
+
);
|
|
14644
|
+
assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
|
|
14645
|
+
});
|
|
14646
|
+
});
|
|
14647
|
+
|
|
13196
14648
|
describe('#clearMeetingData', () => {
|
|
13197
14649
|
beforeEach(() => {
|
|
13198
14650
|
webex.internal.llm.isConnected = sinon.stub().returns(true);
|
|
@@ -13211,7 +14663,7 @@ describe('plugin-meetings', () => {
|
|
|
13211
14663
|
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
13212
14664
|
code: 3050,
|
|
13213
14665
|
reason: 'done (permanent)',
|
|
13214
|
-
});
|
|
14666
|
+
}, 'llm-default-session', meeting.id);
|
|
13215
14667
|
assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
|
|
13216
14668
|
assert.calledWithExactly(
|
|
13217
14669
|
webex.internal.llm.off,
|
|
@@ -13224,10 +14676,13 @@ describe('plugin-meetings', () => {
|
|
|
13224
14676
|
meeting.processLocusLLMEvent
|
|
13225
14677
|
);
|
|
13226
14678
|
assert.calledOnce(meeting.clearLLMHealthCheckTimer);
|
|
13227
|
-
assert.calledOnce(meeting.stopTranscription);
|
|
13228
|
-
assert.isUndefined(meeting.transcription);
|
|
13229
14679
|
assert.calledOnce(meeting.clearDataChannelToken);
|
|
13230
|
-
|
|
14680
|
+
// stopTranscription and annotation.deregisterEvents are not
|
|
14681
|
+
// called here: they run in stopListeningForMeetingEvents()
|
|
14682
|
+
// before /leave to avoid double-emitting
|
|
14683
|
+
// MEETING_STOPPED_RECEIVING_TRANSCRIPTION.
|
|
14684
|
+
assert.notCalled(meeting.stopTranscription);
|
|
14685
|
+
assert.notCalled(meeting.annotation.deregisterEvents);
|
|
13231
14686
|
});
|
|
13232
14687
|
it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
|
|
13233
14688
|
webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
|
|
@@ -13246,19 +14701,65 @@ describe('plugin-meetings', () => {
|
|
|
13246
14701
|
meeting.processLocusLLMEvent
|
|
13247
14702
|
);
|
|
13248
14703
|
assert.calledOnce(meeting.clearLLMHealthCheckTimer);
|
|
13249
|
-
assert.calledOnce(meeting.stopTranscription);
|
|
13250
|
-
assert.isUndefined(meeting.transcription);
|
|
13251
14704
|
assert.calledOnce(meeting.clearDataChannelToken);
|
|
13252
|
-
assert.
|
|
14705
|
+
assert.notCalled(meeting.stopTranscription);
|
|
14706
|
+
assert.notCalled(meeting.annotation.deregisterEvents);
|
|
13253
14707
|
});
|
|
13254
|
-
it('always calls stopTranscription even when transcription is undefined', async () => {
|
|
13255
|
-
meeting.transcription = undefined;
|
|
13256
14708
|
|
|
13257
|
-
|
|
14709
|
+
describe('ownership tag', () => {
|
|
14710
|
+
beforeEach(() => {
|
|
14711
|
+
webex.internal.llm.getOwnerMeetingId = sinon.stub();
|
|
14712
|
+
});
|
|
13258
14713
|
|
|
13259
|
-
|
|
13260
|
-
|
|
13261
|
-
|
|
14714
|
+
it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
|
|
14715
|
+
webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
|
|
14716
|
+
|
|
14717
|
+
await meeting.clearMeetingData();
|
|
14718
|
+
|
|
14719
|
+
assert.notCalled(webex.internal.llm.disconnectLLM);
|
|
14720
|
+
// clearDataChannelToken is always delegated; llm enforces
|
|
14721
|
+
// ownership and no-ops for non-owners internally.
|
|
14722
|
+
assert.calledOnce(meeting.clearDataChannelToken);
|
|
14723
|
+
// Listeners owned by *this* Meeting instance must still be
|
|
14724
|
+
// removed so a leaving subordinate meeting stops receiving
|
|
14725
|
+
// relay/locus events from the shared singleton.
|
|
14726
|
+
assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
|
|
14727
|
+
assert.calledWithExactly(
|
|
14728
|
+
webex.internal.llm.off,
|
|
14729
|
+
'event:relay.event',
|
|
14730
|
+
meeting.processRelayEvent
|
|
14731
|
+
);
|
|
14732
|
+
assert.calledWithExactly(
|
|
14733
|
+
webex.internal.llm.off,
|
|
14734
|
+
'event:locus.state_message',
|
|
14735
|
+
meeting.processLocusLLMEvent
|
|
14736
|
+
);
|
|
14737
|
+
assert.calledOnce(meeting.clearLLMHealthCheckTimer);
|
|
14738
|
+
});
|
|
14739
|
+
|
|
14740
|
+
it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
|
|
14741
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14742
|
+
|
|
14743
|
+
await meeting.clearMeetingData();
|
|
14744
|
+
|
|
14745
|
+
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
14746
|
+
code: 3050,
|
|
14747
|
+
reason: 'done (permanent)',
|
|
14748
|
+
}, 'llm-default-session', meeting.id);
|
|
14749
|
+
assert.calledOnce(meeting.clearDataChannelToken);
|
|
14750
|
+
});
|
|
14751
|
+
|
|
14752
|
+
it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
|
|
14753
|
+
webex.internal.llm.getOwnerMeetingId.returns(undefined);
|
|
14754
|
+
|
|
14755
|
+
await meeting.clearMeetingData();
|
|
14756
|
+
|
|
14757
|
+
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
14758
|
+
code: 3050,
|
|
14759
|
+
reason: 'done (permanent)',
|
|
14760
|
+
}, 'llm-default-session', meeting.id);
|
|
14761
|
+
assert.calledOnce(meeting.clearDataChannelToken);
|
|
14762
|
+
});
|
|
13262
14763
|
});
|
|
13263
14764
|
});
|
|
13264
14765
|
});
|
|
@@ -15012,16 +16513,25 @@ describe('plugin-meetings', () => {
|
|
|
15012
16513
|
assert.notCalled(meeting.meetingRequest.sendReaction);
|
|
15013
16514
|
});
|
|
15014
16515
|
|
|
15015
|
-
it('should
|
|
16516
|
+
it('should send a custom reaction type not in the known list', async () => {
|
|
15016
16517
|
meeting.locusInfo.controls = {reactions: {reactionChannelUrl: 'Fake URL'}};
|
|
15017
16518
|
|
|
15018
|
-
|
|
15019
|
-
meeting.sendReaction('invalid_reaction', 'light'),
|
|
15020
|
-
Error,
|
|
15021
|
-
'invalid_reaction is not a valid reaction.'
|
|
15022
|
-
);
|
|
16519
|
+
const reactionPromise = meeting.sendReaction('custom_reaction', 'light');
|
|
15023
16520
|
|
|
15024
|
-
assert.
|
|
16521
|
+
assert.exists(reactionPromise.then);
|
|
16522
|
+
await reactionPromise;
|
|
16523
|
+
assert.calledOnceWithExactly(meeting.meetingRequest.sendReaction, {
|
|
16524
|
+
reactionChannelUrl: 'Fake URL',
|
|
16525
|
+
reaction: {
|
|
16526
|
+
type: 'custom_reaction',
|
|
16527
|
+
tone: {
|
|
16528
|
+
type: 'light_skin_tone',
|
|
16529
|
+
codepoints: '1F3FB',
|
|
16530
|
+
shortcodes: ':skin-tone-2:',
|
|
16531
|
+
},
|
|
16532
|
+
},
|
|
16533
|
+
participantId: meeting.members.selfId,
|
|
16534
|
+
});
|
|
15025
16535
|
});
|
|
15026
16536
|
|
|
15027
16537
|
it('should send a reaction with default skin tone if provided skinToneType is invalid ', async () => {
|
|
@@ -16045,4 +17555,4 @@ describe('plugin-meetings', () => {
|
|
|
16045
17555
|
assert.calledOnceWithExactly(meeting.meetingRequest.cancelSipCallOut, participantId);
|
|
16046
17556
|
});
|
|
16047
17557
|
});
|
|
16048
|
-
});
|
|
17558
|
+
});
|