@webex/plugin-meetings 3.12.0-next.6 → 3.12.0-next.60
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 +6 -3
- 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 +38 -24
- 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 +10 -1
- package/dist/hashTree/constants.js.map +1 -1
- package/dist/hashTree/hashTreeParser.js +716 -370
- package/dist/hashTree/hashTreeParser.js.map +1 -1
- package/dist/hashTree/utils.js +22 -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/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/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 +289 -87
- package/dist/locus-info/index.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 +3 -1
- package/dist/meeting/in-meeting-actions.js.map +1 -1
- package/dist/meeting/index.js +907 -535
- package/dist/meeting/index.js.map +1 -1
- package/dist/meeting/util.js +19 -2
- package/dist/meeting/util.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 +2 -0
- package/dist/types/controls-options-manager/constants.d.ts +6 -1
- package/dist/types/controls-options-manager/index.d.ts +10 -0
- package/dist/types/hashTree/constants.d.ts +1 -0
- package/dist/types/hashTree/hashTreeParser.d.ts +92 -16
- package/dist/types/hashTree/utils.d.ts +11 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/interceptors/locusRetry.d.ts +4 -4
- package/dist/types/locus-info/index.d.ts +46 -6
- 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 +2 -0
- package/dist/types/meeting/index.d.ts +87 -3
- package/dist/types/meeting/util.d.ts +8 -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 +361 -235
- 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 +5 -1
- package/src/controls-options-manager/constants.ts +14 -1
- package/src/controls-options-manager/index.ts +47 -24
- package/src/controls-options-manager/util.ts +81 -1
- package/src/hashTree/constants.ts +9 -0
- package/src/hashTree/hashTreeParser.ts +429 -183
- package/src/hashTree/utils.ts +17 -0
- package/src/index.ts +5 -0
- package/src/interceptors/locusRetry.ts +25 -4
- package/src/interpretation/index.ts +25 -8
- package/src/locus-info/controlsUtils.ts +3 -1
- package/src/locus-info/index.ts +291 -97
- 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 +4 -0
- package/src/meeting/index.ts +388 -33
- package/src/meeting/util.ts +20 -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 +162 -21
- 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 +140 -29
- package/test/unit/spec/controls-options-manager/util.js +165 -0
- package/test/unit/spec/hashTree/hashTreeParser.ts +1508 -149
- package/test/unit/spec/hashTree/utils.ts +88 -1
- 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 +475 -81
- package/test/unit/spec/media/index.ts +31 -0
- package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
- package/test/unit/spec/meeting/index.js +1131 -49
- package/test/unit/spec/meeting/muteState.js +3 -0
- package/test/unit/spec/meeting/utils.js +33 -0
- package/test/unit/spec/meetings/index.js +360 -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 +141 -16
|
@@ -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,
|
|
@@ -417,6 +420,160 @@ describe('plugin-meetings', () => {
|
|
|
417
420
|
assert.instanceOf(meeting.mediaRequestManagers.screenShareVideo, MediaRequestManager);
|
|
418
421
|
});
|
|
419
422
|
|
|
423
|
+
it('getIngressPayloadType on webrtcMediaConnection is invoked for H264 when sending multistream video requests', () => {
|
|
424
|
+
const getIngressPayloadType = sinon.stub().returns(97);
|
|
425
|
+
|
|
426
|
+
meeting.isMultistream = true;
|
|
427
|
+
meeting.mediaProperties.webrtcMediaConnection = {
|
|
428
|
+
getIngressPayloadType,
|
|
429
|
+
requestMedia: sinon.stub(),
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const fakeReceiveSlot = {
|
|
433
|
+
on: sinon.stub(),
|
|
434
|
+
off: sinon.stub(),
|
|
435
|
+
sourceState: 'live',
|
|
436
|
+
mediaType: MediaType.VideoMain,
|
|
437
|
+
wcmeReceiveSlot: {id: 'fake-wcme-slot'},
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
meeting.mediaRequestManagers.video.addRequest(
|
|
441
|
+
{
|
|
442
|
+
policyInfo: {
|
|
443
|
+
policy: 'receiver-selected',
|
|
444
|
+
csi: 42,
|
|
445
|
+
},
|
|
446
|
+
receiveSlots: [fakeReceiveSlot],
|
|
447
|
+
codecInfo: {
|
|
448
|
+
codec: 'h264',
|
|
449
|
+
maxFs: 3600,
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
true
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
assert.calledOnceWithExactly(
|
|
456
|
+
getIngressPayloadType,
|
|
457
|
+
MediaType.VideoMain,
|
|
458
|
+
MediaCodecMimeType.H264
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('getIngressPayloadType on webrtcMediaConnection is invoked for H264 and AV1 for slides video when AV1 slides support is enabled', () => {
|
|
463
|
+
const localWebex = new MockWebex({
|
|
464
|
+
children: {
|
|
465
|
+
meetings: Meetings,
|
|
466
|
+
credentials: Credentials,
|
|
467
|
+
support: Support,
|
|
468
|
+
llm: LLM,
|
|
469
|
+
mercury: Mercury,
|
|
470
|
+
},
|
|
471
|
+
config: {
|
|
472
|
+
credentials: {
|
|
473
|
+
client_id: 'mock-client-id',
|
|
474
|
+
},
|
|
475
|
+
meetings: {
|
|
476
|
+
reconnection: {
|
|
477
|
+
enabled: false,
|
|
478
|
+
},
|
|
479
|
+
mediaSettings: {},
|
|
480
|
+
metrics: {},
|
|
481
|
+
stats: {},
|
|
482
|
+
experimental: {enableUnifiedMeetings: true},
|
|
483
|
+
degradationPreferences: {maxMacroblocksLimit: 8192},
|
|
484
|
+
enableAv1SlidesSupport: true,
|
|
485
|
+
},
|
|
486
|
+
metrics: {
|
|
487
|
+
type: ['behavioral'],
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
localWebex.internal.newMetrics.callDiagnosticMetrics.clearErrorCache = sinon.stub();
|
|
493
|
+
localWebex.internal.newMetrics.callDiagnosticMetrics.clearEventLimitsForCorrelationId =
|
|
494
|
+
sinon.stub();
|
|
495
|
+
localWebex.internal.support.submitLogs = sinon.stub().returns(Promise.resolve());
|
|
496
|
+
localWebex.internal.services = {get: sinon.stub().returns('locus-url')};
|
|
497
|
+
localWebex.credentials.getOrgId = sinon.stub().returns('fake-org-id');
|
|
498
|
+
localWebex.internal.metrics.submitClientMetrics = sinon.stub().returns(Promise.resolve());
|
|
499
|
+
localWebex.meetings.uploadLogs = sinon.stub().returns(Promise.resolve());
|
|
500
|
+
localWebex.meetings.reachability = {
|
|
501
|
+
isAnyPublicClusterReachable: sinon.stub().resolves(true),
|
|
502
|
+
getReachabilityResults: sinon.stub().resolves(undefined),
|
|
503
|
+
getReachabilityMetrics: sinon.stub().resolves({}),
|
|
504
|
+
stopReachability: sinon.stub(),
|
|
505
|
+
isSubnetReachable: sinon.stub().returns(true),
|
|
506
|
+
};
|
|
507
|
+
localWebex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
|
|
508
|
+
localWebex.internal.llm.on = sinon.stub();
|
|
509
|
+
localWebex.internal.voicea.announce = sinon.stub();
|
|
510
|
+
localWebex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies(
|
|
511
|
+
{},
|
|
512
|
+
{parent: localWebex}
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
Metrics.initialSetup(localWebex);
|
|
516
|
+
|
|
517
|
+
const localMeeting = new Meeting(
|
|
518
|
+
{
|
|
519
|
+
userId: uuid1,
|
|
520
|
+
resource: uuid2,
|
|
521
|
+
deviceUrl: uuid3,
|
|
522
|
+
locus: {url: url1},
|
|
523
|
+
destination: testDestination,
|
|
524
|
+
destinationType: DESTINATION_TYPE.MEETING_ID,
|
|
525
|
+
correlationId,
|
|
526
|
+
selfId: uuid1,
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
parent: localWebex,
|
|
530
|
+
}
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
const getIngressPayloadType = sinon.stub().callsFake((_mediaType, codecMimeType) => {
|
|
534
|
+
if (codecMimeType === MediaCodecMimeType.H264) {
|
|
535
|
+
return 97;
|
|
536
|
+
}
|
|
537
|
+
if (codecMimeType === MediaCodecMimeType.AV1) {
|
|
538
|
+
return 98;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return undefined;
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
localMeeting.isMultistream = true;
|
|
545
|
+
localMeeting.mediaProperties.webrtcMediaConnection = {
|
|
546
|
+
getIngressPayloadType,
|
|
547
|
+
requestMedia: sinon.stub(),
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const fakeReceiveSlot = {
|
|
551
|
+
on: sinon.stub(),
|
|
552
|
+
off: sinon.stub(),
|
|
553
|
+
sourceState: 'live',
|
|
554
|
+
mediaType: MediaType.VideoSlides,
|
|
555
|
+
wcmeReceiveSlot: {id: 'fake-wcme-slides-slot'},
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
localMeeting.mediaRequestManagers.screenShareVideo.addRequest(
|
|
559
|
+
{
|
|
560
|
+
policyInfo: {
|
|
561
|
+
policy: 'receiver-selected',
|
|
562
|
+
csi: 42,
|
|
563
|
+
},
|
|
564
|
+
receiveSlots: [fakeReceiveSlot],
|
|
565
|
+
codecInfo: {
|
|
566
|
+
codec: 'h264',
|
|
567
|
+
maxFs: 3600,
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
true
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
assert.calledWith(getIngressPayloadType, MediaType.VideoSlides, MediaCodecMimeType.H264);
|
|
574
|
+
assert.calledWith(getIngressPayloadType, MediaType.VideoSlides, MediaCodecMimeType.AV1);
|
|
575
|
+
});
|
|
576
|
+
|
|
420
577
|
it('uses meeting id as correlation id if not provided in constructor', () => {
|
|
421
578
|
const newMeeting = new Meeting(
|
|
422
579
|
{
|
|
@@ -1977,16 +2134,124 @@ describe('plugin-meetings', () => {
|
|
|
1977
2134
|
fakeProcessedReaction
|
|
1978
2135
|
);
|
|
1979
2136
|
});
|
|
2137
|
+
|
|
2138
|
+
[
|
|
2139
|
+
{
|
|
2140
|
+
title: 'should skip a reaction when the default relay route does not match the LLM binding',
|
|
2141
|
+
isPracticeSessionConnected: false,
|
|
2142
|
+
route: 'wrong-default-route',
|
|
2143
|
+
defaultBinding: 'default-route',
|
|
2144
|
+
practiceBinding: 'practice-route',
|
|
2145
|
+
shouldProcess: false,
|
|
2146
|
+
expectedSessionLabel: 'default session',
|
|
2147
|
+
},
|
|
2148
|
+
{
|
|
2149
|
+
title: 'should process a reaction when the default relay route matches the LLM binding',
|
|
2150
|
+
isPracticeSessionConnected: false,
|
|
2151
|
+
route: 'default-route',
|
|
2152
|
+
defaultBinding: 'default-route',
|
|
2153
|
+
practiceBinding: 'practice-route',
|
|
2154
|
+
shouldProcess: true,
|
|
2155
|
+
},
|
|
2156
|
+
{
|
|
2157
|
+
title:
|
|
2158
|
+
'should process a reaction when the practice-session relay route matches the practice-session LLM binding',
|
|
2159
|
+
isPracticeSessionConnected: true,
|
|
2160
|
+
route: 'practice-route',
|
|
2161
|
+
defaultBinding: 'default-route',
|
|
2162
|
+
practiceBinding: 'practice-route',
|
|
2163
|
+
shouldProcess: true,
|
|
2164
|
+
},
|
|
2165
|
+
{
|
|
2166
|
+
title:
|
|
2167
|
+
'should skip a reaction when the practice-session relay route does not match the practice-session LLM binding',
|
|
2168
|
+
isPracticeSessionConnected: true,
|
|
2169
|
+
route: 'default-route',
|
|
2170
|
+
defaultBinding: 'default-route',
|
|
2171
|
+
practiceBinding: 'practice-route',
|
|
2172
|
+
shouldProcess: false,
|
|
2173
|
+
expectedSessionLabel: 'practice session',
|
|
2174
|
+
},
|
|
2175
|
+
].forEach(
|
|
2176
|
+
({
|
|
2177
|
+
title,
|
|
2178
|
+
isPracticeSessionConnected,
|
|
2179
|
+
route,
|
|
2180
|
+
defaultBinding,
|
|
2181
|
+
practiceBinding,
|
|
2182
|
+
shouldProcess,
|
|
2183
|
+
expectedSessionLabel,
|
|
2184
|
+
}) => {
|
|
2185
|
+
it(title, () => {
|
|
2186
|
+
meeting.isReactionsSupported = sinon.stub().returns(true);
|
|
2187
|
+
meeting.config.receiveReactions = true;
|
|
2188
|
+
const fakeSendersName = 'Fake reactors name';
|
|
2189
|
+
meeting.members.membersCollection.get = sinon.stub().returns({name: fakeSendersName});
|
|
2190
|
+
webex.internal.llm.isConnected = sinon.stub().callsFake((llmSessionId) => {
|
|
2191
|
+
return llmSessionId === LLM_PRACTICE_SESSION && isPracticeSessionConnected;
|
|
2192
|
+
});
|
|
2193
|
+
webex.internal.llm.getBinding = sinon.stub().callsFake((llmSessionId) => {
|
|
2194
|
+
if (llmSessionId === LLM_PRACTICE_SESSION) {
|
|
2195
|
+
return practiceBinding;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
return defaultBinding;
|
|
2199
|
+
});
|
|
2200
|
+
const fakeReactionPayload = {
|
|
2201
|
+
type: 'fake_type',
|
|
2202
|
+
codepoints: 'fake_codepoints',
|
|
2203
|
+
shortcodes: 'fake_shortcodes',
|
|
2204
|
+
};
|
|
2205
|
+
const fakeSenderPayload = {
|
|
2206
|
+
participantId: 'fake_participant_id',
|
|
2207
|
+
};
|
|
2208
|
+
const fakeRelayEvent = {
|
|
2209
|
+
headers: {route},
|
|
2210
|
+
data: {
|
|
2211
|
+
relayType: REACTION_RELAY_TYPES.REACTION,
|
|
2212
|
+
reaction: fakeReactionPayload,
|
|
2213
|
+
sender: fakeSenderPayload,
|
|
2214
|
+
},
|
|
2215
|
+
};
|
|
2216
|
+
const fakeProcessedReaction = {
|
|
2217
|
+
reaction: fakeReactionPayload,
|
|
2218
|
+
sender: {
|
|
2219
|
+
id: fakeSenderPayload.participantId,
|
|
2220
|
+
name: fakeSendersName,
|
|
2221
|
+
},
|
|
2222
|
+
};
|
|
2223
|
+
|
|
2224
|
+
TriggerProxy.trigger.resetHistory();
|
|
2225
|
+
meeting.processRelayEvent(fakeRelayEvent);
|
|
2226
|
+
|
|
2227
|
+
if (shouldProcess) {
|
|
2228
|
+
assert.calledWith(
|
|
2229
|
+
TriggerProxy.trigger,
|
|
2230
|
+
sinon.match.instanceOf(Meeting),
|
|
2231
|
+
{
|
|
2232
|
+
file: 'meeting/index',
|
|
2233
|
+
function: 'join',
|
|
2234
|
+
},
|
|
2235
|
+
EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
|
|
2236
|
+
fakeProcessedReaction
|
|
2237
|
+
);
|
|
2238
|
+
} else {
|
|
2239
|
+
assert.notCalled(TriggerProxy.trigger);
|
|
2240
|
+
}
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
);
|
|
1980
2244
|
});
|
|
1981
2245
|
|
|
1982
2246
|
describe('#handleLLMOnline', () => {
|
|
1983
2247
|
beforeEach(() => {
|
|
1984
2248
|
webex.internal.llm.off = sinon.stub();
|
|
2249
|
+
webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
|
|
2250
|
+
webex.internal.voicea.updateSubchannelSubscriptions = sinon.stub();
|
|
1985
2251
|
});
|
|
1986
2252
|
|
|
1987
|
-
it('
|
|
2253
|
+
it('emits transcription connected events', () => {
|
|
1988
2254
|
meeting.handleLLMOnline();
|
|
1989
|
-
assert.calledOnceWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
|
|
1990
2255
|
assert.calledWith(
|
|
1991
2256
|
TriggerProxy.trigger,
|
|
1992
2257
|
sinon.match.instanceOf(Meeting),
|
|
@@ -1997,6 +2262,24 @@ describe('plugin-meetings', () => {
|
|
|
1997
2262
|
EVENT_TRIGGERS.MEETING_TRANSCRIPTION_CONNECTED
|
|
1998
2263
|
);
|
|
1999
2264
|
});
|
|
2265
|
+
|
|
2266
|
+
it('restores transcription subscription when caption intent is enabled', () => {
|
|
2267
|
+
webex.internal.voicea.getIsCaptionBoxOn.returns(true);
|
|
2268
|
+
|
|
2269
|
+
meeting.handleLLMOnline();
|
|
2270
|
+
|
|
2271
|
+
assert.calledOnceWithExactly(webex.internal.voicea.updateSubchannelSubscriptions, {
|
|
2272
|
+
subscribe: ['transcription'],
|
|
2273
|
+
});
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
it('does not restore transcription subscription when caption intent is disabled', () => {
|
|
2277
|
+
webex.internal.voicea.getIsCaptionBoxOn.returns(false);
|
|
2278
|
+
|
|
2279
|
+
meeting.handleLLMOnline();
|
|
2280
|
+
|
|
2281
|
+
assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
|
|
2282
|
+
});
|
|
2000
2283
|
});
|
|
2001
2284
|
|
|
2002
2285
|
describe('#join', () => {
|
|
@@ -2016,6 +2299,7 @@ describe('plugin-meetings', () => {
|
|
|
2016
2299
|
it('should have #join', () => {
|
|
2017
2300
|
assert.exists(meeting.join);
|
|
2018
2301
|
});
|
|
2302
|
+
|
|
2019
2303
|
beforeEach(() => {
|
|
2020
2304
|
setCorrelationIdSpy = sinon.spy(meeting, 'setCorrelationId');
|
|
2021
2305
|
meeting.setLocus = sinon.stub().returns(true);
|
|
@@ -2169,7 +2453,6 @@ describe('plugin-meetings', () => {
|
|
|
2169
2453
|
await meeting.join().catch(() => {
|
|
2170
2454
|
assert.calledOnce(MeetingUtil.joinMeeting);
|
|
2171
2455
|
|
|
2172
|
-
// Assert that client.locus.join.response error event is not sent from this function, it is now emitted from MeetingUtil.joinMeeting
|
|
2173
2456
|
assert.calledOnce(webex.internal.newMetrics.submitClientEvent);
|
|
2174
2457
|
assert.calledWithMatch(webex.internal.newMetrics.submitClientEvent, {
|
|
2175
2458
|
name: 'client.call.initiated',
|
|
@@ -2201,6 +2484,7 @@ describe('plugin-meetings', () => {
|
|
|
2201
2484
|
});
|
|
2202
2485
|
});
|
|
2203
2486
|
});
|
|
2487
|
+
|
|
2204
2488
|
describe('lmm, transcription & permissionTokenRefresh decoupling', () => {
|
|
2205
2489
|
beforeEach(() => {
|
|
2206
2490
|
sandbox.stub(MeetingUtil, 'joinMeeting').returns(Promise.resolve(joinMeetingResult));
|
|
@@ -2271,7 +2555,6 @@ describe('plugin-meetings', () => {
|
|
|
2271
2555
|
const locusInfoParseStub = sinon.stub(meeting.locusInfo, 'parse');
|
|
2272
2556
|
sinon.stub(meeting, 'isJoined').returns(true);
|
|
2273
2557
|
|
|
2274
|
-
// Set up llm.on stub to capture the registered listener when updateLLMConnection is called
|
|
2275
2558
|
let locusLLMEventListener;
|
|
2276
2559
|
meeting.webex.internal.llm.on = sinon.stub().callsFake((eventName, callback) => {
|
|
2277
2560
|
if (eventName === 'event:locus.state_message') {
|
|
@@ -2280,16 +2563,12 @@ describe('plugin-meetings', () => {
|
|
|
2280
2563
|
});
|
|
2281
2564
|
meeting.webex.internal.llm.off = sinon.stub();
|
|
2282
2565
|
|
|
2283
|
-
// we need the real meeting.updateLLMConnection not the mock
|
|
2284
2566
|
meeting.updateLLMConnection.restore();
|
|
2285
2567
|
|
|
2286
|
-
// Call updateLLMConnection to register the listener
|
|
2287
2568
|
await meeting.updateLLMConnection();
|
|
2288
2569
|
|
|
2289
|
-
// Verify the listener was registered and we captured it
|
|
2290
2570
|
assert.isDefined(locusLLMEventListener, 'LLM event listener should be registered');
|
|
2291
2571
|
|
|
2292
|
-
// Now trigger the event
|
|
2293
2572
|
const eventData = {
|
|
2294
2573
|
eventType: 'locus.state_message',
|
|
2295
2574
|
stateElementsMessage: {
|
|
@@ -2309,13 +2588,10 @@ describe('plugin-meetings', () => {
|
|
|
2309
2588
|
sinon.stub(meeting.webex.internal.llm, 'hasEverConnected').value(true);
|
|
2310
2589
|
sinon.stub(meeting.webex.internal.llm, 'registerAndConnect').resolves({});
|
|
2311
2590
|
|
|
2312
|
-
// Restore the real updateLLMConnection
|
|
2313
2591
|
meeting.updateLLMConnection.restore();
|
|
2314
2592
|
|
|
2315
|
-
// Call updateLLMConnection to start the timer
|
|
2316
2593
|
await meeting.updateLLMConnection();
|
|
2317
2594
|
|
|
2318
|
-
// Fast forward time by 3 minutes
|
|
2319
2595
|
fakeClock.tick(3 * 60 * 1000);
|
|
2320
2596
|
|
|
2321
2597
|
assert.calledWith(
|
|
@@ -2340,18 +2616,14 @@ describe('plugin-meetings', () => {
|
|
|
2340
2616
|
.stub(meeting.webex.internal.llm, 'getDatachannelUrl')
|
|
2341
2617
|
.returns('https://datachannel1.example.com');
|
|
2342
2618
|
|
|
2343
|
-
// Restore the real updateLLMConnection
|
|
2344
2619
|
meeting.updateLLMConnection.restore();
|
|
2345
2620
|
|
|
2346
|
-
// First, connect LLM and start the timer
|
|
2347
2621
|
isJoinedStub.returns(true);
|
|
2348
2622
|
meeting.webex.internal.llm.isConnected.returns(false);
|
|
2349
2623
|
await meeting.updateLLMConnection();
|
|
2350
2624
|
|
|
2351
|
-
// Verify timer was started
|
|
2352
2625
|
assert.exists(meeting.llmHealthCheckTimer);
|
|
2353
2626
|
|
|
2354
|
-
// Now simulate that we're no longer joined
|
|
2355
2627
|
isJoinedStub.returns(false);
|
|
2356
2628
|
meeting.webex.internal.llm.isConnected.returns(true);
|
|
2357
2629
|
|
|
@@ -2359,10 +2631,8 @@ describe('plugin-meetings', () => {
|
|
|
2359
2631
|
|
|
2360
2632
|
assert.calledOnce(meeting.webex.internal.llm.disconnectLLM);
|
|
2361
2633
|
|
|
2362
|
-
// Verify the timer was cleared (should be undefined)
|
|
2363
2634
|
assert.isUndefined(meeting.llmHealthCheckTimer);
|
|
2364
2635
|
|
|
2365
|
-
// Fast forward time to ensure no metric is sent
|
|
2366
2636
|
Metrics.sendBehavioralMetric.resetHistory();
|
|
2367
2637
|
fakeClock.tick(3 * 60 * 1000);
|
|
2368
2638
|
|
|
@@ -2397,7 +2667,6 @@ describe('plugin-meetings', () => {
|
|
|
2397
2667
|
.stub()
|
|
2398
2668
|
.rejects(new CaptchaError('bad captcha'));
|
|
2399
2669
|
const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
|
|
2400
|
-
const joinMeetingOptionsSpy = sinon.spy(MeetingUtil, 'joinMeetingOptions');
|
|
2401
2670
|
|
|
2402
2671
|
try {
|
|
2403
2672
|
await meeting.join();
|
|
@@ -2411,8 +2680,7 @@ describe('plugin-meetings', () => {
|
|
|
2411
2680
|
);
|
|
2412
2681
|
assert.instanceOf(error, CaptchaError);
|
|
2413
2682
|
assert.equal(error.message, 'bad captcha');
|
|
2414
|
-
|
|
2415
|
-
assert.notCalled(joinMeetingOptionsSpy);
|
|
2683
|
+
assert.notCalled(MeetingUtil.joinMeeting);
|
|
2416
2684
|
}
|
|
2417
2685
|
});
|
|
2418
2686
|
|
|
@@ -2421,7 +2689,6 @@ describe('plugin-meetings', () => {
|
|
|
2421
2689
|
.stub()
|
|
2422
2690
|
.rejects(new PasswordError('bad password'));
|
|
2423
2691
|
const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
|
|
2424
|
-
const joinMeetingOptionsSpy = sinon.spy(MeetingUtil.joinMeetingOptions);
|
|
2425
2692
|
|
|
2426
2693
|
try {
|
|
2427
2694
|
await meeting.join();
|
|
@@ -2435,8 +2702,7 @@ describe('plugin-meetings', () => {
|
|
|
2435
2702
|
);
|
|
2436
2703
|
assert.instanceOf(error, PasswordError);
|
|
2437
2704
|
assert.equal(error.message, 'bad password');
|
|
2438
|
-
|
|
2439
|
-
assert.notCalled(joinMeetingOptionsSpy);
|
|
2705
|
+
assert.notCalled(MeetingUtil.joinMeeting);
|
|
2440
2706
|
}
|
|
2441
2707
|
});
|
|
2442
2708
|
|
|
@@ -2445,7 +2711,6 @@ describe('plugin-meetings', () => {
|
|
|
2445
2711
|
.stub()
|
|
2446
2712
|
.rejects(new PermissionError('bad permission'));
|
|
2447
2713
|
const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
|
|
2448
|
-
const joinMeetingOptionsSpy = sinon.spy(MeetingUtil.joinMeetingOptions);
|
|
2449
2714
|
|
|
2450
2715
|
try {
|
|
2451
2716
|
await meeting.join();
|
|
@@ -2459,14 +2724,14 @@ describe('plugin-meetings', () => {
|
|
|
2459
2724
|
);
|
|
2460
2725
|
assert.instanceOf(error, PermissionError);
|
|
2461
2726
|
assert.equal(error.message, 'bad permission');
|
|
2462
|
-
|
|
2463
|
-
assert.notCalled(joinMeetingOptionsSpy);
|
|
2727
|
+
assert.notCalled(MeetingUtil.joinMeeting);
|
|
2464
2728
|
}
|
|
2465
2729
|
});
|
|
2466
2730
|
});
|
|
2467
2731
|
});
|
|
2468
2732
|
});
|
|
2469
2733
|
|
|
2734
|
+
|
|
2470
2735
|
describe('#addMedia', () => {
|
|
2471
2736
|
const muteStateStub = {
|
|
2472
2737
|
handleClientRequest: sinon.stub().returns(Promise.resolve(true)),
|
|
@@ -4533,6 +4798,297 @@ describe('plugin-meetings', () => {
|
|
|
4533
4798
|
},
|
|
4534
4799
|
});
|
|
4535
4800
|
});
|
|
4801
|
+
|
|
4802
|
+
describe('handles STATS_UPDATE event for SRTP cipher detection', () => {
|
|
4803
|
+
it('emits MEETING_SRTP_CIPHER_UPDATED event when srtpCipher is found in transport stats', async () => {
|
|
4804
|
+
const fakeStats = new Map([
|
|
4805
|
+
[
|
|
4806
|
+
'transport-1',
|
|
4807
|
+
{
|
|
4808
|
+
type: 'transport',
|
|
4809
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
4810
|
+
dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
|
|
4811
|
+
},
|
|
4812
|
+
],
|
|
4813
|
+
[
|
|
4814
|
+
'outbound-rtp-1',
|
|
4815
|
+
{
|
|
4816
|
+
type: 'outbound-rtp',
|
|
4817
|
+
ssrc: 12345,
|
|
4818
|
+
},
|
|
4819
|
+
],
|
|
4820
|
+
]);
|
|
4821
|
+
|
|
4822
|
+
statsAnalyzerStub.emit(
|
|
4823
|
+
{file: 'test', function: 'test'},
|
|
4824
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
4825
|
+
{stats: fakeStats}
|
|
4826
|
+
);
|
|
4827
|
+
|
|
4828
|
+
assert.calledWith(
|
|
4829
|
+
TriggerProxy.trigger,
|
|
4830
|
+
sinon.match.instanceOf(Meeting),
|
|
4831
|
+
{
|
|
4832
|
+
file: 'meeting/index',
|
|
4833
|
+
function: 'setupStatsAnalyzerEventHandlers',
|
|
4834
|
+
},
|
|
4835
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
4836
|
+
{srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
|
|
4837
|
+
);
|
|
4838
|
+
|
|
4839
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
4840
|
+
});
|
|
4841
|
+
|
|
4842
|
+
it('updates meeting.mediaProperties.srtpCipher when cipher changes', async () => {
|
|
4843
|
+
const firstStats = new Map([
|
|
4844
|
+
[
|
|
4845
|
+
'transport-1',
|
|
4846
|
+
{
|
|
4847
|
+
type: 'transport',
|
|
4848
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
4849
|
+
},
|
|
4850
|
+
],
|
|
4851
|
+
]);
|
|
4852
|
+
|
|
4853
|
+
statsAnalyzerStub.emit(
|
|
4854
|
+
{file: 'test', function: 'test'},
|
|
4855
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
4856
|
+
{stats: firstStats}
|
|
4857
|
+
);
|
|
4858
|
+
|
|
4859
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
4860
|
+
|
|
4861
|
+
const secondStats = new Map([
|
|
4862
|
+
[
|
|
4863
|
+
'transport-1',
|
|
4864
|
+
{
|
|
4865
|
+
type: 'transport',
|
|
4866
|
+
srtpCipher: 'AEAD_AES_256_GCM',
|
|
4867
|
+
},
|
|
4868
|
+
],
|
|
4869
|
+
]);
|
|
4870
|
+
|
|
4871
|
+
TriggerProxy.trigger.resetHistory();
|
|
4872
|
+
|
|
4873
|
+
statsAnalyzerStub.emit(
|
|
4874
|
+
{file: 'test', function: 'test'},
|
|
4875
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
4876
|
+
{stats: secondStats}
|
|
4877
|
+
);
|
|
4878
|
+
|
|
4879
|
+
assert.calledWith(
|
|
4880
|
+
TriggerProxy.trigger,
|
|
4881
|
+
sinon.match.instanceOf(Meeting),
|
|
4882
|
+
{
|
|
4883
|
+
file: 'meeting/index',
|
|
4884
|
+
function: 'setupStatsAnalyzerEventHandlers',
|
|
4885
|
+
},
|
|
4886
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
4887
|
+
{srtpCipher: 'AEAD_AES_256_GCM'}
|
|
4888
|
+
);
|
|
4889
|
+
|
|
4890
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AEAD_AES_256_GCM');
|
|
4891
|
+
});
|
|
4892
|
+
|
|
4893
|
+
it('does not emit event when srtpCipher has not changed', async () => {
|
|
4894
|
+
const firstStats = new Map([
|
|
4895
|
+
[
|
|
4896
|
+
'transport-1',
|
|
4897
|
+
{
|
|
4898
|
+
type: 'transport',
|
|
4899
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
4900
|
+
},
|
|
4901
|
+
],
|
|
4902
|
+
]);
|
|
4903
|
+
|
|
4904
|
+
statsAnalyzerStub.emit(
|
|
4905
|
+
{file: 'test', function: 'test'},
|
|
4906
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
4907
|
+
{stats: firstStats}
|
|
4908
|
+
);
|
|
4909
|
+
|
|
4910
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
4911
|
+
|
|
4912
|
+
TriggerProxy.trigger.resetHistory();
|
|
4913
|
+
|
|
4914
|
+
// Emit same cipher again
|
|
4915
|
+
statsAnalyzerStub.emit(
|
|
4916
|
+
{file: 'test', function: 'test'},
|
|
4917
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
4918
|
+
{stats: firstStats}
|
|
4919
|
+
);
|
|
4920
|
+
|
|
4921
|
+
// Should not trigger event again
|
|
4922
|
+
assert.neverCalledWith(
|
|
4923
|
+
TriggerProxy.trigger,
|
|
4924
|
+
sinon.match.instanceOf(Meeting),
|
|
4925
|
+
sinon.match.any,
|
|
4926
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
4927
|
+
sinon.match.any
|
|
4928
|
+
);
|
|
4929
|
+
|
|
4930
|
+
// Cipher should remain the same
|
|
4931
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
4932
|
+
});
|
|
4933
|
+
|
|
4934
|
+
it('does not emit event when stats contain no transport with srtpCipher', async () => {
|
|
4935
|
+
const fakeStats = new Map([
|
|
4936
|
+
[
|
|
4937
|
+
'outbound-rtp-1',
|
|
4938
|
+
{
|
|
4939
|
+
type: 'outbound-rtp',
|
|
4940
|
+
ssrc: 12345,
|
|
4941
|
+
},
|
|
4942
|
+
],
|
|
4943
|
+
[
|
|
4944
|
+
'inbound-rtp-1',
|
|
4945
|
+
{
|
|
4946
|
+
type: 'inbound-rtp',
|
|
4947
|
+
ssrc: 67890,
|
|
4948
|
+
},
|
|
4949
|
+
],
|
|
4950
|
+
]);
|
|
4951
|
+
|
|
4952
|
+
statsAnalyzerStub.emit(
|
|
4953
|
+
{file: 'test', function: 'test'},
|
|
4954
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
4955
|
+
{stats: fakeStats}
|
|
4956
|
+
);
|
|
4957
|
+
|
|
4958
|
+
assert.neverCalledWith(
|
|
4959
|
+
TriggerProxy.trigger,
|
|
4960
|
+
sinon.match.instanceOf(Meeting),
|
|
4961
|
+
sinon.match.any,
|
|
4962
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
4963
|
+
sinon.match.any
|
|
4964
|
+
);
|
|
4965
|
+
|
|
4966
|
+
assert.isUndefined(meeting.mediaProperties.srtpCipher);
|
|
4967
|
+
});
|
|
4968
|
+
|
|
4969
|
+
it('does not emit event when transport stat has no srtpCipher property', async () => {
|
|
4970
|
+
const fakeStats = new Map([
|
|
4971
|
+
[
|
|
4972
|
+
'transport-1',
|
|
4973
|
+
{
|
|
4974
|
+
type: 'transport',
|
|
4975
|
+
dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
|
|
4976
|
+
// no srtpCipher property
|
|
4977
|
+
},
|
|
4978
|
+
],
|
|
4979
|
+
]);
|
|
4980
|
+
|
|
4981
|
+
statsAnalyzerStub.emit(
|
|
4982
|
+
{file: 'test', function: 'test'},
|
|
4983
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
4984
|
+
{stats: fakeStats}
|
|
4985
|
+
);
|
|
4986
|
+
|
|
4987
|
+
assert.neverCalledWith(
|
|
4988
|
+
TriggerProxy.trigger,
|
|
4989
|
+
sinon.match.instanceOf(Meeting),
|
|
4990
|
+
sinon.match.any,
|
|
4991
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
4992
|
+
sinon.match.any
|
|
4993
|
+
);
|
|
4994
|
+
|
|
4995
|
+
assert.isUndefined(meeting.mediaProperties.srtpCipher);
|
|
4996
|
+
});
|
|
4997
|
+
|
|
4998
|
+
it('uses first transport with srtpCipher when multiple transports exist', async () => {
|
|
4999
|
+
const fakeStats = new Map([
|
|
5000
|
+
[
|
|
5001
|
+
'transport-1',
|
|
5002
|
+
{
|
|
5003
|
+
type: 'transport',
|
|
5004
|
+
srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
|
|
5005
|
+
},
|
|
5006
|
+
],
|
|
5007
|
+
[
|
|
5008
|
+
'transport-2',
|
|
5009
|
+
{
|
|
5010
|
+
type: 'transport',
|
|
5011
|
+
srtpCipher: 'AEAD_AES_256_GCM',
|
|
5012
|
+
},
|
|
5013
|
+
],
|
|
5014
|
+
[
|
|
5015
|
+
'outbound-rtp-1',
|
|
5016
|
+
{
|
|
5017
|
+
type: 'outbound-rtp',
|
|
5018
|
+
ssrc: 12345,
|
|
5019
|
+
},
|
|
5020
|
+
],
|
|
5021
|
+
]);
|
|
5022
|
+
|
|
5023
|
+
statsAnalyzerStub.emit(
|
|
5024
|
+
{file: 'test', function: 'test'},
|
|
5025
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5026
|
+
{stats: fakeStats}
|
|
5027
|
+
);
|
|
5028
|
+
|
|
5029
|
+
assert.calledWith(
|
|
5030
|
+
TriggerProxy.trigger,
|
|
5031
|
+
sinon.match.instanceOf(Meeting),
|
|
5032
|
+
{
|
|
5033
|
+
file: 'meeting/index',
|
|
5034
|
+
function: 'setupStatsAnalyzerEventHandlers',
|
|
5035
|
+
},
|
|
5036
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5037
|
+
{srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
|
|
5038
|
+
);
|
|
5039
|
+
|
|
5040
|
+
assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
|
|
5041
|
+
});
|
|
5042
|
+
|
|
5043
|
+
it('handles empty stats map without errors', async () => {
|
|
5044
|
+
const emptyStats = new Map();
|
|
5045
|
+
|
|
5046
|
+
statsAnalyzerStub.emit(
|
|
5047
|
+
{file: 'test', function: 'test'},
|
|
5048
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5049
|
+
{stats: emptyStats}
|
|
5050
|
+
);
|
|
5051
|
+
|
|
5052
|
+
assert.neverCalledWith(
|
|
5053
|
+
TriggerProxy.trigger,
|
|
5054
|
+
sinon.match.instanceOf(Meeting),
|
|
5055
|
+
sinon.match.any,
|
|
5056
|
+
EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
|
|
5057
|
+
sinon.match.any
|
|
5058
|
+
);
|
|
5059
|
+
|
|
5060
|
+
assert.isUndefined(meeting.mediaProperties.srtpCipher);
|
|
5061
|
+
});
|
|
5062
|
+
|
|
5063
|
+
it('logs cipher change when cipher is updated', async () => {
|
|
5064
|
+
const loggerSpy = sinon.spy(LoggerProxy.logger, 'info');
|
|
5065
|
+
|
|
5066
|
+
meeting.mediaProperties.srtpCipher = 'AES_CM_128_HMAC_SHA1_80';
|
|
5067
|
+
|
|
5068
|
+
const newStats = new Map([
|
|
5069
|
+
[
|
|
5070
|
+
'transport-1',
|
|
5071
|
+
{
|
|
5072
|
+
type: 'transport',
|
|
5073
|
+
srtpCipher: 'AEAD_AES_256_GCM',
|
|
5074
|
+
},
|
|
5075
|
+
],
|
|
5076
|
+
]);
|
|
5077
|
+
|
|
5078
|
+
statsAnalyzerStub.emit(
|
|
5079
|
+
{file: 'test', function: 'test'},
|
|
5080
|
+
StatsAnalyzerEventNames.STATS_UPDATE,
|
|
5081
|
+
{stats: newStats}
|
|
5082
|
+
);
|
|
5083
|
+
|
|
5084
|
+
assert.calledWithMatch(
|
|
5085
|
+
loggerSpy,
|
|
5086
|
+
sinon.match(/SRTP cipher changed from AES_CM_128_HMAC_SHA1_80 to AEAD_AES_256_GCM/)
|
|
5087
|
+
);
|
|
5088
|
+
|
|
5089
|
+
loggerSpy.restore();
|
|
5090
|
+
});
|
|
5091
|
+
});
|
|
4536
5092
|
});
|
|
4537
5093
|
|
|
4538
5094
|
describe('handles StatsMonitor events', () => {
|
|
@@ -6428,6 +6984,9 @@ describe('plugin-meetings', () => {
|
|
|
6428
6984
|
|
|
6429
6985
|
meeting.annotation.deregisterEvents = sinon.stub();
|
|
6430
6986
|
webex.internal.llm.off = sinon.stub();
|
|
6987
|
+
webex.internal.mercury.off = sinon.stub();
|
|
6988
|
+
meeting.mercuryOnlineHandler = sinon.stub();
|
|
6989
|
+
meeting.mercuryOfflineHandler = sinon.stub();
|
|
6431
6990
|
|
|
6432
6991
|
// A meeting needs to be joined to leave
|
|
6433
6992
|
meeting.meetingState = 'ACTIVE';
|
|
@@ -6451,6 +7010,67 @@ describe('plugin-meetings', () => {
|
|
|
6451
7010
|
assert.calledOnce(meeting.clearMeetingData);
|
|
6452
7011
|
});
|
|
6453
7012
|
|
|
7013
|
+
it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /leave', async () => {
|
|
7014
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
7015
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
7016
|
+
|
|
7017
|
+
await meeting.leave();
|
|
7018
|
+
|
|
7019
|
+
// All llm/mercury consumers (direct listeners, voicea transcription,
|
|
7020
|
+
// annotation) must be detached before the /leave request so that
|
|
7021
|
+
// in-flight events do not trigger unnecessary Locus syncs
|
|
7022
|
+
// (per Locus team recommendation).
|
|
7023
|
+
assert.callOrder(
|
|
7024
|
+
webex.internal.llm.off,
|
|
7025
|
+
webex.internal.mercury.off,
|
|
7026
|
+
meeting.stopTranscription,
|
|
7027
|
+
meeting.annotation.deregisterEvents,
|
|
7028
|
+
meeting.meetingRequest.leaveMeeting
|
|
7029
|
+
);
|
|
7030
|
+
assert.calledWithExactly(
|
|
7031
|
+
webex.internal.llm.off,
|
|
7032
|
+
'event:relay.event',
|
|
7033
|
+
meeting.processRelayEvent
|
|
7034
|
+
);
|
|
7035
|
+
assert.calledWithExactly(
|
|
7036
|
+
webex.internal.llm.off,
|
|
7037
|
+
LOCUS_LLM_EVENT,
|
|
7038
|
+
meeting.processLocusLLMEvent
|
|
7039
|
+
);
|
|
7040
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
7041
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
7042
|
+
assert.isUndefined(meeting.mercuryOnlineHandler);
|
|
7043
|
+
assert.isUndefined(meeting.mercuryOfflineHandler);
|
|
7044
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
7045
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
7046
|
+
assert.isUndefined(meeting.transcription);
|
|
7047
|
+
});
|
|
7048
|
+
|
|
7049
|
+
it('tears down llm/mercury/transcription/annotation even when /leave rejects', async () => {
|
|
7050
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
7051
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
7052
|
+
meeting.meetingRequest.leaveMeeting = sinon
|
|
7053
|
+
.stub()
|
|
7054
|
+
.returns(Promise.reject(new Error('leave failed')));
|
|
7055
|
+
|
|
7056
|
+
await meeting.leave().catch(() => {});
|
|
7057
|
+
|
|
7058
|
+
assert.calledWithExactly(
|
|
7059
|
+
webex.internal.llm.off,
|
|
7060
|
+
'event:relay.event',
|
|
7061
|
+
meeting.processRelayEvent
|
|
7062
|
+
);
|
|
7063
|
+
assert.calledWithExactly(
|
|
7064
|
+
webex.internal.llm.off,
|
|
7065
|
+
LOCUS_LLM_EVENT,
|
|
7066
|
+
meeting.processLocusLLMEvent
|
|
7067
|
+
);
|
|
7068
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
7069
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
7070
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
7071
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
7072
|
+
});
|
|
7073
|
+
|
|
6454
7074
|
it('should reset call diagnostic latencies correctly', async () => {
|
|
6455
7075
|
const leave = meeting.leave();
|
|
6456
7076
|
|
|
@@ -8458,6 +9078,9 @@ describe('plugin-meetings', () => {
|
|
|
8458
9078
|
|
|
8459
9079
|
meeting.annotation.deregisterEvents = sinon.stub();
|
|
8460
9080
|
webex.internal.llm.off = sinon.stub();
|
|
9081
|
+
webex.internal.mercury.off = sinon.stub();
|
|
9082
|
+
meeting.mercuryOnlineHandler = sinon.stub();
|
|
9083
|
+
meeting.mercuryOfflineHandler = sinon.stub();
|
|
8461
9084
|
|
|
8462
9085
|
// A meeting needs to be joined to end
|
|
8463
9086
|
meeting.meetingState = 'ACTIVE';
|
|
@@ -8480,6 +9103,66 @@ describe('plugin-meetings', () => {
|
|
|
8480
9103
|
assert.calledOnce(meeting?.unsetPeerConnections);
|
|
8481
9104
|
assert.calledOnce(meeting?.clearMeetingData);
|
|
8482
9105
|
});
|
|
9106
|
+
|
|
9107
|
+
it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /end', async () => {
|
|
9108
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
9109
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
9110
|
+
|
|
9111
|
+
await meeting.endMeetingForAll();
|
|
9112
|
+
|
|
9113
|
+
// All llm/mercury consumers (direct listeners, voicea transcription,
|
|
9114
|
+
// annotation) must be detached before the /end request so that
|
|
9115
|
+
// in-flight events do not trigger unnecessary Locus syncs
|
|
9116
|
+
// (per Locus team recommendation).
|
|
9117
|
+
assert.callOrder(
|
|
9118
|
+
webex.internal.llm.off,
|
|
9119
|
+
webex.internal.mercury.off,
|
|
9120
|
+
meeting.stopTranscription,
|
|
9121
|
+
meeting.annotation.deregisterEvents,
|
|
9122
|
+
meeting.meetingRequest.endMeetingForAll
|
|
9123
|
+
);
|
|
9124
|
+
assert.calledWithExactly(
|
|
9125
|
+
webex.internal.llm.off,
|
|
9126
|
+
'event:relay.event',
|
|
9127
|
+
meeting.processRelayEvent
|
|
9128
|
+
);
|
|
9129
|
+
assert.calledWithExactly(
|
|
9130
|
+
webex.internal.llm.off,
|
|
9131
|
+
LOCUS_LLM_EVENT,
|
|
9132
|
+
meeting.processLocusLLMEvent
|
|
9133
|
+
);
|
|
9134
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
9135
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
9136
|
+
assert.isUndefined(meeting.mercuryOnlineHandler);
|
|
9137
|
+
assert.isUndefined(meeting.mercuryOfflineHandler);
|
|
9138
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
9139
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
9140
|
+
});
|
|
9141
|
+
|
|
9142
|
+
it('tears down llm/mercury/transcription/annotation even when /end rejects', async () => {
|
|
9143
|
+
const onlineHandler = meeting.mercuryOnlineHandler;
|
|
9144
|
+
const offlineHandler = meeting.mercuryOfflineHandler;
|
|
9145
|
+
meeting.meetingRequest.endMeetingForAll = sinon
|
|
9146
|
+
.stub()
|
|
9147
|
+
.returns(Promise.reject(new Error('end failed')));
|
|
9148
|
+
|
|
9149
|
+
await meeting.endMeetingForAll().catch(() => {});
|
|
9150
|
+
|
|
9151
|
+
assert.calledWithExactly(
|
|
9152
|
+
webex.internal.llm.off,
|
|
9153
|
+
'event:relay.event',
|
|
9154
|
+
meeting.processRelayEvent
|
|
9155
|
+
);
|
|
9156
|
+
assert.calledWithExactly(
|
|
9157
|
+
webex.internal.llm.off,
|
|
9158
|
+
LOCUS_LLM_EVENT,
|
|
9159
|
+
meeting.processLocusLLMEvent
|
|
9160
|
+
);
|
|
9161
|
+
assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
|
|
9162
|
+
assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
|
|
9163
|
+
assert.calledOnceWithExactly(meeting.stopTranscription);
|
|
9164
|
+
assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
|
|
9165
|
+
});
|
|
8483
9166
|
});
|
|
8484
9167
|
|
|
8485
9168
|
describe('#moveTo', () => {
|
|
@@ -10416,14 +11099,24 @@ describe('plugin-meetings', () => {
|
|
|
10416
11099
|
);
|
|
10417
11100
|
done();
|
|
10418
11101
|
});
|
|
10419
|
-
it('listens to the self admitted guest event', (
|
|
11102
|
+
it('listens to the self admitted guest event without blocking on token prefetch', async () => {
|
|
10420
11103
|
meeting.stopKeepAlive = sinon.stub();
|
|
10421
11104
|
meeting.updateLLMConnection = sinon.stub();
|
|
11105
|
+
let resolvePrefetch;
|
|
11106
|
+
|
|
11107
|
+
meeting.ensureDefaultDatachannelTokenAfterAdmit = sinon
|
|
11108
|
+
.stub()
|
|
11109
|
+
.returns(new Promise((resolve) => {
|
|
11110
|
+
resolvePrefetch = resolve;
|
|
11111
|
+
}));
|
|
10422
11112
|
meeting.rtcMetrics = {
|
|
10423
11113
|
sendNextMetrics: sinon.stub(),
|
|
10424
11114
|
};
|
|
11115
|
+
|
|
10425
11116
|
meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ADMITTED_GUEST', test1);
|
|
11117
|
+
|
|
10426
11118
|
assert.calledOnceWithExactly(meeting.stopKeepAlive);
|
|
11119
|
+
assert.calledOnceWithExactly(meeting.ensureDefaultDatachannelTokenAfterAdmit);
|
|
10427
11120
|
assert.calledThrice(TriggerProxy.trigger);
|
|
10428
11121
|
assert.calledWith(
|
|
10429
11122
|
TriggerProxy.trigger,
|
|
@@ -10442,7 +11135,11 @@ describe('plugin-meetings', () => {
|
|
|
10442
11135
|
correlation_id: meeting.correlationId,
|
|
10443
11136
|
}
|
|
10444
11137
|
);
|
|
10445
|
-
|
|
11138
|
+
|
|
11139
|
+
resolvePrefetch(false);
|
|
11140
|
+
await Promise.resolve();
|
|
11141
|
+
|
|
11142
|
+
assert.calledOnce(meeting.updateLLMConnection);
|
|
10446
11143
|
});
|
|
10447
11144
|
|
|
10448
11145
|
it('listens to the breakouts changed event', () => {
|
|
@@ -10956,6 +11653,92 @@ describe('plugin-meetings', () => {
|
|
|
10956
11653
|
);
|
|
10957
11654
|
});
|
|
10958
11655
|
|
|
11656
|
+
const recordingTestCases = [
|
|
11657
|
+
{
|
|
11658
|
+
description: 'triggers MEETING_STARTED_RECORDING when state is RECORDING',
|
|
11659
|
+
state: RECORDING_STATE.RECORDING,
|
|
11660
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
|
|
11661
|
+
expectedRecordingState: RECORDING_STATE.RECORDING,
|
|
11662
|
+
},
|
|
11663
|
+
{
|
|
11664
|
+
description: 'triggers MEETING_STOPPED_RECORDING when state is IDLE',
|
|
11665
|
+
state: RECORDING_STATE.IDLE,
|
|
11666
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_STOPPED_RECORDING,
|
|
11667
|
+
expectedRecordingState: RECORDING_STATE.IDLE,
|
|
11668
|
+
},
|
|
11669
|
+
{
|
|
11670
|
+
description: 'triggers MEETING_PAUSED_RECORDING when state is PAUSED',
|
|
11671
|
+
state: RECORDING_STATE.PAUSED,
|
|
11672
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_PAUSED_RECORDING,
|
|
11673
|
+
expectedRecordingState: RECORDING_STATE.PAUSED,
|
|
11674
|
+
},
|
|
11675
|
+
{
|
|
11676
|
+
description:
|
|
11677
|
+
'triggers MEETING_RESUMED_RECORDING and sets state to RECORDING when state is RESUMED',
|
|
11678
|
+
state: RECORDING_STATE.RESUMED,
|
|
11679
|
+
expectedEvent: EVENT_TRIGGERS.MEETING_RESUMED_RECORDING,
|
|
11680
|
+
expectedRecordingState: RECORDING_STATE.RECORDING,
|
|
11681
|
+
},
|
|
11682
|
+
];
|
|
11683
|
+
|
|
11684
|
+
recordingTestCases.forEach(({description, state, expectedEvent, expectedRecordingState}) => {
|
|
11685
|
+
it(`listens to CONTROLS_RECORDING_UPDATED - ${description}`, async () => {
|
|
11686
|
+
const modifiedBy = 'user-id-123';
|
|
11687
|
+
const lastModified = '2026-01-01T00:00:00Z';
|
|
11688
|
+
|
|
11689
|
+
await meeting.locusInfo.emitScoped(
|
|
11690
|
+
{function: 'test', file: 'test'},
|
|
11691
|
+
LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
|
|
11692
|
+
{state, modifiedBy, lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined}
|
|
11693
|
+
);
|
|
11694
|
+
|
|
11695
|
+
assert.deepEqual(meeting.recording, {
|
|
11696
|
+
state: expectedRecordingState,
|
|
11697
|
+
modifiedBy,
|
|
11698
|
+
lastModified,
|
|
11699
|
+
modifiedByServiceAppName: undefined,
|
|
11700
|
+
modifiedByServiceAppId: undefined,
|
|
11701
|
+
});
|
|
11702
|
+
|
|
11703
|
+
assert.calledWith(
|
|
11704
|
+
TriggerProxy.trigger,
|
|
11705
|
+
meeting,
|
|
11706
|
+
{file: 'meeting/index', function: 'setupLocusControlsListener'},
|
|
11707
|
+
expectedEvent,
|
|
11708
|
+
meeting.recording
|
|
11709
|
+
);
|
|
11710
|
+
});
|
|
11711
|
+
});
|
|
11712
|
+
|
|
11713
|
+
it('listens to CONTROLS_RECORDING_UPDATED and includes modifiedByServiceAppName and modifiedByServiceAppId when present', async () => {
|
|
11714
|
+
const modifiedBy = 'user-id-123';
|
|
11715
|
+
const lastModified = '2026-01-01T00:00:00Z';
|
|
11716
|
+
const modifiedByServiceAppName = 'My Bot';
|
|
11717
|
+
const modifiedByServiceAppId = 'app-id-123';
|
|
11718
|
+
|
|
11719
|
+
await meeting.locusInfo.emitScoped(
|
|
11720
|
+
{function: 'test', file: 'test'},
|
|
11721
|
+
LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
|
|
11722
|
+
{state: RECORDING_STATE.RECORDING, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}
|
|
11723
|
+
);
|
|
11724
|
+
|
|
11725
|
+
assert.deepEqual(meeting.recording, {
|
|
11726
|
+
state: RECORDING_STATE.RECORDING,
|
|
11727
|
+
modifiedBy,
|
|
11728
|
+
lastModified,
|
|
11729
|
+
modifiedByServiceAppName,
|
|
11730
|
+
modifiedByServiceAppId,
|
|
11731
|
+
});
|
|
11732
|
+
|
|
11733
|
+
assert.calledWith(
|
|
11734
|
+
TriggerProxy.trigger,
|
|
11735
|
+
meeting,
|
|
11736
|
+
{file: 'meeting/index', function: 'setupLocusControlsListener'},
|
|
11737
|
+
EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
|
|
11738
|
+
meeting.recording
|
|
11739
|
+
);
|
|
11740
|
+
});
|
|
11741
|
+
|
|
10959
11742
|
it('listens to the locus interpretation update event', () => {
|
|
10960
11743
|
const interpretation = {
|
|
10961
11744
|
siLanguages: [{languageCode: 20, languageName: 'en'}],
|
|
@@ -11009,6 +11792,7 @@ describe('plugin-meetings', () => {
|
|
|
11009
11792
|
meeting.annotation.locusUrlUpdate = sinon.stub();
|
|
11010
11793
|
meeting.simultaneousInterpretation.locusUrlUpdate = sinon.stub();
|
|
11011
11794
|
meeting.webinar.locusUrlUpdate = sinon.stub();
|
|
11795
|
+
meeting.aiEnableRequest.locusUrlUpdate = sinon.stub();
|
|
11012
11796
|
|
|
11013
11797
|
meeting.locusInfo.emit(
|
|
11014
11798
|
{function: 'test', file: 'test'},
|
|
@@ -11023,6 +11807,7 @@ describe('plugin-meetings', () => {
|
|
|
11023
11807
|
assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, false);
|
|
11024
11808
|
assert.calledWith(meeting.simultaneousInterpretation.locusUrlUpdate, newLocusUrl);
|
|
11025
11809
|
assert.calledWith(meeting.webinar.locusUrlUpdate, newLocusUrl);
|
|
11810
|
+
assert.calledWith(meeting.aiEnableRequest.locusUrlUpdate, newLocusUrl);
|
|
11026
11811
|
assert.equal(meeting.locusUrl, newLocusUrl);
|
|
11027
11812
|
assert(meeting.locusId, '12345');
|
|
11028
11813
|
|
|
@@ -11338,6 +12123,109 @@ describe('plugin-meetings', () => {
|
|
|
11338
12123
|
});
|
|
11339
12124
|
});
|
|
11340
12125
|
|
|
12126
|
+
describe('#finalizeMeetingAfterInitialLocusSetup', () => {
|
|
12127
|
+
it('refreshes destination from synced locus when destination type is LOCUS_ID', () => {
|
|
12128
|
+
const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12129
|
+
|
|
12130
|
+
meeting.destinationType = DESTINATION_TYPE.LOCUS_ID;
|
|
12131
|
+
meeting.destination = {info: {topic: 'old'}};
|
|
12132
|
+
|
|
12133
|
+
meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
|
|
12134
|
+
|
|
12135
|
+
assert.equal(meeting.destination, syncedLocus);
|
|
12136
|
+
});
|
|
12137
|
+
|
|
12138
|
+
it('does not refresh destination when destination type is not LOCUS_ID', () => {
|
|
12139
|
+
const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12140
|
+
const originalDestination = {destination: 'original-destination'};
|
|
12141
|
+
|
|
12142
|
+
meeting.destinationType = DESTINATION_TYPE.CONVERSATION_URL;
|
|
12143
|
+
meeting.destination = originalDestination;
|
|
12144
|
+
|
|
12145
|
+
meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
|
|
12146
|
+
|
|
12147
|
+
assert.equal(meeting.destination, originalDestination);
|
|
12148
|
+
});
|
|
12149
|
+
|
|
12150
|
+
it('fetches meeting info when meetingInfo is empty and destination has info', () => {
|
|
12151
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12152
|
+
|
|
12153
|
+
meeting.meetingInfo = {};
|
|
12154
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12155
|
+
|
|
12156
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12157
|
+
|
|
12158
|
+
assert.calledOnceWithExactly(fetchMeetingInfoStub, {});
|
|
12159
|
+
});
|
|
12160
|
+
|
|
12161
|
+
it('does not fetch meeting info when destination has no info', () => {
|
|
12162
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12163
|
+
|
|
12164
|
+
meeting.meetingInfo = {};
|
|
12165
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123'};
|
|
12166
|
+
|
|
12167
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12168
|
+
|
|
12169
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12170
|
+
});
|
|
12171
|
+
|
|
12172
|
+
it('does not fetch meeting info when meetingInfo is already populated', () => {
|
|
12173
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12174
|
+
|
|
12175
|
+
meeting.meetingInfo = {meetingJoinUrl: 'https://example.com/join/abc'};
|
|
12176
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12177
|
+
|
|
12178
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12179
|
+
|
|
12180
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12181
|
+
});
|
|
12182
|
+
|
|
12183
|
+
it('does not fetch meeting info when delayed fetch timer is already scheduled', () => {
|
|
12184
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12185
|
+
|
|
12186
|
+
meeting.meetingInfo = {};
|
|
12187
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12188
|
+
meeting.fetchMeetingInfoTimeoutId = 42;
|
|
12189
|
+
|
|
12190
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12191
|
+
|
|
12192
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12193
|
+
});
|
|
12194
|
+
|
|
12195
|
+
['CALL', 'SIP_BRIDGE', 'SPACE_SHARE'].forEach((fullStateType) => {
|
|
12196
|
+
it(`does not fetch meeting info when destination is a 1:1 call (fullState.type ${fullStateType})`, () => {
|
|
12197
|
+
const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
|
|
12198
|
+
|
|
12199
|
+
meeting.meetingInfo = {};
|
|
12200
|
+
meeting.destination = {
|
|
12201
|
+
url: 'https://locus.example.com/locus/123',
|
|
12202
|
+
info: {topic: 'x'},
|
|
12203
|
+
};
|
|
12204
|
+
|
|
12205
|
+
meeting.finalizeMeetingAfterInitialLocusSetup({fullState: {type: fullStateType}});
|
|
12206
|
+
|
|
12207
|
+
assert.notCalled(fetchMeetingInfoStub);
|
|
12208
|
+
});
|
|
12209
|
+
});
|
|
12210
|
+
|
|
12211
|
+
it('swallows async fetchMeetingInfo errors and logs info', async () => {
|
|
12212
|
+
const error = new Error('fetch failed');
|
|
12213
|
+
|
|
12214
|
+
meeting.meetingInfo = {};
|
|
12215
|
+
meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
|
|
12216
|
+
sinon.stub(meeting, 'fetchMeetingInfo').returns(Promise.reject(error));
|
|
12217
|
+
const loggerInfoStub = sinon.stub(LoggerProxy.logger, 'info');
|
|
12218
|
+
|
|
12219
|
+
await meeting.finalizeMeetingAfterInitialLocusSetup({});
|
|
12220
|
+
|
|
12221
|
+
assert.calledOnce(loggerInfoStub);
|
|
12222
|
+
assert.match(
|
|
12223
|
+
loggerInfoStub.firstCall.args[0],
|
|
12224
|
+
/Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: fetch failed/
|
|
12225
|
+
);
|
|
12226
|
+
});
|
|
12227
|
+
});
|
|
12228
|
+
|
|
11341
12229
|
describe('#emailInput', () => {
|
|
11342
12230
|
it('should set the email input', () => {
|
|
11343
12231
|
assert.notOk(meeting.emailInput);
|
|
@@ -11940,6 +12828,7 @@ describe('plugin-meetings', () => {
|
|
|
11940
12828
|
let showAutoEndMeetingWarningSpy;
|
|
11941
12829
|
let canAttendeeRequestAiAssistantEnabledSpy;
|
|
11942
12830
|
let attendeeRequestAiAssistantDeclinedAllSpy;
|
|
12831
|
+
let isAnonymizeDisplayNamesEnabledSpy;
|
|
11943
12832
|
// Due to import tree issues, hasHints must be stubed within the scope of the `it`.
|
|
11944
12833
|
|
|
11945
12834
|
beforeEach(() => {
|
|
@@ -11988,6 +12877,10 @@ describe('plugin-meetings', () => {
|
|
|
11988
12877
|
MeetingUtil,
|
|
11989
12878
|
'attendeeRequestAiAssistantDeclinedAll'
|
|
11990
12879
|
);
|
|
12880
|
+
isAnonymizeDisplayNamesEnabledSpy = sinon.spy(
|
|
12881
|
+
MeetingUtil,
|
|
12882
|
+
'isAnonymizeDisplayNamesEnabled'
|
|
12883
|
+
);
|
|
11991
12884
|
});
|
|
11992
12885
|
|
|
11993
12886
|
afterEach(() => {
|
|
@@ -11996,6 +12889,7 @@ describe('plugin-meetings', () => {
|
|
|
11996
12889
|
showAutoEndMeetingWarningSpy.restore();
|
|
11997
12890
|
canAttendeeRequestAiAssistantEnabledSpy.restore();
|
|
11998
12891
|
attendeeRequestAiAssistantDeclinedAllSpy.restore();
|
|
12892
|
+
isAnonymizeDisplayNamesEnabledSpy.restore();
|
|
11999
12893
|
});
|
|
12000
12894
|
|
|
12001
12895
|
forEach(
|
|
@@ -12553,6 +13447,7 @@ describe('plugin-meetings', () => {
|
|
|
12553
13447
|
meeting.roles
|
|
12554
13448
|
);
|
|
12555
13449
|
assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
|
|
13450
|
+
assert.calledWith(isAnonymizeDisplayNamesEnabledSpy, userDisplayHints);
|
|
12556
13451
|
|
|
12557
13452
|
assert.calledWith(ControlsOptionsUtil.hasHints, {
|
|
12558
13453
|
requiredHints: [DISPLAY_HINTS.MUTE_ALL],
|
|
@@ -13124,7 +14019,9 @@ describe('plugin-meetings', () => {
|
|
|
13124
14019
|
info: {datachannelUrl: 'a datachannel url'},
|
|
13125
14020
|
};
|
|
13126
14021
|
|
|
13127
|
-
webex.internal.llm.getDatachannelToken
|
|
14022
|
+
webex.internal.llm.getDatachannelToken
|
|
14023
|
+
.withArgs('llm-default-session')
|
|
14024
|
+
.returns('token-123');
|
|
13128
14025
|
|
|
13129
14026
|
await meeting.updateLLMConnection();
|
|
13130
14027
|
|
|
@@ -13178,6 +14075,131 @@ describe('plugin-meetings', () => {
|
|
|
13178
14075
|
assert.notCalled(webex.internal.llm.setDatachannelToken);
|
|
13179
14076
|
});
|
|
13180
14077
|
|
|
14078
|
+
describe('ownership tag', () => {
|
|
14079
|
+
beforeEach(() => {
|
|
14080
|
+
// Make the owner stub dynamic so setOwnerMeetingId() writes
|
|
14081
|
+
// propagate back to getOwnerMeetingId() reads. This mirrors the
|
|
14082
|
+
// real LLM singleton behavior so the finally-block release in
|
|
14083
|
+
// cleanupLLMConneciton is reflected in subsequent reads.
|
|
14084
|
+
webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
|
|
14085
|
+
webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
|
|
14086
|
+
webex.internal.llm.getOwnerMeetingId.returns(id);
|
|
14087
|
+
});
|
|
14088
|
+
});
|
|
14089
|
+
|
|
14090
|
+
it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
|
|
14091
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14092
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14093
|
+
webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
|
|
14094
|
+
// Locus/datachannel URL mismatch is the *normal* case when
|
|
14095
|
+
// another meeting owns the live socket -- each meeting has its
|
|
14096
|
+
// own locus URL. URL mismatch must NOT trigger a reclaim,
|
|
14097
|
+
// because doing so would tear down the owning meeting's healthy
|
|
14098
|
+
// LLM socket and break its data channel.
|
|
14099
|
+
webex.internal.llm.getLocusUrl.returns('owner-locus-url');
|
|
14100
|
+
webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
|
|
14101
|
+
meeting.locusInfo = {
|
|
14102
|
+
url: 'a different url',
|
|
14103
|
+
info: {datachannelUrl: 'a different datachannel url'},
|
|
14104
|
+
self: {},
|
|
14105
|
+
};
|
|
14106
|
+
|
|
14107
|
+
const result = await meeting.updateLLMConnection();
|
|
14108
|
+
|
|
14109
|
+
assert.equal(result, undefined);
|
|
14110
|
+
assert.notCalled(webex.internal.llm.disconnectLLM);
|
|
14111
|
+
assert.notCalled(webex.internal.llm.registerAndConnect);
|
|
14112
|
+
assert.notCalled(webex.internal.llm.setOwnerMeetingId);
|
|
14113
|
+
assert.notCalled(meeting.startLLMHealthCheckTimer);
|
|
14114
|
+
});
|
|
14115
|
+
|
|
14116
|
+
|
|
14117
|
+
it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
|
|
14118
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14119
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14120
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14121
|
+
webex.internal.llm.getLocusUrl.returns('a url');
|
|
14122
|
+
webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
|
|
14123
|
+
webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
|
|
14124
|
+
meeting.locusInfo = {
|
|
14125
|
+
url: 'a different url',
|
|
14126
|
+
info: {datachannelUrl: 'a datachannel url'},
|
|
14127
|
+
self: {},
|
|
14128
|
+
};
|
|
14129
|
+
|
|
14130
|
+
try {
|
|
14131
|
+
await meeting.updateLLMConnection();
|
|
14132
|
+
} catch (e) {
|
|
14133
|
+
/* updateLLMConnection may reject when cleanup throws */
|
|
14134
|
+
}
|
|
14135
|
+
|
|
14136
|
+
// The owner-eligible finally branch must release the tag so a
|
|
14137
|
+
// subsequent reconnect attempt from any meeting is not blocked.
|
|
14138
|
+
assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
|
|
14139
|
+
});
|
|
14140
|
+
|
|
14141
|
+
it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
|
|
14142
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14143
|
+
webex.internal.llm.isConnected.returns(true);
|
|
14144
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14145
|
+
webex.internal.llm.getLocusUrl.returns('a url');
|
|
14146
|
+
webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
|
|
14147
|
+
meeting.locusInfo = {
|
|
14148
|
+
url: 'a different url',
|
|
14149
|
+
info: {datachannelUrl: 'a datachannel url'},
|
|
14150
|
+
self: {},
|
|
14151
|
+
};
|
|
14152
|
+
|
|
14153
|
+
await meeting.updateLLMConnection();
|
|
14154
|
+
|
|
14155
|
+
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
14156
|
+
code: 3050,
|
|
14157
|
+
reason: 'done (permanent)',
|
|
14158
|
+
});
|
|
14159
|
+
assert.calledWithExactly(
|
|
14160
|
+
webex.internal.llm.registerAndConnect,
|
|
14161
|
+
'a different url',
|
|
14162
|
+
'a datachannel url',
|
|
14163
|
+
undefined
|
|
14164
|
+
);
|
|
14165
|
+
// setOwnerMeetingId is called twice: first with undefined in
|
|
14166
|
+
// cleanupLLMConneciton's finally block (so a failed disconnect
|
|
14167
|
+
// cannot leave a stale owner), then with this meeting's id
|
|
14168
|
+
// after registerAndConnect resolves.
|
|
14169
|
+
assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
|
|
14170
|
+
assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
|
|
14171
|
+
assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
|
|
14172
|
+
});
|
|
14173
|
+
|
|
14174
|
+
it('claims ownership after successful registerAndConnect on initial connect', async () => {
|
|
14175
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14176
|
+
webex.internal.llm.isConnected.returns(false);
|
|
14177
|
+
webex.internal.llm.getOwnerMeetingId.returns(undefined);
|
|
14178
|
+
meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14179
|
+
|
|
14180
|
+
await meeting.updateLLMConnection();
|
|
14181
|
+
|
|
14182
|
+
assert.calledOnce(webex.internal.llm.registerAndConnect);
|
|
14183
|
+
assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
|
|
14184
|
+
});
|
|
14185
|
+
|
|
14186
|
+
it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
|
|
14187
|
+
// Defensive path: if the LLM reports not-connected but an old
|
|
14188
|
+
// ownerId is still present (e.g. race before a successful
|
|
14189
|
+
// connections.delete), this meeting can still claim a fresh
|
|
14190
|
+
// connection.
|
|
14191
|
+
meeting.joinedWith = {state: 'JOINED'};
|
|
14192
|
+
webex.internal.llm.isConnected.returns(false);
|
|
14193
|
+
webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
|
|
14194
|
+
meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
|
|
14195
|
+
|
|
14196
|
+
await meeting.updateLLMConnection();
|
|
14197
|
+
|
|
14198
|
+
assert.calledOnce(webex.internal.llm.registerAndConnect);
|
|
14199
|
+
assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
|
|
14200
|
+
});
|
|
14201
|
+
});
|
|
14202
|
+
|
|
13181
14203
|
describe('#clearMeetingData', () => {
|
|
13182
14204
|
beforeEach(() => {
|
|
13183
14205
|
webex.internal.llm.isConnected = sinon.stub().returns(true);
|
|
@@ -13209,10 +14231,13 @@ describe('plugin-meetings', () => {
|
|
|
13209
14231
|
meeting.processLocusLLMEvent
|
|
13210
14232
|
);
|
|
13211
14233
|
assert.calledOnce(meeting.clearLLMHealthCheckTimer);
|
|
13212
|
-
assert.calledOnce(meeting.stopTranscription);
|
|
13213
|
-
assert.isUndefined(meeting.transcription);
|
|
13214
14234
|
assert.calledOnce(meeting.clearDataChannelToken);
|
|
13215
|
-
|
|
14235
|
+
// stopTranscription and annotation.deregisterEvents are not
|
|
14236
|
+
// called here: they run in stopListeningForMeetingEvents()
|
|
14237
|
+
// before /leave to avoid double-emitting
|
|
14238
|
+
// MEETING_STOPPED_RECEIVING_TRANSCRIPTION.
|
|
14239
|
+
assert.notCalled(meeting.stopTranscription);
|
|
14240
|
+
assert.notCalled(meeting.annotation.deregisterEvents);
|
|
13216
14241
|
});
|
|
13217
14242
|
it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
|
|
13218
14243
|
webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
|
|
@@ -13231,19 +14256,67 @@ describe('plugin-meetings', () => {
|
|
|
13231
14256
|
meeting.processLocusLLMEvent
|
|
13232
14257
|
);
|
|
13233
14258
|
assert.calledOnce(meeting.clearLLMHealthCheckTimer);
|
|
13234
|
-
assert.calledOnce(meeting.stopTranscription);
|
|
13235
|
-
assert.isUndefined(meeting.transcription);
|
|
13236
14259
|
assert.calledOnce(meeting.clearDataChannelToken);
|
|
13237
|
-
assert.
|
|
14260
|
+
assert.notCalled(meeting.stopTranscription);
|
|
14261
|
+
assert.notCalled(meeting.annotation.deregisterEvents);
|
|
13238
14262
|
});
|
|
13239
|
-
it('always calls stopTranscription even when transcription is undefined', async () => {
|
|
13240
|
-
meeting.transcription = undefined;
|
|
13241
14263
|
|
|
13242
|
-
|
|
14264
|
+
describe('ownership tag', () => {
|
|
14265
|
+
beforeEach(() => {
|
|
14266
|
+
webex.internal.llm.getOwnerMeetingId = sinon.stub();
|
|
14267
|
+
});
|
|
13243
14268
|
|
|
13244
|
-
|
|
13245
|
-
|
|
13246
|
-
|
|
14269
|
+
it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
|
|
14270
|
+
webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
|
|
14271
|
+
|
|
14272
|
+
await meeting.clearMeetingData();
|
|
14273
|
+
|
|
14274
|
+
assert.notCalled(webex.internal.llm.disconnectLLM);
|
|
14275
|
+
// Shared data-channel auth tokens belong to the owner meeting's
|
|
14276
|
+
// live LLM session and must not be wiped by a non-owner
|
|
14277
|
+
// teardown, otherwise the owner's next reconnect would lose
|
|
14278
|
+
// its Data-Channel-Auth-Token.
|
|
14279
|
+
assert.notCalled(meeting.clearDataChannelToken);
|
|
14280
|
+
// Listeners owned by *this* Meeting instance must still be
|
|
14281
|
+
// removed so a leaving subordinate meeting stops receiving
|
|
14282
|
+
// relay/locus events from the shared singleton.
|
|
14283
|
+
assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
|
|
14284
|
+
assert.calledWithExactly(
|
|
14285
|
+
webex.internal.llm.off,
|
|
14286
|
+
'event:relay.event',
|
|
14287
|
+
meeting.processRelayEvent
|
|
14288
|
+
);
|
|
14289
|
+
assert.calledWithExactly(
|
|
14290
|
+
webex.internal.llm.off,
|
|
14291
|
+
'event:locus.state_message',
|
|
14292
|
+
meeting.processLocusLLMEvent
|
|
14293
|
+
);
|
|
14294
|
+
assert.calledOnce(meeting.clearLLMHealthCheckTimer);
|
|
14295
|
+
});
|
|
14296
|
+
|
|
14297
|
+
it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
|
|
14298
|
+
webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
|
|
14299
|
+
|
|
14300
|
+
await meeting.clearMeetingData();
|
|
14301
|
+
|
|
14302
|
+
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
14303
|
+
code: 3050,
|
|
14304
|
+
reason: 'done (permanent)',
|
|
14305
|
+
});
|
|
14306
|
+
assert.calledOnce(meeting.clearDataChannelToken);
|
|
14307
|
+
});
|
|
14308
|
+
|
|
14309
|
+
it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
|
|
14310
|
+
webex.internal.llm.getOwnerMeetingId.returns(undefined);
|
|
14311
|
+
|
|
14312
|
+
await meeting.clearMeetingData();
|
|
14313
|
+
|
|
14314
|
+
assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
|
|
14315
|
+
code: 3050,
|
|
14316
|
+
reason: 'done (permanent)',
|
|
14317
|
+
});
|
|
14318
|
+
assert.calledOnce(meeting.clearDataChannelToken);
|
|
14319
|
+
});
|
|
13247
14320
|
});
|
|
13248
14321
|
});
|
|
13249
14322
|
});
|
|
@@ -14997,16 +16070,25 @@ describe('plugin-meetings', () => {
|
|
|
14997
16070
|
assert.notCalled(meeting.meetingRequest.sendReaction);
|
|
14998
16071
|
});
|
|
14999
16072
|
|
|
15000
|
-
it('should
|
|
16073
|
+
it('should send a custom reaction type not in the known list', async () => {
|
|
15001
16074
|
meeting.locusInfo.controls = {reactions: {reactionChannelUrl: 'Fake URL'}};
|
|
15002
16075
|
|
|
15003
|
-
|
|
15004
|
-
meeting.sendReaction('invalid_reaction', 'light'),
|
|
15005
|
-
Error,
|
|
15006
|
-
'invalid_reaction is not a valid reaction.'
|
|
15007
|
-
);
|
|
16076
|
+
const reactionPromise = meeting.sendReaction('custom_reaction', 'light');
|
|
15008
16077
|
|
|
15009
|
-
assert.
|
|
16078
|
+
assert.exists(reactionPromise.then);
|
|
16079
|
+
await reactionPromise;
|
|
16080
|
+
assert.calledOnceWithExactly(meeting.meetingRequest.sendReaction, {
|
|
16081
|
+
reactionChannelUrl: 'Fake URL',
|
|
16082
|
+
reaction: {
|
|
16083
|
+
type: 'custom_reaction',
|
|
16084
|
+
tone: {
|
|
16085
|
+
type: 'light_skin_tone',
|
|
16086
|
+
codepoints: '1F3FB',
|
|
16087
|
+
shortcodes: ':skin-tone-2:',
|
|
16088
|
+
},
|
|
16089
|
+
},
|
|
16090
|
+
participantId: meeting.members.selfId,
|
|
16091
|
+
});
|
|
15010
16092
|
});
|
|
15011
16093
|
|
|
15012
16094
|
it('should send a reaction with default skin tone if provided skinToneType is invalid ', async () => {
|
|
@@ -16030,4 +17112,4 @@ describe('plugin-meetings', () => {
|
|
|
16030
17112
|
assert.calledOnceWithExactly(meeting.meetingRequest.cancelSipCallOut, participantId);
|
|
16031
17113
|
});
|
|
16032
17114
|
});
|
|
16033
|
-
});
|
|
17115
|
+
});
|