From cd7cf5bdd973c773186d32c70a9bbf740cd64ec9 Mon Sep 17 00:00:00 2001 From: Marcin Date: Wed, 20 Nov 2024 07:31:08 +0000 Subject: [PATCH 01/30] fix: cannot unmute web client in moderated unmute mode when unmuteAllowed is false (#3992) --- .../plugin-meetings/src/meeting/index.ts | 2 +- .../plugin-meetings/src/meeting/muteState.ts | 8 +++- .../test/unit/spec/meeting/index.js | 37 +++++++++++++++++++ .../test/unit/spec/meeting/muteState.js | 12 ++++-- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index b11fd127e64..e7ffb18fd1f 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -3104,7 +3104,7 @@ export default class Meeting extends StatelessWebexPlugin { private setUpLocusInfoSelfListener() { this.locusInfo.on(LOCUSINFO.EVENTS.LOCAL_UNMUTE_REQUIRED, (payload) => { if (this.audio) { - this.audio.handleServerLocalUnmuteRequired(this); + this.audio.handleServerLocalUnmuteRequired(this, payload.unmuteAllowed); Trigger.trigger( this, { diff --git a/packages/@webex/plugin-meetings/src/meeting/muteState.ts b/packages/@webex/plugin-meetings/src/meeting/muteState.ts index e834514e732..2adbd562887 100644 --- a/packages/@webex/plugin-meetings/src/meeting/muteState.ts +++ b/packages/@webex/plugin-meetings/src/meeting/muteState.ts @@ -394,21 +394,25 @@ export class MuteState { * @public * @memberof MuteState * @param {Object} [meeting] the meeting object + * @param {Boolean} [unmuteAllowed] whether the user is allowed to unmute self * @returns {undefined} */ - public handleServerLocalUnmuteRequired(meeting?: any) { + public handleServerLocalUnmuteRequired(meeting: any, unmuteAllowed: boolean) { if (!this.state.client.enabled) { LoggerProxy.logger.warn( `Meeting:muteState#handleServerLocalUnmuteRequired --> ${this.type}: localAudioUnmuteRequired received while ${this.type} is disabled -> local unmute will not result in ${this.type} being sent` ); } else { LoggerProxy.logger.info( - `Meeting:muteState#handleServerLocalUnmuteRequired --> ${this.type}: localAudioUnmuteRequired received -> doing local unmute` + `Meeting:muteState#handleServerLocalUnmuteRequired --> ${this.type}: localAudioUnmuteRequired received -> doing local unmute (unmuteAllowed=${unmuteAllowed})` ); } // todo: I'm seeing "you can now unmute yourself " popup when this happens - but same thing happens on web.w.c so we can ignore for now this.state.server.remoteMute = false; + this.state.server.unmuteAllowed = unmuteAllowed; + + this.applyUnmuteAllowedToStream(meeting); // change user mute state to false, but keep localMute true if overall mute state is still true this.muteLocalStream(meeting, false, 'localUnmuteRequired'); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 87dee5f9ceb..9f8ed9cc4b7 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -12200,6 +12200,43 @@ describe('plugin-meetings', () => { await testEmit(false); }); }); + + describe('LOCAL_UNMUTE_REQUIRED locus event', () => { + const testEmit = async (unmuteAllowed) => { + meeting.audio = { + handleServerLocalUnmuteRequired: sinon.stub(), + } + await meeting.locusInfo.emitScoped( + {}, + LOCUSINFO.EVENTS.LOCAL_UNMUTE_REQUIRED, + { + unmuteAllowed, + } + ); + + assert.calledWith( + TriggerProxy.trigger, + sinon.match.instanceOf(Meeting), + { + file: 'meeting/index', + function: 'setUpLocusInfoSelfListener', + }, + EVENT_TRIGGERS.MEETING_SELF_UNMUTED_BY_OTHERS, + { + payload: { + unmuteAllowed, + }, + } + ); + assert.calledOnceWithExactly(meeting.audio.handleServerLocalUnmuteRequired, meeting, unmuteAllowed) + }; + + [true, false].forEach((unmuteAllowed) => { + it(`emits the expected event and calls handleServerLocalUnmuteRequired() when unmuteAllowed=${unmuteAllowed}`, async () => { + await testEmit(unmuteAllowed); + }); + }); + }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js index 7af0df9e88b..c89e591ff06 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js @@ -151,7 +151,7 @@ describe('plugin-meetings', () => { meeting.mediaProperties.audioStream.setServerMuted = sinon.stub().callsFake((muted) => { meeting.mediaProperties.audioStream.userMuted = muted; }); - audio.handleServerLocalUnmuteRequired(meeting); + audio.handleServerLocalUnmuteRequired(meeting, true); await testUtils.flushPromises(); @@ -161,6 +161,8 @@ describe('plugin-meetings', () => { false, 'localUnmuteRequired' ); + // and unmuteAllowed was updated + assert.calledWith(meeting.mediaProperties.audioStream.setUnmuteAllowed, true); // and local unmute was sent to server assert.calledOnce(MeetingUtil.remoteUpdateAudioVideo); @@ -184,7 +186,7 @@ describe('plugin-meetings', () => { meeting.mediaProperties.audioStream.setServerMuted = sinon.stub().callsFake((muted) => { meeting.mediaProperties.audioStream.userMuted = muted; }); - audio.handleServerLocalUnmuteRequired(meeting); + audio.handleServerLocalUnmuteRequired(meeting, true); await testUtils.flushPromises(); @@ -215,7 +217,7 @@ describe('plugin-meetings', () => { meeting.mediaProperties.videoStream.setServerMuted = sinon.stub().callsFake((muted) => { meeting.mediaProperties.videoStream.userMuted = muted; }); - video.handleServerLocalUnmuteRequired(meeting); + video.handleServerLocalUnmuteRequired(meeting, true); await testUtils.flushPromises(); @@ -225,6 +227,8 @@ describe('plugin-meetings', () => { false, 'localUnmuteRequired' ); + // and unmuteAllowed was updated + assert.calledWith(meeting.mediaProperties.videoStream.setUnmuteAllowed, true); // and local unmute was sent to server assert.calledOnce(MeetingUtil.remoteUpdateAudioVideo); @@ -248,7 +252,7 @@ describe('plugin-meetings', () => { meeting.mediaProperties.videoStream.setServerMuted = sinon.stub().callsFake((muted) => { meeting.mediaProperties.videoStream.userMuted = muted; }); - video.handleServerLocalUnmuteRequired(meeting); + video.handleServerLocalUnmuteRequired(meeting, true); await testUtils.flushPromises(); From 3bec5143564146fa28f9c9175a292a5e9a92b131 Mon Sep 17 00:00:00 2001 From: Charles Burkett Date: Wed, 20 Nov 2024 08:07:58 -0500 Subject: [PATCH 02/30] feat(consolidated-interstitial): add joinFlowVersion as CA event option (#3991) --- .../internal-plugin-metrics/package.json | 2 +- .../call-diagnostic-metrics.ts | 8 +++ .../src/metrics.types.ts | 3 + .../call-diagnostic-metrics.ts | 65 +++++++++++++++---- yarn.lock | 10 +-- 5 files changed, 70 insertions(+), 18 deletions(-) diff --git a/packages/@webex/internal-plugin-metrics/package.json b/packages/@webex/internal-plugin-metrics/package.json index 002feb20183..bffd332e58d 100644 --- a/packages/@webex/internal-plugin-metrics/package.json +++ b/packages/@webex/internal-plugin-metrics/package.json @@ -37,7 +37,7 @@ "dependencies": { "@webex/common": "workspace:*", "@webex/common-timers": "workspace:*", - "@webex/event-dictionary-ts": "^1.0.1546", + "@webex/event-dictionary-ts": "^1.0.1594", "@webex/internal-plugin-metrics": "workspace:*", "@webex/test-helper-chai": "workspace:*", "@webex/test-helper-mock-webex": "workspace:*", diff --git a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts index 2a64fc5fe92..583b4b55579 100644 --- a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts @@ -718,6 +718,10 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { webexSubServiceType: this.getSubServiceType(meeting), }; + if (options.joinFlowVersion) { + clientEventObject.joinFlowVersion = options.joinFlowVersion; + } + return clientEventObject; } @@ -761,6 +765,10 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { loginType: this.getCurLoginType(), }; + if (options.joinFlowVersion) { + clientEventObject.joinFlowVersion = options.joinFlowVersion; + } + return clientEventObject; } diff --git a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts index a72093d4978..1946d4fcd4b 100644 --- a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts +++ b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts @@ -110,6 +110,8 @@ export type MetricEventVerb = | 'warn' | 'exit'; +export type MetricEventJoinFlowVersion = 'Other' | 'NewFTE'; + export type SubmitClientEventOptions = { meetingId?: string; mediaConnections?: any[]; @@ -123,6 +125,7 @@ export type SubmitClientEventOptions = { browserLaunchMethod?: BrowserLaunchMethodType; webexConferenceIdStr?: string; globalMeetingId?: string; + joinFlowVersion?: MetricEventJoinFlowVersion; }; export type SubmitMQEOptions = { diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts index bbbe37ecbf9..ceb9d5b7afe 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts @@ -56,7 +56,7 @@ describe('internal-plugin-metrics', () => { id: '3', correlationId: 'correlationId3', sessionCorrelationId: 'sessionCorrelationId3', - } + }; const fakeMeetings = { 1: fakeMeeting, @@ -103,8 +103,8 @@ describe('internal-plugin-metrics', () => { return '192.168.1.90'; } }, - } - } + }, + }; }, }, geoHintInfo: { @@ -771,7 +771,6 @@ describe('internal-plugin-metrics', () => { webexConferenceIdStr: undefined, sessionCorrelationId: undefined, globalMeetingId: undefined, - sessionCorrelationId: undefined, }); assert.notCalled(generateClientEventErrorPayloadSpy); assert.calledWith( @@ -1075,7 +1074,7 @@ describe('internal-plugin-metrics', () => { correlationId: 'correlationId', webexConferenceIdStr: 'webexConferenceIdStr1', globalMeetingId: 'globalMeetingId1', - sessionCorrelationId: 'sessionCorrelationId1' + sessionCorrelationId: 'sessionCorrelationId1', }; cd.submitClientEvent({ @@ -1169,7 +1168,7 @@ describe('internal-plugin-metrics', () => { webexConferenceIdStr: 'webexConferenceIdStr1', globalMeetingId: 'globalMeetingId1', preLoginId: 'myPreLoginId', - sessionCorrelationId: 'sessionCorrelationId1' + sessionCorrelationId: 'sessionCorrelationId1', }; cd.submitClientEvent({ @@ -1182,7 +1181,7 @@ describe('internal-plugin-metrics', () => { webexConferenceIdStr: 'webexConferenceIdStr1', globalMeetingId: 'globalMeetingId1', preLoginId: 'myPreLoginId', - sessionCorrelationId: 'sessionCorrelationId1' + sessionCorrelationId: 'sessionCorrelationId1', }); assert.notCalled(generateClientEventErrorPayloadSpy); @@ -1293,7 +1292,7 @@ describe('internal-plugin-metrics', () => { const options = { meetingId: fakeMeeting2.id, mediaConnections: [{mediaAgentAlias: 'alias', mediaAgentGroupId: '1'}], - sessionCorrelationId: 'sessionCorrelationId1' + sessionCorrelationId: 'sessionCorrelationId1', }; cd.submitClientEvent({ @@ -1462,7 +1461,7 @@ describe('internal-plugin-metrics', () => { category: 'other', errorCode: 9999, errorData: { - errorName: 'Error' + errorName: 'Error', }, serviceErrorCode: 9999, errorDescription: 'UnknownError', @@ -1536,7 +1535,7 @@ describe('internal-plugin-metrics', () => { category: 'other', errorCode: 9999, errorData: { - errorName: 'Error' + errorName: 'Error', }, serviceErrorCode: 9999, errorDescription: 'UnknownError', @@ -1785,7 +1784,7 @@ describe('internal-plugin-metrics', () => { meetingId: fakeMeeting.id, webexConferenceIdStr: 'webexConferenceIdStr1', globalMeetingId: 'globalMeetingId1', - sessionCorrelationId: 'sessionCorrelationId1' + sessionCorrelationId: 'sessionCorrelationId1', }; cd.submitMQE({ @@ -2251,7 +2250,7 @@ describe('internal-plugin-metrics', () => { serviceErrorCode: 9999, errorCode: 9999, errorData: { - errorName: 'Error' + errorName: 'Error', }, rawErrorMessage: 'bad times', }); @@ -2757,6 +2756,48 @@ describe('internal-plugin-metrics', () => { ]); }); }); + + it('includes expected joinFlowVersion when in-meeting', async () => { + // meetingId means in-meeting + const options = { + meetingId: fakeMeeting.id, + preLoginId: 'myPreLoginId', + joinFlowVersion: 'NewFTE', + }; + + const triggered = new Date(); + const fetchOptions = await cd.buildClientEventFetchRequestOptions({ + name: 'client.exit.app', + payload: {trigger: 'user-interaction', canProceed: false}, + options, + }); + + assert.equal( + fetchOptions.body.metrics[0].eventPayload.event.joinFlowVersion, + options.joinFlowVersion + ); + }); + + it('includes expected joinFlowVersion during prejoin', async () => { + // correlationId and no meeting id means prejoin + const options = { + correlationId: 'myCorrelationId', + preLoginId: 'myPreLoginId', + joinFlowVersion: 'NewFTE', + }; + + const triggered = new Date(); + const fetchOptions = await cd.buildClientEventFetchRequestOptions({ + name: 'client.exit.app', + payload: {trigger: 'user-interaction', canProceed: false}, + options, + }); + + assert.equal( + fetchOptions.body.metrics[0].eventPayload.event.joinFlowVersion, + options.joinFlowVersion + ); + }); }); describe('#submitToCallDiagnosticsPreLogin', () => { diff --git a/yarn.lock b/yarn.lock index a0fe1d26df7..b8425801341 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7618,9 +7618,9 @@ __metadata: languageName: unknown linkType: soft -"@webex/event-dictionary-ts@npm:^1.0.1546": - version: 1.0.1546 - resolution: "@webex/event-dictionary-ts@npm:1.0.1546" +"@webex/event-dictionary-ts@npm:^1.0.1594": + version: 1.0.1594 + resolution: "@webex/event-dictionary-ts@npm:1.0.1594" dependencies: amf-client-js: ^5.2.6 json-schema-to-typescript: ^12.0.0 @@ -7628,7 +7628,7 @@ __metadata: ramldt2jsonschema: ^1.2.3 shelljs: ^0.8.5 webapi-parser: ^0.5.0 - checksum: d938300584c5dcdeb5924a072c20aac85e9826c9631961e604c0e1dd577172f5d11a34c0b52dbb49f86386a80ecb842446a368172e5e64c273d06903f5843fa4 + checksum: 814a1029031bc47b7579ff52aa6ca3bc83323246e81927df5c97d531d487709befb84d2f1d7a85f9deb6ce5148eff57ee2a1d219f4568371c2c0a8b12aa167ee languageName: node linkType: hard @@ -8126,7 +8126,7 @@ __metadata: "@webex/common": "workspace:*" "@webex/common-timers": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/event-dictionary-ts": ^1.0.1546 + "@webex/event-dictionary-ts": ^1.0.1594 "@webex/internal-plugin-metrics": "workspace:*" "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" From 67df770476cfc7e398bc6b803d018acc5df48917 Mon Sep 17 00:00:00 2001 From: Charles Burkett Date: Wed, 20 Nov 2024 20:40:29 -0500 Subject: [PATCH 03/30] feat(consolidated-interstitial): joinFlowVersion from meeting.callStateForMetrics (#3997) --- .../call-diagnostic-metrics.ts | 5 ++- .../call-diagnostic-metrics.ts | 45 +++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts index 583b4b55579..a8865bfe80a 100644 --- a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts @@ -718,8 +718,9 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { webexSubServiceType: this.getSubServiceType(meeting), }; - if (options.joinFlowVersion) { - clientEventObject.joinFlowVersion = options.joinFlowVersion; + const joinFlowVersion = options.joinFlowVersion ?? meeting.callStateForMetrics?.joinFlowVersion; + if (joinFlowVersion) { + clientEventObject.joinFlowVersion = joinFlowVersion; } return clientEventObject; diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts index ceb9d5b7afe..d269f8e7a87 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts @@ -48,7 +48,7 @@ describe('internal-plugin-metrics', () => { ...fakeMeeting, id: '2', correlationId: 'correlationId2', - callStateForMetrics: {loginType: 'fakeLoginType'}, + callStateForMetrics: {loginType: 'fakeLoginType', joinFlowVersion: 'Other'}, }; const fakeMeeting3 = { @@ -1270,6 +1270,7 @@ describe('internal-plugin-metrics', () => { loginType: 'fakeLoginType', name: 'client.alert.displayed', userType: 'host', + joinFlowVersion: 'Other', isConvergedArchitectureEnabled: undefined, webexSubServiceType: undefined, }, @@ -1321,6 +1322,7 @@ describe('internal-plugin-metrics', () => { loginType: 'fakeLoginType', name: 'client.alert.displayed', userType: 'host', + joinFlowVersion: 'Other', isConvergedArchitectureEnabled: undefined, webexSubServiceType: undefined, }, @@ -2757,11 +2759,10 @@ describe('internal-plugin-metrics', () => { }); }); - it('includes expected joinFlowVersion when in-meeting', async () => { + it('includes expected joinFlowVersion from options when in-meeting', async () => { // meetingId means in-meeting const options = { meetingId: fakeMeeting.id, - preLoginId: 'myPreLoginId', joinFlowVersion: 'NewFTE', }; @@ -2778,7 +2779,43 @@ describe('internal-plugin-metrics', () => { ); }); - it('includes expected joinFlowVersion during prejoin', async () => { + it('includes expected joinFlowVersion from meeting callStateForMetrics when in-meeting', async () => { + // meetingId means in-meeting + const options = { + meetingId: fakeMeeting2.id, + }; + + const triggered = new Date(); + const fetchOptions = await cd.buildClientEventFetchRequestOptions({ + name: 'client.exit.app', + payload: {trigger: 'user-interaction', canProceed: false}, + options, + }); + + assert.equal(fetchOptions.body.metrics[0].eventPayload.event.joinFlowVersion, 'Other'); + }); + + it('prioritizes joinFlowVersion from options over meeting callStateForMetrics', async () => { + // meetingId means in-meeting + const options = { + meetingId: fakeMeeting2.id, + joinFlowVersion: 'NewFTE', + }; + + const triggered = new Date(); + const fetchOptions = await cd.buildClientEventFetchRequestOptions({ + name: 'client.exit.app', + payload: {trigger: 'user-interaction', canProceed: false}, + options, + }); + + assert.equal( + fetchOptions.body.metrics[0].eventPayload.event.joinFlowVersion, + options.joinFlowVersion + ); + }); + + it('includes expected joinFlowVersion from options during prejoin', async () => { // correlationId and no meeting id means prejoin const options = { correlationId: 'myCorrelationId', From 8d6d1bc8cf4bfdda514d318bd1f80426dbc6831a Mon Sep 17 00:00:00 2001 From: chrisadubois Date: Wed, 20 Nov 2024 17:55:08 -0800 Subject: [PATCH 04/30] fix(convo): coerce to 0 (#3994) --- .../internal-plugin-conversation/src/conversation.js | 3 ++- .../test/integration/spec/get.js | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/@webex/internal-plugin-conversation/src/conversation.js b/packages/@webex/internal-plugin-conversation/src/conversation.js index 414c26050b1..a3fa8f89c85 100644 --- a/packages/@webex/internal-plugin-conversation/src/conversation.js +++ b/packages/@webex/internal-plugin-conversation/src/conversation.js @@ -68,8 +68,9 @@ const getConvoLimit = (options = {}) => { let limit; if (options.conversationsLimit) { + const value = Math.max(options.conversationsLimit, 0); limit = { - value: options.conversationsLimit, + value, name: 'conversationsLimit', }; } diff --git a/packages/@webex/internal-plugin-conversation/test/integration/spec/get.js b/packages/@webex/internal-plugin-conversation/test/integration/spec/get.js index 81f2a45b535..c54ba480b5d 100644 --- a/packages/@webex/internal-plugin-conversation/test/integration/spec/get.js +++ b/packages/@webex/internal-plugin-conversation/test/integration/spec/get.js @@ -363,6 +363,15 @@ describe('plugin-conversation', function () { assert.include(map(conversations, 'url'), conversation2.url); })); + it('retrieves no conversations', () => + webex.internal.conversation + .list({ + conversationsLimit: -100, + }) + .then((conversations) => { + assert.lengthOf(conversations, 0); + })); + it('retrieves a paginated set of conversations', () => webex.internal.conversation .paginate({ From ea7c9b801b09848554e878243cdaba0532ec1bc8 Mon Sep 17 00:00:00 2001 From: chrisadubois Date: Thu, 21 Nov 2024 11:33:43 -0800 Subject: [PATCH 05/30] fix(mercury): add client timestamp (#3993) --- packages/@webex/internal-plugin-mercury/src/mercury.js | 2 ++ .../internal-plugin-mercury/test/unit/spec/mercury.js | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/@webex/internal-plugin-mercury/src/mercury.js b/packages/@webex/internal-plugin-mercury/src/mercury.js index 25d0a7bbc69..5c94ebc4265 100644 --- a/packages/@webex/internal-plugin-mercury/src/mercury.js +++ b/packages/@webex/internal-plugin-mercury/src/mercury.js @@ -185,6 +185,8 @@ const Mercury = WebexPlugin.extend({ webSocketUrl.query.multipleConnections = true; } + webSocketUrl.query.clientTimestamp = Date.now(); + return url.format(webSocketUrl); }); }, diff --git a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js index 275cc9c8494..cd65a870e0c 100644 --- a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js +++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js @@ -447,7 +447,7 @@ describe('plugin-mercury', () => { assert.isFalse(mercury.connecting, 'Mercury is not connecting'); assert.calledWith( Socket.prototype.open, - sinon.match(/ws:\/\/providedurl.com/), + sinon.match(/ws:\/\/providedurl.com.*clientTimestamp[=]\d+/), sinon.match.any ); }); @@ -783,16 +783,16 @@ describe('plugin-mercury', () => { it('uses provided webSocketUrl', () => webex.internal.mercury ._prepareUrl('ws://provided.com') - .then((wsUrl) => assert.match(wsUrl, /provided.com/))); + .then((wsUrl) => assert.match(wsUrl, /.*provided.com.*/))); it('requests text-mode WebSockets', () => webex.internal.mercury ._prepareUrl() - .then((wsUrl) => assert.match(wsUrl, /outboundWireFormat=text/))); + .then((wsUrl) => assert.match(wsUrl, /.*outboundWireFormat=text.*/))); it('requests the buffer state message', () => webex.internal.mercury ._prepareUrl() - .then((wsUrl) => assert.match(wsUrl, /bufferStates=true/))); + .then((wsUrl) => assert.match(wsUrl, /.*bufferStates=true.*/))); it('does not add conditional properties', () => webex.internal.mercury._prepareUrl().then((wsUrl) => { From c80bb5e2d078d68414ad6fa1d912cfde131a8824 Mon Sep 17 00:00:00 2001 From: mickelr <121160648+mickelr@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:00:34 +0800 Subject: [PATCH 06/30] feat(Webinar) - Locus API and DTO update for Webinar (#3972) Co-authored-by: JudyZhu <120536178+JudyZhuHz@users.noreply.github.com> --- packages/@webex/plugin-meetings/README.md | 3 +- .../@webex/plugin-meetings/src/constants.ts | 25 ++++ .../src/controls-options-manager/enums.ts | 1 + .../src/controls-options-manager/index.ts | 21 +++- .../src/controls-options-manager/types.ts | 2 + .../src/controls-options-manager/util.ts | 12 ++ .../src/locus-info/controlsUtils.ts | 48 +++++++- .../src/locus-info/fullState.ts | 1 + .../plugin-meetings/src/locus-info/index.ts | 60 +++++++++ .../src/meeting/in-meeting-actions.ts | 37 ++++++ .../plugin-meetings/src/meeting/index.ts | 100 +++++++++++++-- .../plugin-meetings/src/members/index.ts | 6 +- .../plugin-meetings/src/members/util.ts | 4 +- .../plugin-meetings/src/webinar/index.ts | 48 +++++--- .../spec/controls-options-manager/index.js | 88 ++++++++----- .../spec/controls-options-manager/util.js | 44 +++++++ .../unit/spec/locus-info/controlsUtils.js | 84 ++++++++++++- .../test/unit/spec/locus-info/index.js | 61 ++++++++- .../unit/spec/meeting/in-meeting-actions.ts | 18 +++ .../test/unit/spec/meeting/index.js | 116 +++++++++++++++--- .../test/unit/spec/members/index.js | 27 +++- .../test/unit/spec/members/request.js | 40 +++++- .../test/unit/spec/members/utils.js | 16 ++- .../test/unit/spec/webinar/index.ts | 98 ++++++++++++--- 24 files changed, 852 insertions(+), 108 deletions(-) diff --git a/packages/@webex/plugin-meetings/README.md b/packages/@webex/plugin-meetings/README.md index 52d211515c2..4a3568f9747 100644 --- a/packages/@webex/plugin-meetings/README.md +++ b/packages/@webex/plugin-meetings/README.md @@ -857,7 +857,8 @@ meeting.members.raiseOrLowerHand(memberId); // You can lower all hands in a meeting // use a memberId string to indicate who is requesting lowering all hands -meeting.members.lowerAllHands(requestingMemberId); +// (optional) use a roles array to indicate who should have their hands lowered, default to all roles +meeting.members.lowerAllHands(requestingMemberId, roles); // You can transfer the host role to another member in the meeting, this is proxied by meeting.transfer // use a memberId string and a moderator boolean to transfer or not, default to true diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index 5e357a2263c..6c2a155a86c 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -356,6 +356,11 @@ export const EVENT_TRIGGERS = { 'meeting:controls:view-the-participants-list:updated', MEETING_CONTROLS_RAISE_HAND_UPDATED: 'meeting:controls:raise-hand:updated', MEETING_CONTROLS_VIDEO_UPDATED: 'meeting:controls:video:updated', + MEETING_CONTROLS_STAGE_VIEW_UPDATED: 'meeting:controls:stage-view:updated', + MEETING_CONTROLS_WEBCAST_UPDATED: 'meeting:controls:webcast:updated', + MEETING_CONTROLS_MEETING_FULL_UPDATED: 'meeting:controls:meeting-full:updated', + MEETING_CONTROLS_PRACTICE_SESSION_STATUS_UPDATED: + 'meeting:controls:practice-session-status:updated', // Locus URL changed MEETING_LOCUS_URL_UPDATE: 'meeting:locus:locusUrl:update', MEETING_STREAM_PUBLISH_STATE_CHANGED: 'meeting:streamPublishStateChanged', @@ -676,7 +681,11 @@ export const LOCUSINFO = { CONTROLS_REACTIONS_CHANGED: 'CONTROLS_REACTIONS_CHANGED', CONTROLS_VIEW_THE_PARTICIPANTS_LIST_CHANGED: 'CONTROLS_VIEW_THE_PARTICIPANTS_LIST_CHANGED', CONTROLS_RAISE_HAND_CHANGED: 'CONTROLS_RAISE_HAND_CHANGED', + CONTROLS_WEBCAST_CHANGED: 'CONTROLS_WEBCAST_CHANGED', + CONTROLS_MEETING_FULL_CHANGED: 'CONTROLS_MEETING_FULL_CHANGED', + CONTROLS_PRACTICE_SESSION_STATUS_UPDATED: 'CONTROLS_PRACTICE_SESSION_STATUS_UPDATED', CONTROLS_VIDEO_CHANGED: 'CONTROLS_VIDEO_CHANGED', + CONTROLS_STAGE_VIEW_UPDATED: 'CONTROLS_STAGE_VIEW_UPDATED', SELF_UNADMITTED_GUEST: 'SELF_UNADMITTED_GUEST', SELF_ADMITTED_GUEST: 'SELF_ADMITTED_GUEST', SELF_REMOTE_VIDEO_MUTE_STATUS_UPDATED: 'SELF_REMOTE_VIDEO_MUTE_STATUS_UPDATED', @@ -702,6 +711,7 @@ export const LOCUSINFO = { SELF_MEETING_INTERPRETATION_CHANGED: 'SELF_MEETING_INTERPRETATION_CHANGED', MEDIA_INACTIVITY: 'MEDIA_INACTIVITY', LINKS_SERVICES: 'LINKS_SERVICES', + LINKS_RESOURCES: 'LINKS_RESOURCES', }, }; @@ -944,6 +954,11 @@ export const DISPLAY_HINTS = { // participants list DISABLE_VIEW_THE_PARTICIPANT_LIST: 'DISABLE_VIEW_THE_PARTICIPANT_LIST', ENABLE_VIEW_THE_PARTICIPANT_LIST: 'ENABLE_VIEW_THE_PARTICIPANT_LIST', + // for webinar participants list + DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST: 'DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST', + ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST: 'ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST', + DISABLE_SHOW_ATTENDEE_COUNT: 'DISABLE_SHOW_ATTENDEE_COUNT', + ENABLE_SHOW_ATTENDEE_COUNT: 'ENABLE_SHOW_ATTENDEE_COUNT', // raise hand DISABLE_RAISE_HAND: 'DISABLE_RAISE_HAND', @@ -963,6 +978,16 @@ export const DISPLAY_HINTS = { // Voip (audio/video) VOIP_IS_ENABLED: 'VOIP_IS_ENABLED', + + // Webcast + WEBCAST_CONTROL_START: 'WEBCAST_CONTROL_START', + WEBCAST_CONTROL_STOP: 'WEBCAST_CONTROL_STOP', + + // Stage View + STAGE_VIEW_ACTIVE: 'STAGE_VIEW_ACTIVE', + STAGE_VIEW_INACTIVE: 'STAGE_VIEW_INACTIVE', + ENABLE_STAGE_VIEW: 'ENABLE_STAGE_VIEW', + DISABLE_STAGE_VIEW: 'DISABLE_STAGE_VIEW', }; export const INTERSTITIAL_DISPLAY_HINTS = [DISPLAY_HINTS.VOIP_IS_ENABLED]; diff --git a/packages/@webex/plugin-meetings/src/controls-options-manager/enums.ts b/packages/@webex/plugin-meetings/src/controls-options-manager/enums.ts index 2e3ac29d86e..323a7ffd1a0 100644 --- a/packages/@webex/plugin-meetings/src/controls-options-manager/enums.ts +++ b/packages/@webex/plugin-meetings/src/controls-options-manager/enums.ts @@ -2,6 +2,7 @@ enum Setting { disallowUnmute = 'DisallowUnmute', muteOnEntry = 'MuteOnEntry', muted = 'Muted', + roles = 'Roles', } enum Control { diff --git a/packages/@webex/plugin-meetings/src/controls-options-manager/index.ts b/packages/@webex/plugin-meetings/src/controls-options-manager/index.ts index d09a2f7446e..92ad7db2604 100644 --- a/packages/@webex/plugin-meetings/src/controls-options-manager/index.ts +++ b/packages/@webex/plugin-meetings/src/controls-options-manager/index.ts @@ -177,7 +177,12 @@ export default class ControlsOptionsManager { * @memberof ControlsOptionsManager * @returns {Promise} */ - private setControls(setting: {[key in Setting]?: boolean}): Promise { + private setControls(setting: { + [Setting.muted]?: boolean; + [Setting.disallowUnmute]?: boolean; + [Setting.muteOnEntry]?: boolean; + [Setting.roles]?: Array; + }): Promise { LoggerProxy.logger.log( `ControlsOptionsManager:index#setControls --> ${JSON.stringify(setting)}` ); @@ -190,6 +195,7 @@ export default class ControlsOptionsManager { Object.entries(setting).forEach(([key, value]) => { if ( !shouldSkipCheckToMergeBody && + value !== undefined && !Util?.[`${value ? CAN_SET : CAN_UNSET}${key}`](this.displayHints) ) { error = new PermissionError(`${key} [${value}] not allowed, due to moderator property.`); @@ -219,6 +225,14 @@ export default class ControlsOptionsManager { } break; + case Setting.roles: + if (Array.isArray(value)) { + body.audio = body.audio + ? {...body.audio, [camelCase(key)]: value} + : {[camelCase(key)]: value}; + } + break; + default: error = new PermissionError(`${key} [${value}] not allowed, due to moderator property.`); } @@ -261,18 +275,21 @@ export default class ControlsOptionsManager { * @param {boolean} mutedEnabled * @param {boolean} disallowUnmuteEnabled * @param {boolean} muteOnEntryEnabled + * @param {array} roles which should be muted * @memberof ControlsOptionsManager * @returns {Promise} */ public setMuteAll( mutedEnabled: boolean, disallowUnmuteEnabled: boolean, - muteOnEntryEnabled: boolean + muteOnEntryEnabled: boolean, + roles: Array ): Promise { return this.setControls({ [Setting.muted]: mutedEnabled, [Setting.disallowUnmute]: disallowUnmuteEnabled, [Setting.muteOnEntry]: muteOnEntryEnabled, + [Setting.roles]: roles, }); } } diff --git a/packages/@webex/plugin-meetings/src/controls-options-manager/types.ts b/packages/@webex/plugin-meetings/src/controls-options-manager/types.ts index cefa0a76ddd..5d155b464ed 100644 --- a/packages/@webex/plugin-meetings/src/controls-options-manager/types.ts +++ b/packages/@webex/plugin-meetings/src/controls-options-manager/types.ts @@ -36,6 +36,8 @@ export interface VideoProperties { export interface ViewTheParticipantListProperties { enabled?: boolean; + panelistEnabled?: boolean; + attendeeCount?: boolean; } export type Properties = diff --git a/packages/@webex/plugin-meetings/src/controls-options-manager/util.ts b/packages/@webex/plugin-meetings/src/controls-options-manager/util.ts index a297e51b405..0dad61a9938 100644 --- a/packages/@webex/plugin-meetings/src/controls-options-manager/util.ts +++ b/packages/@webex/plugin-meetings/src/controls-options-manager/util.ts @@ -217,6 +217,18 @@ class Utils { if (control.properties.enabled === false) { requiredHints.push(DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST); } + if (control.properties.panelistEnabled === true) { + requiredHints.push(DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST); + } + if (control.properties.panelistEnabled === false) { + requiredHints.push(DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST); + } + if (control.properties.attendeeCount === true) { + requiredHints.push(DISPLAY_HINTS.ENABLE_SHOW_ATTENDEE_COUNT); + } + if (control.properties.attendeeCount === false) { + requiredHints.push(DISPLAY_HINTS.DISABLE_SHOW_ATTENDEE_COUNT); + } return Utils.hasHints({requiredHints, displayHints}); } diff --git a/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts b/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts index f2b4594a74a..6fbc52b930b 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts @@ -79,7 +79,11 @@ ControlsUtils.parse = (controls: any) => { } if (controls?.viewTheParticipantList) { - parsedControls.viewTheParticipantList = {enabled: controls.viewTheParticipantList.enabled}; + parsedControls.viewTheParticipantList = { + enabled: controls.viewTheParticipantList?.enabled ?? false, + panelistEnabled: controls.viewTheParticipantList?.panelistEnabled ?? false, + attendeeCount: controls.viewTheParticipantList?.attendeeCount ?? 0, + }; } if (controls?.raiseHand) { @@ -90,6 +94,23 @@ ControlsUtils.parse = (controls: any) => { parsedControls.video = {enabled: controls.video.enabled}; } + if (controls?.webcastControl) { + parsedControls.webcastControl = {streaming: controls.webcastControl.streaming}; + } + + if (controls?.meetingFull) { + parsedControls.meetingFull = { + meetingFull: controls.meetingFull?.meetingFull ?? false, + meetingPanelistFull: controls.meetingFull?.meetingPanelistFull ?? false, + }; + } + + if (controls?.practiceSession) { + parsedControls.practiceSession = { + enabled: controls.practiceSession.enabled, + }; + } + return parsedControls; }; @@ -121,7 +142,11 @@ ControlsUtils.getControls = (oldControls: any, newControls: any) => { previous?.reactions?.showDisplayNameWithReactions, hasViewTheParticipantListChanged: - current?.viewTheParticipantList?.enabled !== previous?.viewTheParticipantList?.enabled, + current?.viewTheParticipantList?.enabled !== previous?.viewTheParticipantList?.enabled || + current?.viewTheParticipantList?.panelistEnabled !== + previous?.viewTheParticipantList?.panelistEnabled || + current?.viewTheParticipantList?.attendeeCount !== + previous?.viewTheParticipantList?.attendeeCount, hasRaiseHandChanged: current?.raiseHand?.enabled !== previous?.raiseHand?.enabled, @@ -167,6 +192,25 @@ ControlsUtils.getControls = (oldControls: any, newControls: any) => { hasVideoEnabledChanged: newControls.video?.enabled !== undefined && !isEqual(previous?.videoEnabled, current?.videoEnabled), + + hasWebcastChanged: !isEqual( + previous?.webcastControl?.streaming, + current?.webcastControl?.streaming + ), + + hasMeetingFullChanged: + !isEqual(previous?.meetingFull?.meetingFull, current?.meetingFull?.meetingFull) || + !isEqual( + previous?.meetingFull?.meetingPanelistFull, + current?.meetingFull?.meetingPanelistFull + ), + + hasPracticeSessionEnabledChanged: !isEqual( + previous?.practiceSession?.enabled, + current?.practiceSession?.enabled + ), + + hasStageViewChanged: !isEqual(previous?.videoLayout, current?.videoLayout), }, }; }; diff --git a/packages/@webex/plugin-meetings/src/locus-info/fullState.ts b/packages/@webex/plugin-meetings/src/locus-info/fullState.ts index b3feb424da4..f207823a675 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/fullState.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/fullState.ts @@ -6,6 +6,7 @@ FullState.parse = (fullState) => ({ type: fullState.type || FULL_STATE.UNKNOWN, meetingState: fullState.state, locked: fullState.locked, + attendeeCount: typeof fullState.attendeeCount === 'number' ? fullState.attendeeCount : 0, }); FullState.getFullState = (oldFullState, newFullState) => { diff --git a/packages/@webex/plugin-meetings/src/locus-info/index.ts b/packages/@webex/plugin-meetings/src/locus-info/index.ts index 489d12fda57..93df547fb20 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/index.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/index.ts @@ -64,6 +64,7 @@ export default class LocusInfo extends EventsScope { replace: any; url: any; services: any; + resources: any; mainSessionLocusCache: any; /** * Constructor @@ -263,6 +264,7 @@ export default class LocusInfo extends EventsScope { this.updateHostInfo(locus.host); this.updateMediaShares(locus.mediaShares); this.updateServices(locus.links?.services); + this.updateResources(locus.links?.resources); } /** @@ -452,6 +454,7 @@ export default class LocusInfo extends EventsScope { this.updateIdentifiers(locus.identities); this.updateEmbeddedApps(locus.embeddedApps); this.updateServices(locus.links?.services); + this.updateResources(locus.links?.resources); this.compareAndUpdate(); // update which required to compare different objects from locus } @@ -805,6 +808,10 @@ export default class LocusInfo extends EventsScope { hasRaiseHandChanged, hasVideoChanged, hasInterpretationChanged, + hasWebcastChanged, + hasMeetingFullChanged, + hasPracticeSessionEnabledChanged, + hasStageViewChanged, }, current, } = ControlsUtils.getControls(this.controls, controls); @@ -1008,6 +1015,38 @@ export default class LocusInfo extends EventsScope { ); } + if (hasWebcastChanged) { + this.emitScoped( + {file: 'locus-info', function: 'updateControls'}, + LOCUSINFO.EVENTS.CONTROLS_WEBCAST_CHANGED, + {state: current.webcastControl} + ); + } + + if (hasMeetingFullChanged) { + this.emitScoped( + {file: 'locus-info', function: 'updateControls'}, + LOCUSINFO.EVENTS.CONTROLS_MEETING_FULL_CHANGED, + {state: current.meetingFull} + ); + } + + if (hasPracticeSessionEnabledChanged) { + this.emitScoped( + {file: 'locus-info', function: 'updateControls'}, + LOCUSINFO.EVENTS.CONTROLS_PRACTICE_SESSION_STATUS_UPDATED, + {state: current.practiceSession} + ); + } + + if (hasStageViewChanged) { + this.emitScoped( + {file: 'locus-info', function: 'updateControls'}, + LOCUSINFO.EVENTS.CONTROLS_STAGE_VIEW_UPDATED, + {state: current.videoLayout} + ); + } + this.controls = controls; } } @@ -1064,6 +1103,27 @@ export default class LocusInfo extends EventsScope { } } + /** + * @param {Object} resources + * @returns {undefined} + * @memberof LocusInfo + */ + updateResources(resources: Record<'webcastInstance', {url: string}>) { + if (resources && !isEqual(this.resources, resources)) { + this.resources = resources; + this.emitScoped( + { + file: 'locus-info', + function: 'updateResources', + }, + LOCUSINFO.EVENTS.LINKS_RESOURCES, + { + resources, + } + ); + } + } + /** * @param {Object} fullState * @returns {undefined} diff --git a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts index 161e90a9fbe..3d1810ad539 100644 --- a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts +++ b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts @@ -3,6 +3,7 @@ */ import {MEETINGS} from '../constants'; +import ControlsOptionsUtil from '../controls-options-manager/util'; /** * IInMeetingActions @@ -64,6 +65,10 @@ interface IInMeetingActions { canUpdateShareControl?: boolean; canEnableViewTheParticipantsList?: boolean; canDisableViewTheParticipantsList?: boolean; + canEnableViewTheParticipantsListPanelist?: boolean; + canDisableViewTheParticipantsListPanelist?: boolean; + canEnableShowAttendeeCount?: boolean; + canDisableShowAttendeeCount?: boolean; canEnableRaiseHand?: boolean; canDisableRaiseHand?: boolean; canEnableVideo?: boolean; @@ -83,6 +88,11 @@ interface IInMeetingActions { canShareWhiteBoard?: boolean; enforceVirtualBackground?: boolean; canPollingAndQA?: boolean; + canStartWebcast?: boolean; + canStopWebcast?: boolean; + canShowStageView?: boolean; + canEnableStageView?: boolean; + canDisableStageView?: boolean; } /** @@ -201,6 +211,14 @@ export default class InMeetingActions implements IInMeetingActions { canDisableViewTheParticipantsList = null; + canEnableViewTheParticipantsListPanelist = null; + + canDisableViewTheParticipantsListPanelist = null; + + canEnableShowAttendeeCount = null; + + canDisableShowAttendeeCount = null; + canEnableRaiseHand = null; canDisableRaiseHand = null; @@ -238,6 +256,16 @@ export default class InMeetingActions implements IInMeetingActions { canShareWhiteBoard = null; canPollingAndQA = null; + + canStartWebcast = null; + + canStopWebcast = null; + + canShowStageView = null; + + canEnableStageView = null; + + canDisableStageView = null; /** * Returns all meeting action options * @returns {Object} @@ -298,6 +326,10 @@ export default class InMeetingActions implements IInMeetingActions { canUpdateShareControl: this.canUpdateShareControl, canEnableViewTheParticipantsList: this.canEnableViewTheParticipantsList, canDisableViewTheParticipantsList: this.canDisableViewTheParticipantsList, + canEnableViewTheParticipantsListPanelist: this.canEnableViewTheParticipantsListPanelist, + canDisableViewTheParticipantsListPanelist: this.canDisableViewTheParticipantsListPanelist, + canEnableShowAttendeeCount: this.canEnableShowAttendeeCount, + canDisableShowAttendeeCount: this.canDisableShowAttendeeCount, canEnableRaiseHand: this.canEnableRaiseHand, canDisableRaiseHand: this.canDisableRaiseHand, canEnableVideo: this.canEnableVideo, @@ -317,6 +349,11 @@ export default class InMeetingActions implements IInMeetingActions { supportHDV: this.supportHDV, canShareWhiteBoard: this.canShareWhiteBoard, canPollingAndQA: this.canPollingAndQA, + canStartWebcast: this.canStartWebcast, + canStopWebcast: this.canStopWebcast, + canShowStageView: this.canShowStageView, + canEnableStageView: this.canEnableStageView, + canDisableStageView: this.canDisableStageView, }); /** diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index e7ffb18fd1f..f658039e002 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -2014,6 +2014,7 @@ export default class Meeting extends StatelessWebexPlugin { this.setUpLocusInfoSelfListener(); this.setUpLocusInfoMeetingListener(); this.setUpLocusServicesListener(); + this.setUpLocusResourcesListener(); // members update listeners this.setUpLocusFullStateListener(); this.setUpLocusUrlListener(); @@ -2635,6 +2636,42 @@ export default class Meeting extends StatelessWebexPlugin { ); }); + this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_WEBCAST_CHANGED, ({state}) => { + Trigger.trigger( + this, + {file: 'meeting/index', function: 'setupLocusControlsListener'}, + EVENT_TRIGGERS.MEETING_CONTROLS_WEBCAST_UPDATED, + {state} + ); + }); + + this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_MEETING_FULL_CHANGED, ({state}) => { + Trigger.trigger( + this, + {file: 'meeting/index', function: 'setupLocusControlsListener'}, + EVENT_TRIGGERS.MEETING_CONTROLS_MEETING_FULL_UPDATED, + {state} + ); + }); + + this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_PRACTICE_SESSION_STATUS_UPDATED, ({state}) => { + Trigger.trigger( + this, + {file: 'meeting/index', function: 'setupLocusControlsListener'}, + EVENT_TRIGGERS.MEETING_CONTROLS_PRACTICE_SESSION_STATUS_UPDATED, + {state} + ); + }); + + this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_STAGE_VIEW_UPDATED, ({state}) => { + Trigger.trigger( + this, + {file: 'meeting/index', function: 'setupLocusControlsListener'}, + EVENT_TRIGGERS.MEETING_CONTROLS_STAGE_VIEW_UPDATED, + {state} + ); + }); + this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_VIDEO_CHANGED, ({state}) => { Trigger.trigger( this, @@ -2996,10 +3033,20 @@ export default class Meeting extends StatelessWebexPlugin { this.breakouts.breakoutServiceUrlUpdate(payload?.services?.breakout?.url); this.annotation.approvalUrlUpdate(payload?.services?.approval?.url); this.simultaneousInterpretation.approvalUrlUpdate(payload?.services?.approval?.url); - this.webinar.webcastUrlUpdate(payload?.services?.webcast?.url); - this.webinar.webinarAttendeesSearchingUrlUpdate( - payload?.services?.webinarAttendeesSearching?.url - ); + }); + } + + /** + * Set up the locus info resources link listener + * update the locusInfo for webcast instance url + * @param {Object} payload - The event payload + * @returns {undefined} + * @private + * @memberof Meeting + */ + private setUpLocusResourcesListener() { + this.locusInfo.on(LOCUSINFO.EVENTS.LINKS_RESOURCES, (payload) => { + this.webinar.updateWebcastUrl(payload); }); } @@ -3311,7 +3358,7 @@ export default class Meeting extends StatelessWebexPlugin { this.simultaneousInterpretation.updateCanManageInterpreters( payload.newRoles?.includes(SELF_ROLES.MODERATOR) ); - this.webinar.updateCanManageWebcast(payload.newRoles?.includes(SELF_ROLES.MODERATOR)); + this.webinar.updateRoleChanged(payload); Trigger.trigger( this, { @@ -3805,6 +3852,22 @@ export default class Meeting extends StatelessWebexPlugin { requiredHints: [DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST], displayHints: this.userDisplayHints, }), + canEnableViewTheParticipantsListPanelist: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST], + displayHints: this.userDisplayHints, + }), + canDisableViewTheParticipantsListPanelist: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST], + displayHints: this.userDisplayHints, + }), + canEnableShowAttendeeCount: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.ENABLE_SHOW_ATTENDEE_COUNT], + displayHints: this.userDisplayHints, + }), + canDisableShowAttendeeCount: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.DISABLE_SHOW_ATTENDEE_COUNT], + displayHints: this.userDisplayHints, + }), canEnableRaiseHand: ControlsOptionsUtil.hasHints({ requiredHints: [DISPLAY_HINTS.ENABLE_RAISE_HAND], displayHints: this.userDisplayHints, @@ -3821,6 +3884,26 @@ export default class Meeting extends StatelessWebexPlugin { requiredHints: [DISPLAY_HINTS.DISABLE_VIDEO], displayHints: this.userDisplayHints, }), + canStartWebcast: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.WEBCAST_CONTROL_START], + displayHints: this.userDisplayHints, + }), + canStopWebcast: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.WEBCAST_CONTROL_STOP], + displayHints: this.userDisplayHints, + }), + canShowStageView: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.STAGE_VIEW_ACTIVE], + displayHints: this.userDisplayHints, + }), + canEnableStageView: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.ENABLE_STAGE_VIEW], + displayHints: this.userDisplayHints, + }), + canDisableStageView: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.DISABLE_STAGE_VIEW], + displayHints: this.userDisplayHints, + }), canShareFile: (ControlsOptionsUtil.hasHints({ requiredHints: [DISPLAY_HINTS.SHARE_FILE], @@ -7927,18 +8010,21 @@ export default class Meeting extends StatelessWebexPlugin { * @param {boolean} mutedEnabled * @param {boolean} disallowUnmuteEnabled * @param {boolean} muteOnEntryEnabled + * @param {array} roles * @public * @memberof Meeting */ public setMuteAll( mutedEnabled: boolean, disallowUnmuteEnabled: boolean, - muteOnEntryEnabled: boolean + muteOnEntryEnabled: boolean, + roles: Array ) { return this.controlsOptionsManager.setMuteAll( mutedEnabled, disallowUnmuteEnabled, - muteOnEntryEnabled + muteOnEntryEnabled, + roles ); } diff --git a/packages/@webex/plugin-meetings/src/members/index.ts b/packages/@webex/plugin-meetings/src/members/index.ts index e29cd2d50cb..f709100b3a4 100644 --- a/packages/@webex/plugin-meetings/src/members/index.ts +++ b/packages/@webex/plugin-meetings/src/members/index.ts @@ -915,11 +915,12 @@ export default class Members extends StatelessWebexPlugin { /** * Lower all hands of members in a meeting * @param {String} requestingMemberId - id of the participant which requested the lower all hands + * @param {array} roles which should be lowered * @returns {Promise} * @public * @memberof Members */ - public lowerAllHands(requestingMemberId: string) { + public lowerAllHands(requestingMemberId: string, roles: Array) { if (!this.locusUrl) { return Promise.reject( new ParameterError( @@ -936,7 +937,8 @@ export default class Members extends StatelessWebexPlugin { } const options = MembersUtil.generateLowerAllHandsMemberOptions( requestingMemberId, - this.locusUrl + this.locusUrl, + roles ); return this.membersRequest.lowerAllHandsMember(options); diff --git a/packages/@webex/plugin-meetings/src/members/util.ts b/packages/@webex/plugin-meetings/src/members/util.ts index bd491145c0d..449d686f22a 100644 --- a/packages/@webex/plugin-meetings/src/members/util.ts +++ b/packages/@webex/plugin-meetings/src/members/util.ts @@ -166,9 +166,10 @@ const MembersUtil = { locusUrl, }), - generateLowerAllHandsMemberOptions: (requestingParticipantId, locusUrl) => ({ + generateLowerAllHandsMemberOptions: (requestingParticipantId, locusUrl, roles) => ({ requestingParticipantId, locusUrl, + ...(roles !== undefined && {roles}), }), /** @@ -253,6 +254,7 @@ const MembersUtil = { const body = { hand: { raised: false, + ...(options.roles !== undefined && {roles: options.roles}), }, requestingParticipantId: options.requestingParticipantId, }; diff --git a/packages/@webex/plugin-meetings/src/webinar/index.ts b/packages/@webex/plugin-meetings/src/webinar/index.ts index aa2016541f6..709ca7f9cde 100644 --- a/packages/@webex/plugin-meetings/src/webinar/index.ts +++ b/packages/@webex/plugin-meetings/src/webinar/index.ts @@ -2,7 +2,8 @@ * Copyright (c) 2015-2023 Cisco Systems, Inc. See LICENSE file. */ import {WebexPlugin} from '@webex/webex-core'; -import {MEETINGS} from '../constants'; +import {get} from 'lodash'; +import {MEETINGS, SELF_ROLES} from '../constants'; import WebinarCollection from './collection'; @@ -17,14 +18,15 @@ const Webinar = WebexPlugin.extend({ props: { locusUrl: 'string', // appears current webinar's locus url - webcastUrl: 'string', // current webinar's webcast url - webinarAttendeesSearchingUrl: 'string', // current webinarAttendeesSearching url + webcastInstanceUrl: 'string', // current webinar's webcast instance url canManageWebcast: 'boolean', // appears the ability to manage webcast + selfIsPanelist: 'boolean', // self is panelist + selfIsAttendee: 'boolean', // self is attendee }, /** * Update the current locus url of the webinar - * @param {string} locusUrl // locus url + * @param {string} locusUrl * @returns {void} */ locusUrlUpdate(locusUrl) { @@ -32,21 +34,12 @@ const Webinar = WebexPlugin.extend({ }, /** - * Update the current webcast url of the meeting - * @param {string} webcastUrl // webcast url + * Update the current webcast instance url of the meeting + * @param {object} payload * @returns {void} */ - webcastUrlUpdate(webcastUrl) { - this.set('webcastUrl', webcastUrl); - }, - - /** - * Update the current webinarAttendeesSearching url of the meeting - * @param {string} webinarAttendeesSearchingUrl // webinarAttendeesSearching url - * @returns {void} - */ - webinarAttendeesSearchingUrlUpdate(webinarAttendeesSearchingUrl) { - this.set('webinarAttendeesSearchingUrl', webinarAttendeesSearchingUrl); + updateWebcastUrl(payload) { + this.set('webcastInstanceUrl', get(payload, 'resources.webcastInstance.url')); }, /** @@ -57,6 +50,27 @@ const Webinar = WebexPlugin.extend({ updateCanManageWebcast(canManageWebcast) { this.set('canManageWebcast', canManageWebcast); }, + + /** + * Updates user roles and manages associated state transitions + * @param {object} payload + * @param {string[]} payload.oldRoles - Previous roles of the user + * @param {string[]} payload.newRoles - New roles of the user + * @returns {{isPromoted: boolean, isDemoted: boolean}} Role transition states + */ + updateRoleChanged(payload) { + const isPromoted = + get(payload, 'oldRoles', []).includes(SELF_ROLES.ATTENDEE) && + get(payload, 'newRoles', []).includes(SELF_ROLES.PANELIST); + const isDemoted = + get(payload, 'oldRoles', []).includes(SELF_ROLES.PANELIST) && + get(payload, 'newRoles', []).includes(SELF_ROLES.ATTENDEE); + this.set('selfIsPanelist', get(payload, 'newRoles', []).includes(SELF_ROLES.PANELIST)); + this.set('selfIsAttendee', get(payload, 'newRoles', []).includes(SELF_ROLES.ATTENDEE)); + this.updateCanManageWebcast(get(payload, 'newRoles', []).includes(SELF_ROLES.MODERATOR)); + + return {isPromoted, isDemoted}; + }, }); export default Webinar; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/index.js b/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/index.js index 4b0e84e0be6..f78893badee 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/index.js @@ -22,7 +22,7 @@ describe('plugin-meetings', () => { describe('Mute On Entry', () => { let manager; - + beforeEach(() => { request = { request: sinon.stub().returns(Promise.resolve()), @@ -37,85 +37,85 @@ describe('plugin-meetings', () => { }); describe('setMuteOnEntry', () => { - it('rejects when correct display hint is not present enabled=false', () => { + it('rejects when correct display hint is not present enabled=false', () => { const result = manager.setMuteOnEntry(false); - + assert.notCalled(request.request); - + assert.isRejected(result); }); - it('rejects when correct display hint is not present enabled=true', () => { + it('rejects when correct display hint is not present enabled=true', () => { const result = manager.setMuteOnEntry(true); - + assert.notCalled(request.request); - + assert.isRejected(result); }); - + it('can set mute on entry when the display hint is available enabled=true', () => { manager.setDisplayHints(['ENABLE_MUTE_ON_ENTRY']); - + const result = manager.setMuteOnEntry(true); - + assert.calledWith(request.request, { uri: 'test/id/controls', body: { muteOnEntry: { enabled: true } }, method: HTTP_VERBS.PATCH}); - + assert.deepEqual(result, request.request.firstCall.returnValue); }); it('can set mute on entry when the display hint is available enabled=false', () => { manager.setDisplayHints(['DISABLE_MUTE_ON_ENTRY']); - + const result = manager.setMuteOnEntry(false); - + assert.calledWith(request.request, { uri: 'test/id/controls', body: { muteOnEntry: { enabled: false } }, method: HTTP_VERBS.PATCH}); - + assert.deepEqual(result, request.request.firstCall.returnValue); }); }); describe('setDisallowUnmute', () => { - it('rejects when correct display hint is not present enabled=false', () => { + it('rejects when correct display hint is not present enabled=false', () => { const result = manager.setDisallowUnmute(false); - + assert.notCalled(request.request); - + assert.isRejected(result); }); - it('rejects when correct display hint is not present enabled=true', () => { + it('rejects when correct display hint is not present enabled=true', () => { const result = manager.setDisallowUnmute(true); - + assert.notCalled(request.request); - + assert.isRejected(result); }); - - it('can set mute on entry when the display hint is available enabled=true', () => { + + it('can set disallow unmute when ENABLE_HARD_MUTE display hint is available', () => { manager.setDisplayHints(['ENABLE_HARD_MUTE']); - + const result = manager.setDisallowUnmute(true); - + assert.calledWith(request.request, { uri: 'test/id/controls', body: { disallowUnmute: { enabled: true } }, method: HTTP_VERBS.PATCH}); - + assert.deepEqual(result, request.request.firstCall.returnValue); }); - it('can set mute on entry when the display hint is available enabled=false', () => { + it('can set allow unmute when DISABLE_HARD_MUTE display hint is available', () => { manager.setDisplayHints(['DISABLE_HARD_MUTE']); - + const result = manager.setDisallowUnmute(false); - + assert.calledWith(request.request, { uri: 'test/id/controls', body: { disallowUnmute: { enabled: false } }, method: HTTP_VERBS.PATCH}); - + assert.deepEqual(result, request.request.firstCall.returnValue); }); }); @@ -218,7 +218,7 @@ describe('plugin-meetings', () => { }) }); - it('rejects when correct display hint is not present mutedEnabled=false', () => { + it('rejects when correct display hint is not present mutedEnabled=false', () => { const result = manager.setMuteAll(false, false, false); assert.notCalled(request.request); @@ -226,7 +226,7 @@ describe('plugin-meetings', () => { assert.isRejected(result); }); - it('rejects when correct display hint is not present mutedEnabled=true', () => { + it('rejects when correct display hint is not present mutedEnabled=true', () => { const result = manager.setMuteAll(true, false, false); assert.notCalled(request.request); @@ -281,7 +281,31 @@ describe('plugin-meetings', () => { assert.deepEqual(result, request.request.firstCall.returnValue); }); + + it('can set mute all panelists when the display hint is available mutedEnabled=true', () => { + manager.setDisplayHints(['MUTE_ALL', 'DISABLE_HARD_MUTE', 'DISABLE_MUTE_ON_ENTRY']); + + const result = manager.setMuteAll(true, true, true, ['panelist']); + + assert.calledWith(request.request, { uri: 'test/id/controls', + body: { audio: { muted: true, disallowUnmute: true, muteOnEntry: true, roles: ['panelist'] } }, + method: HTTP_VERBS.PATCH}); + + assert.deepEqual(result, request.request.firstCall.returnValue); + }); + + it('can set mute all attendees when the display hint is available mutedEnabled=true', () => { + manager.setDisplayHints(['MUTE_ALL', 'DISABLE_HARD_MUTE', 'DISABLE_MUTE_ON_ENTRY']); + + const result = manager.setMuteAll(true, true, true, ['attendee']); + + assert.calledWith(request.request, { uri: 'test/id/controls', + body: { audio: { muted: true, disallowUnmute: true, muteOnEntry: true, roles: ['attendee'] } }, + method: HTTP_VERBS.PATCH}); + + assert.deepEqual(result, request.request.firstCall.returnValue); + }); }); }); }); -}); \ No newline at end of file +}); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/util.js b/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/util.js index 375cf02487b..083c4399e4e 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/util.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/util.js @@ -348,6 +348,50 @@ describe('plugin-meetings', () => { }); }); + it('should call hasHints() with proper hints when `panelistEnabled` is true, attendeeCount is false', () => { + ControlsOptionsUtil.canUpdateViewTheParticipantsList({properties: {enabled: true, panelistEnabled: true, attendeeCount: false}}, []); + + assert.calledWith(ControlsOptionsUtil.hasHints, { + requiredHints: [DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST, + DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST, + DISPLAY_HINTS.DISABLE_SHOW_ATTENDEE_COUNT], + displayHints: [], + }); + }); + + it('should call hasHints() with proper hints when `panelistEnabled` is true, attendeeCount is true', () => { + ControlsOptionsUtil.canUpdateViewTheParticipantsList({properties: {enabled: true, panelistEnabled: true, attendeeCount: true}}, []); + + assert.calledWith(ControlsOptionsUtil.hasHints, { + requiredHints: [DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST, + DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST, + DISPLAY_HINTS.ENABLE_SHOW_ATTENDEE_COUNT], + displayHints: [], + }); + }); + + it('should call hasHints() with proper hints when `panelistEnabled` is false, attendeeCount is false', () => { + ControlsOptionsUtil.canUpdateViewTheParticipantsList({properties: {enabled: true, panelistEnabled: false, attendeeCount: false}}, []); + + assert.calledWith(ControlsOptionsUtil.hasHints, { + requiredHints: [DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST, + DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST, + DISPLAY_HINTS.DISABLE_SHOW_ATTENDEE_COUNT], + displayHints: [], + }); + }); + + it('should call hasHints() with proper hints when `panelistEnabled` is false, attendeeCount is true', () => { + ControlsOptionsUtil.canUpdateViewTheParticipantsList({properties: {enabled: true, panelistEnabled: false, attendeeCount: true}}, []); + + assert.calledWith(ControlsOptionsUtil.hasHints, { + requiredHints: [DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST, + DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST, + DISPLAY_HINTS.ENABLE_SHOW_ATTENDEE_COUNT], + displayHints: [], + }); + }); + it('should return the resolution of hasHints()', () => { const expected = 'example-return-value'; ControlsOptionsUtil.hasHints.returns(expected); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js index 92249d2a4bb..38717f39ad1 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js @@ -82,11 +82,13 @@ describe('plugin-meetings', () => { }); it('should parse the viewTheParticipantList control', () => { - const newControls = {viewTheParticipantList: {enabled: true}}; + const newControls = {viewTheParticipantList: {enabled: true, panelistEnabled: true, attendeeCount: false}}; const parsedControls = ControlsUtils.parse(newControls); assert.equal(parsedControls.viewTheParticipantList.enabled, newControls.viewTheParticipantList.enabled); + assert.equal(parsedControls.viewTheParticipantList.panelistEnabled, newControls.viewTheParticipantList.panelistEnabled); + assert.equal(parsedControls.viewTheParticipantList.attendeeCount, newControls.viewTheParticipantList.attendeeCount); }); it('should parse the raiseHand control', () => { @@ -105,6 +107,42 @@ describe('plugin-meetings', () => { assert.equal(parsedControls.video.enabled, newControls.video.enabled); }); + it('should parse the webcast control', () => { + const newControls = {webcastControl: {streaming: true}}; + + const parsedControls = ControlsUtils.parse(newControls); + + assert.equal(parsedControls.webcastControl.streaming, newControls.webcastControl.streaming); + }); + + it('should parse the meeting full control', () => { + const newControls = {meetingFull: {meetingFull: true, meetingPanelistFull: false}}; + + const parsedControls = ControlsUtils.parse(newControls); + + assert.equal(parsedControls.meetingFull.meetingFull, newControls.meetingFull.meetingFull); + assert.equal(parsedControls.meetingFull.meetingPanelistFull, newControls.meetingFull.meetingPanelistFull); + }); + + it('should parse the practiceSession control', () => { + const newControls = {practiceSession: {enabled: true}}; + + const parsedControls = ControlsUtils.parse(newControls); + + assert.equal(parsedControls.practiceSession.enabled, newControls.practiceSession.enabled); + }); + + it('should parse the videoLayout control', () => { + const newControls = {videoLayout: {overrideDefault: true, lockAttendeeViewOnStageOnly:false, stageParameters: {}}}; + + const parsedControls = ControlsUtils.parse(newControls); + + assert.equal(parsedControls.videoLayout.overrideDefault, newControls.videoLayout.overrideDefault); + assert.equal(parsedControls.videoLayout.lockAttendeeViewOnStageOnly, newControls.videoLayout.lockAttendeeViewOnStageOnly); + assert.equal(parsedControls.videoLayout.stageParameters, newControls.videoLayout.stageParameters); + + }); + describe('videoEnabled', () => { it('returns expected', () => { const result = ControlsUtils.parse({video: {enabled: true}}); @@ -170,11 +208,21 @@ describe('plugin-meetings', () => { }); it('returns hasViewTheParticipantListChanged = true when changed', () => { - const newControls = {viewTheParticipantList: {enabled: true}}; + const oldControls = {viewTheParticipantList: {enabled: true, panelistEnabled: true, attendeeCount: false}}; - const {updates} = ControlsUtils.getControls(defaultControls, newControls); + let result = ControlsUtils.getControls(oldControls, {viewTheParticipantList: {enabled: false, panelistEnabled: true, attendeeCount: false}}); + + assert.equal(result.updates.hasViewTheParticipantListChanged, true); + + result = ControlsUtils.getControls(oldControls, {viewTheParticipantList: {enabled: true, panelistEnabled: false, attendeeCount: false}}); - assert.equal(updates.hasViewTheParticipantListChanged, true); + assert.equal(result.updates.hasViewTheParticipantListChanged, true); + result = ControlsUtils.getControls(oldControls, {viewTheParticipantList: {enabled: true, panelistEnabled: true, attendeeCount: true}}); + + assert.equal(result.updates.hasViewTheParticipantListChanged, true); + result = ControlsUtils.getControls(oldControls, {viewTheParticipantList: {enabled: true, panelistEnabled: true, attendeeCount: false}}); + + assert.equal(result.updates.hasViewTheParticipantListChanged, false); }); it('returns hasRaiseHandChanged = true when changed', () => { @@ -193,6 +241,34 @@ describe('plugin-meetings', () => { assert.equal(updates.hasVideoChanged, true); }); + it('returns hasWebcastChanged = true when changed', () => { + const newControls = {webcastControl: {streaming: true}}; + + const {updates} = ControlsUtils.getControls(defaultControls, newControls); + + assert.equal(updates.hasWebcastChanged, true); + }); + + it('returns hasMeetingFullChanged = true when changed', () => { + const newControls = {meetingFull: {meetingFull: true, meetingPanelistFull: false}}; + + let result = ControlsUtils.getControls(defaultControls, newControls); + + assert.equal(result.updates.hasMeetingFullChanged, true); + + result = ControlsUtils.getControls(newControls, {meetingFull: {meetingFull: true, meetingPanelistFull: true}}); + + assert.equal(result.updates.hasMeetingFullChanged, true); + }); + + it('returns hasPracticeSessionEnabledChanged = true when changed', () => { + const newControls = {practiceSession: {enabled: true}}; + + const {updates} = ControlsUtils.getControls(defaultControls, newControls); + + assert.equal(updates.hasPracticeSessionEnabledChanged, true); + }); + it('returns hasEntryExitToneChanged = true when mode changed', () => { const newControls = { entryExitTone: { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js index 599f5a6f389..e9c012ba2b4 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js @@ -81,7 +81,7 @@ describe('plugin-meetings', () => { newControls = { disallowUnmute: {enabled: true}, lock: {}, - meetingFull: {}, + meetingFull: {meetingFull: false, meetingPanelistFull: true}, muteOnEntry: {enabled: true}, raiseHand: {enabled: true}, reactions: {enabled: true, showDisplayNameWithReactions: true}, @@ -95,12 +95,15 @@ describe('plugin-meetings', () => { }, shareControl: {control: 'example-value'}, transcribe: {}, - viewTheParticipantList: {enabled: true}, + viewTheParticipantList: {enabled: true, panelistEnabled: true, attendeeCount: false}, meetingContainer: { meetingContainerUrl: 'http://new-url.com', }, entryExitTone: {enabled: true, mode: 'foo'}, video: {enabled: true}, + videoLayout: {overrideDefault: true, lockAttendeeViewOnStageOnly:false, stageParameters: {}}, + webcastControl: {streaming: false}, + practiceSession: {enabled: true}, }; }); @@ -205,6 +208,58 @@ describe('plugin-meetings', () => { ); }); + it('should trigger the CONTROLS_STAGE_VIEW_UPDATED event when necessary', () => { + locusInfo.controls = {}; + locusInfo.emitScoped = sinon.stub(); + locusInfo.updateControls(newControls); + + assert.calledWith( + locusInfo.emitScoped, + {file: 'locus-info', function: 'updateControls'}, + LOCUSINFO.EVENTS.CONTROLS_STAGE_VIEW_UPDATED, + {state: newControls.videoLayout} + ); + }); + + it('should trigger the CONTROLS_WEBCAST_CHANGED event when necessary', () => { + locusInfo.controls = {}; + locusInfo.emitScoped = sinon.stub(); + locusInfo.updateControls(newControls); + + assert.calledWith( + locusInfo.emitScoped, + {file: 'locus-info', function: 'updateControls'}, + LOCUSINFO.EVENTS.CONTROLS_WEBCAST_CHANGED, + {state: newControls.webcastControl} + ); + }); + + it('should trigger the CONTROLS_MEETING_FULL_CHANGED event when necessary', () => { + locusInfo.controls = {}; + locusInfo.emitScoped = sinon.stub(); + locusInfo.updateControls(newControls); + + assert.calledWith( + locusInfo.emitScoped, + {file: 'locus-info', function: 'updateControls'}, + LOCUSINFO.EVENTS.CONTROLS_MEETING_FULL_CHANGED, + {state: newControls.meetingFull} + ); + }); + + it('should trigger the CONTROLS_PRACTICE_SESSION_STATUS_UPDATED event when necessary', () => { + locusInfo.controls = {}; + locusInfo.emitScoped = sinon.stub(); + locusInfo.updateControls(newControls); + + assert.calledWith( + locusInfo.emitScoped, + {file: 'locus-info', function: 'updateControls'}, + LOCUSINFO.EVENTS.CONTROLS_PRACTICE_SESSION_STATUS_UPDATED, + {state: newControls.practiceSession} + ); + }); + it('should not trigger the CONTROLS_RECORDING_UPDATED event', () => { locusInfo.controls = {}; locusInfo.emitScoped = sinon.stub(); @@ -1729,6 +1784,7 @@ describe('plugin-meetings', () => { locusInfo.updateMemberShip = sinon.stub(); locusInfo.updateIdentifiers = sinon.stub(); locusInfo.updateEmbeddedApps = sinon.stub(); + locusInfo.updateResources = sinon.stub(); locusInfo.compareAndUpdate = sinon.stub(); locusInfo.updateLocusInfo(newLocus); @@ -1750,6 +1806,7 @@ describe('plugin-meetings', () => { assert.notCalled(locusInfo.updateMemberShip); assert.notCalled(locusInfo.updateIdentifiers); assert.notCalled(locusInfo.updateEmbeddedApps); + assert.notCalled(locusInfo.updateResources); assert.notCalled(locusInfo.compareAndUpdate); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts b/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts index 9fe27480635..c64833dd0f6 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts @@ -60,6 +60,10 @@ describe('plugin-meetings', () => { canUpdateShareControl: null, canEnableViewTheParticipantsList: null, canDisableViewTheParticipantsList: null, + canEnableViewTheParticipantsListPanelist: null, + canDisableViewTheParticipantsListPanelist: null, + canEnableShowAttendeeCount: null, + canDisableShowAttendeeCount: null, canEnableRaiseHand: null, canDisableRaiseHand: null, canEnableVideo: null, @@ -79,6 +83,11 @@ describe('plugin-meetings', () => { canShareWhiteBoard: null, enforceVirtualBackground: null, canPollingAndQA: null, + canStartWebcast: null, + canStopWebcast: null, + canShowStageView: null, + canEnableStageView: null, + canDisableStageView: null, ...expected, }; @@ -144,6 +153,10 @@ describe('plugin-meetings', () => { 'canUpdateShareControl', 'canEnableViewTheParticipantsList', 'canDisableViewTheParticipantsList', + 'canEnableViewTheParticipantsListPanelist', + 'canDisableViewTheParticipantsListPanelist', + 'canEnableShowAttendeeCount', + 'canDisableShowAttendeeCount', 'canEnableRaiseHand', 'canDisableRaiseHand', 'canEnableVideo', @@ -163,6 +176,11 @@ describe('plugin-meetings', () => { 'canShareWhiteBoard', 'enforceVirtualBackground', 'canPollingAndQA', + 'canStartWebcast', + 'canStopWebcast', + 'canShowStageView', + 'canEnableStageView', + 'canDisableStageView', ].forEach((key) => { it(`get and set for ${key} work as expected`, () => { const inMeetingActions = new InMeetingActions(); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 9f8ed9cc4b7..e276ca66656 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -8885,6 +8885,78 @@ describe('plugin-meetings', () => { ); }); + it('listens to MEETING_CONTROLS_WEBCAST_UPDATED', async () => { + const state = {example: 'value'}; + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_WEBCAST_CHANGED, + {state} + ); + + assert.calledWith( + TriggerProxy.trigger, + meeting, + {file: 'meeting/index', function: 'setupLocusControlsListener'}, + EVENT_TRIGGERS.MEETING_CONTROLS_WEBCAST_UPDATED, + {state} + ); + }); + + it('listens to MEETING_CONTROLS_MEETING_FULL_UPDATED', async () => { + const state = {example: 'value'}; + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_MEETING_FULL_CHANGED, + {state} + ); + + assert.calledWith( + TriggerProxy.trigger, + meeting, + {file: 'meeting/index', function: 'setupLocusControlsListener'}, + EVENT_TRIGGERS.MEETING_CONTROLS_MEETING_FULL_UPDATED, + {state} + ); + }); + + it('listens to MEETING_CONTROLS_PRACTICE_SESSION_STATUS_UPDATED', async () => { + const state = {example: 'value'}; + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_PRACTICE_SESSION_STATUS_UPDATED, + {state} + ); + + assert.calledWith( + TriggerProxy.trigger, + meeting, + {file: 'meeting/index', function: 'setupLocusControlsListener'}, + EVENT_TRIGGERS.MEETING_CONTROLS_PRACTICE_SESSION_STATUS_UPDATED, + {state} + ); + }); + + it('listens to MEETING_CONTROLS_STAGE_VIEW_UPDATED', async () => { + const state = {example: 'value'}; + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_STAGE_VIEW_UPDATED, + {state} + ); + + assert.calledWith( + TriggerProxy.trigger, + meeting, + {file: 'meeting/index', function: 'setupLocusControlsListener'}, + EVENT_TRIGGERS.MEETING_CONTROLS_STAGE_VIEW_UPDATED, + {state} + ); + }); + it('listens to MEETING_CONTROLS_VIDEO_UPDATED', async () => { const state = {example: 'value'}; @@ -8998,12 +9070,6 @@ describe('plugin-meetings', () => { approval: { url: 'url', }, - webcast: { - url: 'url', - }, - webinarAttendeesSearching: { - url: 'url', - }, }, }; @@ -9017,10 +9083,6 @@ describe('plugin-meetings', () => { meeting.simultaneousInterpretation = { approvalUrlUpdate: sinon.stub().returns(undefined), }; - meeting.webinar = { - webcastUrlUpdate: sinon.stub().returns(undefined), - webinarAttendeesSearchingUrlUpdate: sinon.stub().returns(undefined), - }; meeting.locusInfo.emit( {function: 'test', file: 'test'}, @@ -9040,15 +9102,37 @@ describe('plugin-meetings', () => { meeting.simultaneousInterpretation.approvalUrlUpdate, newLocusServices.services.approval.url ); - assert.calledWith( - meeting.webinar.webcastUrlUpdate, - newLocusServices.services.webcast.url + assert.calledOnce(meeting.recordingController.setSessionId); + done(); + }); + }); + + describe('#setUpLocusResourcesListener', () => { + it('listens to the locus resources update event', (done) => { + const newLocusResources = { + resources: { + webcastInstance: { + url: 'url', + }, + + }, + }; + + meeting.webinar = { + updateWebcastUrl: sinon.stub().returns(undefined), + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + 'LINKS_RESOURCES', + newLocusResources ); + assert.calledWith( - meeting.webinar.webinarAttendeesSearchingUrlUpdate, - newLocusServices.services.webinarAttendeesSearching.url + meeting.webinar.updateWebcastUrl, + newLocusResources ); - assert.calledOnce(meeting.recordingController.setSessionId); + done(); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/index.js b/packages/@webex/plugin-meetings/test/unit/spec/members/index.js index 64421f2df8c..23627ab151b 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/index.js @@ -660,17 +660,20 @@ describe('plugin-meetings', () => { resultPromise, spies, expectedRequestingMemberId, - expectedLocusUrl + expectedLocusUrl, + expectedRoles, ) => { await assert.isFulfilled(resultPromise); assert.calledOnceWithExactly( spies.generateLowerAllHandsMemberOptions, expectedRequestingMemberId, - expectedLocusUrl + expectedLocusUrl, + expectedRoles, ); assert.calledOnceWithExactly(spies.lowerAllHandsMember, { requestingParticipantId: expectedRequestingMemberId, locusUrl: expectedLocusUrl, + ...(expectedRoles !== undefined && { roles: expectedRoles }) }); assert.strictEqual(resultPromise, spies.lowerAllHandsMember.getCall(0).returnValue); }; @@ -707,6 +710,26 @@ describe('plugin-meetings', () => { await checkValid(resultPromise, spies, requestingMemberId, url1); }); + + it('should make the correct request when called with valid requestingMemberId and roles', async () => { + const requestingMemberId = 'test-member-id'; + const roles = ['panelist', 'attendee']; + const { members, spies } = setup('test-locus-url'); + + const resultPromise = members.lowerAllHands(requestingMemberId, roles); + + await checkValid(resultPromise, spies, requestingMemberId, 'test-locus-url', roles); + }); + + it('should handle an empty roles array correctly', async () => { + const requestingMemberId = 'test-member-id'; + const roles = []; + const { members, spies } = setup('test-locus-url'); + + const resultPromise = members.lowerAllHands(requestingMemberId, roles); + + await checkValid(resultPromise, spies, requestingMemberId, 'test-locus-url', roles); + }); }); describe('#editDisplayName', () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/request.js b/packages/@webex/plugin-meetings/test/unit/spec/members/request.js index e1fe2b3606c..c743c2fc6a9 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/request.js @@ -225,7 +225,7 @@ describe('plugin-meetings', () => { }); describe('#assignRolesMember', () => { - it('sends a PATCH to the locus endpoint', async () => { + it('sends a assignRolesMember PATCH to the locus endpoint', async () => { const locusUrl = url1; const memberId = 'test1'; const roles = [ @@ -255,7 +255,7 @@ describe('plugin-meetings', () => { }); describe('#raiseHand', () => { - it('sends a PATCH to the locus endpoint', async () => { + it('sends a raiseOrLowerHandMember PATCH to the locus endpoint', async () => { const locusUrl = url1; const memberId = 'test1'; @@ -319,7 +319,7 @@ describe('plugin-meetings', () => { assert.strictEqual(result, requestResponse); }); - it('sends a PATCH to the locus endpoint', async () => { + it('sends a lowerAllHandsMember PATCH to the locus endpoint', async () => { const locusUrl = url1; const memberId = 'test1'; @@ -348,6 +348,40 @@ describe('plugin-meetings', () => { }, }); }); + + it('sends a lowerAllHandsMember PATCH to the locus endpoint with roles', async () => { + const locusUrl = url1; + const memberId = 'test1'; + const roles = ['attendee']; + + const options = { + requestingParticipantId: memberId, + locusUrl, + roles, + }; + + const getRequestParamsSpy = sandbox.spy(membersUtil, 'getLowerAllHandsMemberRequestParams'); + + await membersRequest.lowerAllHandsMember(options); + + assert.calledOnceWithExactly(getRequestParamsSpy, { + requestingParticipantId: memberId, + locusUrl: url1, + roles: ['attendee'], + }); + + checkRequest({ + method: 'PATCH', + uri: `${locusUrl}/controls`, + body: { + hand: { + raised: false, + roles: ['attendee'], + }, + requestingParticipantId: memberId, + }, + }); + }); }); describe('#editDisplayName', () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js b/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js index 1ce608fe8a0..c5477c8b340 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js @@ -101,7 +101,7 @@ describe('plugin-meetings', () => { }); }); describe('#generateLowerAllHandsMemberOptions', () => { - it('returns the correct options', () => { + it('returns the correct options without roles', () => { const requestingParticipantId = 'test'; const locusUrl = 'urlTest1'; @@ -113,6 +113,20 @@ describe('plugin-meetings', () => { } ); }); + it('returns the correct options with roles', () => { + const requestingParticipantId = 'test'; + const locusUrl = 'urlTest1'; + const roles = ['panelist']; + + assert.deepEqual( + MembersUtil.generateLowerAllHandsMemberOptions(requestingParticipantId, locusUrl, roles), + { + requestingParticipantId, + locusUrl, + roles, + } + ); + }); }); describe('#generateEditDisplayNameMemberOptions', () => { it('returns the correct options', () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts index d4988d44bee..8a06824220c 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts @@ -29,32 +29,98 @@ describe('plugin-meetings', () => { }); }); - describe('#webcastUrlUpdate', () => { - it('sets the webcast url', () => { - webinar.webcastUrlUpdate('newUrl'); + describe('#updateWebcastUrl', () => { + it('sets the webcast instance url', () => { + webinar.updateWebcastUrl({resources: {webcastInstance: {url:'newUrl'}}}); - assert.equal(webinar.webcastUrl, 'newUrl'); + assert.equal(webinar.webcastInstanceUrl, 'newUrl'); }); }); - describe('#webinarAttendeesSearchingUrlUpdate', () => { - it('sets the webinarAttendeesSearching url', () => { - webinar.webinarAttendeesSearchingUrlUpdate('newUrl'); - - assert.equal(webinar.webinarAttendeesSearchingUrl, 'newUrl'); - }); - }); describe('#updateCanManageWebcast', () => { - it('update canManageWebcast', () => { - webinar.updateCanManageWebcast(true); + it('sets the webcast instance url when valid', () => { + webinar.updateWebcastUrl({resources: {webcastInstance: {url:'newUrl'}}}); + assert.equal(webinar.webcastInstanceUrl, 'newUrl', 'webcast instance URL should be updated'); + }); - assert.equal(webinar.canManageWebcast, true); + it('handles missing resources gracefully', () => { + webinar.updateWebcastUrl({}); + assert.isUndefined(webinar.webcastInstanceUrl, 'webcast instance URL should be undefined'); + }); - webinar.updateCanManageWebcast(false); + it('handles missing webcastInstance gracefully', () => { + webinar.updateWebcastUrl({resources: {}}); + assert.isUndefined(webinar.webcastInstanceUrl, 'webcast instance URL should be undefined'); + }); - assert.equal(webinar.canManageWebcast, false); + it('handles missing URL gracefully', () => { + webinar.updateWebcastUrl({resources: {webcastInstance: {}}}); + assert.isUndefined(webinar.webcastInstanceUrl, 'webcast instance URL should be undefined'); }); }); + + describe('#updateRoleChanged', () => { + it('updates roles when promoted from attendee to panelist', () => { + const payload = { + oldRoles: ['ATTENDEE'], + newRoles: ['PANELIST'] + }; + + const result = webinar.updateRoleChanged(payload); + + assert.equal(webinar.selfIsPanelist, true, 'self should be a panelist'); + assert.equal(webinar.selfIsAttendee, false, 'self should not be an attendee'); + assert.equal(webinar.canManageWebcast, false, 'self should not have manage webcast capability'); + assert.equal(result.isPromoted, true, 'should indicate promotion'); + assert.equal(result.isDemoted, false, 'should not indicate demotion'); + }); + + it('updates roles when demoted from panelist to attendee', () => { + const payload = { + oldRoles: ['PANELIST'], + newRoles: ['ATTENDEE'] + }; + + const result = webinar.updateRoleChanged(payload); + + assert.equal(webinar.selfIsPanelist, false, 'self should not be a panelist'); + assert.equal(webinar.selfIsAttendee, true, 'self should be an attendee'); + assert.equal(webinar.canManageWebcast, false, 'self should not have manage webcast capability'); + assert.equal(result.isPromoted, false, 'should not indicate promotion'); + assert.equal(result.isDemoted, true, 'should indicate demotion'); + }); + + it('updates roles when promoted to moderator', () => { + const payload = { + oldRoles: ['PANELIST'], + newRoles: ['MODERATOR'] + }; + + const result = webinar.updateRoleChanged(payload); + + assert.equal(webinar.selfIsPanelist, false, 'self should not be a panelist'); + assert.equal(webinar.selfIsAttendee, false, 'self should not be an attendee'); + assert.equal(webinar.canManageWebcast, true, 'self should have manage webcast capability'); + assert.equal(result.isPromoted, false, 'should not indicate promotion'); + assert.equal(result.isDemoted, false, 'should not indicate demotion'); + }); + + it('updates roles when unchanged (remains as panelist)', () => { + const payload = { + oldRoles: ['PANELIST'], + newRoles: ['PANELIST'] + }; + + const result = webinar.updateRoleChanged(payload); + + assert.equal(webinar.selfIsPanelist, true, 'self should remain a panelist'); + assert.equal(webinar.selfIsAttendee, false, 'self should not be an attendee'); + assert.equal(webinar.canManageWebcast, false, 'self should not have manage webcast capability'); + assert.equal(result.isPromoted, false, 'should not indicate promotion'); + assert.equal(result.isDemoted, false, 'should not indicate demotion'); + }); + }); + }) }) From ed1798d6f99832f91764c638d30a7e6ab36ac2da Mon Sep 17 00:00:00 2001 From: xinhyao <142190897+xinhyao@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:39:23 +0800 Subject: [PATCH 07/30] feat(automotive): qr code login flow for automotive app (#3974) --- .../src/authorization.js | 169 +++++++++++ .../test/unit/spec/authorization.js | 274 ++++++++++++++++++ 2 files changed, 443 insertions(+) diff --git a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js index 477b64e751d..cc65a772edd 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js @@ -6,6 +6,7 @@ import querystring from 'querystring'; import url from 'url'; +import {EventEmitter} from 'events'; import {base64, oneFlight, whileInFlight} from '@webex/common'; import {grantErrors, WebexPlugin} from '@webex/webex-core'; @@ -66,6 +67,18 @@ const Authorization = WebexPlugin.extend({ namespace: 'Credentials', + + /** + * Stores the interval ID for QR code polling + * @instance + * @memberof AuthorizationBrowserFirstParty + * @type {?number} + * @private + */ + pollingRequest: null, + + eventEmitter: new EventEmitter(), + /** * Initializer * @instance @@ -240,6 +253,162 @@ const Authorization = WebexPlugin.extend({ }); }, + /** + * Get an OAuth Login URL for QRCode. Generate QR code based on the returned URL. + * @instance + * @memberof AuthorizationBrowserFirstParty + * @emits #qRCodeLogin + */ + initQRCodeLogin() { + if (this.pollingRequest) { + this.eventEmitter.emit('qRCodeLogin', { + eventType: 'getUserCodeFailure', + data: {message: 'There is already a polling request'}, + }); + return; + } + + this.webex + .request({ + method: 'POST', + service: 'oauth-helper', + resource: '/actions/device/authorize', + form: { + client_id: this.config.client_id, + scope: this.config.scope, + }, + auth: { + user: this.config.client_id, + pass: this.config.client_secret, + sendImmediately: true, + }, + }) + .then((res) => { + const {user_code, verification_uri, verification_uri_complete} = res.body; + this.eventEmitter.emit('qRCodeLogin', { + eventType: 'getUserCodeSuccess', + userData: { + userCode: user_code, + verificationUri: verification_uri, + verificationUriComplete: verification_uri_complete, + } + }); + // if device authorization success, then start to poll server to check whether the user has completed authorization + this._startQRCodePolling(res.body); + }) + .catch((res) => { + this.eventEmitter.emit('qRCodeLogin', { + eventType: 'getUserCodeFailure', + data: res.body, + }); + }); + }, + + /** + * Polling the server to check whether the user has completed authorization + * @instance + * @memberof AuthorizationBrowserFirstParty + * @param {Object} options + * @emits #qRCodeLogin + */ + _startQRCodePolling(options = {}) { + if (!options.device_code) { + this.eventEmitter.emit('qRCodeLogin', { + eventType: 'authorizationFailure', + data: {message: 'A deviceCode is required'}, + }); + return; + } + + if (this.pollingRequest) { + this.eventEmitter.emit('qRCodeLogin', { + eventType: 'authorizationFailure', + data: {message: 'There is already a polling request'}, + }); + return; + } + + const {device_code: deviceCode, interval = 2, expires_in: expiresIn = 300} = options; + + let attempts = 0; + const maxAttempts = expiresIn / interval; + + this.pollingRequest = setInterval(() => { + attempts += 1; + + const currentAttempts = attempts; + this.webex + .request({ + method: 'POST', + service: 'oauth-helper', + resource: '/actions/device/token', + form: { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + client_id: this.config.client_id, + }, + auth: { + user: this.config.client_id, + pass: this.config.client_secret, + sendImmediately: true, + }, + }) + .then((res) => { + if (this.pollingRequest === null) return; + + this.eventEmitter.emit('qRCodeLogin', { + eventType: 'authorizationSuccess', + data: res.body, + }); + this.cancelQRCodePolling(); + }) + .catch((res) => { + if (this.pollingRequest === null) return; + + if (currentAttempts >= maxAttempts) { + this.eventEmitter.emit('qRCodeLogin', { + eventType: 'authorizationFailure', + data: {message: 'Authorization timed out'} + }); + this.cancelQRCodePolling(); + return; + } + // if the statusCode is 428 which means that the authorization request is still pending + // as the end user hasn't yet completed the user-interaction steps. So keep polling. + if (res.statusCode === 428) { + this.eventEmitter.emit('qRCodeLogin', { + eventType: 'authorizationPending', + data: res.body + }); + return; + } + + this.cancelQRCodePolling(); + + this.eventEmitter.emit('qRCodeLogin', { + eventType: 'authorizationFailure', + data: res.body + }); + }); + }, interval * 1000); + }, + + /** + * cancel polling request + * @instance + * @memberof AuthorizationBrowserFirstParty + * @returns {void} + */ + cancelQRCodePolling() { + if (this.pollingRequest) { + clearInterval(this.pollingRequest); + this.eventEmitter.emit('qRCodeLogin', { + eventType: 'pollingCanceled', + }); + this.pollingRequest = null; + } + }, + /** * Extracts the orgId from the returned code from idbroker * Description of how to parse the code can be found here: diff --git a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js index 6069b45ac15..5bf294485fb 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js @@ -443,6 +443,280 @@ describe('plugin-authorization-browser-first-party', () => { }); }); + describe('#initQRCodeLogin()', () => { + it('should prevent concurrent request if there is already a polling request', async () => { + const webex = makeWebex('http://example.com'); + + webex.authorization.pollingRequest = 1; + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + webex.authorization.initQRCodeLogin(); + + assert.calledOnce(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure'); + webex.authorization.pollingRequest = null; + }); + + it('should send correct request parameters to the API', async () => { + const clock = sinon.useFakeTimers(); + const testClientId = 'test-client-id'; + const testScope = 'test-scope'; + const sampleData = { + device_code: "test123", + expires_in: 300, + user_code: "421175", + verification_uri: "http://example.com", + verification_uri_complete: "http://example.com", + interval: 2 + }; + + const webex = makeWebex('http://example.com', undefined, undefined, { + credentials: { + client_id: testClientId, + scope: testScope, + } + }); + webex.request.onFirstCall().resolves({statusCode: 200, body: sampleData}); + sinon.spy(webex.authorization, '_startQRCodePolling'); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization.initQRCodeLogin(); + clock.tick(2000); + await clock.runAllAsync() + + assert.calledTwice(webex.request); + assert.calledOnce(webex.authorization._startQRCodePolling); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeSuccess'); + + const request = webex.request.getCall(0); + + assert.equal(request.args[0].form.client_id, testClientId); + assert.equal(request.args[0].form.scope, testScope); + clock.restore(); + }); + + it('should use POST method and correct endpoint', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const sampleData = { + device_code: "test123", + expires_in: 300, + user_code: "421175", + verification_uri: "http://example.com", + verification_uri_complete: "http://example.com", + interval: 2 + }; + webex.request.resolves().resolves({statusCode: 200, body: sampleData}); + + webex.authorization.initQRCodeLogin(); + clock.tick(2000); + await clock.runAllAsync() + + const request = webex.request.getCall(0); + assert.equal(request.args[0].method, 'POST'); + assert.equal(request.args[0].service, 'oauth-helper'); + assert.equal(request.args[0].resource, '/actions/device/authorize'); + clock.restore(); + }); + + it('should emit getUserCodeFailure event', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + webex.request.rejects(new Error('API Error')); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization.initQRCodeLogin(); + + await clock.runAllAsync() + + assert.calledOnce(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure'); + clock.restore(); + }); + }); + + describe('#_startQRCodePolling()', () => { + it('requires a deviceCode', () => { + const webex = makeWebex('http://example.com'); + + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization._startQRCodePolling({}); + + assert.calledOnce(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure'); + }); + + it('should send correct request parameters to the API', async () => { + const clock = sinon.useFakeTimers(); + const testClientId = 'test-client-id'; + const testDeviceCode = 'test-device-code'; + + const options = { + device_code: testDeviceCode, + interval: 2, + expires_in: 300, + }; + + const webex = makeWebex('http://example.com', undefined, undefined, { + credentials: { + client_id: testClientId, + } + }); + + webex.request.onFirstCall().resolves({statusCode: 200, body: {access_token: 'token'}}); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + sinon.spy(webex.authorization, 'cancelQRCodePolling'); + + webex.authorization._startQRCodePolling(options); + clock.tick(2000); + await clock.runAllAsync() + + assert.calledOnce(webex.request); + + const request = webex.request.getCall(0); + + assert.equal(request.args[0].form.client_id, testClientId); + assert.equal(request.args[0].form.device_code, testDeviceCode); + assert.equal(request.args[0].form.grant_type, 'urn:ietf:params:oauth:grant-type:device_code'); + + assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledTwice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled'); + + clock.restore(); + }); + + it('should respect polling interval', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test-device-code', + interval: 2, + expires_in: 300 + }; + + webex.request.onFirstCall().rejects({statusCode: 428, body: {message: 'authorization_pending'}}); + webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}}); + sinon.spy(webex.authorization, 'cancelQRCodePolling'); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization._startQRCodePolling(options); + clock.tick(4000); + await clock.runAllAsync() + + assert.calledTwice(webex.request); + assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledTwice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled'); + clock.restore(); + }); + + it('should timeout after expires_in seconds', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test-device-code', + interval: 5, + expires_in: 10 + }; + + webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}}); + sinon.spy(webex.authorization, 'cancelQRCodePolling'); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization._startQRCodePolling(options); + clock.tick(10000); + await clock.runAllAsync() + + assert.calledTwice(webex.request); + assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledThrice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationFailure'); + assert.equal(emitSpy.getCall(2).args[1].eventType, 'pollingCanceled'); + clock.restore(); + }); + + it('should prevent concurrent polling attempts if this is already a polling request', async () => { + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test-device-code', + interval: 2, + expires_in: 300 + }; + + webex.authorization.pollingRequest = 1; + + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + webex.authorization._startQRCodePolling(options); + + assert.calledOnce(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure'); + webex.authorization.pollingRequest = null; + }); + }); + + describe('#cancelQRCodePolling()', () => { + it('should stop polling after cancellation', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test-device-code', + interval: 2, + expires_in: 300 + }; + + webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}}); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization._startQRCodePolling(options); + // First poll + clock.tick(2000); + assert.calledOnce(webex.request); + + webex.authorization.cancelQRCodePolling(); + // Wait for next interval + clock.tick(2000); + + const eventArgs = emitSpy.getCall(0).args; + + // Verify no additional requests were made + assert.calledOnce(webex.request); + assert.calledOnce(emitSpy); + assert.equal(eventArgs[1].eventType, 'pollingCanceled'); + clock.restore(); + }); + it('should clear interval and reset polling request', () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + + const options = { + device_code: 'test_device_code', + interval: 2, + expires_in: 300, + }; + + webex.authorization._startQRCodePolling(options); + assert.isDefined(webex.authorization.pollingRequest); + + webex.authorization.cancelQRCodePolling(); + assert.isNull(webex.authorization.pollingRequest); + + clock.restore(); + }); + + it('should handle cancellation when no polling is in progress', () => { + const webex = makeWebex('http://example.com'); + assert.isNull(webex.authorization.pollingRequest); + + webex.authorization.cancelQRCodePolling(); + assert.isNull(webex.authorization.pollingRequest); + }); + + }); + describe('#_generateCodeChallenge', () => { const expectedCodeChallenge = 'code challenge'; // eslint-disable-next-line no-underscore-dangle From 70a014c7b3fd2f7c6ecf25b6d79b2d56b023f0e1 Mon Sep 17 00:00:00 2001 From: Marcin Date: Fri, 22 Nov 2024 15:45:12 +0000 Subject: [PATCH 08/30] fix(plugin-meetings): added default bundlePolicy value (#3996) --- .../plugin-meetings/src/meeting/index.ts | 2 +- .../test/unit/spec/meeting/index.js | 75 +++++++++++-------- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index f658039e002..6fa1a188e31 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -7123,7 +7123,7 @@ export default class Meeting extends StatelessWebexPlugin { shareAudioEnabled = true, shareVideoEnabled = true, remoteMediaManagerConfig, - bundlePolicy, + bundlePolicy = 'max-bundle', } = options; this.allowMediaInLobby = options?.allowMediaInLobby; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index e276ca66656..75d4627b912 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -3442,47 +3442,60 @@ describe('plugin-meetings', () => { }); }); - it('should pass bundlePolicy to createMediaConnection', async () => { + describe('bundlePolicy', () => { const FAKE_TURN_URL = 'turns:webex.com:3478'; const FAKE_TURN_USER = 'some-turn-username'; const FAKE_TURN_PASSWORD = 'some-password'; - meeting.meetingState = 'ACTIVE'; - Media.createMediaConnection.resetHistory(); - - meeting.roap.doTurnDiscovery = sinon.stub().resolves({ - turnServerInfo: { - url: FAKE_TURN_URL, - username: FAKE_TURN_USER, - password: FAKE_TURN_PASSWORD, - }, - turnDiscoverySkippedReason: undefined, - }); - const media = meeting.addMedia({ - mediaSettings: {}, - bundlePolicy: 'bundlePolicy-value', - }); + beforeEach(() => { + meeting.meetingState = 'ACTIVE'; + Media.createMediaConnection.resetHistory(); - assert.exists(media); - await media; - assert.calledOnce(meeting.roap.doTurnDiscovery); - assert.calledWith(meeting.roap.doTurnDiscovery, meeting, false); - assert.calledOnce(Media.createMediaConnection); - assert.calledWith( - Media.createMediaConnection, - false, - meeting.getMediaConnectionDebugId(), - meeting.id, - sinon.match({ + meeting.roap.doTurnDiscovery = sinon.stub().resolves({ turnServerInfo: { url: FAKE_TURN_URL, username: FAKE_TURN_USER, password: FAKE_TURN_PASSWORD, }, - bundlePolicy: 'bundlePolicy-value', - }) - ); - assert.calledOnce(fakeMediaConnection.initiateOffer); + turnDiscoverySkippedReason: undefined, + }); + }); + + const runCheck = async (bundlePolicy, expectedValue) => { + const media = meeting.addMedia({ + mediaSettings: {}, + bundlePolicy, + }); + + assert.exists(media); + await media; + assert.calledOnce(meeting.roap.doTurnDiscovery); + assert.calledWith(meeting.roap.doTurnDiscovery, meeting, false); + assert.calledOnce(Media.createMediaConnection); + assert.calledWith( + Media.createMediaConnection, + false, + meeting.getMediaConnectionDebugId(), + meeting.id, + sinon.match({ + turnServerInfo: { + url: FAKE_TURN_URL, + username: FAKE_TURN_USER, + password: FAKE_TURN_PASSWORD, + }, + bundlePolicy: expectedValue, + }) + ); + assert.calledOnce(fakeMediaConnection.initiateOffer); + }; + + it('should pass bundlePolicy to createMediaConnection', async () => { + await runCheck('max-compat', 'max-compat'); + }); + + it('should pass max-bundle to createMediaConnection if bundlePolicy is not provided', async () => { + await runCheck(undefined, 'max-bundle'); + }); }); it('succeeds even if getDevices() throws', async () => { From 6b5dae23ebfcc109402a33c9e494bddce077de19 Mon Sep 17 00:00:00 2001 From: Marcin Date: Fri, 22 Nov 2024 19:07:18 +0000 Subject: [PATCH 09/30] feat: added 'forced' param to IpNetworkDetector.detect() (#4001) --- .../src/ipNetworkDetector.ts | 34 +++++++- .../test/unit/spec/ipNetworkDetector.js | 85 ++++++++++++++++++- .../plugin-meetings/src/reachability/index.ts | 6 +- .../test/unit/spec/reachability/index.ts | 2 +- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/packages/@webex/internal-plugin-device/src/ipNetworkDetector.ts b/packages/@webex/internal-plugin-device/src/ipNetworkDetector.ts index cf7b828d002..44c3c9c9649 100644 --- a/packages/@webex/internal-plugin-device/src/ipNetworkDetector.ts +++ b/packages/@webex/internal-plugin-device/src/ipNetworkDetector.ts @@ -4,6 +4,12 @@ import {WebexPlugin} from '@webex/webex-core'; +const STATE = { + INITIAL: 'initial', + IN_PROGRESS: 'in-progress', + IDLE: 'idle', +}; + /** * @class */ @@ -17,6 +23,8 @@ const IpNetworkDetector = WebexPlugin.extend({ firstIpV6: ['number', true, -1], // time [ms] it took to receive first IPv6 candidate firstMdns: ['number', true, -1], // time [ms] it took to receive first mDNS candidate totalTime: ['number', true, -1], // total time [ms] it took to do the last IP network detection + state: ['string', true, STATE.INITIAL], + pendingDetection: ['object', false, undefined], }, derived: { @@ -155,21 +163,41 @@ const IpNetworkDetector = WebexPlugin.extend({ * Detects if we are on IPv4 and/or IPv6 network. Once it resolves, read the * supportsIpV4 and supportsIpV6 props to find out the result. * - * @returns {Promise} + * @param {boolean} force - if false, the detection will only be done if we haven't managed to get any meaningful results yet + * @returns {Promise} */ - async detect() { + async detect(force = false) { let results; let pc; + if (this.state === STATE.IN_PROGRESS) { + this.pendingDetection = {force}; + + return; + } + + if (!force && this.state !== STATE.INITIAL && !this.receivedOnlyMDnsCandidates()) { + // we already have the results, no need to do the detection again + return; + } + try { + this.state = STATE.IN_PROGRESS; + pc = new RTCPeerConnection(); results = await this.gatherLocalCandidates(pc); } finally { pc.close(); + this.state = STATE.IDLE; } - return results; + if (this.pendingDetection) { + const {force: forceParam} = this.pendingDetection; + + this.pendingDetection = undefined; + this.detect(forceParam); + } }, }); diff --git a/packages/@webex/internal-plugin-device/test/unit/spec/ipNetworkDetector.js b/packages/@webex/internal-plugin-device/test/unit/spec/ipNetworkDetector.js index 5e44e318fe5..b5d57f6ce30 100644 --- a/packages/@webex/internal-plugin-device/test/unit/spec/ipNetworkDetector.js +++ b/packages/@webex/internal-plugin-device/test/unit/spec/ipNetworkDetector.js @@ -3,6 +3,8 @@ import sinon from 'sinon'; import IpNetworkDetector from '@webex/internal-plugin-device/src/ipNetworkDetector'; import MockWebex from '@webex/test-helper-mock-webex'; +const flushPromises = () => new Promise(setImmediate); + describe('plugin-device', () => { describe('IpNetworkDetector', () => { let webex; @@ -326,7 +328,7 @@ describe('plugin-device', () => { }); // now call detect() again - const promise2 = ipNetworkDetector.detect(); + const promise2 = ipNetworkDetector.detect(true); // everything should have been reset assert.equal(ipNetworkDetector.supportsIpV4, undefined); @@ -340,6 +342,87 @@ describe('plugin-device', () => { await promise2; }); + it('queues another detect() call if one is already in progress', async () => { + const promise = ipNetworkDetector.detect(); + + simulateCandidate(50, '192.168.0.1'); + + await flushPromises(); + + assert.calledOnce(fakePeerConnection.createDataChannel); + assert.calledOnce(fakePeerConnection.createOffer); + assert.calledOnce(fakePeerConnection.setLocalDescription); + + // now call detect() again + ipNetworkDetector.detect(true); + + // simulate the end of the detection -> another one should be started + simulateEndOfCandidateGathering(10); + + await promise; + + assert.calledTwice(fakePeerConnection.createDataChannel); + assert.calledTwice(fakePeerConnection.createOffer); + assert.calledTwice(fakePeerConnection.setLocalDescription); + + simulateCandidate(50, '2a02:c7c:a0d0:8a00:db9b:d4de:d1f7:4c49'); + simulateEndOfCandidateGathering(10); + + // results should reflect the last run detection + checkResults({ + supportsIpV4: false, + supportsIpV6: true, + timings: { + totalTime: 60, + ipv4: -1, + ipv6: 50, + mdns: -1, + }, + }); + + await flushPromises(); + + // no more detections should be started + assert.calledTwice(fakePeerConnection.createDataChannel); + }); + + it.each` + force | state | receivedOnlyMDnsCandidates | expectedToRunDetection + ${true} | ${'initial'} | ${false} | ${true} + ${true} | ${'idle'} | ${false} | ${true} + ${true} | ${'initial'} | ${true} | ${true} + ${true} | ${'idle'} | ${true} | ${true} + ${false} | ${'initial'} | ${false} | ${true} + ${false} | ${'initial'} | ${true} | ${true} + ${false} | ${'idle'} | ${true} | ${true} + ${false} | ${'idle'} | ${false} | ${false} + `( + 'force=$force, state=$state, receivedOnlyMDnsCandidates=$receivedOnlyMDnsCandidates => expectedToRunDetection=$expectedToRunDetection', + async ({force, state, receivedOnlyMDnsCandidates, expectedToRunDetection}) => { + ipNetworkDetector.state = state; + sinon + .stub(ipNetworkDetector, 'receivedOnlyMDnsCandidates') + .returns(receivedOnlyMDnsCandidates); + + const result = ipNetworkDetector.detect(force); + + if (expectedToRunDetection) { + simulateEndOfCandidateGathering(10); + } + await result; + + if (expectedToRunDetection) { + assert.calledOnce(fakePeerConnection.createDataChannel); + assert.calledOnce(fakePeerConnection.createOffer); + assert.calledOnce(fakePeerConnection.setLocalDescription); + } else { + assert.notCalled(fakePeerConnection.createDataChannel); + assert.notCalled(fakePeerConnection.createOffer); + assert.notCalled(fakePeerConnection.setLocalDescription); + } + } + ); + it('rejects if one of RTCPeerConnection operations fails', async () => { const fakeError = new Error('fake error'); diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts index 7b6868f04f0..ff6d47a14b7 100644 --- a/packages/@webex/plugin-meetings/src/reachability/index.ts +++ b/packages/@webex/plugin-meetings/src/reachability/index.ts @@ -154,10 +154,10 @@ export default class Reachability extends EventsScope { try { this.lastTrigger = trigger; - // kick off ip version detection. For now we don't await it, as we're doing it - // to gather the timings and send them with our reachability metrics + // kick off ip version detection. We don't await it, as we don't want to waste time + // and if it fails, that's ok we can still carry on // @ts-ignore - this.webex.internal.device.ipNetworkDetector.detect(); + this.webex.internal.device.ipNetworkDetector.detect(true); const {clusters, joinCookie} = await this.getClusters(); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts index 29b9ae371ec..029668742ee 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts @@ -1234,7 +1234,7 @@ describe('gatherReachability', () => { assert.equal(receivedEvents['done'], 1); // and that ip network detection was started - assert.calledOnceWithExactly(webex.internal.device.ipNetworkDetector.detect); + assert.calledOnceWithExactly(webex.internal.device.ipNetworkDetector.detect, true); // finally, check the metrics - they should contain values from ipNetworkDetector assert.calledWith(Metrics.sendBehavioralMetric, 'js_sdk_reachability_completed', { From e8a8fc010e392ab013905a9e4b5afe2193f3a7dc Mon Sep 17 00:00:00 2001 From: shnaaz <82164376+shnaaz@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:38:42 +0000 Subject: [PATCH 10/30] feat(internal-device-plugin): added deleteDevices (#3998) --- .../internal-plugin-device/src/device.js | 77 +++++++++++++-- .../test/unit/spec/device.js | 96 +++++++++++++++++++ 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/packages/@webex/internal-plugin-device/src/device.js b/packages/@webex/internal-plugin-device/src/device.js index 585827006e8..14eb5635acb 100644 --- a/packages/@webex/internal-plugin-device/src/device.js +++ b/packages/@webex/internal-plugin-device/src/device.js @@ -2,6 +2,7 @@ import {deprecated, oneFlight} from '@webex/common'; import {persist, waitForValue, WebexPlugin} from '@webex/webex-core'; import {safeSetTimeout} from '@webex/common-timers'; +import {orderBy} from 'lodash'; import METRICS from './metrics'; import {FEATURE_COLLECTION_NAMES, DEVICE_EVENT_REGISTRATION_SUCCESS} from './constants'; @@ -439,6 +440,74 @@ const Device = WebexPlugin.extend({ }); }); }, + /** + * Fetches the web devices and deletes the third of them which are not recent devices in use + * @returns {Promise} + */ + deleteDevices() { + // Fetch devices with a GET request + return this.request({ + method: 'GET', + service: 'wdm', + resource: 'devices', + }) + .then((response) => { + const {devices} = response.body; + + const {deviceType} = this._getBody(); + + // Filter devices of type deviceType + const webDevices = devices.filter((item) => item.deviceType === deviceType); + + const sortedDevices = orderBy(webDevices, [(item) => new Date(item.modificationTime)]); + + // If there are more than two devices, delete the last third + if (sortedDevices.length > 2) { + const totalItems = sortedDevices.length; + const countToDelete = Math.ceil(totalItems / 3); + const urlsToDelete = sortedDevices.slice(0, countToDelete).map((item) => item.url); + + return Promise.race( + urlsToDelete.map((url) => { + return this.request({ + uri: url, + method: 'DELETE', + }); + }) + ); + } + + return Promise.resolve(); + }) + .catch((error) => { + this.logger.error('Failed to retrieve devices:', error); + + return Promise.reject(error); + }); + }, + + /** + * Registers and when fails deletes devices + */ + @oneFlight + @waitForValue('@') + register(deviceRegistrationOptions = {}) { + return this._registerInternal(deviceRegistrationOptions).catch((error) => { + if (error?.body?.message === 'User has excessive device registrations') { + return this.deleteDevices().then(() => { + return this._registerInternal(deviceRegistrationOptions); + }); + } + throw error; + }); + }, + + _getBody() { + return { + ...(this.config.defaults.body ? this.config.defaults.body : {}), + ...(this.config.body ? this.config.body : {}), + }; + }, /** * Register or refresh a device depending on the current device state. Device @@ -451,7 +520,7 @@ const Device = WebexPlugin.extend({ */ @oneFlight @waitForValue('@') - register(deviceRegistrationOptions = {}) { + _registerInternal(deviceRegistrationOptions = {}) { this.logger.info('device: registering'); this.webex.internal.newMetrics.callDiagnosticMetrics.setDeviceInfo(this); @@ -466,10 +535,7 @@ const Device = WebexPlugin.extend({ } // Merge body configurations, overriding defaults. - const body = { - ...(this.config.defaults.body ? this.config.defaults.body : {}), - ...(this.config.body ? this.config.body : {}), - }; + const body = this._getBody(); // Merge header configurations, overriding defaults. const headers = { @@ -527,7 +593,6 @@ const Device = WebexPlugin.extend({ }); }); }, - /** * Unregister the current registered device if available. Unregistering a * device utilizes the services plugin to send the request to the **WDM** diff --git a/packages/@webex/internal-plugin-device/test/unit/spec/device.js b/packages/@webex/internal-plugin-device/test/unit/spec/device.js index 754a48706f1..b2da5312b1a 100644 --- a/packages/@webex/internal-plugin-device/test/unit/spec/device.js +++ b/packages/@webex/internal-plugin-device/test/unit/spec/device.js @@ -357,6 +357,68 @@ describe('plugin-device', () => { }); }); + describe('deleteDevices()', () => { + const setup = (deviceType) => { + device.config.defaults = {body: {deviceType}}; + }; + ['WEB', 'WEBCLIENT'].forEach(deviceType => { + it(`should delete correct number of devices for ${deviceType}`, async () => { + setup(deviceType); + const response = { + body: { + devices: [ + {url: 'url3', modificationTime: '2023-10-03T10:00:00Z', deviceType}, + {url: 'url4', modificationTime: '2023-10-04T10:00:00Z', deviceType: 'notweb'}, + {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType}, + {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType}, + {url: 'url5', modificationTime: '2023-10-00T10:00:00Z', deviceType}, + {url: 'url6', modificationTime: '2023-09-50T10:00:00Z', deviceType}, + {url: 'url7', modificationTime: '2023-09-30T10:00:00Z', deviceType}, + {url: 'url8', modificationTime: '2023-08-30T10:00:00Z', deviceType}, + ] + } + }; + const requestStub = sinon.stub(device, 'request'); + requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response); + requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves(); + + await device.deleteDevices(); + + const expectedDeletions = ['url8', 'url7', 'url1']; + + expectedDeletions.forEach(url => { + assert(requestStub.calledWith(sinon.match({uri: url, method: 'DELETE'}))); + }); + + const notDeletedUrls = ['url2', 'url3', 'url5', 'url6', 'url4']; + notDeletedUrls.forEach(url => { + assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'}))); + }); + });}); + + it('does not delete when there are just 2 devices', async () => { + setup('WEB'); + const response = { + body: { + devices: [ + {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'WEB'}, + {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType: 'WEB'}, + ] + } + }; + + const requestStub = sinon.stub(device, 'request'); + requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response); + requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves(); + + await device.deleteDevices(); + const notDeletedUrls = ['url1', 'url2']; + notDeletedUrls.forEach(url => { + assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'}))); + }); + }); + }); + describe('#register()', () => { const setup = (config = {}) => { webex.internal.metrics.submitClientMetrics = sinon.stub(); @@ -386,6 +448,40 @@ describe('plugin-device', () => { }); }); + it('calls delete devices when errors with User has excessive device registrations', async () => { + setup(); + const deleteDeviceSpy = sinon.stub(device, 'deleteDevices').callsFake(() => Promise.resolve()); + const registerStub = sinon.stub(device, '_registerInternal'); + + registerStub.onFirstCall().rejects({body: {message: 'User has excessive device registrations'}}); + registerStub.onSecondCall().callsFake(() => Promise.resolve({exampleKey: 'example response value',})); + + const result = await device.register(); + + assert.calledOnce(deleteDeviceSpy); + + assert.equal(registerStub.callCount, 2); + + assert.deepEqual(result, {exampleKey: 'example response value'}); + }); + + it('does not call delete devices when some other error', async () => { + setup(); + + const deleteDeviceSpy = sinon.stub(device, 'deleteDevices').callsFake(() => Promise.resolve()); + const registerStub = sinon.stub(device, '_registerInternal').rejects(new Error('some error')); + + try { + await device.register({deleteFlag: true}); + } catch (error) { + assert.notCalled(deleteDeviceSpy); + + assert.equal(registerStub.callCount, 1); + + assert.match(error.message, /some error/, 'Expected error message not matched'); + } + }); + it('checks that submitInternalEvent gets called with internal.register.device.response on error', async () => { setup(); sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve()); From 30d29dd72dba39d756080db1965583747f8c3204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Wa=C5=9Bniowski?= Date: Mon, 25 Nov 2024 14:12:08 +0100 Subject: [PATCH 11/30] fix(plugin-meetings): stop stats analyzer during reconnection (#3990) Co-authored-by: kwasniow --- .../plugin-meetings/src/meeting/index.ts | 19 +++++ .../test/unit/spec/meeting/index.js | 69 ++++++++++++++----- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 6fa1a188e31..a1cd18ad353 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -4904,6 +4904,8 @@ export default class Meeting extends StatelessWebexPlugin { ); } + this.cleanUpBeforeReconnection(); + return this.reconnectionManager .reconnect(options, async () => { await this.waitForRemoteSDPAnswer(); @@ -7032,6 +7034,23 @@ export default class Meeting extends StatelessWebexPlugin { } } + private async cleanUpBeforeReconnection(): Promise { + try { + // when media fails, we want to upload a webrtc dump to see whats going on + // this function is async, but returns once the stats have been gathered + await this.forceSendStatsReport({callFrom: 'cleanUpBeforeReconnection'}); + + if (this.statsAnalyzer) { + await this.statsAnalyzer.stopAnalyzer(); + } + } catch (error) { + LoggerProxy.logger.error( + 'Meeting:index#cleanUpBeforeReconnection --> Error during cleanup: ', + error + ); + } + } + /** * Creates an instance of LocusMediaRequest for this meeting - it is needed for doing any calls * to Locus /media API (these are used for sending Roap messages and updating audio/video mute status). diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 75d4627b912..78d5fb58bc9 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -377,7 +377,10 @@ describe('plugin-meetings', () => { } ); assert.equal(newMeeting.correlationId, newMeeting.id); - assert.deepEqual(newMeeting.callStateForMetrics, {correlationId: newMeeting.id, sessionCorrelationId: ''}); + assert.deepEqual(newMeeting.callStateForMetrics, { + correlationId: newMeeting.id, + sessionCorrelationId: '', + }); }); it('correlationId can be provided in callStateForMetrics', () => { @@ -3808,12 +3811,12 @@ describe('plugin-meetings', () => { id: 'fake locus from mocked join request', locusUrl: 'fake locus url', mediaId: 'fake media id', - }) + }); sinon.stub(meeting.meetingRequest, 'joinMeeting').resolves({ headers: { trackingid: 'fake tracking id', - } - }) + }, + }); await meeting.join({enableMultistream: isMultistream}); }); @@ -4006,7 +4009,10 @@ describe('plugin-meetings', () => { assert.notCalled( meeting.sendSlotManager.getSlot(MediaType.AudioMain).publishStream ); - assert.throws(meeting.publishStreams(localStreams), `Attempted to publish microphone stream with ended readyState, correlationId=${meeting.correlationId}`); + assert.throws( + meeting.publishStreams(localStreams), + `Attempted to publish microphone stream with ended readyState, correlationId=${meeting.correlationId}` + ); } else { assert.calledOnceWithExactly( meeting.sendSlotManager.getSlot(MediaType.AudioMain).publishStream, @@ -4019,7 +4025,10 @@ describe('plugin-meetings', () => { assert.notCalled( meeting.sendSlotManager.getSlot(MediaType.VideoMain).publishStream ); - assert.throws(meeting.publishStreams(localStreams), `Attempted to publish camera stream with ended readyState, correlationId=${meeting.correlationId}`); + assert.throws( + meeting.publishStreams(localStreams), + `Attempted to publish camera stream with ended readyState, correlationId=${meeting.correlationId}` + ); } else { assert.calledOnceWithExactly( meeting.sendSlotManager.getSlot(MediaType.VideoMain).publishStream, @@ -4032,7 +4041,10 @@ describe('plugin-meetings', () => { assert.notCalled( meeting.sendSlotManager.getSlot(MediaType.AudioSlides).publishStream ); - assert.throws(meeting.publishStreams(localStreams), `Attempted to publish screenShare audio stream with ended readyState, correlationId=${meeting.correlationId}`); + assert.throws( + meeting.publishStreams(localStreams), + `Attempted to publish screenShare audio stream with ended readyState, correlationId=${meeting.correlationId}` + ); } else { assert.calledOnceWithExactly( meeting.sendSlotManager.getSlot(MediaType.AudioSlides).publishStream, @@ -4045,7 +4057,10 @@ describe('plugin-meetings', () => { assert.notCalled( meeting.sendSlotManager.getSlot(MediaType.VideoSlides).publishStream ); - assert.throws(meeting.publishStreams(localStreams), `Attempted to publish screenShare video stream with ended readyState, correlationId=${meeting.correlationId}`); + assert.throws( + meeting.publishStreams(localStreams), + `Attempted to publish screenShare video stream with ended readyState, correlationId=${meeting.correlationId}` + ); } else { assert.calledOnceWithExactly( meeting.sendSlotManager.getSlot(MediaType.VideoSlides).publishStream, @@ -4340,14 +4355,14 @@ describe('plugin-meetings', () => { const handleDeviceLoggingSpy = sinon.spy(Meeting, 'handleDeviceLogging'); await meeting.addMedia({audioEnabled: false}); //calling handleDeviceLogging with audioEnaled as true adn videoEnabled as false - assert.calledWith(handleDeviceLoggingSpy,false,true); + assert.calledWith(handleDeviceLoggingSpy, false, true); }); it('addMedia() works correctly when video is disabled with no streams to publish', async () => { const handleDeviceLoggingSpy = sinon.spy(Meeting, 'handleDeviceLogging'); await meeting.addMedia({videoEnabled: false}); //calling handleDeviceLogging audioEnabled as true videoEnabled as false - assert.calledWith(handleDeviceLoggingSpy,true,false); + assert.calledWith(handleDeviceLoggingSpy, true, false); }); it('addMedia() works correctly when video is disabled with no streams to publish', async () => { @@ -4416,12 +4431,11 @@ describe('plugin-meetings', () => { assert.calledTwice(locusMediaRequestStub); }); - it('addMedia() works correctly when both shareAudio and shareVideo is disabled with no streams publish', async () => { const handleDeviceLoggingSpy = sinon.spy(Meeting, 'handleDeviceLogging'); await meeting.addMedia({shareAudioEnabled: false, shareVideoEnabled: false}); //calling handleDeviceLogging with audioEnabled true and videoEnabled as true - assert.calledWith(handleDeviceLoggingSpy,true,true); + assert.calledWith(handleDeviceLoggingSpy, true, true); }); describe('publishStreams()/unpublishStreams() calls', () => { @@ -6998,7 +7012,10 @@ describe('plugin-meetings', () => { assert.deepEqual(meeting.callStateForMetrics, {correlationId, sessionCorrelationId: ''}); meeting.setCorrelationId(uuid1); assert.equal(meeting.correlationId, uuid1); - assert.deepEqual(meeting.callStateForMetrics, {correlationId: uuid1, sessionCorrelationId: ''}); + assert.deepEqual(meeting.callStateForMetrics, { + correlationId: uuid1, + sessionCorrelationId: '', + }); }); }); @@ -7670,11 +7687,11 @@ describe('plugin-meetings', () => { id: 'stream', getTracks: () => [{id: 'track', addEventListener: sinon.stub()}], }; - const simulateConnectionStateChange = (newState) => { + const simulateConnectionStateChange = async (newState) => { meeting.mediaProperties.webrtcMediaConnection.getConnectionState = sinon .stub() .returns(newState); - eventListeners[MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED](); + await eventListeners[MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED](); }; beforeEach(() => { @@ -7930,7 +7947,7 @@ describe('plugin-meetings', () => { meeting.reconnectionManager = new ReconnectionManager(meeting); meeting.reconnectionManager.iceReconnected = sinon.stub().returns(undefined); meeting.setNetworkStatus = sinon.stub().returns(undefined); - meeting.statsAnalyzer = {startAnalyzer: sinon.stub()}; + meeting.statsAnalyzer = {startAnalyzer: sinon.stub(), stopAnalyzer: sinon.stub()}; meeting.mediaProperties.webrtcMediaConnection = { // mock the on() method and store all the listeners on: sinon.stub().callsFake((event, listener) => { @@ -8005,10 +8022,10 @@ describe('plugin-meetings', () => { }); describe('CONNECTION_STATE_CHANGED event when state = "Failed"', () => { - const mockFailedEvent = () => { + const mockFailedEvent = async () => { meeting.setupMediaConnectionListeners(); - simulateConnectionStateChange(ConnectionState.Failed); + await simulateConnectionStateChange(ConnectionState.Failed); }; const checkBehavioralMetricSent = (hasMediaConnectionConnectedAtLeastOnce = false) => { @@ -8038,6 +8055,22 @@ describe('plugin-meetings', () => { assert.notCalled(webex.internal.newMetrics.submitClientEvent); checkBehavioralMetricSent(true); }); + + it('stop stats analyzer during reconnection ', async () => { + meeting.hasMediaConnectionConnectedAtLeastOnce = true; + meeting.statsAnalyzer.stopAnalyzer = sinon.stub().resolves(); + meeting.reconnectionManager = { + reconnect: sinon.stub().resolves(), + resetReconnectionTimer: () => {} + }; + meeting.currentMediaStatus = { + video: true + }; + + await mockFailedEvent(); + + assert.calledOnce(meeting.statsAnalyzer.stopAnalyzer); + }); }); describe('should send correct metrics for ROAP_FAILURE event', () => { From 7e0ed3c9b4493d28926e943c2739d5ae5fbf63e3 Mon Sep 17 00:00:00 2001 From: Coread Date: Mon, 25 Nov 2024 14:23:40 +0000 Subject: [PATCH 12/30] feat(meetings): timeout for cluster discovery request (#4004) --- packages/@webex/plugin-meetings/src/config.ts | 1 + .../src/reachability/request.ts | 1 + .../test/unit/spec/reachability/request.js | 21 ++++++++++++------- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/config.ts b/packages/@webex/plugin-meetings/src/config.ts index c3d82c71146..c95a2b43405 100644 --- a/packages/@webex/plugin-meetings/src/config.ts +++ b/packages/@webex/plugin-meetings/src/config.ts @@ -95,5 +95,6 @@ export default { // This only applies to non-multistream meetings iceCandidatesGatheringTimeout: undefined, backendIpv6NativeSupport: false, + reachabilityGetClusterTimeout: 5000, }, }; diff --git a/packages/@webex/plugin-meetings/src/reachability/request.ts b/packages/@webex/plugin-meetings/src/reachability/request.ts index 19dbf959d0e..d3878651469 100644 --- a/packages/@webex/plugin-meetings/src/reachability/request.ts +++ b/packages/@webex/plugin-meetings/src/reachability/request.ts @@ -46,6 +46,7 @@ class ReachabilityRequest { JCSupport: 1, ipver: ipVersion, }, + timeout: this.webex.config.meetings.reachabilityGetClusterTimeout, }), 'internal.get.cluster.time' ) diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js b/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js index 372c398b4b4..e34fc5355a9 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js @@ -57,18 +57,23 @@ describe('plugin-meetings/reachability', () => { } })); + webex.config.meetings.reachabilityGetClusterTimeout = 3000; + const res = await reachabilityRequest.getClusters(IP_VERSION.only_ipv4); const requestParams = webex.request.getCall(0).args[0]; - assert.equal(requestParams.method, 'GET'); - assert.equal(requestParams.resource, `clusters`); - assert.equal(requestParams.api, 'calliopeDiscovery'); - assert.equal(requestParams.shouldRefreshAccessToken, false); - - assert.deepEqual(requestParams.qs, { - JCSupport: 1, - ipver: 4, + assert.deepEqual(requestParams, { + method: 'GET', + resource: `clusters`, + api: 'calliopeDiscovery', + shouldRefreshAccessToken: false, + qs: { + JCSupport: 1, + ipver: 4, + }, + timeout: 3000, }); + assert.deepEqual(res.clusters.clusterId, {udp: "testUDP", isVideoMesh: true}) assert.deepEqual(res.joinCookie, {anycastEntryPoint: "aws-eu-west-1"}) assert.calledOnceWithExactly(webex.internal.newMetrics.callDiagnosticLatencies.measureLatency, sinon.match.func, 'internal.get.cluster.time'); From c4ba3e5055b8d0099996c1bfd45f636d78438df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Wa=C5=9Bniowski?= Date: Tue, 26 Nov 2024 14:23:41 +0100 Subject: [PATCH 13/30] fix(plugin-meetings): skip empty ice candidates for Firefox (#4003) Co-authored-by: kwasniow --- .../plugin-meetings/src/meeting/index.ts | 2 +- .../test/unit/spec/meeting/index.js | 50 +++++++++++-------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index a1cd18ad353..edff3f1de51 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -6323,7 +6323,7 @@ export default class Meeting extends StatelessWebexPlugin { this.mediaProperties.webrtcMediaConnection.on( MediaConnectionEventNames.ICE_CANDIDATE, (event) => { - if (event.candidate) { + if (event.candidate && event.candidate.candidate && event.candidate.candidate.length > 0) { this.iceCandidatesCount += 1; } } diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 78d5fb58bc9..a94ea8b5737 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -90,8 +90,8 @@ import WebExMeetingsErrors from '../../../../src/common/errors/webex-meetings-er import ParameterError from '../../../../src/common/errors/parameter'; import PasswordError from '../../../../src/common/errors/password-error'; import CaptchaError from '../../../../src/common/errors/captcha-error'; -import PermissionError from '../../../../src/common/errors/permission'; -import WebinarRegistrationError from '../../../../src/common/errors/webinar-registration-error'; +import PermissionError from '../../../../src/common/errors/permission'; +import WebinarRegistrationError from '../../../../src/common/errors/webinar-registration-error'; import IntentToJoinError from '../../../../src/common/errors/intent-to-join'; import testUtils from '../../../utils/testUtils'; import { @@ -6290,14 +6290,22 @@ describe('plugin-meetings', () => { meeting.attrs.meetingInfoProvider = { fetchMeetingInfo: sinon .stub() - .throws(new MeetingInfoV2WebinarRegistrationError(403021, FAKE_MEETING_INFO, 'a message')), + .throws( + new MeetingInfoV2WebinarRegistrationError(403021, FAKE_MEETING_INFO, 'a message') + ), }; - await assert.isRejected(meeting.fetchMeetingInfo({sendCAevents: true}), WebinarRegistrationError); + await assert.isRejected( + meeting.fetchMeetingInfo({sendCAevents: true}), + WebinarRegistrationError + ); assert.deepEqual(meeting.meetingInfo, FAKE_MEETING_INFO); assert.equal(meeting.meetingInfoFailureCode, 403021); - assert.equal(meeting.meetingInfoFailureReason, MEETING_INFO_FAILURE_REASON.WEBINAR_REGISTRATION); + assert.equal( + meeting.meetingInfoFailureReason, + MEETING_INFO_FAILURE_REASON.WEBINAR_REGISTRATION + ); }); }); @@ -7761,11 +7769,17 @@ describe('plugin-meetings', () => { }); it('should collect ice candidates', () => { - eventListeners[MediaConnectionEventNames.ICE_CANDIDATE]({candidate: 'candidate'}); + eventListeners[MediaConnectionEventNames.ICE_CANDIDATE]({candidate: {candidate: 'candidate'}}); assert.equal(meeting.iceCandidatesCount, 1); }); + it('should not collect empty ice candidates', () => { + eventListeners[MediaConnectionEventNames.ICE_CANDIDATE]({candidate: {candidate: ''}}); + + assert.equal(meeting.iceCandidatesCount, 0); + }); + it('should not collect null ice candidates', () => { eventListeners[MediaConnectionEventNames.ICE_CANDIDATE]({candidate: null}); @@ -9160,7 +9174,6 @@ describe('plugin-meetings', () => { webcastInstance: { url: 'url', }, - }, }; @@ -9174,10 +9187,7 @@ describe('plugin-meetings', () => { newLocusResources ); - assert.calledWith( - meeting.webinar.updateWebcastUrl, - newLocusResources - ); + assert.calledWith(meeting.webinar.updateWebcastUrl, newLocusResources); done(); }); @@ -12335,14 +12345,10 @@ describe('plugin-meetings', () => { const testEmit = async (unmuteAllowed) => { meeting.audio = { handleServerLocalUnmuteRequired: sinon.stub(), - } - await meeting.locusInfo.emitScoped( - {}, - LOCUSINFO.EVENTS.LOCAL_UNMUTE_REQUIRED, - { - unmuteAllowed, - } - ); + }; + await meeting.locusInfo.emitScoped({}, LOCUSINFO.EVENTS.LOCAL_UNMUTE_REQUIRED, { + unmuteAllowed, + }); assert.calledWith( TriggerProxy.trigger, @@ -12358,7 +12364,11 @@ describe('plugin-meetings', () => { }, } ); - assert.calledOnceWithExactly(meeting.audio.handleServerLocalUnmuteRequired, meeting, unmuteAllowed) + assert.calledOnceWithExactly( + meeting.audio.handleServerLocalUnmuteRequired, + meeting, + unmuteAllowed + ); }; [true, false].forEach((unmuteAllowed) => { From 4666a02efe26e10b42846bc1226f0cc9b7f7e639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Wa=C5=9Bniowski?= Date: Thu, 28 Nov 2024 09:07:14 +0100 Subject: [PATCH 14/30] fix: update internal-media-core version (#4010) Co-authored-by: kwasniow --- packages/@webex/media-helpers/package.json | 2 +- packages/@webex/plugin-meetings/package.json | 2 +- packages/calling/package.json | 2 +- yarn.lock | 34 ++++++++++---------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json index 8a91c6266bf..2f74b136b3a 100644 --- a/packages/@webex/media-helpers/package.json +++ b/packages/@webex/media-helpers/package.json @@ -22,7 +22,7 @@ "deploy:npm": "yarn npm publish" }, "dependencies": { - "@webex/internal-media-core": "2.11.3", + "@webex/internal-media-core": "2.12.2", "@webex/ts-events": "^1.1.0", "@webex/web-media-effects": "2.19.0" }, diff --git a/packages/@webex/plugin-meetings/package.json b/packages/@webex/plugin-meetings/package.json index 9772e87d059..09bcb56df67 100644 --- a/packages/@webex/plugin-meetings/package.json +++ b/packages/@webex/plugin-meetings/package.json @@ -62,7 +62,7 @@ }, "dependencies": { "@webex/common": "workspace:*", - "@webex/internal-media-core": "2.11.3", + "@webex/internal-media-core": "2.12.2", "@webex/internal-plugin-conversation": "workspace:*", "@webex/internal-plugin-device": "workspace:*", "@webex/internal-plugin-llm": "workspace:*", diff --git a/packages/calling/package.json b/packages/calling/package.json index b5684f56853..007d081094a 100644 --- a/packages/calling/package.json +++ b/packages/calling/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@types/platform": "1.3.4", - "@webex/internal-media-core": "2.11.3", + "@webex/internal-media-core": "2.12.2", "@webex/media-helpers": "workspace:*", "async-mutex": "0.4.0", "buffer": "6.0.3", diff --git a/yarn.lock b/yarn.lock index b8425801341..3c8f7a98dbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7419,7 +7419,7 @@ __metadata: "@typescript-eslint/eslint-plugin": 5.38.1 "@typescript-eslint/parser": 5.38.1 "@web/dev-server": 0.4.5 - "@webex/internal-media-core": 2.11.3 + "@webex/internal-media-core": 2.12.2 "@webex/media-helpers": "workspace:*" async-mutex: 0.4.0 buffer: 6.0.3 @@ -7710,22 +7710,22 @@ __metadata: languageName: unknown linkType: soft -"@webex/internal-media-core@npm:2.11.3": - version: 2.11.3 - resolution: "@webex/internal-media-core@npm:2.11.3" +"@webex/internal-media-core@npm:2.12.2": + version: 2.12.2 + resolution: "@webex/internal-media-core@npm:2.12.2" dependencies: "@babel/runtime": ^7.18.9 "@babel/runtime-corejs2": ^7.25.0 "@webex/rtcstats": ^1.5.0 "@webex/ts-sdp": 1.7.0 "@webex/web-capabilities": ^1.4.1 - "@webex/web-client-media-engine": 3.24.2 + "@webex/web-client-media-engine": 3.26.2 events: ^3.3.0 typed-emitter: ^2.1.0 uuid: ^8.3.2 webrtc-adapter: ^8.1.2 xstate: ^4.30.6 - checksum: b95c917890c98ded1346d093656a8c54cb4ae7c0a8a93ccccaf39e72913f3c2c8a53829d257eb5ac74a087ee2c4583c37ed3752acfbab179e6533761eb9a5ed0 + checksum: 78adf83e60b4bdf2f9f18eb3fada692220f163eb6fcec66521f136093370ae5019dcf7b6fee424ca3396ba713313af3dc022d53573e339a5420d49119583c058 languageName: node linkType: hard @@ -8392,10 +8392,10 @@ __metadata: languageName: unknown linkType: soft -"@webex/json-multistream@npm:2.1.6": - version: 2.1.6 - resolution: "@webex/json-multistream@npm:2.1.6" - checksum: 18cd8e24151c88fc563c6224cc358c9e2e3cda78d80baddba8dd58aa3e79bf4d78ff12613b27cad5a0242856e84e3c6001e12916e404a68398c68e5439e5154b +"@webex/json-multistream@npm:2.2.0": + version: 2.2.0 + resolution: "@webex/json-multistream@npm:2.2.0" + checksum: 2f3f8de556e083cd7e9aa0fa0ddec13c5d0a63f5d809e5ce7075110cfa34178f12b9dde2e093fa49ed6f78fcbbe72de9059d2cbfd23e371e4c595baf9ad0449b languageName: node linkType: hard @@ -8484,7 +8484,7 @@ __metadata: "@babel/preset-typescript": 7.22.11 "@webex/babel-config-legacy": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.11.3 + "@webex/internal-media-core": 2.12.2 "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" "@webex/test-helper-chai": "workspace:*" @@ -8720,7 +8720,7 @@ __metadata: "@webex/babel-config-legacy": "workspace:*" "@webex/common": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.11.3 + "@webex/internal-media-core": 2.12.2 "@webex/internal-plugin-conversation": "workspace:*" "@webex/internal-plugin-device": "workspace:*" "@webex/internal-plugin-llm": "workspace:*" @@ -9423,11 +9423,11 @@ __metadata: languageName: node linkType: hard -"@webex/web-client-media-engine@npm:3.24.2": - version: 3.24.2 - resolution: "@webex/web-client-media-engine@npm:3.24.2" +"@webex/web-client-media-engine@npm:3.26.2": + version: 3.26.2 + resolution: "@webex/web-client-media-engine@npm:3.26.2" dependencies: - "@webex/json-multistream": 2.1.6 + "@webex/json-multistream": 2.2.0 "@webex/rtcstats": ^1.5.0 "@webex/ts-events": ^1.0.1 "@webex/ts-sdp": 1.7.0 @@ -9438,7 +9438,7 @@ __metadata: js-logger: ^1.6.1 typed-emitter: ^2.1.0 uuid: ^8.3.2 - checksum: 26ac75ffcb519a11b3a9f2b601b13b85c4c4e4b685503da0e752d03137af65d22472329f45fbd08de138d93ebd92295645f98c9b879617c838afee61c0589df7 + checksum: 85e990dd48320fc9dd478541c1ffdea591f048286fb5186cfb23a163e3b9f0c530aa7d37460cd1f0dc577c7d359fc289c31a4321bc915ffaca41be252f19cab1 languageName: node linkType: hard From 99c21e860a377d1d7c6cb8161923413a97dd94b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Vad=C3=A1sz?= Date: Fri, 29 Nov 2024 15:46:45 +0000 Subject: [PATCH 15/30] fix(authorization): qrcode - too fast polling (#4007) --- .../src/authorization.js | 132 ++++++--- .../src/index.js | 2 +- .../test/unit/spec/authorization.js | 278 +++++++++++------- 3 files changed, 275 insertions(+), 137 deletions(-) diff --git a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js index cc65a772edd..381b63c037d 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js @@ -22,6 +22,16 @@ const lodash = require('lodash'); const OAUTH2_CSRF_TOKEN = 'oauth2-csrf-token'; const OAUTH2_CODE_VERIFIER = 'oauth2-code-verifier'; +/** + * Authorization plugin events + */ +export const Events = { + /** + * QR code login events + */ + qRCodeLogin: 'qRCodeLogin', +}; + /** * Browser support for OAuth2. Automatically parses the URL query for an * authorization code @@ -67,17 +77,49 @@ const Authorization = WebexPlugin.extend({ namespace: 'Credentials', + /** + * EventEmitter for authorization events + * @instance + * @memberof AuthorizationBrowserFirstParty + * @type {EventEmitter} + * @public + */ + eventEmitter: new EventEmitter(), /** - * Stores the interval ID for QR code polling + * Stores the timer ID for QR code polling * @instance * @memberof AuthorizationBrowserFirstParty * @type {?number} * @private */ - pollingRequest: null, + pollingTimer: null, + /** + * Stores the expiration timer ID for QR code polling + * @instance + * @memberof AuthorizationBrowserFirstParty + * @type {?number} + * @private + */ + pollingExpirationTimer: null, - eventEmitter: new EventEmitter(), + /** + * Monotonically increasing id to identify the current polling request + * @instance + * @memberof AuthorizationBrowserFirstParty + * @type {number} + * @private + */ + pollingId: 0, + + /** + * Identifier for the current polling request + * @instance + * @memberof AuthorizationBrowserFirstParty + * @type {?number} + * @private + */ + currentPollingId: null, /** * Initializer @@ -260,8 +302,8 @@ const Authorization = WebexPlugin.extend({ * @emits #qRCodeLogin */ initQRCodeLogin() { - if (this.pollingRequest) { - this.eventEmitter.emit('qRCodeLogin', { + if (this.pollingTimer) { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'getUserCodeFailure', data: {message: 'There is already a polling request'}, }); @@ -285,19 +327,19 @@ const Authorization = WebexPlugin.extend({ }) .then((res) => { const {user_code, verification_uri, verification_uri_complete} = res.body; - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'getUserCodeSuccess', userData: { userCode: user_code, verificationUri: verification_uri, verificationUriComplete: verification_uri_complete, - } + }, }); // if device authorization success, then start to poll server to check whether the user has completed authorization this._startQRCodePolling(res.body); }) .catch((res) => { - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'getUserCodeFailure', data: res.body, }); @@ -313,30 +355,36 @@ const Authorization = WebexPlugin.extend({ */ _startQRCodePolling(options = {}) { if (!options.device_code) { - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'authorizationFailure', data: {message: 'A deviceCode is required'}, }); return; } - if (this.pollingRequest) { - this.eventEmitter.emit('qRCodeLogin', { + if (this.pollingTimer) { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'authorizationFailure', data: {message: 'There is already a polling request'}, }); return; } - const {device_code: deviceCode, interval = 2, expires_in: expiresIn = 300} = options; + const {device_code: deviceCode, expires_in: expiresIn = 300} = options; + let interval = options.interval ?? 2; - let attempts = 0; - const maxAttempts = expiresIn / interval; + this.pollingExpirationTimer = setTimeout(() => { + this.cancelQRCodePolling(false); + this.eventEmitter.emit(Events.qRCodeLogin, { + eventType: 'authorizationFailure', + data: {message: 'Authorization timed out'}, + }); + }, expiresIn * 1000); - this.pollingRequest = setInterval(() => { - attempts += 1; + const polling = () => { + this.pollingId += 1; + this.currentPollingId = this.pollingId; - const currentAttempts = attempts; this.webex .request({ method: 'POST', @@ -354,43 +402,50 @@ const Authorization = WebexPlugin.extend({ }, }) .then((res) => { - if (this.pollingRequest === null) return; + // if the pollingId has changed, it means that the polling request has been canceled + if (this.currentPollingId !== this.pollingId) return; - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'authorizationSuccess', data: res.body, }); this.cancelQRCodePolling(); }) .catch((res) => { - if (this.pollingRequest === null) return; + // if the pollingId has changed, it means that the polling request has been canceled + if (this.currentPollingId !== this.pollingId) return; - if (currentAttempts >= maxAttempts) { - this.eventEmitter.emit('qRCodeLogin', { - eventType: 'authorizationFailure', - data: {message: 'Authorization timed out'} - }); - this.cancelQRCodePolling(); + // When server sends 400 status code with message 'slow_down', it means that last request happened too soon. + // So, skip one interval and then poll again. + if (res.statusCode === 400 && res.body.message === 'slow_down') { + schedulePolling(interval * 2); return; } + // if the statusCode is 428 which means that the authorization request is still pending // as the end user hasn't yet completed the user-interaction steps. So keep polling. if (res.statusCode === 428) { - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'authorizationPending', - data: res.body + data: res.body, }); + schedulePolling(interval); return; } this.cancelQRCodePolling(); - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'authorizationFailure', - data: res.body + data: res.body, }); }); - }, interval * 1000); + }; + + const schedulePolling = (interval) => + (this.pollingTimer = setTimeout(polling, interval * 1000)); + + schedulePolling(interval); }, /** @@ -399,14 +454,19 @@ const Authorization = WebexPlugin.extend({ * @memberof AuthorizationBrowserFirstParty * @returns {void} */ - cancelQRCodePolling() { - if (this.pollingRequest) { - clearInterval(this.pollingRequest); - this.eventEmitter.emit('qRCodeLogin', { + cancelQRCodePolling(withCancelEvent = true) { + if (this.pollingTimer && withCancelEvent) { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'pollingCanceled', }); - this.pollingRequest = null; } + + this.currentPollingId = null; + + clearTimeout(this.pollingExpirationTimer); + this.pollingExpirationTimer = null; + clearTimeout(this.pollingTimer); + this.pollingTimer = null; }, /** diff --git a/packages/@webex/plugin-authorization-browser-first-party/src/index.js b/packages/@webex/plugin-authorization-browser-first-party/src/index.js index 02ca4c2bbe8..4870be6d0ed 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/src/index.js +++ b/packages/@webex/plugin-authorization-browser-first-party/src/index.js @@ -14,5 +14,5 @@ registerPlugin('authorization', Authorization, { proxies, }); -export {default} from './authorization'; +export {default, Events} from './authorization'; export {default as config} from './config'; diff --git a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js index 5bf294485fb..26709ca3da2 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js @@ -18,7 +18,6 @@ import Authorization from '@webex/plugin-authorization-browser-first-party'; // Necessary to require lodash this way in order to stub the method const lodash = require('lodash'); - describe('plugin-authorization-browser-first-party', () => { describe('Authorization', () => { function makeWebex( @@ -187,14 +186,16 @@ describe('plugin-authorization-browser-first-party', () => { const webex = makeWebex( `http://example.com/?code=${code}&state=${base64.encode( JSON.stringify({emailhash: 'someemailhash'}) - )}`, + )}` ); const requestAuthorizationCodeGrantStub = sinon.stub( Authorization.prototype, 'requestAuthorizationCodeGrant' ); - const collectPreauthCatalogStub = sinon.stub(Services.prototype, 'collectPreauthCatalog').resolves(); + const collectPreauthCatalogStub = sinon + .stub(Services.prototype, 'collectPreauthCatalog') + .resolves(); await webex.authorization.when('change:ready'); @@ -206,9 +207,7 @@ describe('plugin-authorization-browser-first-party', () => { it('collects the preauth catalog no emailhash is present in the state', async () => { const code = 'authcode_clusterid_theOrgId'; - const webex = makeWebex( - `http://example.com/?code=${code}` - ); + const webex = makeWebex(`http://example.com/?code=${code}`); const requestAuthorizationCodeGrantStub = sinon.stub( Authorization.prototype, @@ -271,12 +270,13 @@ describe('plugin-authorization-browser-first-party', () => { it('throws a grant error', () => { let err = null; try { - makeWebex('http://127.0.0.1:8000/?error=invalid_scope&error_description=The%20requested%20scope%20is%20invalid.'); - } - catch (e) { + makeWebex( + 'http://127.0.0.1:8000/?error=invalid_scope&error_description=The%20requested%20scope%20is%20invalid.' + ); + } catch (e) { err = e; } - expect(err?.message).toBe('Cannot convert object to primitive value') + expect(err?.message).toBe('Cannot convert object to primitive value'); }); }); @@ -446,14 +446,14 @@ describe('plugin-authorization-browser-first-party', () => { describe('#initQRCodeLogin()', () => { it('should prevent concurrent request if there is already a polling request', async () => { const webex = makeWebex('http://example.com'); - - webex.authorization.pollingRequest = 1; + + webex.authorization.pollingTimer = 1; const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); webex.authorization.initQRCodeLogin(); - + assert.calledOnce(emitSpy); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure'); - webex.authorization.pollingRequest = null; + assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure'); + webex.authorization.pollingTimer = null; }); it('should send correct request parameters to the API', async () => { @@ -461,19 +461,19 @@ describe('plugin-authorization-browser-first-party', () => { const testClientId = 'test-client-id'; const testScope = 'test-scope'; const sampleData = { - device_code: "test123", + device_code: 'test123', expires_in: 300, - user_code: "421175", - verification_uri: "http://example.com", - verification_uri_complete: "http://example.com", - interval: 2 + user_code: '421175', + verification_uri: 'http://example.com', + verification_uri_complete: 'http://example.com', + interval: 2, }; const webex = makeWebex('http://example.com', undefined, undefined, { credentials: { client_id: testClientId, scope: testScope, - } + }, }); webex.request.onFirstCall().resolves({statusCode: 200, body: sampleData}); sinon.spy(webex.authorization, '_startQRCodePolling'); @@ -481,11 +481,11 @@ describe('plugin-authorization-browser-first-party', () => { webex.authorization.initQRCodeLogin(); clock.tick(2000); - await clock.runAllAsync() + await clock.runAllAsync(); assert.calledTwice(webex.request); assert.calledOnce(webex.authorization._startQRCodePolling); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeSuccess'); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeSuccess'); const request = webex.request.getCall(0); @@ -498,18 +498,18 @@ describe('plugin-authorization-browser-first-party', () => { const clock = sinon.useFakeTimers(); const webex = makeWebex('http://example.com'); const sampleData = { - device_code: "test123", + device_code: 'test123', expires_in: 300, - user_code: "421175", - verification_uri: "http://example.com", - verification_uri_complete: "http://example.com", - interval: 2 + user_code: '421175', + verification_uri: 'http://example.com', + verification_uri_complete: 'http://example.com', + interval: 2, }; webex.request.resolves().resolves({statusCode: 200, body: sampleData}); webex.authorization.initQRCodeLogin(); clock.tick(2000); - await clock.runAllAsync() + await clock.runAllAsync(); const request = webex.request.getCall(0); assert.equal(request.args[0].method, 'POST'); @@ -517,7 +517,7 @@ describe('plugin-authorization-browser-first-party', () => { assert.equal(request.args[0].resource, '/actions/device/authorize'); clock.restore(); }); - + it('should emit getUserCodeFailure event', async () => { const clock = sinon.useFakeTimers(); const webex = makeWebex('http://example.com'); @@ -526,7 +526,7 @@ describe('plugin-authorization-browser-first-party', () => { webex.authorization.initQRCodeLogin(); - await clock.runAllAsync() + await clock.runAllAsync(); assert.calledOnce(emitSpy); assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure'); @@ -537,15 +537,15 @@ describe('plugin-authorization-browser-first-party', () => { describe('#_startQRCodePolling()', () => { it('requires a deviceCode', () => { const webex = makeWebex('http://example.com'); - + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); webex.authorization._startQRCodePolling({}); assert.calledOnce(emitSpy); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure'); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure'); }); - + it('should send correct request parameters to the API', async () => { const clock = sinon.useFakeTimers(); const testClientId = 'test-client-id'; @@ -560,7 +560,7 @@ describe('plugin-authorization-browser-first-party', () => { const webex = makeWebex('http://example.com', undefined, undefined, { credentials: { client_id: testClientId, - } + }, }); webex.request.onFirstCall().resolves({statusCode: 200, body: {access_token: 'token'}}); @@ -569,7 +569,7 @@ describe('plugin-authorization-browser-first-party', () => { webex.authorization._startQRCodePolling(options); clock.tick(2000); - await clock.runAllAsync() + await clock.runAllAsync(); assert.calledOnce(webex.request); @@ -577,7 +577,10 @@ describe('plugin-authorization-browser-first-party', () => { assert.equal(request.args[0].form.client_id, testClientId); assert.equal(request.args[0].form.device_code, testDeviceCode); - assert.equal(request.args[0].form.grant_type, 'urn:ietf:params:oauth:grant-type:device_code'); + assert.equal( + request.args[0].form.grant_type, + 'urn:ietf:params:oauth:grant-type:device_code' + ); assert.calledOnce(webex.authorization.cancelQRCodePolling); assert.calledTwice(emitSpy); @@ -593,23 +596,26 @@ describe('plugin-authorization-browser-first-party', () => { const options = { device_code: 'test-device-code', interval: 2, - expires_in: 300 + expires_in: 300, }; - - webex.request.onFirstCall().rejects({statusCode: 428, body: {message: 'authorization_pending'}}); + + webex.request + .onFirstCall() + .rejects({statusCode: 428, body: {message: 'authorization_pending'}}); webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}}); sinon.spy(webex.authorization, 'cancelQRCodePolling'); const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); - + webex.authorization._startQRCodePolling(options); - clock.tick(4000); - await clock.runAllAsync() - + await clock.tickAsync(4000); + //await clock.runAllAsync() + assert.calledTwice(webex.request); assert.calledOnce(webex.authorization.cancelQRCodePolling); - assert.calledTwice(emitSpy); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess'); - assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled'); + assert.calledThrice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationSuccess'); + assert.equal(emitSpy.getCall(2).args[1].eventType, 'pollingCanceled'); clock.restore(); }); @@ -619,23 +625,21 @@ describe('plugin-authorization-browser-first-party', () => { const options = { device_code: 'test-device-code', interval: 5, - expires_in: 10 + expires_in: 9, }; - + webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}}); sinon.spy(webex.authorization, 'cancelQRCodePolling'); const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); webex.authorization._startQRCodePolling(options); - clock.tick(10000); - await clock.runAllAsync() - - assert.calledTwice(webex.request); + await clock.tickAsync(10_000); + + assert.calledOnce(webex.request); assert.calledOnce(webex.authorization.cancelQRCodePolling); - assert.calledThrice(emitSpy); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending'); + assert.calledTwice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending'); assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationFailure'); - assert.equal(emitSpy.getCall(2).args[1].eventType, 'pollingCanceled'); clock.restore(); }); @@ -644,54 +648,129 @@ describe('plugin-authorization-browser-first-party', () => { const options = { device_code: 'test-device-code', interval: 2, - expires_in: 300 + expires_in: 300, }; - - webex.authorization.pollingRequest = 1; + + webex.authorization.pollingTimer = 1; const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); webex.authorization._startQRCodePolling(options); assert.calledOnce(emitSpy); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure'); - webex.authorization.pollingRequest = null; + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure'); + webex.authorization.pollingTimer = null; + }); + + it('should skip a interval when server ask for slow_down', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test-device-code', + interval: 2, + expires_in: 300, + }; + + webex.request.onFirstCall().rejects({statusCode: 400, body: {message: 'slow_down'}}); + webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}}); + sinon.spy(webex.authorization, 'cancelQRCodePolling'); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization._startQRCodePolling(options); + await clock.tickAsync(4000); + + // Request only once because of slow_down + assert.calledOnce(webex.request); + + // Wait for next interval + await clock.tickAsync(2000); + + assert.calledTwice(webex.request); + assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledTwice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled'); + clock.restore(); + }); + + it('should ignore the response from the previous polling', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test-device-code', + interval: 2, + expires_in: 300, + }; + + webex.request.onFirstCall().callsFake(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({statusCode: 200, body: {access_token: 'token'}}); + }, 1000); + }); + }); + + webex.request + .onSecondCall() + .rejects({statusCode: 428, body: {message: 'authorizationPending'}}); + sinon.spy(webex.authorization, 'cancelQRCodePolling'); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization._startQRCodePolling(options); + await clock.tickAsync(2500); + + webex.authorization.cancelQRCodePolling(); + + // Start new polling + + webex.authorization._startQRCodePolling(options); + + // Wait for next interval + await clock.tickAsync(3000); + + assert.calledTwice(webex.request); + assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledTwice(emitSpy); + // authorizationSuccess event should not be emitted + assert.equal(emitSpy.getCall(0).args[1].eventType, 'pollingCanceled'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationPending'); + clock.restore(); }); }); describe('#cancelQRCodePolling()', () => { it('should stop polling after cancellation', async () => { - const clock = sinon.useFakeTimers(); - const webex = makeWebex('http://example.com'); - const options = { - device_code: 'test-device-code', - interval: 2, - expires_in: 300 - }; - - webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}}); - const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); - - webex.authorization._startQRCodePolling(options); - // First poll - clock.tick(2000); - assert.calledOnce(webex.request); - - webex.authorization.cancelQRCodePolling(); - // Wait for next interval - clock.tick(2000); - - const eventArgs = emitSpy.getCall(0).args; - - // Verify no additional requests were made - assert.calledOnce(webex.request); - assert.calledOnce(emitSpy); - assert.equal(eventArgs[1].eventType, 'pollingCanceled'); - clock.restore(); - }); + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test-device-code', + interval: 2, + expires_in: 300, + }; + + webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}}); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization._startQRCodePolling(options); + // First poll + clock.tick(2000); + assert.calledOnce(webex.request); + + webex.authorization.cancelQRCodePolling(); + // Wait for next interval + clock.tick(2000); + + const eventArgs = emitSpy.getCall(0).args; + + // Verify no additional requests were made + assert.calledOnce(webex.request); + assert.calledOnce(emitSpy); + assert.equal(eventArgs[1].eventType, 'pollingCanceled'); + clock.restore(); + }); it('should clear interval and reset polling request', () => { const clock = sinon.useFakeTimers(); const webex = makeWebex('http://example.com'); - + const options = { device_code: 'test_device_code', interval: 2, @@ -699,22 +778,21 @@ describe('plugin-authorization-browser-first-party', () => { }; webex.authorization._startQRCodePolling(options); - assert.isDefined(webex.authorization.pollingRequest); - + assert.isDefined(webex.authorization.pollingTimer); + webex.authorization.cancelQRCodePolling(); - assert.isNull(webex.authorization.pollingRequest); - + assert.isNull(webex.authorization.pollingTimer); + clock.restore(); }); - + it('should handle cancellation when no polling is in progress', () => { const webex = makeWebex('http://example.com'); - assert.isNull(webex.authorization.pollingRequest); - + assert.isNull(webex.authorization.pollingTimer); + webex.authorization.cancelQRCodePolling(); - assert.isNull(webex.authorization.pollingRequest); + assert.isNull(webex.authorization.pollingTimer); }); - }); describe('#_generateCodeChallenge', () => { @@ -836,7 +914,7 @@ describe('plugin-authorization-browser-first-party', () => { const orgId = webex.authorization._extractOrgIdFromCode(code); assert.isUndefined(orgId); - }) + }); }); }); }); From daefe97735da016e4afd7e0bc9b3fb524a57e084 Mon Sep 17 00:00:00 2001 From: xinhyao <142190897+xinhyao@users.noreply.github.com> Date: Sun, 1 Dec 2024 06:27:43 +0800 Subject: [PATCH 16/30] fix(automotive): qr code for launching webex app (#4012) --- .../src/authorization.js | 27 ++++++++++++++- .../test/unit/spec/authorization.js | 34 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js index 381b63c037d..91be8a7ce34 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js @@ -295,6 +295,30 @@ const Authorization = WebexPlugin.extend({ }); }, + /** + * Generate a QR code URL to launch the Webex app when scanning with the camera + * @instance + * @memberof AuthorizationBrowserFirstParty + * @param {String} verificationUrl + * @returns {String} + */ + _generateQRCodeVerificationUrl(verificationUrl) { + const baseUrl = 'https://web.webex.com/deviceAuth'; + const urlParams = new URLSearchParams(new URL(verificationUrl).search); + const userCode = urlParams.get('userCode'); + + if (userCode) { + const {services} = this.webex.internal; + const oauthHelperUrl = services.get('oauth-helper'); + const newVerificationUrl = new URL(baseUrl); + newVerificationUrl.searchParams.set('usercode', userCode); + newVerificationUrl.searchParams.set('oauthhelper', oauthHelperUrl); + return newVerificationUrl.toString(); + } else { + return verificationUrl; + } + }, + /** * Get an OAuth Login URL for QRCode. Generate QR code based on the returned URL. * @instance @@ -327,12 +351,13 @@ const Authorization = WebexPlugin.extend({ }) .then((res) => { const {user_code, verification_uri, verification_uri_complete} = res.body; + const verificationUriComplete = this._generateQRCodeVerificationUrl(verification_uri_complete); this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'getUserCodeSuccess', userData: { userCode: user_code, verificationUri: verification_uri, - verificationUriComplete: verification_uri_complete, + verificationUriComplete, }, }); // if device authorization success, then start to poll server to check whether the user has completed authorization diff --git a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js index 26709ca3da2..b08af627998 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js @@ -443,6 +443,38 @@ describe('plugin-authorization-browser-first-party', () => { }); }); + describe('#_generateQRCodeVerificationUrl()', () => { + it('should generate a QR code URL when a userCode is present', () => { + const verificationUrl = 'https://example.com/verify?userCode=123456'; + const oauthHelperUrl = 'https://oauth-helper-a.wbx2.com/helperservice/v1'; + const expectedUrl = 'https://web.webex.com/deviceAuth?usercode=123456&oauthhelper=https%3A%2F%2Foauth-helper-a.wbx2.com%2Fhelperservice%2Fv1'; + + const webex = makeWebex('http://example.com'); + + const oauthHelperSpy = sinon.stub(webex.internal.services, 'get').returns(oauthHelperUrl); + const result = webex.authorization._generateQRCodeVerificationUrl(verificationUrl); + + assert.calledOnce(oauthHelperSpy); + assert.calledWithExactly(oauthHelperSpy, 'oauth-helper'); + assert.equal(result, expectedUrl); + + oauthHelperSpy.restore(); + }); + + it('should return the original verificationUrl when userCode is not present', () => { + const verificationUrl = 'https://example.com/verify'; + const webex = makeWebex('http://example.com'); + + const oauthHelperSpy = sinon.stub(webex.internal.services, 'get'); + const result = webex.authorization._generateQRCodeVerificationUrl(verificationUrl); + + assert.notCalled(oauthHelperSpy); + assert.equal(result, verificationUrl); + + oauthHelperSpy.restore(); + }); + }); + describe('#initQRCodeLogin()', () => { it('should prevent concurrent request if there is already a polling request', async () => { const webex = makeWebex('http://example.com'); @@ -477,6 +509,7 @@ describe('plugin-authorization-browser-first-party', () => { }); webex.request.onFirstCall().resolves({statusCode: 200, body: sampleData}); sinon.spy(webex.authorization, '_startQRCodePolling'); + sinon.spy(webex.authorization, '_generateQRCodeVerificationUrl'); const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); webex.authorization.initQRCodeLogin(); @@ -485,6 +518,7 @@ describe('plugin-authorization-browser-first-party', () => { assert.calledTwice(webex.request); assert.calledOnce(webex.authorization._startQRCodePolling); + assert.calledOnce(webex.authorization._generateQRCodeVerificationUrl); assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeSuccess'); const request = webex.request.getCall(0); From 0ec1a207d97c1798c6a96d8aae349856a8193f55 Mon Sep 17 00:00:00 2001 From: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Date: Sun, 1 Dec 2024 17:32:30 +0530 Subject: [PATCH 17/30] fix(meetings): handle getMe error due to insufficient privileges (#4011) --- .../plugin-meetings/src/meetings/index.ts | 85 ++++++++++--------- .../test/unit/spec/meetings/index.js | 17 +++- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/meetings/index.ts b/packages/@webex/plugin-meetings/src/meetings/index.ts index 6229462791b..dd0ade33a9f 100644 --- a/packages/@webex/plugin-meetings/src/meetings/index.ts +++ b/packages/@webex/plugin-meetings/src/meetings/index.ts @@ -1044,48 +1044,55 @@ export default class Meetings extends WebexPlugin { */ fetchUserPreferredWebexSite() { // @ts-ignore - return this.webex.people._getMe().then((me) => { - const isGuestUser = me.type === 'appuser'; - if (!isGuestUser) { - return this.request.getMeetingPreferences().then((res) => { - if (res) { - const preferredWebexSite = MeetingsUtil.parseDefaultSiteFromMeetingPreferences(res); - this.preferredWebexSite = preferredWebexSite; - // @ts-ignore - this.webex.internal.services._getCatalog().addAllowedDomains([preferredWebexSite]); - } + return this.webex.people + ._getMe() + .then((me) => { + const isGuestUser = me.type === 'appuser'; + if (!isGuestUser) { + return this.request.getMeetingPreferences().then((res) => { + if (res) { + const preferredWebexSite = MeetingsUtil.parseDefaultSiteFromMeetingPreferences(res); + this.preferredWebexSite = preferredWebexSite; + // @ts-ignore + this.webex.internal.services._getCatalog().addAllowedDomains([preferredWebexSite]); + } - // fall back to getting the preferred site from the user information - if (!this.preferredWebexSite) { - // @ts-ignore - return this.webex.internal.user - .get() - .then((user) => { - const preferredWebexSite = - user?.userPreferences?.userPreferencesItems?.preferredWebExSite; - if (preferredWebexSite) { - this.preferredWebexSite = preferredWebexSite; - // @ts-ignore - this.webex.internal.services - ._getCatalog() - .addAllowedDomains([preferredWebexSite]); - } else { - throw new Error('site not found'); - } - }) - .catch(() => { - LoggerProxy.logger.error( - 'Failed to fetch preferred site from user - no site will be set' - ); - }); - } + // fall back to getting the preferred site from the user information + if (!this.preferredWebexSite) { + // @ts-ignore + return this.webex.internal.user + .get() + .then((user) => { + const preferredWebexSite = + user?.userPreferences?.userPreferencesItems?.preferredWebExSite; + if (preferredWebexSite) { + this.preferredWebexSite = preferredWebexSite; + // @ts-ignore + this.webex.internal.services + ._getCatalog() + .addAllowedDomains([preferredWebexSite]); + } else { + throw new Error('site not found'); + } + }) + .catch(() => { + LoggerProxy.logger.error( + 'Failed to fetch preferred site from user - no site will be set' + ); + }); + } - return Promise.resolve(); - }); - } + return Promise.resolve(); + }); + } - return Promise.resolve(); - }); + return Promise.resolve(); + }) + .catch(() => { + LoggerProxy.logger.error( + 'Failed to retrieve user information. No preferredWebexSite will be set' + ); + }); } /** diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js index 89a64498e36..545a05fbd97 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js @@ -2077,6 +2077,21 @@ describe('plugin-meetings', () => { ]); }); + it('should handle failure to get user information if scopes are insufficient', async () => { + loggerProxySpy = sinon.spy(LoggerProxy.logger, 'error'); + Object.assign(webex.people, { + _getMe: sinon.stub().returns(Promise.reject()), + }); + + await webex.meetings.fetchUserPreferredWebexSite(); + + assert.equal(webex.meetings.preferredWebexSite, ''); + assert.calledOnceWithExactly( + loggerProxySpy, + 'Failed to retrieve user information. No preferredWebexSite will be set' + ); + }); + const setup = ({me = { type: 'validuser'}, user} = {}) => { loggerProxySpy = sinon.spy(LoggerProxy.logger, 'error'); assert.deepEqual(webex.internal.services._getCatalog().getAllowedDomains(), []); @@ -2093,7 +2108,7 @@ describe('plugin-meetings', () => { Object.assign(webex.people, { _getMe: sinon.stub().returns(Promise.resolve(me)), - }); + }); }; it('should not call request.getMeetingPreferences if user is a guest', async () => { From 602e8c625df1569277a770cc7c9a72107515e012 Mon Sep 17 00:00:00 2001 From: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Date: Mon, 2 Dec 2024 19:16:32 +0530 Subject: [PATCH 18/30] fix(samples): update wording in samples to avoid confusion (#4014) --- docs/samples/browser-plugin-meetings/app.js | 2 +- docs/samples/calling/index.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/samples/browser-plugin-meetings/app.js b/docs/samples/browser-plugin-meetings/app.js index 04e4d41dfd0..e0aa78e1158 100644 --- a/docs/samples/browser-plugin-meetings/app.js +++ b/docs/samples/browser-plugin-meetings/app.js @@ -375,7 +375,7 @@ function collectMeetings() { createMeetingSelectElm.addEventListener('change', (event) => { if (event.target.value === 'CONVERSATION_URL') { - createMeetingActionElm.innerText = 'Create Adhoc Meeting'; + createMeetingActionElm.innerText = 'Create Adhoc Meeting using conversation URL (INTERNAL-USE ONLY)'; } else { createMeetingActionElm.innerText = 'Create Meeting'; diff --git a/docs/samples/calling/index.html b/docs/samples/calling/index.html index af36f1ea19a..d78988b5473 100644 --- a/docs/samples/calling/index.html +++ b/docs/samples/calling/index.html @@ -49,9 +49,9 @@

Authentication

Advanced Settings

- Following options allow to set the type of registration, service domain (e.g. cisco.webex.com), server region (e.g. east) and the country (e.g. us). + Following options allow to set the type of registration, service domain (only needed for contactcenter - rtw.prod-us1.rtmsprod.net ), server region (e.g. US-EAST) and the country (e.g. US).

- Note: Please update these before Initialize Calling if want to use different values. + Note: Please set these fields before Initialize Calling to customize the registration behavior.

From 8225a78b1131d682bf9055b9aa8b3cbc8989de63 Mon Sep 17 00:00:00 2001 From: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Date: Tue, 3 Dec 2024 20:52:20 +0530 Subject: [PATCH 19/30] fix(meetings): add llm plugin as dependency for meetings.min.js (#4017) --- packages/webex/src/meetings.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/webex/src/meetings.js b/packages/webex/src/meetings.js index 6483fdb533c..26fcc7d7825 100644 --- a/packages/webex/src/meetings.js +++ b/packages/webex/src/meetings.js @@ -24,6 +24,7 @@ require('@webex/internal-plugin-support'); require('@webex/internal-plugin-user'); require('@webex/internal-plugin-voicea'); require('@webex/plugin-people'); +require('@webex/internal-plugin-llm'); const merge = require('lodash/merge'); const WebexCore = require('@webex/webex-core').default; From 61a8ad9ef4d5c78d2e23ea335199279f33f3e5bc Mon Sep 17 00:00:00 2001 From: Marcin Date: Wed, 4 Dec 2024 16:12:55 +0000 Subject: [PATCH 20/30] feat(meetings): added support for "early-call-min-clusters" option in Orpheus API (#4013) --- .../plugin-meetings/src/meeting/index.ts | 2 - .../src/meeting/locusMediaRequest.ts | 12 +- .../plugin-meetings/src/meeting/request.ts | 15 +- .../plugin-meetings/src/meeting/util.ts | 28 +- .../src/reachability/clusterReachability.ts | 15 +- .../plugin-meetings/src/reachability/index.ts | 356 ++++++++++++++---- .../src/reachability/reachability.types.ts | 85 +++++ .../src/reachability/request.ts | 86 +++-- .../@webex/plugin-meetings/src/roap/index.ts | 9 +- .../plugin-meetings/src/roap/request.ts | 74 ++-- .../plugin-meetings/src/roap/turnDiscovery.ts | 6 +- .../test/unit/spec/meeting/index.js | 27 +- .../unit/spec/meeting/locusMediaRequest.ts | 29 +- .../test/unit/spec/meeting/request.js | 29 +- .../test/unit/spec/meeting/utils.js | 66 +++- .../test/unit/spec/reachability/index.ts | 264 +++++++++++++ .../test/unit/spec/reachability/request.js | 60 ++- .../test/unit/spec/roap/index.ts | 2 +- .../test/unit/spec/roap/request.ts | 160 +++----- .../test/unit/spec/roap/turnDiscovery.ts | 349 +++++++++-------- 20 files changed, 1140 insertions(+), 534 deletions(-) create mode 100644 packages/@webex/plugin-meetings/src/reachability/reachability.types.ts diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index edff3f1de51..739c5e22513 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -4771,8 +4771,6 @@ export default class Meeting extends StatelessWebexPlugin { if (!joinResponse) { // This is the 1st attempt or a retry after join request failed -> we need to do a join with TURN discovery - // @ts-ignore - joinOptions.reachability = await this.webex.meetings.reachability.getReachabilityResults(); const turnDiscoveryRequest = await this.roap.generateTurnDiscoveryRequestMessage( this, true diff --git a/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts b/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts index 488d5836dd6..0d56f77a796 100644 --- a/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts +++ b/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts @@ -2,8 +2,9 @@ import {defer} from 'lodash'; import {Defer} from '@webex/common'; import {WebexPlugin} from '@webex/webex-core'; -import {MEDIA, HTTP_VERBS, ROAP, IP_VERSION} from '../constants'; +import {MEDIA, HTTP_VERBS, ROAP} from '../constants'; import LoggerProxy from '../common/logs/logger-proxy'; +import {ClientMediaPreferences} from '../reachability/reachability.types'; export type MediaRequestType = 'RoapMessage' | 'LocalMute'; export type RequestResult = any; @@ -14,9 +15,8 @@ export type RoapRequest = { mediaId: string; roapMessage: any; reachability: any; + clientMediaPreferences: ClientMediaPreferences; sequence?: any; - joinCookie: any; // any, because this is opaque to the client, we pass whatever object we got from one backend component (Orpheus) to the other (Locus) - ipVersion?: IP_VERSION; }; export type LocalMuteRequest = { @@ -202,10 +202,6 @@ export class LocusMediaRequest extends WebexPlugin { const body: any = { device: this.config.device, correlationId: this.config.correlationId, - clientMediaPreferences: { - preferTranscoding: this.config.preferTranscoding, - ipver: request.type === 'RoapMessage' ? request.ipVersion : undefined, - }, }; const localMedias: any = { @@ -223,7 +219,7 @@ export class LocusMediaRequest extends WebexPlugin { case 'RoapMessage': localMedias.roapMessage = request.roapMessage; localMedias.reachability = request.reachability; - body.clientMediaPreferences.joinCookie = request.joinCookie; + body.clientMediaPreferences = request.clientMediaPreferences; // @ts-ignore this.webex.internal.newMetrics.submitClientEvent({ diff --git a/packages/@webex/plugin-meetings/src/meeting/request.ts b/packages/@webex/plugin-meetings/src/meeting/request.ts index 89714139bef..c8b34564d65 100644 --- a/packages/@webex/plugin-meetings/src/meeting/request.ts +++ b/packages/@webex/plugin-meetings/src/meeting/request.ts @@ -26,11 +26,11 @@ import { SEND_DTMF_ENDPOINT, _SLIDES_, ANNOTATION, - IP_VERSION, } from '../constants'; import {SendReactionOptions, ToggleReactionsOptions} from './request.type'; import MeetingUtil from './util'; import {AnnotationInfo} from '../annotation/annotation.types'; +import {ClientMediaPreferences} from '../reachability/reachability.types'; /** * @class MeetingRequest @@ -128,8 +128,8 @@ export default class MeetingRequest extends StatelessWebexPlugin { locale?: string; deviceCapabilities?: Array; liveAnnotationSupported: boolean; - ipVersion?: IP_VERSION; alias?: string; + clientMediaPreferences: ClientMediaPreferences; }) { const { asResourceOccupant, @@ -147,12 +147,11 @@ export default class MeetingRequest extends StatelessWebexPlugin { moveToResource, roapMessage, reachability, - preferTranscoding, breakoutsSupported, locale, deviceCapabilities = [], liveAnnotationSupported, - ipVersion, + clientMediaPreferences, alias, } = options; @@ -160,8 +159,6 @@ export default class MeetingRequest extends StatelessWebexPlugin { let url = ''; - const joinCookie = await this.getJoinCookie(); - const body: any = { asResourceOccupant, device: { @@ -176,11 +173,7 @@ export default class MeetingRequest extends StatelessWebexPlugin { allowMultiDevice: true, ensureConversation: ensureConversation || false, supportsNativeLobby: 1, - clientMediaPreferences: { - preferTranscoding: preferTranscoding ?? true, - joinCookie, - ipver: ipVersion, - }, + clientMediaPreferences, }; if (alias) { diff --git a/packages/@webex/plugin-meetings/src/meeting/util.ts b/packages/@webex/plugin-meetings/src/meeting/util.ts index 8fa62d26d8c..b361b03366a 100644 --- a/packages/@webex/plugin-meetings/src/meeting/util.ts +++ b/packages/@webex/plugin-meetings/src/meeting/util.ts @@ -115,7 +115,7 @@ const MeetingUtil = { return IP_VERSION.unknown; }, - joinMeeting: (meeting, options) => { + joinMeeting: async (meeting, options) => { if (!meeting) { return Promise.reject(new ParameterError('You need a meeting object.')); } @@ -127,6 +127,27 @@ const MeetingUtil = { options: {meetingId: meeting.id}, }); + let reachability; + let clientMediaPreferences = { + // bare minimum fallback value that should allow us to join + ipver: IP_VERSION.unknown, + joinCookie: undefined, + preferTranscoding: !meeting.isMultistream, + }; + + try { + clientMediaPreferences = await webex.meetings.reachability.getClientMediaPreferences( + meeting.isMultistream, + MeetingUtil.getIpVersion(webex) + ); + reachability = await webex.meetings.reachability.getReachabilityReportToAttachToRoap(); + } catch (e) { + LoggerProxy.logger.error( + 'Meeting:util#joinMeeting --> Error getting reachability or clientMediaPreferences:', + e + ); + } + // eslint-disable-next-line no-warning-comments // TODO: check if the meeting is in JOINING state // if Joining state termintate the request as user might click multiple times @@ -138,20 +159,19 @@ const MeetingUtil = { locusUrl: meeting.locusUrl, locusClusterUrl: meeting.meetingInfo?.locusClusterUrl, correlationId: meeting.correlationId, - reachability: options.reachability, + reachability, roapMessage: options.roapMessage, permissionToken: meeting.permissionToken, resourceId: options.resourceId || null, moderator: options.moderator, pin: options.pin, moveToResource: options.moveToResource, - preferTranscoding: !meeting.isMultistream, asResourceOccupant: options.asResourceOccupant, breakoutsSupported: options.breakoutsSupported, locale: options.locale, deviceCapabilities: options.deviceCapabilities, liveAnnotationSupported: options.liveAnnotationSupported, - ipVersion: MeetingUtil.getIpVersion(meeting.getWebexObject()), + clientMediaPreferences, }) .then((res) => { const parsed = MeetingUtil.parseLocusJoin(res); diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index 3803f95e1df..cc3674ac169 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -6,20 +6,7 @@ import {convertStunUrlToTurn, convertStunUrlToTurnTls} from './util'; import EventsScope from '../common/events/events-scope'; import {CONNECTION_STATE, Enum, ICE_GATHERING_STATE} from '../constants'; - -// result for a specific transport protocol (like udp or tcp) -export type TransportResult = { - result: 'reachable' | 'unreachable' | 'untested'; - latencyInMilliseconds?: number; // amount of time it took to get the first ICE candidate - clientMediaIPs?: string[]; -}; - -// reachability result for a specific media cluster -export type ClusterReachabilityResult = { - udp: TransportResult; - tcp: TransportResult; - xtls: TransportResult; -}; +import {ClusterReachabilityResult} from './reachability.types'; // data for the Events.resultReady event export type ResultEventData = { diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts index ff6d47a14b7..a0903d9decc 100644 --- a/packages/@webex/plugin-meetings/src/reachability/index.ts +++ b/packages/@webex/plugin-meetings/src/reachability/index.ts @@ -9,64 +9,31 @@ import {Defer} from '@webex/common'; import LoggerProxy from '../common/logs/logger-proxy'; import MeetingUtil from '../meeting/util'; -import {REACHABILITY} from '../constants'; +import {IP_VERSION, REACHABILITY} from '../constants'; import ReachabilityRequest, {ClusterList} from './request'; +import { + ClusterReachabilityResult, + TransportResult, + ClientMediaPreferences, + ReachabilityMetrics, + ReachabilityReportV0, + ReachabilityReportV1, + ReachabilityResults, + ReachabilityResultsForBackend, + TransportResultForBackend, + GetClustersTrigger, +} from './reachability.types'; import { ClientMediaIpsUpdatedEventData, ClusterReachability, - ClusterReachabilityResult, Events, ResultEventData, - TransportResult, } from './clusterReachability'; import EventsScope from '../common/events/events-scope'; import BEHAVIORAL_METRICS from '../metrics/constants'; import Metrics from '../metrics'; -export type ReachabilityMetrics = { - reachability_public_udp_success: number; - reachability_public_udp_failed: number; - reachability_public_tcp_success: number; - reachability_public_tcp_failed: number; - reachability_public_xtls_success: number; - reachability_public_xtls_failed: number; - reachability_vmn_udp_success: number; - reachability_vmn_udp_failed: number; - reachability_vmn_tcp_success: number; - reachability_vmn_tcp_failed: number; - reachability_vmn_xtls_success: number; - reachability_vmn_xtls_failed: number; -}; - -/** - * This is the type that matches what backend expects us to send to them. It is a bit weird, because - * it uses strings instead of booleans and numbers, but that's what they require. - */ -export type TransportResultForBackend = { - reachable?: 'true' | 'false'; - latencyInMilliseconds?: string; - clientMediaIPs?: string[]; - untested?: 'true'; -}; - -export type ReachabilityResultForBackend = { - udp: TransportResultForBackend; - tcp: TransportResultForBackend; - xtls: TransportResultForBackend; -}; - -// this is the type that is required by the backend when we send them reachability results -export type ReachabilityResultsForBackend = Record; - -// this is the type used by Reachability class internally and stored in local storage -export type ReachabilityResults = Record< - string, - ClusterReachabilityResult & { - isVideoMesh?: boolean; - } ->; - // timeouts in seconds const DEFAULT_TIMEOUT = 3; const VIDEO_MESH_TIMEOUT = 1; @@ -84,6 +51,9 @@ export default class Reachability extends EventsScope { [key: string]: ClusterReachability; }; + minRequiredClusters?: number; + orpheusApiVersion?: number; + reachabilityDefer?: Defer; vmnTimer?: ReturnType; @@ -92,6 +62,8 @@ export default class Reachability extends EventsScope { expectedResultsCount = {videoMesh: {udp: 0}, public: {udp: 0, tcp: 0, xtls: 0}}; resultsCount = {videoMesh: {udp: 0}, public: {udp: 0, tcp: 0, xtls: 0}}; + startTime = undefined; + totalDuration = undefined; protected lastTrigger?: string; @@ -118,14 +90,35 @@ export default class Reachability extends EventsScope { /** * Fetches the list of media clusters from the backend + * @param {string} trigger - explains the reason for starting reachability, used by Orpheus + * @param {Object} previousReport - last reachability report * @param {boolean} isRetry * @private * @returns {Promise<{clusters: ClusterList, joinCookie: any}>} */ - async getClusters(isRetry = false): Promise<{clusters: ClusterList; joinCookie: any}> { + async getClusters( + trigger: GetClustersTrigger, + previousReport?: any, + isRetry = false + ): Promise<{ + clusters: ClusterList; + joinCookie: any; + }> { try { - const {clusters, joinCookie} = await this.reachabilityRequest.getClusters( - MeetingUtil.getIpVersion(this.webex) + const {clusters, joinCookie, discoveryOptions} = await this.reachabilityRequest.getClusters( + trigger, + MeetingUtil.getIpVersion(this.webex), + previousReport + ); + + this.minRequiredClusters = discoveryOptions?.['early-call-min-clusters']; + this.orpheusApiVersion = discoveryOptions?.['report-version']; + + // @ts-ignore + await this.webex.boundedStorage.put( + this.namespace, + REACHABILITY.localStorageJoinCookie, + JSON.stringify(joinCookie) ); return {clusters, joinCookie}; @@ -138,7 +131,7 @@ export default class Reachability extends EventsScope { `Reachability:index#getClusters --> Failed with error: ${error}, retrying...` ); - return this.getClusters(true); + return this.getClusters(trigger, previousReport, true); } } @@ -159,14 +152,7 @@ export default class Reachability extends EventsScope { // @ts-ignore this.webex.internal.device.ipNetworkDetector.detect(true); - const {clusters, joinCookie} = await this.getClusters(); - - // @ts-ignore - await this.webex.boundedStorage.put( - this.namespace, - REACHABILITY.localStorageJoinCookie, - JSON.stringify(joinCookie) - ); + const {clusters} = await this.getClusters('startup'); this.reachabilityDefer = new Defer(); @@ -181,6 +167,98 @@ export default class Reachability extends EventsScope { } } + /** + * Gets the last join cookie we got from Orpheus + * + * @returns {Promise} join cookie + */ + async getJoinCookie() { + // @ts-ignore + const joinCookieRaw = await this.webex.boundedStorage + .get(REACHABILITY.namespace, REACHABILITY.localStorageJoinCookie) + .catch(() => {}); + + let joinCookie; + + if (joinCookieRaw) { + try { + joinCookie = JSON.parse(joinCookieRaw); + } catch (e) { + LoggerProxy.logger.error( + `MeetingRequest#constructor --> Error in parsing join cookie data: ${e}` + ); + } + } + + return joinCookie; + } + + /** + * Returns the reachability report that needs to be attached to the ROAP messages + * that we send to the backend. + * + * @returns {Promise} + */ + async getReachabilityReport(): Promise< + | { + joinCookie: any; + reachability?: ReachabilityReportV1; + } + | { + reachability: ReachabilityReportV0; + } + > { + const reachabilityResult = await this.getReachabilityResults(); + const joinCookie = await this.getJoinCookie(); + + // Orpheus API version 0 + if (!this.orpheusApiVersion) { + return { + reachability: reachabilityResult, + }; + } + + // Orpheus API version 1 + return { + reachability: { + version: 1, + result: { + usedDiscoveryOptions: { + 'early-call-min-clusters': this.minRequiredClusters, + }, + metrics: { + 'total-duration-ms': this.totalDuration, + }, + tests: reachabilityResult, + }, + }, + joinCookie, + }; + } + + /** + * This method is called when we don't succeed in reaching the minimum number of clusters + * required by Orpheus. It sends the results to Orpheus and gets a new list that it tries to reach again. + * @returns {Promise} reachability results + * @public + * @memberof Reachability + */ + public async gatherReachabilityFallback(): Promise { + try { + const reachabilityReport = await this.getReachabilityReport(); + + const {clusters} = await this.getClusters('early-call/no-min-reached', reachabilityReport); + + // stop all previous reachability checks that might still be going on in the background + this.abortCurrentChecks(); + + // Perform Reachability Check + await this.performReachabilityChecks(clusters); + } catch (error) { + LoggerProxy.logger.error(`Reachability:index#gatherReachabilityFallback --> Error:`, error); + } + } + /** * Returns statistics about last reachability results. The returned value is an object * with a flat list of properties so that it can be easily sent with metrics @@ -304,7 +382,7 @@ export default class Reachability extends EventsScope { } catch (e) { // empty storage, that's ok LoggerProxy.logger.warn( - 'Roap:request#attachReachabilityData --> Error parsing reachability data: ', + 'Reachability:index#getReachabilityResults --> Error parsing reachability data: ', e ); } @@ -336,7 +414,7 @@ export default class Reachability extends EventsScope { ); } catch (e) { LoggerProxy.logger.error( - `Roap:request#attachReachabilityData --> Error in parsing reachability data: ${e}` + `Reachability:index#isAnyPublicClusterReachable --> Error in parsing reachability data: ${e}` ); } } @@ -393,7 +471,7 @@ export default class Reachability extends EventsScope { ); } catch (e) { LoggerProxy.logger.error( - `Roap:request#attachReachabilityData --> Error in parsing reachability data: ${e}` + `Reachability:index#isWebexMediaBackendUnreachable --> Error in parsing reachability data: ${e}` ); } } @@ -427,6 +505,30 @@ export default class Reachability extends EventsScope { return unreachableList; } + /** + * Gets the number of reachable clusters from last run reachability check + * @returns {number} reachable clusters count + * @private + * @memberof Reachability + */ + private getNumberOfReachableClusters(): number { + let count = 0; + + Object.entries(this.clusterReachability).forEach(([key, clusterReachability]) => { + const result = clusterReachability.getResult(); + + if ( + result.udp.result === 'reachable' || + result.tcp.result === 'reachable' || + result.xtls.result === 'reachable' + ) { + count += 1; + } + }); + + return count; + } + /** * Make a log of unreachable clusters. * @returns {undefined} @@ -465,18 +567,27 @@ export default class Reachability extends EventsScope { /** * Resolves the promise returned by gatherReachability() method + * @param {boolean} checkMinRequiredClusters - if true, it will check if we have reached the minimum required clusters and do a fallback if needed * @returns {void} */ - private resolveReachabilityPromise() { - if (this.vmnTimer) { - clearTimeout(this.vmnTimer); - } - if (this.publicCloudTimer) { - clearTimeout(this.publicCloudTimer); - } + private resolveReachabilityPromise(checkMinRequiredClusters = true) { + this.totalDuration = performance.now() - this.startTime; + + this.clearTimer('vmnTimer'); + this.clearTimer('publicCloudTimer'); this.logUnreachableClusters(); this.reachabilityDefer?.resolve(); + + if (checkMinRequiredClusters) { + const numReachableClusters = this.getNumberOfReachableClusters(); + if (this.minRequiredClusters && numReachableClusters < this.minRequiredClusters) { + LoggerProxy.logger.log( + `Reachability:index#resolveReachabilityPromise --> minRequiredClusters not reached (${numReachableClusters} < ${this.minRequiredClusters}), doing reachability fallback` + ); + this.gatherReachabilityFallback(); + } + } } /** @@ -591,6 +702,8 @@ export default class Reachability extends EventsScope { `Reachability:index#startTimers --> Reachability checks timed out (${DEFAULT_TIMEOUT}s)` ); + // check against minimum required clusters, do a new call if we don't have enough + // resolve the promise, so that the client won't be blocked waiting on meetings.register() for too long this.resolveReachabilityPromise(); }, DEFAULT_TIMEOUT * 1000); @@ -646,6 +759,32 @@ export default class Reachability extends EventsScope { this.resultsCount.public.xtls = 0; } + /** + * Clears the timer + * + * @param {string} timer name of the timer to clear + * @returns {void} + */ + private clearTimer(timer: string) { + if (this[timer]) { + clearTimeout(this[timer]); + this[timer] = undefined; + } + } + + /** + * Aborts current checks that are in progress + * + * @returns {void} + */ + private abortCurrentChecks() { + this.clearTimer('vmnTimer'); + this.clearTimer('publicCloudTimer'); + this.clearTimer('overallTimer'); + + this.abortClusterReachability(); + } + /** * Performs reachability checks for all clusters * @param {ClusterList} clusterList @@ -656,9 +795,7 @@ export default class Reachability extends EventsScope { this.clusterReachability = {}; - if (!clusterList || !Object.keys(clusterList).length) { - return; - } + this.startTime = performance.now(); LoggerProxy.logger.log( `Reachability:index#performReachabilityChecks --> doing UDP${ @@ -671,7 +808,6 @@ export default class Reachability extends EventsScope { ); this.resetResultCounters(); - this.startTimers(); // sanitize the urls in the clusterList Object.keys(clusterList).forEach((key) => { @@ -721,6 +857,24 @@ export default class Reachability extends EventsScope { // save the initialized results (in case we don't get any "resultReady" events at all) await this.storeResults(results); + if (!clusterList || !Object.keys(clusterList).length) { + // nothing to do, finish immediately + this.resolveReachabilityPromise(false); + + this.emit( + { + file: 'reachability', + function: 'performReachabilityChecks', + }, + 'reachability:done', + {} + ); + + return; + } + + this.startTimers(); + // now start the reachability on all the clusters Object.keys(clusterList).forEach((key) => { const cluster = clusterList[key]; @@ -753,8 +907,7 @@ export default class Reachability extends EventsScope { await this.storeResults(results); if (areAllResultsReady) { - clearTimeout(this.overallTimer); - this.overallTimer = undefined; + this.clearTimer('overallTimer'); this.emit( { file: 'reachability', @@ -785,4 +938,59 @@ export default class Reachability extends EventsScope { this.clusterReachability[key].start(); // not awaiting on purpose }); } + + /** + * Returns the clientMediaPreferences object that needs to be sent to the backend + * when joining a meeting + * + * @param {boolean} isMultistream + * @param {IP_VERSION} ipver + * @returns {Object} + */ + async getClientMediaPreferences( + isMultistream: boolean, + ipver?: IP_VERSION + ): Promise { + // if 0 or undefined, we assume version 0 and don't send any reachability in clientMediaPreferences + if (!this.orpheusApiVersion) { + return { + ipver, + joinCookie: await this.getJoinCookie(), + preferTranscoding: !isMultistream, + }; + } + + // must be version 1 + + // for version 1, the reachability report goes into clientMediaPreferences (and it contains joinCookie) + const reachabilityReport = (await this.getReachabilityReport()) as { + joinCookie: any; + reachability?: ReachabilityReportV1; + }; + + return { + ipver, + preferTranscoding: !isMultistream, + ...reachabilityReport, + }; + } + + /** + * Returns the reachability report that needs to be attached to the ROAP messages + * that we send to the backend. + * It may return undefined, if reachability is not needed to be attached to ROAP messages (that's the case for v1 or Orpheus API) + * + * @returns {Promise} object that needs to be attached to Roap messages + */ + async getReachabilityReportToAttachToRoap(): Promise { + // version 0 + if (!this.orpheusApiVersion) { + return this.getReachabilityResults(); + } + + // version 1 + + // for version 1 we don't attach anything to Roap messages, reachability report is sent inside clientMediaPreferences + return undefined; + } } diff --git a/packages/@webex/plugin-meetings/src/reachability/reachability.types.ts b/packages/@webex/plugin-meetings/src/reachability/reachability.types.ts new file mode 100644 index 00000000000..62b94311637 --- /dev/null +++ b/packages/@webex/plugin-meetings/src/reachability/reachability.types.ts @@ -0,0 +1,85 @@ +import {IP_VERSION} from '../constants'; + +// result for a specific transport protocol (like udp or tcp) +export type TransportResult = { + result: 'reachable' | 'unreachable' | 'untested'; + latencyInMilliseconds?: number; // amount of time it took to get the first ICE candidate + clientMediaIPs?: string[]; +}; + +// reachability result for a specific media cluster +export type ClusterReachabilityResult = { + udp: TransportResult; + tcp: TransportResult; + xtls: TransportResult; +}; + +export type ReachabilityMetrics = { + reachability_public_udp_success: number; + reachability_public_udp_failed: number; + reachability_public_tcp_success: number; + reachability_public_tcp_failed: number; + reachability_public_xtls_success: number; + reachability_public_xtls_failed: number; + reachability_vmn_udp_success: number; + reachability_vmn_udp_failed: number; + reachability_vmn_tcp_success: number; + reachability_vmn_tcp_failed: number; + reachability_vmn_xtls_success: number; + reachability_vmn_xtls_failed: number; +}; + +/** + * This is the type that matches what backend expects us to send to them. It is a bit weird, because + * it uses strings instead of booleans and numbers, but that's what they require. + */ +export type TransportResultForBackend = { + reachable?: 'true' | 'false'; + latencyInMilliseconds?: string; + clientMediaIPs?: string[]; + untested?: 'true'; +}; + +export type ReachabilityResultForBackend = { + udp: TransportResultForBackend; + tcp: TransportResultForBackend; + xtls: TransportResultForBackend; +}; + +// this is the type that is required by the backend when we send them reachability results +export type ReachabilityResultsForBackend = Record; + +// this is the type used by Reachability class internally and stored in local storage +export type ReachabilityResults = Record< + string, + ClusterReachabilityResult & { + isVideoMesh?: boolean; + } +>; + +export type ReachabilityReportV0 = ReachabilityResultsForBackend; + +export type ReachabilityReportV1 = { + version: 1; + result: { + usedDiscoveryOptions: { + 'early-call-min-clusters': number; + // there are more options, but we don't support them yet + }; + metrics: { + 'total-duration-ms': number; + // there are more metrics, but we don't support them yet + }; + tests: Record; + }; +}; + +export interface ClientMediaPreferences { + ipver: IP_VERSION; + joinCookie: any; + preferTranscoding: boolean; + reachability?: ReachabilityReportV1; // only present when using Orpheus API version 1 +} + +/* Orpheus API supports more triggers, but we don't use them yet */ +export type GetClustersTrigger = 'startup' | 'early-call/no-min-reached'; diff --git a/packages/@webex/plugin-meetings/src/reachability/request.ts b/packages/@webex/plugin-meetings/src/reachability/request.ts index d3878651469..0a2e3897376 100644 --- a/packages/@webex/plugin-meetings/src/reachability/request.ts +++ b/packages/@webex/plugin-meetings/src/reachability/request.ts @@ -1,5 +1,6 @@ import LoggerProxy from '../common/logs/logger-proxy'; import {HTTP_VERBS, RESOURCE, API, IP_VERSION} from '../constants'; +import {GetClustersTrigger} from './reachability.types'; export interface ClusterNode { isVideoMesh: boolean; @@ -30,44 +31,67 @@ class ReachabilityRequest { /** * Gets the cluster information * + * @param {string} trigger that's passed to Orpheus * @param {IP_VERSION} ipVersion information about current ip network we're on + * @param {Object} previousReport last reachability result * @returns {Promise} */ - getClusters = (ipVersion?: IP_VERSION): Promise<{clusters: ClusterList; joinCookie: any}> => - this.webex.internal.newMetrics.callDiagnosticLatencies - .measureLatency( - () => - this.webex.request({ - method: HTTP_VERBS.GET, - shouldRefreshAccessToken: false, - api: API.CALLIOPEDISCOVERY, - resource: RESOURCE.CLUSTERS, - qs: { - JCSupport: 1, - ipver: ipVersion, + getClusters = ( + trigger: GetClustersTrigger, + ipVersion?: IP_VERSION, + previousReport?: any + ): Promise<{ + clusters: ClusterList; + joinCookie: any; + discoveryOptions?: Record; + }> => { + // we only measure latency for the initial startup call, not for other triggers + const callWrapper = + trigger === 'startup' + ? this.webex.internal.newMetrics.callDiagnosticLatencies.measureLatency.bind( + this.webex.internal.newMetrics.callDiagnosticLatencies + ) + : (func) => func(); + + return callWrapper( + () => + this.webex.request({ + method: HTTP_VERBS.POST, + shouldRefreshAccessToken: false, + api: API.CALLIOPEDISCOVERY, + resource: RESOURCE.CLUSTERS, + body: { + ipver: ipVersion, + 'supported-options': { + 'report-version': 1, + 'early-call-min-clusters': true, }, - timeout: this.webex.config.meetings.reachabilityGetClusterTimeout, - }), - 'internal.get.cluster.time' - ) - .then((res) => { - const {clusters, joinCookie} = res.body; + 'previous-report': previousReport, + trigger, + }, + timeout: this.webex.config.meetings.reachabilityGetClusterTimeout, + }), + 'internal.get.cluster.time' + ).then((res) => { + const {clusters, joinCookie, discoveryOptions} = res.body; - Object.keys(clusters).forEach((key) => { - clusters[key].isVideoMesh = !!res.body.clusterClasses?.hybridMedia?.includes(key); - }); + Object.keys(clusters).forEach((key) => { + clusters[key].isVideoMesh = !!res.body.clusterClasses?.hybridMedia?.includes(key); + }); - LoggerProxy.logger.log( - `Reachability:request#getClusters --> get clusters (ipver=${ipVersion}) successful:${JSON.stringify( - clusters - )}` - ); + LoggerProxy.logger.log( + `Reachability:request#getClusters --> get clusters (ipver=${ipVersion}) successful:${JSON.stringify( + clusters + )}` + ); - return { - clusters, - joinCookie, - }; - }); + return { + clusters, + joinCookie, + discoveryOptions, + }; + }); + }; /** * gets remote SDP For Clusters diff --git a/packages/@webex/plugin-meetings/src/roap/index.ts b/packages/@webex/plugin-meetings/src/roap/index.ts index 5761db15d5f..e17ad2ca217 100644 --- a/packages/@webex/plugin-meetings/src/roap/index.ts +++ b/packages/@webex/plugin-meetings/src/roap/index.ts @@ -107,7 +107,7 @@ export default class Roap extends StatelessWebexPlugin { roapMessage, locusSelfUrl: meeting.selfUrl, mediaId: options.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, }) .then(() => { @@ -141,7 +141,7 @@ export default class Roap extends StatelessWebexPlugin { roapMessage, locusSelfUrl: meeting.selfUrl, mediaId: options.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, }); } @@ -170,7 +170,7 @@ export default class Roap extends StatelessWebexPlugin { roapMessage, locusSelfUrl: meeting.selfUrl, mediaId: options.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, }) .then(() => { @@ -207,10 +207,9 @@ export default class Roap extends StatelessWebexPlugin { roapMessage, locusSelfUrl: meeting.selfUrl, mediaId: sendEmptyMediaId ? '' : meeting.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, preferTranscoding: !meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, - ipVersion: MeetingUtil.getIpVersion(meeting.webex), }) .then(({locus, mediaConnections}) => { if (mediaConnections) { diff --git a/packages/@webex/plugin-meetings/src/roap/request.ts b/packages/@webex/plugin-meetings/src/roap/request.ts index 30b8522ef27..fc1dc5589f5 100644 --- a/packages/@webex/plugin-meetings/src/roap/request.ts +++ b/packages/@webex/plugin-meetings/src/roap/request.ts @@ -4,44 +4,13 @@ import {StatelessWebexPlugin} from '@webex/webex-core'; import LoggerProxy from '../common/logs/logger-proxy'; import {IP_VERSION, REACHABILITY} from '../constants'; import {LocusMediaRequest} from '../meeting/locusMediaRequest'; +import MeetingUtil from '../meeting/util'; +import {ClientMediaPreferences} from '../reachability/reachability.types'; /** * @class RoapRequest */ export default class RoapRequest extends StatelessWebexPlugin { - /** - * Returns reachability data. - * @param {Object} localSdp - * @returns {Object} - */ - async attachReachabilityData(localSdp) { - let joinCookie; - - // @ts-ignore - const reachabilityResult = await this.webex.meetings.reachability.getReachabilityResults(); - - if (reachabilityResult && Object.keys(reachabilityResult).length) { - localSdp.reachability = reachabilityResult; - } - - // @ts-ignore - const joinCookieRaw = await this.webex.boundedStorage - .get(REACHABILITY.namespace, REACHABILITY.localStorageJoinCookie) - .catch(() => {}); - - if (joinCookieRaw) { - try { - joinCookie = JSON.parse(joinCookieRaw); - } catch (e) { - LoggerProxy.logger.error( - `MeetingRequest#constructor --> Error in parsing join cookie data: ${e}` - ); - } - } - - return {localSdp, joinCookie}; - } - /** * Sends a ROAP message * @param {Object} options @@ -50,18 +19,16 @@ export default class RoapRequest extends StatelessWebexPlugin { * @param {String} options.mediaId * @param {String} options.correlationId * @param {String} options.meetingId - * @param {IP_VERSION} options.ipVersion only required for offers * @returns {Promise} returns the response/failure of the request */ async sendRoap(options: { roapMessage: any; locusSelfUrl: string; mediaId: string; - meetingId: string; - ipVersion?: IP_VERSION; + isMultistream: boolean; locusMediaRequest?: LocusMediaRequest; }) { - const {roapMessage, locusSelfUrl, mediaId, locusMediaRequest, ipVersion} = options; + const {roapMessage, locusSelfUrl, isMultistream, mediaId, locusMediaRequest} = options; if (!mediaId) { LoggerProxy.logger.info('Roap:request#sendRoap --> sending empty mediaID'); @@ -74,13 +41,33 @@ export default class RoapRequest extends StatelessWebexPlugin { return Promise.reject(new Error('sendRoap called when locusMediaRequest is undefined')); } - const {localSdp: localSdpWithReachabilityData, joinCookie} = await this.attachReachabilityData({ - roapMessage, - }); + + let reachability; + let clientMediaPreferences: ClientMediaPreferences = { + // bare minimum fallback value that should allow us to join; + joinCookie: undefined, + ipver: IP_VERSION.unknown, + preferTranscoding: !isMultistream, + }; + + try { + clientMediaPreferences = + // @ts-ignore + await this.webex.meetings.reachability.getClientMediaPreferences( + isMultistream, + // @ts-ignore + MeetingUtil.getIpVersion(this.webex) + ); + reachability = + // @ts-ignore + await this.webex.meetings.reachability.getReachabilityReportToAttachToRoap(); + } catch (error) { + LoggerProxy.logger.error('Roap:request#sendRoap --> reachability error:', error); + } LoggerProxy.logger.info( `Roap:request#sendRoap --> ${roapMessage.messageType} seq:${roapMessage.seq} ${ - ipVersion ? `ipver=${ipVersion} ` : '' + clientMediaPreferences?.ipver ? `ipver=${clientMediaPreferences?.ipver} ` : '' } ${locusSelfUrl}` ); @@ -88,11 +75,10 @@ export default class RoapRequest extends StatelessWebexPlugin { .send({ type: 'RoapMessage', selfUrl: locusSelfUrl, - joinCookie, mediaId, roapMessage, - reachability: localSdpWithReachabilityData.reachability, - ipVersion, + reachability, + clientMediaPreferences, }) .then((res) => { // always it will be the first mediaConnection Object diff --git a/packages/@webex/plugin-meetings/src/roap/turnDiscovery.ts b/packages/@webex/plugin-meetings/src/roap/turnDiscovery.ts index 1c732e93052..a1034df7cd0 100644 --- a/packages/@webex/plugin-meetings/src/roap/turnDiscovery.ts +++ b/packages/@webex/plugin-meetings/src/roap/turnDiscovery.ts @@ -408,10 +408,8 @@ export default class TurnDiscovery { locusSelfUrl: meeting.selfUrl, // @ts-ignore - Fix missing type mediaId: isReconnecting ? '' : meeting.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, - // @ts-ignore - because of meeting.webex - ipVersion: MeetingUtil.getIpVersion(meeting.webex), }) .then(async (response) => { const {mediaConnections} = response; @@ -451,7 +449,7 @@ export default class TurnDiscovery { locusSelfUrl: meeting.selfUrl, // @ts-ignore - fix type mediaId: meeting.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, }); } diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index a94ea8b5737..968dd878f5f 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -649,7 +649,6 @@ describe('plugin-meetings', () => { }); const fakeRoapMessage = {id: 'fake TURN discovery message'}; - const fakeReachabilityResults = {id: 'fake reachability'}; const fakeTurnServerInfo = {id: 'fake turn info'}; const fakeJoinResult = {id: 'join result'}; @@ -667,8 +666,6 @@ describe('plugin-meetings', () => { .stub(meeting, 'addMediaInternal') .returns(Promise.resolve(test4)); - webex.meetings.reachability.getReachabilityResults.resolves(fakeReachabilityResults); - generateTurnDiscoveryRequestMessageStub = sinon .stub(meeting.roap, 'generateTurnDiscoveryRequestMessage') .resolves({roapMessage: fakeRoapMessage}); @@ -688,7 +685,6 @@ describe('plugin-meetings', () => { assert.calledOnceWithExactly(meeting.join, { ...joinOptions, roapMessage: fakeRoapMessage, - reachability: fakeReachabilityResults, }); assert.calledOnceWithExactly(generateTurnDiscoveryRequestMessageStub, meeting, true); assert.calledOnceWithExactly( @@ -725,7 +721,6 @@ describe('plugin-meetings', () => { assert.calledOnceWithExactly(meeting.join, { ...joinOptions, roapMessage: undefined, - reachability: fakeReachabilityResults, }); assert.calledOnceWithExactly(generateTurnDiscoveryRequestMessageStub, meeting, true); assert.notCalled(handleTurnDiscoveryHttpResponseStub); @@ -757,7 +752,6 @@ describe('plugin-meetings', () => { assert.calledOnceWithExactly(meeting.join, { ...joinOptions, roapMessage: fakeRoapMessage, - reachability: fakeReachabilityResults, }); assert.calledOnceWithExactly(generateTurnDiscoveryRequestMessageStub, meeting, true); assert.calledOnceWithExactly( @@ -3692,6 +3686,8 @@ describe('plugin-meetings', () => { meeting.setMercuryListener = sinon.stub(); meeting.locusInfo.onFullLocus = sinon.stub(); meeting.webex.meetings.geoHintInfo = {regionCode: 'EU', countryCode: 'UK'}; + meeting.webex.meetings.reachability.getReachabilityReportToAttachToRoap = sinon.stub().resolves({id: 'fake reachability'}); + meeting.webex.meetings.reachability.getClientMediaPreferences = sinon.stub().resolves({id: 'fake clientMediaPreferences'}); meeting.roap.doTurnDiscovery = sinon.stub().resolves({ turnServerInfo: { url: 'turns:turn-server-url:443?transport=tcp', @@ -3877,6 +3873,9 @@ describe('plugin-meetings', () => { const checkSdpOfferSent = ({audioMuted, videoMuted}) => { const {sdp, seq, tieBreaker} = roapOfferMessage; + assert.calledWith(meeting.webex.meetings.reachability.getClientMediaPreferences, meeting.isMultistream, 0); + assert.calledWith(meeting.webex.meetings.reachability.getReachabilityReportToAttachToRoap); + assert.calledWith(locusMediaRequestStub, { method: 'PUT', uri: `${meeting.selfUrl}/media`, @@ -3890,14 +3889,12 @@ describe('plugin-meetings', () => { correlationId: meeting.correlationId, localMedias: [ { - localSdp: `{"audioMuted":${audioMuted},"videoMuted":${videoMuted},"roapMessage":{"messageType":"OFFER","sdps":["${sdp}"],"version":"2","seq":"${seq}","tieBreaker":"${tieBreaker}","headers":["includeAnswerInHttpResponse","noOkInTransaction"]}}`, + localSdp: `{"audioMuted":${audioMuted},"videoMuted":${videoMuted},"roapMessage":{"messageType":"OFFER","sdps":["${sdp}"],"version":"2","seq":"${seq}","tieBreaker":"${tieBreaker}","headers":["includeAnswerInHttpResponse","noOkInTransaction"]},"reachability":{"id":"fake reachability"}}`, mediaId: 'fake media id', }, ], clientMediaPreferences: { - preferTranscoding: !meeting.isMultistream, - joinCookie: undefined, - ipver: 0, + id: 'fake clientMediaPreferences', }, }, }); @@ -3918,13 +3915,11 @@ describe('plugin-meetings', () => { }, correlationId: meeting.correlationId, clientMediaPreferences: { - preferTranscoding: !meeting.isMultistream, - ipver: undefined, - joinCookie: undefined, + id: 'fake clientMediaPreferences', }, localMedias: [ { - localSdp: `{"audioMuted":${audioMuted},"videoMuted":${videoMuted},"roapMessage":{"messageType":"OK","version":"2","seq":"${seq}"}}`, + localSdp: `{"audioMuted":${audioMuted},"videoMuted":${videoMuted},"roapMessage":{"messageType":"OK","version":"2","seq":"${seq}"},"reachability":{"id":"fake reachability"}}`, mediaId: 'fake media id', }, ], @@ -3950,10 +3945,6 @@ describe('plugin-meetings', () => { mediaId: 'fake media id', }, ], - clientMediaPreferences: { - preferTranscoding: !meeting.isMultistream, - ipver: undefined, - }, respOnlySdp: true, usingResource: null, }, diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/locusMediaRequest.ts b/packages/@webex/plugin-meetings/test/unit/spec/meeting/locusMediaRequest.ts index e0e12e728e5..3ffdd5f53d2 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/locusMediaRequest.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/locusMediaRequest.ts @@ -34,12 +34,19 @@ describe('LocusMediaRequest.send()', () => { 'wjfkm.wjfkm.*': {udp:{reachable: true}, tcp:{reachable:false}}, '1eb65fdf-9643-417f-9974-ad72cae0e10f.59268c12-7a04-4b23-a1a1-4c74be03019a.*': {udp:{reachable: false}, tcp:{reachable:true}}, }, - joinCookie: { - anycastEntryPoint: 'aws-eu-west-1', - clientIpAddress: 'some ip', - timeShot: '2023-05-23T08:03:49Z', - }, - ipVersion: IP_VERSION.only_ipv4, + clientMediaPreferences: { + preferTranscoding: false, + joinCookie: { + anycastEntryPoint: 'aws-eu-west-1', + clientIpAddress: 'some ip', + timeShot: '2023-05-23T08:03:49Z', + }, + ipver: IP_VERSION.only_ipv4, + reachability: { + version: '1', + result: 'some fake reachability result', + } + } }; const createExpectedRoapBody = (expectedMessageType, expectedMute:{audioMuted: boolean, videoMuted: boolean}) => { @@ -53,12 +60,16 @@ describe('LocusMediaRequest.send()', () => { } ], clientMediaPreferences: { - preferTranscoding: true, + preferTranscoding: false, ipver: 4, joinCookie: { anycastEntryPoint: 'aws-eu-west-1', clientIpAddress: 'some ip', timeShot: '2023-05-23T08:03:49Z' + }, + reachability: { + version: '1', + result: 'some fake reachability result', } } }; @@ -87,10 +98,6 @@ describe('LocusMediaRequest.send()', () => { localSdp: `{"audioMuted":${expectedMute.audioMuted},"videoMuted":${expectedMute.videoMuted}}`, }, ], - clientMediaPreferences: { - preferTranscoding: true, - ipver: undefined, - }, }; if (sequence) { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/request.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/request.js index 5e53406d31c..07578638839 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/request.js @@ -196,6 +196,7 @@ describe('plugin-meetings', () => { const permissionToken = 'permission-token'; const installationId = 'installationId'; const reachability = 'reachability'; + const clientMediaPreferences = 'clientMediaPreferences'; await meetingsRequest.joinMeeting({ locusUrl, @@ -204,6 +205,7 @@ describe('plugin-meetings', () => { roapMessage, reachability, permissionToken, + clientMediaPreferences }); const requestParams = meetingsRequest.request.getCall(0).args[0]; @@ -214,6 +216,7 @@ describe('plugin-meetings', () => { assert.equal(requestParams.body.device.countryCode, 'US'); assert.equal(requestParams.body.permissionToken, 'permission-token'); assert.equal(requestParams.body.device.regionCode, 'WEST-COAST'); + assert.equal(requestParams.body.clientMediaPreferences, 'clientMediaPreferences'); assert.include(requestParams.body.device.localIp, '127.0.0'); assert.deepEqual(requestParams.body.localMedias, [ {localSdp: '{"roapMessage":"roap-message","reachability":"reachability"}'}, @@ -386,32 +389,6 @@ describe('plugin-meetings', () => { assert.deepEqual(requestParams.body.alias, undefined); }); - - it('includes joinCookie and ipver correctly', async () => { - const locusUrl = 'locusURL'; - const deviceUrl = 'deviceUrl'; - const correlationId = 'random-uuid'; - const roapMessage = 'roap-message'; - const permissionToken = 'permission-token'; - - await meetingsRequest.joinMeeting({ - locusUrl, - deviceUrl, - correlationId, - roapMessage, - permissionToken, - ipVersion: IP_VERSION.ipv4_and_ipv6, - }); - const requestParams = meetingsRequest.request.getCall(0).args[0]; - - assert.equal(requestParams.method, 'POST'); - assert.equal(requestParams.uri, `${locusUrl}/participant?alternateRedirect=true`); - assert.deepEqual(requestParams.body.clientMediaPreferences, { - joinCookie: {anycastEntryPoint: 'aws-eu-west-1'}, - preferTranscoding: true, - ipver: 1, - }); - }); }); describe('#pstn', () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js index 0c9be4c03f5..2c2059a4d16 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js @@ -22,6 +22,12 @@ describe('plugin-meetings', () => { meetings: Meetings, }, }); + + webex.meetings.reachability = { + getReachabilityReportToAttachToRoap: sinon.stub().resolves({}), + getClientMediaPreferences: sinon.stub().resolves({}), + }; + const logger = { info: sandbox.stub(), log: sandbox.stub(), @@ -408,17 +414,39 @@ describe('plugin-meetings', () => { }); it('#Should call `meetingRequest.joinMeeting', async () => { + meeting.isMultistream = true; + + const FAKE_REACHABILITY_REPORT = { + id: 'fake reachability report', + }; + const FAKE_CLIENT_MEDIA_PREFERENCES = { + id: 'fake client media preferences', + }; + + webex.meetings.reachability.getReachabilityReportToAttachToRoap.resolves(FAKE_REACHABILITY_REPORT); + webex.meetings.reachability.getClientMediaPreferences.resolves(FAKE_CLIENT_MEDIA_PREFERENCES); + + sinon + .stub(webex.internal.device.ipNetworkDetector, 'supportsIpV4') + .get(() => true); + sinon + .stub(webex.internal.device.ipNetworkDetector, 'supportsIpV6') + .get(() => true); + await MeetingUtil.joinMeeting(meeting, { reachability: 'reachability', roapMessage: 'roapMessage', }); + assert.calledOnceWithExactly(webex.meetings.reachability.getReachabilityReportToAttachToRoap); + assert.calledOnceWithExactly(webex.meetings.reachability.getClientMediaPreferences, meeting.isMultistream, IP_VERSION.ipv4_and_ipv6); + assert.calledOnce(meeting.meetingRequest.joinMeeting); const parameter = meeting.meetingRequest.joinMeeting.getCall(0).args[0]; assert.equal(parameter.inviteeAddress, 'meetingJoinUrl'); - assert.equal(parameter.preferTranscoding, true); - assert.equal(parameter.reachability, 'reachability'); + assert.equal(parameter.reachability, FAKE_REACHABILITY_REPORT); + assert.equal(parameter.clientMediaPreferences, FAKE_CLIENT_MEDIA_PREFERENCES); assert.equal(parameter.roapMessage, 'roapMessage'); assert.calledOnce(meeting.setLocus) @@ -445,6 +473,29 @@ describe('plugin-meetings', () => { }); }); + it('should handle failed reachability report retrieval', async () => { + webex.meetings.reachability.getReachabilityReportToAttachToRoap.rejects( + new Error('fake error') + ); + await MeetingUtil.joinMeeting(meeting, {}); + // Verify meeting join still proceeds + assert.calledOnce(meeting.meetingRequest.joinMeeting); + }); + + it('should handle failed clientMediaPreferences retrieval', async () => { + webex.meetings.reachability.getClientMediaPreferences.rejects(new Error('fake error')); + meeting.isMultistream = true; + await MeetingUtil.joinMeeting(meeting, {}); + // Verify meeting join still proceeds + assert.calledOnce(meeting.meetingRequest.joinMeeting); + const parameter = meeting.meetingRequest.joinMeeting.getCall(0).args[0]; + assert.deepEqual(parameter.clientMediaPreferences, { + preferTranscoding: false, + ipver: 0, + joinCookie: undefined, + }); + }); + it('#Should call meetingRequest.joinMeeting with breakoutsSupported=true when passed in as true', async () => { await MeetingUtil.joinMeeting(meeting, { breakoutsSupported: true, @@ -480,17 +531,6 @@ describe('plugin-meetings', () => { assert.deepEqual(parameter.deviceCapabilities, ['TEST']); }); - it('#Should call meetingRequest.joinMeeting with preferTranscoding=false when multistream is enabled', async () => { - meeting.isMultistream = true; - await MeetingUtil.joinMeeting(meeting, {}); - - assert.calledOnce(meeting.meetingRequest.joinMeeting); - const parameter = meeting.meetingRequest.joinMeeting.getCall(0).args[0]; - - assert.equal(parameter.inviteeAddress, 'meetingJoinUrl'); - assert.equal(parameter.preferTranscoding, false); - }); - it('#Should fallback sipUrl if meetingJoinUrl does not exists', async () => { meeting.meetingJoinUrl = undefined; meeting.sipUri = 'sipUri'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts index 029668742ee..f532ad885e4 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts @@ -1664,6 +1664,270 @@ describe('gatherReachability', () => { assert.neverCalledWith(clusterReachabilityCtorStub); }); + + describe('fallback mechanism and multiple calls to getClusters', () => { + let receivedEvents; + + const mockGetClustersEmptyResult = { + discoveryOptions: { + ['early-call-min-clusters']: 0, + ['report-version']: 1, + }, + clusters: {}, // empty cluster list + joinCookie: {id: 'cookie'}, + }; + + beforeEach(() => { + webex.config.meetings.experimental = { + enableTcpReachability: true, + enableTlsReachability: true, + }; + + receivedEvents = { + done: 0, + }; + }); + + it('keeps retrying if minimum required clusters are not reached', async () => { + const reachability = new Reachability(webex); + + reachability.on('reachability:done', () => { + receivedEvents.done += 1; + }); + + const mockGetClustersResult1 = { + discoveryOptions: { + ['early-call-min-clusters']: 2, + ['report-version']: 1, + }, + clusters: { + clusterA0: { + udp: ['udp-urlA'], + tcp: ['tcp-urlA'], + xtls: ['xtls-urlA'], + isVideoMesh: false, + }, + clusterB0: { + udp: ['udp-urlB'], + tcp: ['tcp-urlB'], + xtls: ['xtls-urlB'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'cookie1'}, + }; + const mockGetClustersResult2 = { + discoveryOptions: { + ['early-call-min-clusters']: 2, + ['report-version']: 1, + }, + clusters: { + clusterA1: { + udp: ['udp-urlA'], + tcp: ['tcp-urlA'], + xtls: ['xtls-urlA'], + isVideoMesh: false, + }, + clusterB1: { + udp: ['udp-urlB'], + tcp: ['tcp-urlB'], + xtls: ['xtls-urlB'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'cookie2'}, + }; + const mockGetClustersResult3 = { + discoveryOptions: { + ['early-call-min-clusters']: 1, + ['report-version']: 1, + }, + clusters: { + clusterA2: { + udp: ['udp-urlA'], + tcp: ['tcp-urlA'], + xtls: ['xtls-urlA'], + isVideoMesh: false, + }, + clusterB2: { + udp: ['udp-urlB'], + tcp: ['tcp-urlB'], + xtls: ['xtls-urlB'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'cookie3'}, + }; + + reachability.reachabilityRequest.getClusters = sinon.stub(); + reachability.reachabilityRequest.getClusters.onCall(0).returns(mockGetClustersResult1); + reachability.reachabilityRequest.getClusters.onCall(1).returns(mockGetClustersResult2); + + reachability.reachabilityRequest.getClusters.onCall(2).returns(mockGetClustersResult3); + + const resultPromise = reachability.gatherReachability('test'); + + await testUtils.flushPromises(); + + // trigger some mock result events from ClusterReachability instances, + // but only from 1 cluster, so not enough to reach the minimum required + mockClusterReachabilityInstances['clusterA0'].emitFakeResult('udp', { + result: 'reachable', + clientMediaIPs: ['1.2.3.4'], + latencyInMilliseconds: 11, + }); + + clock.tick(3000); + await resultPromise; + await testUtils.flushPromises(); + + // because the minimum was not reached, another call to getClusters should be made + assert.calledTwice(reachability.reachabilityRequest.getClusters); + + // simulate no results this time + + // check that while the 2nd attempt is in progress, the join cookie is already available from the 2nd call to getClusters + const clientMediaPreferences = await reachability.getClientMediaPreferences( + true, + IP_VERSION.unknown + ); + + assert.deepEqual(clientMediaPreferences.joinCookie, mockGetClustersResult2.joinCookie); + + clock.tick(3000); + await testUtils.flushPromises(); + + assert.calledThrice(reachability.reachabilityRequest.getClusters); + + await testUtils.flushPromises(); + + // this time 1 result will be enough to reach the minimum + mockClusterReachabilityInstances['clusterA2'].emitFakeResult('udp', { + result: 'reachable', + clientMediaIPs: ['1.2.3.4'], + latencyInMilliseconds: 11, + }); + clock.tick(3000); + + // the reachability results should include only results from the last attempt + await checkResults( + { + clusterA2: { + udp: {result: 'reachable', clientMediaIPs: ['1.2.3.4'], latencyInMilliseconds: 11}, + tcp: {result: 'unreachable'}, + xtls: {result: 'unreachable'}, + isVideoMesh: false, + }, + clusterB2: { + udp: {result: 'unreachable'}, + tcp: {result: 'unreachable'}, + xtls: {result: 'unreachable'}, + isVideoMesh: false, + }, + }, + mockGetClustersResult3.joinCookie + ); + + // wait some more time to make sure that there are no timers that fire from one of the previous checks + clock.tick(20000); + + // as the first 2 attempts failed and didn't reach the overall timeout, there should be only 1 done event emitted + assert.equal(receivedEvents.done, 1); + }); + + it('handles getClusters() returning empty list on 1st call', async () => { + const reachability = new Reachability(webex); + + reachability.on('reachability:done', () => { + receivedEvents.done += 1; + }); + + reachability.reachabilityRequest.getClusters = sinon + .stub() + .resolves(mockGetClustersEmptyResult); + + const resultPromise = reachability.gatherReachability('test'); + + await testUtils.flushPromises(); + + clock.tick(3000); + await resultPromise; + await testUtils.flushPromises(); + + assert.calledOnce(reachability.reachabilityRequest.getClusters); + reachability.reachabilityRequest.getClusters.resetHistory(); + + assert.equal(receivedEvents.done, 1); + await checkResults({}, mockGetClustersEmptyResult.joinCookie); + + // because we didn't actually test anything (we got empty cluster list from getClusters()), we should + // not say that webex backend is unreachable + assert.equal(await reachability.isWebexMediaBackendUnreachable(), false); + + // wait to check that there are no other things happening + clock.tick(20000); + await testUtils.flushPromises(); + + assert.notCalled(reachability.reachabilityRequest.getClusters); + assert.equal(receivedEvents.done, 1); + }); + + it('handles getClusters() returning empty list on 2nd call', async () => { + const reachability = new Reachability(webex); + + reachability.on('reachability:done', () => { + receivedEvents.done += 1; + }); + + const mockGetClustersResult1 = { + discoveryOptions: { + ['early-call-min-clusters']: 2, + ['report-version']: 1, + }, + clusters: { + clusterA0: { + udp: ['udp-urlA'], + tcp: ['tcp-urlA'], + xtls: ['xtls-urlA'], + isVideoMesh: false, + }, + clusterB0: { + udp: ['udp-urlB'], + tcp: ['tcp-urlB'], + xtls: ['xtls-urlB'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'cookie1'}, + }; + + reachability.reachabilityRequest.getClusters = sinon.stub(); + reachability.reachabilityRequest.getClusters.onCall(0).returns(mockGetClustersResult1); + reachability.reachabilityRequest.getClusters.onCall(1).returns(mockGetClustersEmptyResult); + + const resultPromise = reachability.gatherReachability('test'); + + await testUtils.flushPromises(); + + clock.tick(3000); + await resultPromise; + await testUtils.flushPromises(); + + // because the minimum was not reached, another call to getClusters should be made + assert.calledTwice(reachability.reachabilityRequest.getClusters); + + // the reachability results should include only results from the last attempt + await checkResults({}, mockGetClustersEmptyResult.joinCookie); + + // as the first 2 attempts failed and didn't reach the overall timeout, there should be only 1 done event emitted + assert.equal(receivedEvents.done, 1); + // because we didn't actually test anything (we got empty cluster list from getClusters()), we should + // not say that webex backend is unreachable + assert.equal(await reachability.isWebexMediaBackendUnreachable(), false); + }); + }); + + }); describe('getReachabilityResults', () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js b/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js index e34fc5355a9..c012b862b9a 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js @@ -35,16 +35,11 @@ describe('plugin-meetings/reachability', () => { }); describe('#getClusters', () => { + let previousReport; beforeEach(() => { sinon.spy(webex.internal.newMetrics.callDiagnosticLatencies, 'measureLatency'); - }); - - afterEach(() => { - sinon.restore(); - }); - it('sends a GET request with the correct params', async () => { webex.request = sinon.mock().returns(Promise.resolve({ body: { clusterClasses: { @@ -59,24 +54,65 @@ describe('plugin-meetings/reachability', () => { webex.config.meetings.reachabilityGetClusterTimeout = 3000; - const res = await reachabilityRequest.getClusters(IP_VERSION.only_ipv4); + previousReport = { + id: 'fake previous report', + } + }); + + afterEach(() => { + sinon.restore(); + }); + + it('sends a POST request with the correct params when trigger is "startup"', async () => { + const res = await reachabilityRequest.getClusters('startup', IP_VERSION.only_ipv4, previousReport); const requestParams = webex.request.getCall(0).args[0]; assert.deepEqual(requestParams, { - method: 'GET', + method: 'POST', resource: `clusters`, api: 'calliopeDiscovery', shouldRefreshAccessToken: false, - qs: { - JCSupport: 1, - ipver: 4, - }, timeout: 3000, + body: { + ipver: IP_VERSION.only_ipv4, + 'supported-options': { + 'report-version': 1, + 'early-call-min-clusters': true, + }, + 'previous-report': previousReport, + trigger: 'startup', + }, }); assert.deepEqual(res.clusters.clusterId, {udp: "testUDP", isVideoMesh: true}) assert.deepEqual(res.joinCookie, {anycastEntryPoint: "aws-eu-west-1"}) assert.calledOnceWithExactly(webex.internal.newMetrics.callDiagnosticLatencies.measureLatency, sinon.match.func, 'internal.get.cluster.time'); }); + + it('sends a POST request with the correct params when trigger is other than "startup"', async () => { + const res = await reachabilityRequest.getClusters('early-call/no-min-reached', IP_VERSION.only_ipv4, previousReport); + const requestParams = webex.request.getCall(0).args[0]; + + assert.deepEqual(requestParams, { + method: 'POST', + resource: `clusters`, + api: 'calliopeDiscovery', + shouldRefreshAccessToken: false, + timeout: 3000, + body: { + ipver: IP_VERSION.only_ipv4, + 'supported-options': { + 'report-version': 1, + 'early-call-min-clusters': true, + }, + 'previous-report': previousReport, + trigger: 'early-call/no-min-reached', + }, + }); + + assert.deepEqual(res.clusters.clusterId, {udp: "testUDP", isVideoMesh: true}) + assert.deepEqual(res.joinCookie, {anycastEntryPoint: "aws-eu-west-1"}) + assert.notCalled(webex.internal.newMetrics.callDiagnosticLatencies.measureLatency); + }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/roap/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/roap/index.ts index 4b8bcc621d5..baefe4ee274 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/roap/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/roap/index.ts @@ -161,7 +161,7 @@ describe('Roap', () => { roapMessage: expectedRoapMessage, locusSelfUrl: meeting.selfUrl, mediaId: meeting.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, }) ); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/roap/request.ts b/packages/@webex/plugin-meetings/test/unit/spec/roap/request.ts index 70cb8df3bbd..ace38ba8a17 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/roap/request.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/roap/request.ts @@ -4,6 +4,7 @@ import {assert} from '@webex/test-helper-chai'; import MockWebex from '@webex/test-helper-mock-webex'; import Meetings from '@webex/plugin-meetings'; import RoapRequest from '@webex/plugin-meetings/src/roap/request'; +import MeetingUtil from '@webex/plugin-meetings/src/meeting/util'; import {IP_VERSION, REACHABILITY} from '@webex/plugin-meetings/src/constants'; describe('plugin-meetings/roap', () => { @@ -23,6 +24,11 @@ describe('plugin-meetings/roap', () => { regionCode: 'WEST-COAST', }; + webex.meetings.reachability = { + getReachabilityReportToAttachToRoap: sinon.stub().resolves({}), + getClientMediaPreferences: sinon.stub().resolves({}), + }; + webex.internal = { services: { get: sinon.mock().returns(locusUrl), @@ -36,6 +42,8 @@ describe('plugin-meetings/roap', () => { }, }; + sinon.stub(MeetingUtil, 'getIpVersion').returns(IP_VERSION.ipv4_and_ipv6); + // @ts-ignore roapRequest = new RoapRequest({webex}); @@ -74,146 +82,80 @@ describe('plugin-meetings/roap', () => { ); }); - describe('#attachReachabilityData', () => { - it('returns the correct reachability data', async () => { - const res = await roapRequest.attachReachabilityData({}); - - assert.deepEqual(res.localSdp, { - reachability: { - clusterId: { - udp: { - reachable: 'true', - latencyInMilliseconds: '10', - }, - tcp: { - reachable: 'false', - }, - xtls: { - untested: 'true', - } - }, - }, - }); - assert.deepEqual(res.joinCookie, { - anycastEntryPoint: 'aws-eu-west-1', - }); - }); + afterEach(() => { + sinon.restore(); + }) - it('handles the case when reachability data does not exist', async () => { - await webex.boundedStorage.del(REACHABILITY.namespace, REACHABILITY.localStorageJoinCookie); + describe('sendRoap', () => { + it('includes clientMediaPreferences and reachability in the request correctly', async () => { + const locusMediaRequest = {send: sinon.stub().resolves({body: {locus: {}}})}; - await webex.boundedStorage.del(REACHABILITY.namespace, REACHABILITY.localStorageResult); - const sdp = { - some: 'attribute', + const FAKE_REACHABILITY_REPORT = { + id: 'fake reachability report', + }; + const FAKE_CLIENT_MEDIA_PREFERENCES = { + id: 'fake client media preferences', }; - const result = await roapRequest.attachReachabilityData(sdp); - - assert.deepEqual(result, { - joinCookie: undefined, - localSdp: { - some: 'attribute', - }, - }); - }); - }); - - describe('sendRoap', () => { - it('includes joinCookie in the request correctly', async () => { - const locusMediaRequest = {send: sinon.stub().resolves({body: {locus: {}}})}; - const ipVersion = IP_VERSION.unknown; + webex.meetings.reachability.getReachabilityReportToAttachToRoap.resolves(FAKE_REACHABILITY_REPORT); + webex.meetings.reachability.getClientMediaPreferences.resolves(FAKE_CLIENT_MEDIA_PREFERENCES); await roapRequest.sendRoap({ locusSelfUrl: locusUrl, - ipVersion, mediaId: 'mediaId', roapMessage: { seq: 'seq', }, meetingId: 'meeting-id', + isMultistream: true, locusMediaRequest, }); + assert.calledOnceWithExactly(webex.meetings.reachability.getReachabilityReportToAttachToRoap); + assert.calledOnceWithExactly(webex.meetings.reachability.getClientMediaPreferences, true, IP_VERSION.ipv4_and_ipv6); + const requestParams = locusMediaRequest.send.getCall(0).args[0]; assert.deepEqual(requestParams, { type: 'RoapMessage', selfUrl: locusUrl, - ipVersion, - joinCookie: { - anycastEntryPoint: 'aws-eu-west-1', - }, + clientMediaPreferences: FAKE_CLIENT_MEDIA_PREFERENCES, mediaId: 'mediaId', roapMessage: { seq: 'seq', }, - reachability: { - clusterId: { - tcp: { - reachable: 'false', - }, - udp: { - latencyInMilliseconds: '10', - reachable: 'true', - }, - xtls: { - untested: 'true', - }, - }, - }, + reachability: FAKE_REACHABILITY_REPORT, }); }); - }); - it('calls attachReachabilityData when sendRoap', async () => { - const locusMediaRequest = { send: sinon.stub().resolves({body: {locus: {}}})}; + it('includes default clientMediaPreferences if calls to reachability fail', async () => { + const locusMediaRequest = {send: sinon.stub().resolves({body: {locus: {}}})}; - const newSdp = { - new: 'sdp', - reachability: { someResult: 'whatever' } - }; - const ipVersion = IP_VERSION.only_ipv6; + webex.meetings.reachability.getClientMediaPreferences.rejects(new Error('fake error')); - roapRequest.attachReachabilityData = sinon.stub().returns( - Promise.resolve({ - localSdp: newSdp, - joinCookie: { - anycastEntryPoint: 'aws-eu-west-1', + await roapRequest.sendRoap({ + locusSelfUrl: locusUrl, + mediaId: 'mediaId', + roapMessage: { + seq: 'seq', }, - }) - ); - - await roapRequest.sendRoap({ - roapMessage: { - seq: 1, - }, - locusSelfUrl: 'locusSelfUrl', - ipVersion, - mediaId: 'mediaId', - meetingId: 'meetingId', - preferTranscoding: true, - locusMediaRequest - }); - - const requestParams = locusMediaRequest.send.getCall(0).args[0]; + meetingId: 'meeting-id', + isMultistream: true, + locusMediaRequest, + }); - assert.deepEqual(requestParams, { - type: 'RoapMessage', - selfUrl: 'locusSelfUrl', - ipVersion, - joinCookie: { - anycastEntryPoint: 'aws-eu-west-1', - }, - mediaId: 'mediaId', - roapMessage: { - seq: 1, - }, - reachability: { someResult: 'whatever' }, - }); + assert.calledOnce(webex.meetings.reachability.getClientMediaPreferences); - assert.calledOnceWithExactly(roapRequest.attachReachabilityData, { - roapMessage: { - seq: 1, - }, + const requestParams = locusMediaRequest.send.getCall(0).args[0]; + assert.deepEqual(requestParams, { + type: 'RoapMessage', + selfUrl: locusUrl, + clientMediaPreferences: {ipver: 0, joinCookie: undefined, preferTranscoding: false}, + mediaId: 'mediaId', + roapMessage: { + seq: 'seq', + }, + reachability: undefined, + }); }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/roap/turnDiscovery.ts b/packages/@webex/plugin-meetings/test/unit/spec/roap/turnDiscovery.ts index c8ce7ce9699..cef80410c05 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/roap/turnDiscovery.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/roap/turnDiscovery.ts @@ -38,21 +38,25 @@ describe('TurnDiscovery', () => { mediaId: 'fake media id', locusUrl: `https://locus-a.wbx2.com/locus/api/v1/loci/${FAKE_LOCUS_ID}`, roapSeq: -1, - audio:{ + audio: { isLocallyMuted: () => true, }, - video:{ + video: { isLocallyMuted: () => false, }, setRoapSeq: sinon.fake((newSeq) => { testMeeting.roapSeq = newSeq; }), updateMediaConnections: sinon.stub(), - webex: {meetings: {reachability: { - isAnyPublicClusterReachable: () => Promise.resolve(false), - }}}, + webex: { + meetings: { + reachability: { + isAnyPublicClusterReachable: () => Promise.resolve(false), + }, + }, + }, isMultistream: false, - locusMediaRequest: { fake: true }, + locusMediaRequest: {fake: true}, }; }); @@ -78,13 +82,15 @@ describe('TurnDiscovery', () => { }, locusSelfUrl: testMeeting.selfUrl, mediaId: expectedMediaId, - meetingId: testMeeting.id, + isMultistream: testMeeting.isMultistream, locusMediaRequest: testMeeting.locusMediaRequest, }; if (messageType === 'TURN_DISCOVERY_REQUEST') { - expectedSendRoapArgs.ipVersion = 0; - expectedSendRoapArgs.roapMessage.headers = ['includeAnswerInHttpResponse', 'noOkInTransaction']; + expectedSendRoapArgs.roapMessage.headers = [ + 'includeAnswerInHttpResponse', + 'noOkInTransaction', + ]; } assert.calledWith(mockRoapRequest.sendRoap, expectedSendRoapArgs); @@ -121,7 +127,12 @@ describe('TurnDiscovery', () => { }); // checks that OK roap message was sent or not sent and that the result is as expected - const checkResult = async (resultPromise, expectedRoapMessageSent, expectedResult, expectedSkipReason?: string) => { + const checkResult = async ( + resultPromise, + expectedRoapMessageSent, + expectedResult, + expectedSkipReason?: string + ) => { let turnServerInfo, turnDiscoverySkippedReason; if (expectedRoapMessageSent === 'OK') { @@ -261,9 +272,11 @@ describe('TurnDiscovery', () => { }, ], }; - mockRoapRequest.sendRoap = sinon.fake.returns(new Promise((resolve) => { - sendRoapPromiseResolve = resolve; - })); + mockRoapRequest.sendRoap = sinon.fake.returns( + new Promise((resolve) => { + sendRoapPromiseResolve = resolve; + }) + ); const td = new TurnDiscovery(mockRoapRequest); const result = td.doTurnDiscovery(testMeeting, false); @@ -302,7 +315,12 @@ describe('TurnDiscovery', () => { // @ts-ignore mockRoapRequest.sendRoap.resetHistory(); - await checkResult(result, undefined, undefined, 'failure: Unexpected token o in JSON at position 1'); + await checkResult( + result, + undefined, + undefined, + 'failure: Unexpected token o in JSON at position 1' + ); checkFailureMetricsSent(); }); @@ -366,7 +384,12 @@ describe('TurnDiscovery', () => { // @ts-ignore mockRoapRequest.sendRoap.resetHistory(); - await checkResult(result, undefined, undefined, 'failure: TURN_DISCOVERY_RESPONSE in http response has unexpected messageType: {"seq":"0","messageType":"ERROR"}'); + await checkResult( + result, + undefined, + undefined, + 'failure: TURN_DISCOVERY_RESPONSE in http response has unexpected messageType: {"seq":"0","messageType":"ERROR"}' + ); }); }); }); @@ -534,7 +557,10 @@ describe('TurnDiscovery', () => { const {turnServerInfo, turnDiscoverySkippedReason} = await promise; assert.isUndefined(turnServerInfo); - assert.equal(turnDiscoverySkippedReason, 'failure: Timed out waiting for TURN_DISCOVERY_RESPONSE'); + assert.equal( + turnDiscoverySkippedReason, + 'failure: Timed out waiting for TURN_DISCOVERY_RESPONSE' + ); checkFailureMetricsSent(); }); @@ -559,7 +585,10 @@ describe('TurnDiscovery', () => { const {turnServerInfo, turnDiscoverySkippedReason} = await turnDiscoveryPromise; assert.isUndefined(turnServerInfo); - assert.equal(turnDiscoverySkippedReason, `failure: TURN_DISCOVERY_RESPONSE from test missing some headers: ["x-cisco-turn-url=${FAKE_TURN_URL}","x-cisco-turn-username=${FAKE_TURN_USERNAME}"]`); + assert.equal( + turnDiscoverySkippedReason, + `failure: TURN_DISCOVERY_RESPONSE from test missing some headers: ["x-cisco-turn-url=${FAKE_TURN_URL}","x-cisco-turn-username=${FAKE_TURN_USERNAME}"]` + ); checkFailureMetricsSent(); }); @@ -576,7 +605,10 @@ describe('TurnDiscovery', () => { const {turnServerInfo, turnDiscoverySkippedReason} = await turnDiscoveryPromise; assert.isUndefined(turnServerInfo); - assert.equal(turnDiscoverySkippedReason, 'failure: TURN_DISCOVERY_RESPONSE from test missing some headers: undefined'); + assert.equal( + turnDiscoverySkippedReason, + 'failure: TURN_DISCOVERY_RESPONSE from test missing some headers: undefined' + ); checkFailureMetricsSent(); }); @@ -596,7 +628,10 @@ describe('TurnDiscovery', () => { const {turnServerInfo, turnDiscoverySkippedReason} = await turnDiscoveryPromise; assert.isUndefined(turnServerInfo); - assert.equal(turnDiscoverySkippedReason, 'failure: TURN_DISCOVERY_RESPONSE from test missing some headers: []'); + assert.equal( + turnDiscoverySkippedReason, + 'failure: TURN_DISCOVERY_RESPONSE from test missing some headers: []' + ); checkFailureMetricsSent(); }); @@ -646,17 +681,21 @@ describe('TurnDiscovery', () => { {isAnyPublicClusterReachable: true, expectedIsSkipped: true}, {isAnyPublicClusterReachable: false, expectedIsSkipped: false}, ].forEach(({isAnyPublicClusterReachable, expectedIsSkipped}) => { - it(`returns ${expectedIsSkipped} when isAnyPublicClusterReachable() returns ${isAnyPublicClusterReachable ? 'true' : 'false'}`, async () => { - sinon.stub(testMeeting.webex.meetings.reachability, 'isAnyPublicClusterReachable').resolves(isAnyPublicClusterReachable); + it(`returns ${expectedIsSkipped} when isAnyPublicClusterReachable() returns ${ + isAnyPublicClusterReachable ? 'true' : 'false' + }`, async () => { + sinon + .stub(testMeeting.webex.meetings.reachability, 'isAnyPublicClusterReachable') + .resolves(isAnyPublicClusterReachable); const td = new TurnDiscovery(mockRoapRequest); const isSkipped = await td.isSkipped(testMeeting); assert.equal(isSkipped, expectedIsSkipped); - }) - }) - }) + }); + }); + }); describe('handleTurnDiscoveryResponse', () => { it("doesn't do anything if turn discovery was not started", () => { @@ -664,14 +703,17 @@ describe('TurnDiscovery', () => { // there is not much we can check, but we mainly want to make // sure that it doesn't crash - td.handleTurnDiscoveryResponse({ - messageType: 'TURN_DISCOVERY_RESPONSE', - headers: [ - `x-cisco-turn-url=${FAKE_TURN_URL}`, - `x-cisco-turn-username=${FAKE_TURN_USERNAME}`, - `x-cisco-turn-password=${FAKE_TURN_PASSWORD}`, - ], - }, 'from test'); + td.handleTurnDiscoveryResponse( + { + messageType: 'TURN_DISCOVERY_RESPONSE', + headers: [ + `x-cisco-turn-url=${FAKE_TURN_URL}`, + `x-cisco-turn-username=${FAKE_TURN_USERNAME}`, + `x-cisco-turn-password=${FAKE_TURN_PASSWORD}`, + ], + }, + 'from test' + ); assert.notCalled(mockRoapRequest.sendRoap); }); @@ -743,9 +785,11 @@ describe('TurnDiscovery', () => { let promiseResolve; // set it up so that doTurnDiscovery doesn't complete - mockRoapRequest.sendRoap = sinon.fake.returns(new Promise((resolve) => { - promiseResolve = resolve; - })); + mockRoapRequest.sendRoap = sinon.fake.returns( + new Promise((resolve) => { + promiseResolve = resolve; + }) + ); td.doTurnDiscovery(testMeeting, false, true); // now call generateTurnDiscoveryRequestMessage @@ -775,19 +819,19 @@ describe('TurnDiscovery', () => { `x-cisco-turn-url=${FAKE_TURN_URL}`, `x-cisco-turn-username=${FAKE_TURN_USERNAME}`, `x-cisco-turn-password=${FAKE_TURN_PASSWORD}`, - 'noOkInTransaction' + 'noOkInTransaction', ], - } + }; td = new TurnDiscovery(mockRoapRequest); }); // checks if another TURN discovery can be started without any problem const checkNextTurnDiscovery = async () => { - // after each test check that another TURN discovery can be started without any problems - const secondMessage = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + // after each test check that another TURN discovery can be started without any problems + const secondMessage = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - assert.isDefined(secondMessage.roapMessage); + assert.isDefined(secondMessage.roapMessage); }; it('works as expected when called with undefined httpResponse', async () => { @@ -804,8 +848,14 @@ describe('TurnDiscovery', () => { [ {testCase: 'is missing mediaConnections', httpResponse: {}}, {testCase: 'is missing mediaConnections[0]', httpResponse: {mediaConnections: []}}, - {testCase: 'is missing mediaConnections[0].remoteSdp', httpResponse: {mediaConnections: [{}]}}, - {testCase: 'is missing roapMesssage in mediaConnections[0].remoteSdp', httpResponse: {mediaConnections: [{remoteSdp: JSON.stringify({something: "whatever"})}]}}, + { + testCase: 'is missing mediaConnections[0].remoteSdp', + httpResponse: {mediaConnections: [{}]}, + }, + { + testCase: 'is missing roapMesssage in mediaConnections[0].remoteSdp', + httpResponse: {mediaConnections: [{remoteSdp: JSON.stringify({something: 'whatever'})}]}, + }, ].forEach(({testCase, httpResponse}) => { it(`handles httpResponse that ${testCase}`, async () => { await td.generateTurnDiscoveryRequestMessage(testMeeting, true); @@ -817,145 +867,150 @@ describe('TurnDiscovery', () => { turnDiscoverySkippedReason: 'missing http response', }); }); - }); - - it('handles httpResponse with invalid JSON in mediaConnections[0].remoteSdp', async () => { - await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + }); - const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, {mediaConnections: [{remoteSdp: 'not a json'}]}); + it('handles httpResponse with invalid JSON in mediaConnections[0].remoteSdp', async () => { + await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - assert.deepEqual(result, { - turnServerInfo: undefined, - turnDiscoverySkippedReason: 'failure: Unexpected token o in JSON at position 1', - }); + const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, { + mediaConnections: [{remoteSdp: 'not a json'}], }); - it('fails when called before generateTurnDiscoveryRequestMessage() was called', async () => { - const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; - await assert.isRejected(td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse), - 'handleTurnDiscoveryHttpResponse() called before generateTurnDiscoveryRequestMessage()'); + assert.deepEqual(result, { + turnServerInfo: undefined, + turnDiscoverySkippedReason: 'failure: Unexpected token o in JSON at position 1', }); + }); - it('works as expected when called with valid httpResponse', async () => { - const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; + it('fails when called before generateTurnDiscoveryRequestMessage() was called', async () => { + const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; + await assert.isRejected( + td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse), + 'handleTurnDiscoveryHttpResponse() called before generateTurnDiscoveryRequestMessage()' + ); + }); - // we spy on handleTurnDiscoveryResponse and check that it's called so that we don't have to repeat - // all the edge case tests here, they're already covered in other tests that call handleTurnDiscoveryResponse - const handleTurnDiscoveryResponseSpy = sinon.spy(td, 'handleTurnDiscoveryResponse'); + it('works as expected when called with valid httpResponse', async () => { + const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; - await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); + // we spy on handleTurnDiscoveryResponse and check that it's called so that we don't have to repeat + // all the edge case tests here, they're already covered in other tests that call handleTurnDiscoveryResponse + const handleTurnDiscoveryResponseSpy = sinon.spy(td, 'handleTurnDiscoveryResponse'); - assert.deepEqual(result, { - turnServerInfo: { - url: FAKE_TURN_URL, - username: FAKE_TURN_USERNAME, - password: FAKE_TURN_PASSWORD, - }, - turnDiscoverySkippedReason: undefined, - }); + await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); - assert.calledOnceWithExactly(handleTurnDiscoveryResponseSpy, roapMessage, 'in http response'); + assert.deepEqual(result, { + turnServerInfo: { + url: FAKE_TURN_URL, + username: FAKE_TURN_USERNAME, + password: FAKE_TURN_PASSWORD, + }, + turnDiscoverySkippedReason: undefined, }); - it('works as expected when httpResponse is missing some headers', async () => { - roapMessage.headers = [ - `x-cisco-turn-url=${FAKE_TURN_URL}`, // missing headers for username and password - ]; + assert.calledOnceWithExactly(handleTurnDiscoveryResponseSpy, roapMessage, 'in http response'); + }); - const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; + it('works as expected when httpResponse is missing some headers', async () => { + roapMessage.headers = [ + `x-cisco-turn-url=${FAKE_TURN_URL}`, // missing headers for username and password + ]; - // we spy on handleTurnDiscoveryResponse and check that it's called so that we don't have to repeat - // all the edge case tests here, they're already covered in other tests that call handleTurnDiscoveryResponse - // we test just this 1 edge case here to confirm that when handleTurnDiscoveryResponse rejects, we get the correct result - const handleTurnDiscoveryResponseSpy = sinon.spy(td, 'handleTurnDiscoveryResponse'); + const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; - await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); + // we spy on handleTurnDiscoveryResponse and check that it's called so that we don't have to repeat + // all the edge case tests here, they're already covered in other tests that call handleTurnDiscoveryResponse + // we test just this 1 edge case here to confirm that when handleTurnDiscoveryResponse rejects, we get the correct result + const handleTurnDiscoveryResponseSpy = sinon.spy(td, 'handleTurnDiscoveryResponse'); - assert.deepEqual(result, { - turnServerInfo: undefined, - turnDiscoverySkippedReason: 'failure: TURN_DISCOVERY_RESPONSE in http response missing some headers: ["x-cisco-turn-url=turns:fakeTurnServer.com:443?transport=tcp"]', - }); - assert.calledOnceWithExactly(handleTurnDiscoveryResponseSpy, roapMessage, 'in http response'); + await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); - checkNextTurnDiscovery(); + assert.deepEqual(result, { + turnServerInfo: undefined, + turnDiscoverySkippedReason: + 'failure: TURN_DISCOVERY_RESPONSE in http response missing some headers: ["x-cisco-turn-url=turns:fakeTurnServer.com:443?transport=tcp"]', }); + assert.calledOnceWithExactly(handleTurnDiscoveryResponseSpy, roapMessage, 'in http response'); - it('sends OK when required', async () => { - roapMessage.headers = [ - `x-cisco-turn-url=${FAKE_TURN_URL}`, - `x-cisco-turn-username=${FAKE_TURN_USERNAME}`, - `x-cisco-turn-password=${FAKE_TURN_PASSWORD}`, - // noOkInTransaction is missing - ]; - const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; + checkNextTurnDiscovery(); + }); - await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); + it('sends OK when required', async () => { + roapMessage.headers = [ + `x-cisco-turn-url=${FAKE_TURN_URL}`, + `x-cisco-turn-username=${FAKE_TURN_USERNAME}`, + `x-cisco-turn-password=${FAKE_TURN_PASSWORD}`, + // noOkInTransaction is missing + ]; + const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; - assert.deepEqual(result, { - turnServerInfo: { - url: FAKE_TURN_URL, - username: FAKE_TURN_USERNAME, - password: FAKE_TURN_PASSWORD, - }, - turnDiscoverySkippedReason: undefined, - }); + await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); - // check that OK was sent along with the metric for it - await checkRoapMessageSent('OK', 0); + assert.deepEqual(result, { + turnServerInfo: { + url: FAKE_TURN_URL, + username: FAKE_TURN_USERNAME, + password: FAKE_TURN_PASSWORD, + }, + turnDiscoverySkippedReason: undefined, + }); - assert.calledWith( - Metrics.sendBehavioralMetric, - BEHAVIORAL_METRICS.TURN_DISCOVERY_REQUIRES_OK, - sinon.match({ - correlation_id: testMeeting.correlationId, - locus_id: FAKE_LOCUS_ID, - }) - ); + // check that OK was sent along with the metric for it + await checkRoapMessageSent('OK', 0); - checkNextTurnDiscovery(); - }); + assert.calledWith( + Metrics.sendBehavioralMetric, + BEHAVIORAL_METRICS.TURN_DISCOVERY_REQUIRES_OK, + sinon.match({ + correlation_id: testMeeting.correlationId, + locus_id: FAKE_LOCUS_ID, + }) + ); - describe('abort', () => { - it('allows starting a new TURN discovery', async () => { - let result; + checkNextTurnDiscovery(); + }); - // this mock is required for doTurnDiscovery() to work - mockRoapRequest.sendRoap = sinon.fake.resolves({ - mediaConnections: [ - { - mediaId: '464ff97f-4bda-466a-ad06-3a22184a2274', - remoteSdp: `{"roapMessage": {"messageType":"TURN_DISCOVERY_RESPONSE","seq":"0","headers": ["x-cisco-turn-url=${FAKE_TURN_URL}","x-cisco-turn-username=${FAKE_TURN_USERNAME}","x-cisco-turn-password=${FAKE_TURN_PASSWORD}", "noOkInTransaction"]}}`, - }, - ], - }); + describe('abort', () => { + it('allows starting a new TURN discovery', async () => { + let result; - result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - assert.isDefined(result.roapMessage); + // this mock is required for doTurnDiscovery() to work + mockRoapRequest.sendRoap = sinon.fake.resolves({ + mediaConnections: [ + { + mediaId: '464ff97f-4bda-466a-ad06-3a22184a2274', + remoteSdp: `{"roapMessage": {"messageType":"TURN_DISCOVERY_RESPONSE","seq":"0","headers": ["x-cisco-turn-url=${FAKE_TURN_URL}","x-cisco-turn-username=${FAKE_TURN_USERNAME}","x-cisco-turn-password=${FAKE_TURN_PASSWORD}", "noOkInTransaction"]}}`, + }, + ], + }); - td.abort(); + result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + assert.isDefined(result.roapMessage); - result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - assert.isDefined(result.roapMessage); + td.abort(); - td.abort(); + result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + assert.isDefined(result.roapMessage); - // check also that doTurnDiscovery() works after abort() - result = await td.doTurnDiscovery(testMeeting, false); - }); + td.abort(); + + // check also that doTurnDiscovery() works after abort() + result = await td.doTurnDiscovery(testMeeting, false); + }); - it('does nothing when called outside of a TURN discovery', async () => { - let result; + it('does nothing when called outside of a TURN discovery', async () => { + let result; - // call abort() without any other calls before it - it should do nothing - // there is not much we can check, so afterwards we just check that we can start a new TURN discovery - td.abort(); + // call abort() without any other calls before it - it should do nothing + // there is not much we can check, so afterwards we just check that we can start a new TURN discovery + td.abort(); - result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - assert.isDefined(result.roapMessage); - }); + result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + assert.isDefined(result.roapMessage); }); + }); }); }); From 425cdb682ebfbf860d63a6630f91e7b39cb52c99 Mon Sep 17 00:00:00 2001 From: Marcin Date: Wed, 4 Dec 2024 16:49:13 +0000 Subject: [PATCH 21/30] feat(meetings): incremental log uploads (#3899) --- .../internal-plugin-support/src/config.js | 1 + .../internal-plugin-support/src/support.js | 4 ++ packages/@webex/plugin-logger/src/logger.js | 14 ++++ packages/@webex/plugin-meetings/src/config.ts | 1 + .../plugin-meetings/src/meeting/index.ts | 65 +++++++++++++++++++ .../plugin-meetings/src/meeting/util.ts | 1 + .../test/unit/spec/meeting/index.js | 57 ++++++++++++++++ .../test/unit/spec/meeting/utils.js | 2 + 8 files changed, 145 insertions(+) diff --git a/packages/@webex/internal-plugin-support/src/config.js b/packages/@webex/internal-plugin-support/src/config.js index c85a0aa5cca..8fcd1226b2a 100644 --- a/packages/@webex/internal-plugin-support/src/config.js +++ b/packages/@webex/internal-plugin-support/src/config.js @@ -17,5 +17,6 @@ export default { appType: '', appVersion: '', languageCode: '', + incrementalLogs: false, }, }; diff --git a/packages/@webex/internal-plugin-support/src/support.js b/packages/@webex/internal-plugin-support/src/support.js index f41d380637e..b5cb0824f19 100644 --- a/packages/@webex/internal-plugin-support/src/support.js +++ b/packages/@webex/internal-plugin-support/src/support.js @@ -107,6 +107,10 @@ const Support = WebexPlugin.extend({ return this.webex.upload(options); }) .then((body) => { + if (this.config.incrementalLogs) { + this.webex.logger.clearBuffers(); + } + if (userId && !body.userId) { body.userId = userId; } diff --git a/packages/@webex/plugin-logger/src/logger.js b/packages/@webex/plugin-logger/src/logger.js index 7ef5a925fa9..183602dbe50 100644 --- a/packages/@webex/plugin-logger/src/logger.js +++ b/packages/@webex/plugin-logger/src/logger.js @@ -252,6 +252,20 @@ const Logger = WebexPlugin.extend({ return this.getCurrentLevel(); }, + /** + * Clears the log buffers + * + * @instance + * @memberof Logger + * @public + * @returns {undefined} + */ + clearBuffers() { + this.clientBuffer = []; + this.sdkBuffer = []; + this.buffer = []; + }, + /** * Format logs (for upload) * diff --git a/packages/@webex/plugin-meetings/src/config.ts b/packages/@webex/plugin-meetings/src/config.ts index c95a2b43405..28c451ab152 100644 --- a/packages/@webex/plugin-meetings/src/config.ts +++ b/packages/@webex/plugin-meetings/src/config.ts @@ -96,5 +96,6 @@ export default { iceCandidatesGatheringTimeout: undefined, backendIpv6NativeSupport: false, reachabilityGetClusterTimeout: 5000, + logUploadIntervalMultiplicationFactor: 0, // if set to 0 or undefined, logs won't be uploaded periodically }, }; diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 739c5e22513..a788e46cd36 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -5,6 +5,7 @@ import jwtDecode from 'jwt-decode'; import {StatelessWebexPlugin} from '@webex/webex-core'; // @ts-ignore - Types not available for @webex/common import {Defer} from '@webex/common'; +import {safeSetTimeout, safeSetInterval} from '@webex/common-timers'; import { ClientEvent, ClientEventLeaveReason, @@ -702,6 +703,8 @@ export default class Meeting extends StatelessWebexPlugin { private iceCandidateErrors: Map; private iceCandidatesCount: number; private rtcMetrics?: RtcMetrics; + private uploadLogsTimer?: ReturnType; + private logUploadIntervalIndex: number; /** * @param {Object} attrs @@ -770,6 +773,8 @@ export default class Meeting extends StatelessWebexPlugin { ); this.callStateForMetrics.correlationId = this.id; } + this.logUploadIntervalIndex = 0; + /** * @instance * @type {String} @@ -4060,6 +4065,65 @@ export default class Meeting extends StatelessWebexPlugin { Trigger.trigger(this, options, EVENTS.REQUEST_UPLOAD_LOGS, this); } + /** + * sets the timer for periodic log upload + * @returns {void} + */ + private setLogUploadTimer() { + // start with short timeouts and increase them later on so in case users have very long multi-hour meetings we don't get too fragmented logs + const LOG_UPLOAD_INTERVALS = [0.1, 1, 15, 15, 30, 30, 30, 60]; + + const delay = + 1000 * + // @ts-ignore - config coming from registerPlugin + this.config.logUploadIntervalMultiplicationFactor * + LOG_UPLOAD_INTERVALS[this.logUploadIntervalIndex]; + + if (this.logUploadIntervalIndex < LOG_UPLOAD_INTERVALS.length - 1) { + this.logUploadIntervalIndex += 1; + } + + this.uploadLogsTimer = safeSetTimeout(() => { + this.uploadLogsTimer = undefined; + + this.uploadLogs(); + + // just as an extra precaution, to avoid uploading logs forever in case something goes wrong + // and the page remains opened, we stop it if there is no media connection + if (!this.mediaProperties.webrtcMediaConnection) { + return; + } + + this.setLogUploadTimer(); + }, delay); + } + + /** + * Starts a periodic upload of logs + * + * @returns {undefined} + */ + public startPeriodicLogUpload() { + // @ts-ignore - config coming from registerPlugin + if (this.config.logUploadIntervalMultiplicationFactor && !this.uploadLogsTimer) { + this.logUploadIntervalIndex = 0; + + this.setLogUploadTimer(); + } + } + + /** + * Stops the periodic upload of logs + * + * @returns {undefined} + */ + public stopPeriodicLogUpload() { + if (this.uploadLogsTimer) { + clearTimeout(this.uploadLogsTimer); + this.uploadLogsTimer = undefined; + } + } + /** * Removes remote audio, video and share streams from class instance's mediaProperties * @returns {undefined} @@ -7245,6 +7309,7 @@ export default class Meeting extends StatelessWebexPlugin { // We can log ReceiveSlot SSRCs only after the SDP exchange, so doing it here: this.remoteMediaManager?.logAllReceiveSlots(); + this.startPeriodicLogUpload(); } catch (error) { LoggerProxy.logger.error(`${LOG_HEADER} failed to establish media connection: `, error); diff --git a/packages/@webex/plugin-meetings/src/meeting/util.ts b/packages/@webex/plugin-meetings/src/meeting/util.ts index b361b03366a..0b727d67874 100644 --- a/packages/@webex/plugin-meetings/src/meeting/util.ts +++ b/packages/@webex/plugin-meetings/src/meeting/util.ts @@ -197,6 +197,7 @@ const MeetingUtil = { cleanUp: (meeting) => { meeting.getWebexObject().internal.device.meetingEnded(); + meeting.stopPeriodicLogUpload(); meeting.breakouts.cleanUp(); meeting.simultaneousInterpretation.cleanUp(); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 968dd878f5f..feb8bb78ef6 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -2465,6 +2465,63 @@ describe('plugin-meetings', () => { checkWorking(); }); + it('should upload logs periodically', async () => { + const clock = sinon.useFakeTimers(); + + meeting.roap.doTurnDiscovery = sinon + .stub() + .resolves({turnServerInfo: undefined, turnDiscoverySkippedReason: undefined}); + + let logUploadCounter = 0; + + TriggerProxy.trigger.callsFake((meetingObject, options, event) => { + if ( + meetingObject === meeting && + options.file === 'meeting/index' && + options.function === 'uploadLogs' && + event === 'REQUEST_UPLOAD_LOGS' + ) { + logUploadCounter += 1; + } + }); + + meeting.config.logUploadIntervalMultiplicationFactor = 1; + meeting.meetingState = 'ACTIVE'; + + await meeting.addMedia({ + mediaSettings: {}, + }); + + const checkLogCounter = (delay, expectedCounter) => { + // first check that the counter is not increased just before the delay + clock.tick(delay - 50); + assert.equal(logUploadCounter, expectedCounter - 1); + + // and now check that it has reached expected value after the delay + clock.tick(50); + assert.equal(logUploadCounter, expectedCounter); + }; + + checkLogCounter(100, 1); + checkLogCounter(1000, 2); + checkLogCounter(15000, 3); + checkLogCounter(15000, 4); + checkLogCounter(30000, 5); + checkLogCounter(30000, 6); + checkLogCounter(30000, 7); + checkLogCounter(60000, 8); + checkLogCounter(60000, 9); + checkLogCounter(60000, 10); + + // simulate media connection being removed -> no more log uploads should happen + meeting.mediaProperties.webrtcMediaConnection = undefined; + + clock.tick(60000); + assert.equal(logUploadCounter, 11); + + clock.restore(); + }); + it('should attach the media and return promise when in the lobby if allowMediaInLobby is set', async () => { meeting.roap.doTurnDiscovery = sinon .stub() diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js index 2c2059a4d16..6389862bfd2 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js @@ -45,6 +45,7 @@ describe('plugin-meetings', () => { meeting.cleanupLocalStreams = sinon.stub().returns(Promise.resolve()); meeting.closeRemoteStreams = sinon.stub().returns(Promise.resolve()); meeting.closePeerConnections = sinon.stub().returns(Promise.resolve()); + meeting.stopPeriodicLogUpload = sinon.stub(); meeting.unsetRemoteStreams = sinon.stub(); meeting.unsetPeerConnections = sinon.stub(); @@ -70,6 +71,7 @@ describe('plugin-meetings', () => { assert.calledOnce(meeting.cleanupLocalStreams); assert.calledOnce(meeting.closeRemoteStreams); assert.calledOnce(meeting.closePeerConnections); + assert.calledOnce(meeting.stopPeriodicLogUpload); assert.calledOnce(meeting.unsetRemoteStreams); assert.calledOnce(meeting.unsetPeerConnections); From d545259be91a60943484bb6a62cef821a7c1cba0 Mon Sep 17 00:00:00 2001 From: Marcin Date: Fri, 6 Dec 2024 10:43:05 +0000 Subject: [PATCH 22/30] fix: lobby entry/exit metrics (#4018) --- .../@webex/plugin-meetings/src/meeting/index.ts | 6 ++++++ .../plugin-meetings/src/metrics/constants.ts | 2 ++ .../test/unit/spec/meeting/index.js | 14 ++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index a788e46cd36..9ab2f03b185 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -3254,6 +3254,9 @@ export default class Meeting extends StatelessWebexPlugin { options: {meetingId: this.id}, }); } + Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.GUEST_ENTERED_LOBBY, { + correlation_id: this.correlationId, + }); this.updateLLMConnection(); }); this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, async (payload) => { @@ -3277,6 +3280,9 @@ export default class Meeting extends StatelessWebexPlugin { name: 'client.lobby.exited', options: {meetingId: this.id}, }); + Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.GUEST_EXITED_LOBBY, { + correlation_id: this.correlationId, + }); } this.rtcMetrics?.sendNextMetrics(); this.updateLLMConnection(); diff --git a/packages/@webex/plugin-meetings/src/metrics/constants.ts b/packages/@webex/plugin-meetings/src/metrics/constants.ts index 3cc6cb108fd..65b82975ba4 100644 --- a/packages/@webex/plugin-meetings/src/metrics/constants.ts +++ b/packages/@webex/plugin-meetings/src/metrics/constants.ts @@ -71,6 +71,8 @@ const BEHAVIORAL_METRICS = { TURN_DISCOVERY_REQUIRES_OK: 'js_sdk_turn_discovery_requires_ok', REACHABILITY_COMPLETED: 'js_sdk_reachability_completed', WEBINAR_REGISTRATION_ERROR: 'js_sdk_webinar_registration_error', + GUEST_ENTERED_LOBBY: 'js_sdk_guest_entered_lobby', + GUEST_EXITED_LOBBY: 'js_sdk_guest_exited_lobby', }; export {BEHAVIORAL_METRICS as default}; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index feb8bb78ef6..791f04e0c39 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -8677,6 +8677,13 @@ describe('plugin-meetings', () => { {payload: test1} ); assert.calledOnce(meeting.updateLLMConnection); + assert.calledOnceWithExactly( + Metrics.sendBehavioralMetric, + BEHAVIORAL_METRICS.GUEST_ENTERED_LOBBY, + { + correlation_id: meeting.correlationId, + } + ); done(); }); it('listens to the self admitted guest event', (done) => { @@ -8698,6 +8705,13 @@ describe('plugin-meetings', () => { assert.calledOnce(meeting.updateLLMConnection); assert.calledOnceWithExactly(meeting.rtcMetrics.sendNextMetrics); + assert.calledOnceWithExactly( + Metrics.sendBehavioralMetric, + BEHAVIORAL_METRICS.GUEST_EXITED_LOBBY, + { + correlation_id: meeting.correlationId, + } + ); done(); }); From 056f75f7011ce4fa0f37c0969bf84fcef664b3e9 Mon Sep 17 00:00:00 2001 From: xinhyao <142190897+xinhyao@users.noreply.github.com> Date: Wed, 11 Dec 2024 06:23:17 +0800 Subject: [PATCH 23/30] fix(authorization): set supertoken after authorization success (#4026) --- .../src/authorization.js | 1 + .../test/unit/spec/authorization.js | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js index 91be8a7ce34..4c79d317fb8 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js @@ -434,6 +434,7 @@ const Authorization = WebexPlugin.extend({ eventType: 'authorizationSuccess', data: res.body, }); + this.webex.credentials.set({supertoken: res.body}); this.cancelQRCodePolling(); }) .catch((res) => { diff --git a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js index b08af627998..a5a95285de2 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js @@ -599,6 +599,7 @@ describe('plugin-authorization-browser-first-party', () => { webex.request.onFirstCall().resolves({statusCode: 200, body: {access_token: 'token'}}); const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + const credentialsSetSpy = sinon.spy(webex.credentials, 'set'); sinon.spy(webex.authorization, 'cancelQRCodePolling'); webex.authorization._startQRCodePolling(options); @@ -617,6 +618,7 @@ describe('plugin-authorization-browser-first-party', () => { ); assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledOnce(credentialsSetSpy); assert.calledTwice(emitSpy); assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess'); assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled'); @@ -708,7 +710,8 @@ describe('plugin-authorization-browser-first-party', () => { webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}}); sinon.spy(webex.authorization, 'cancelQRCodePolling'); const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); - + const credentialsSetSpy = sinon.spy(webex.credentials, 'set'); + webex.authorization._startQRCodePolling(options); await clock.tickAsync(4000); @@ -720,6 +723,7 @@ describe('plugin-authorization-browser-first-party', () => { assert.calledTwice(webex.request); assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledOnce(credentialsSetSpy); assert.calledTwice(emitSpy); assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess'); assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled'); From 9902ae88f85515eb629af98a27d09cd667c6f80d Mon Sep 17 00:00:00 2001 From: Coread Date: Wed, 11 Dec 2024 15:13:22 +0000 Subject: [PATCH 24/30] feat(plugin-meetings): set up the listeners before triggering ice gathering (#4025) --- .../src/reachability/clusterReachability.ts | 5 ++++- .../test/unit/spec/reachability/clusterReachability.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index cc3674ac169..a9472e3a8f1 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -357,11 +357,14 @@ export class ClusterReachability extends EventsScope { this.startTimestamp = performance.now(); + // Set up the state change listeners before triggering the ICE gathering + const gatherIceCandidatePromise = this.gatherIceCandidates(); + // not awaiting the next call on purpose, because we're not sending the offer anywhere and there won't be any answer // we just need to make this call to trigger the ICE gathering process this.pc.setLocalDescription(offer); - await this.gatherIceCandidates(); + await gatherIceCandidatePromise; } catch (error) { LoggerProxy.logger.warn(`Reachability:ClusterReachability#start --> Error: `, error); } diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts index b2a23609004..8b76fe0eef3 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts @@ -15,6 +15,7 @@ describe('ClusterReachability', () => { let previousRTCPeerConnection; let clusterReachability; let fakePeerConnection; + let gatherIceCandidatesSpy; const emittedEvents: Record = { [Events.resultReady]: [], @@ -44,6 +45,8 @@ describe('ClusterReachability', () => { xtls: ['stun:xtls1.webex.com', 'stun:xtls2.webex.com:443'], }); + gatherIceCandidatesSpy = sinon.spy(clusterReachability, 'gatherIceCandidates'); + resetEmittedEvents(); clusterReachability.on(Events.resultReady, (data: ResultEventData) => { @@ -151,6 +154,10 @@ describe('ClusterReachability', () => { assert.calledOnceWithExactly(fakePeerConnection.createOffer, {offerToReceiveAudio: true}); assert.calledOnce(fakePeerConnection.setLocalDescription); + // Make sure that gatherIceCandidates is called before setLocalDescription + // as setLocalDescription triggers the ICE gathering process + assert.isTrue(gatherIceCandidatesSpy.calledBefore(fakePeerConnection.setLocalDescription)); + clusterReachability.abort(); await promise; From 71f56bfa4a0de954bedab404940885dde6e9589e Mon Sep 17 00:00:00 2001 From: mickelr <121160648+mickelr@users.noreply.github.com> Date: Mon, 16 Dec 2024 14:44:38 +0800 Subject: [PATCH 25/30] Feat/practice session - start/stop control API and displayhint (#4008) --- .../@webex/plugin-meetings/src/constants.ts | 6 ++ .../src/meeting/in-meeting-actions.ts | 17 ++++ .../plugin-meetings/src/meeting/index.ts | 18 ++++ .../plugin-meetings/src/members/util.ts | 1 + .../plugin-meetings/src/webinar/index.ts | 48 ++++++++-- .../unit/spec/meeting/in-meeting-actions.ts | 12 ++- .../test/unit/spec/meeting/index.js | 3 + .../test/unit/spec/members/utils.js | 95 +++++++++++++++++++ .../test/unit/spec/webinar/index.ts | 47 +++++++++ 9 files changed, 238 insertions(+), 9 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index 6c2a155a86c..5231d3e0184 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -988,6 +988,12 @@ export const DISPLAY_HINTS = { STAGE_VIEW_INACTIVE: 'STAGE_VIEW_INACTIVE', ENABLE_STAGE_VIEW: 'ENABLE_STAGE_VIEW', DISABLE_STAGE_VIEW: 'DISABLE_STAGE_VIEW', + + // Practice Session + PRACTICE_SESSION_ON: 'PRACTICE_SESSION_ON', + PRACTICE_SESSION_OFF: 'PRACTICE_SESSION_OFF', + SHOW_PRACTICE_SESSION_START: 'SHOW_PRACTICE_SESSION_START', + SHOW_PRACTICE_SESSION_STOP: 'SHOW_PRACTICE_SESSION_STOP', }; export const INTERSTITIAL_DISPLAY_HINTS = [DISPLAY_HINTS.VOIP_IS_ENABLED]; diff --git a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts index 3d1810ad539..f803251507c 100644 --- a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts +++ b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts @@ -93,6 +93,10 @@ interface IInMeetingActions { canShowStageView?: boolean; canEnableStageView?: boolean; canDisableStageView?: boolean; + isPracticeSessionOn?: boolean; + isPracticeSessionOff?: boolean; + canStartPracticeSession?: boolean; + canStopPracticeSession?: boolean; } /** @@ -266,6 +270,15 @@ export default class InMeetingActions implements IInMeetingActions { canEnableStageView = null; canDisableStageView = null; + + isPracticeSessionOn = null; + + isPracticeSessionOff = null; + + canStartPracticeSession = null; + + canStopPracticeSession = null; + /** * Returns all meeting action options * @returns {Object} @@ -354,6 +367,10 @@ export default class InMeetingActions implements IInMeetingActions { canShowStageView: this.canShowStageView, canEnableStageView: this.canEnableStageView, canDisableStageView: this.canDisableStageView, + isPracticeSessionOn: this.isPracticeSessionOn, + isPracticeSessionOff: this.isPracticeSessionOff, + canStartPracticeSession: this.canStartPracticeSession, + canStopPracticeSession: this.canStopPracticeSession, }); /** diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 9ab2f03b185..ba985aef88f 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -2660,6 +2660,7 @@ export default class Meeting extends StatelessWebexPlugin { }); this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_PRACTICE_SESSION_STATUS_UPDATED, ({state}) => { + this.webinar.updatePracticeSessionStatus(state); Trigger.trigger( this, {file: 'meeting/index', function: 'setupLocusControlsListener'}, @@ -3516,6 +3517,7 @@ export default class Meeting extends StatelessWebexPlugin { emailAddress: string; email: string; phoneNumber: string; + roles: Array; }, alertIfActive = true ) { @@ -3915,6 +3917,22 @@ export default class Meeting extends StatelessWebexPlugin { requiredHints: [DISPLAY_HINTS.DISABLE_STAGE_VIEW], displayHints: this.userDisplayHints, }), + isPracticeSessionOn: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.PRACTICE_SESSION_ON], + displayHints: this.userDisplayHints, + }), + isPracticeSessionOff: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.PRACTICE_SESSION_OFF], + displayHints: this.userDisplayHints, + }), + canStartPracticeSession: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.SHOW_PRACTICE_SESSION_START], + displayHints: this.userDisplayHints, + }), + canStopPracticeSession: ControlsOptionsUtil.hasHints({ + requiredHints: [DISPLAY_HINTS.SHOW_PRACTICE_SESSION_STOP], + displayHints: this.userDisplayHints, + }), canShareFile: (ControlsOptionsUtil.hasHints({ requiredHints: [DISPLAY_HINTS.SHARE_FILE], diff --git a/packages/@webex/plugin-meetings/src/members/util.ts b/packages/@webex/plugin-meetings/src/members/util.ts index 449d686f22a..930d187b2d7 100644 --- a/packages/@webex/plugin-meetings/src/members/util.ts +++ b/packages/@webex/plugin-meetings/src/members/util.ts @@ -46,6 +46,7 @@ const MembersUtil = { { address: options.invitee.emailAddress || options.invitee.email || options.invitee.phoneNumber, + ...(options.invitee.roles ? {roles: options.invitee.roles} : {}), }, ], alertIfActive: options.alertIfActive, diff --git a/packages/@webex/plugin-meetings/src/webinar/index.ts b/packages/@webex/plugin-meetings/src/webinar/index.ts index 709ca7f9cde..f185161d8b6 100644 --- a/packages/@webex/plugin-meetings/src/webinar/index.ts +++ b/packages/@webex/plugin-meetings/src/webinar/index.ts @@ -3,9 +3,10 @@ */ import {WebexPlugin} from '@webex/webex-core'; import {get} from 'lodash'; -import {MEETINGS, SELF_ROLES} from '../constants'; +import {HTTP_VERBS, MEETINGS, SELF_ROLES} from '../constants'; import WebinarCollection from './collection'; +import LoggerProxy from '../common/logs/logger-proxy'; /** * @class Webinar @@ -22,6 +23,7 @@ const Webinar = WebexPlugin.extend({ canManageWebcast: 'boolean', // appears the ability to manage webcast selfIsPanelist: 'boolean', // self is panelist selfIsAttendee: 'boolean', // self is attendee + practiceSessionEnabled: 'boolean', // practice session enabled }, /** @@ -59,18 +61,48 @@ const Webinar = WebexPlugin.extend({ * @returns {{isPromoted: boolean, isDemoted: boolean}} Role transition states */ updateRoleChanged(payload) { + const oldRoles = get(payload, 'oldRoles', []); + const newRoles = get(payload, 'newRoles', []); + const isPromoted = - get(payload, 'oldRoles', []).includes(SELF_ROLES.ATTENDEE) && - get(payload, 'newRoles', []).includes(SELF_ROLES.PANELIST); + oldRoles.includes(SELF_ROLES.ATTENDEE) && newRoles.includes(SELF_ROLES.PANELIST); const isDemoted = - get(payload, 'oldRoles', []).includes(SELF_ROLES.PANELIST) && - get(payload, 'newRoles', []).includes(SELF_ROLES.ATTENDEE); - this.set('selfIsPanelist', get(payload, 'newRoles', []).includes(SELF_ROLES.PANELIST)); - this.set('selfIsAttendee', get(payload, 'newRoles', []).includes(SELF_ROLES.ATTENDEE)); - this.updateCanManageWebcast(get(payload, 'newRoles', []).includes(SELF_ROLES.MODERATOR)); + oldRoles.includes(SELF_ROLES.PANELIST) && newRoles.includes(SELF_ROLES.ATTENDEE); + this.set('selfIsPanelist', newRoles.includes(SELF_ROLES.PANELIST)); + this.set('selfIsAttendee', newRoles.includes(SELF_ROLES.ATTENDEE)); + this.updateCanManageWebcast(newRoles.includes(SELF_ROLES.MODERATOR)); return {isPromoted, isDemoted}; }, + + /** + * start or stop practice session for webinar + * @param {boolean} enabled + * @returns {Promise} + */ + setPracticeSessionState(enabled) { + return this.request({ + method: HTTP_VERBS.PATCH, + uri: `${this.locusUrl}/controls`, + body: { + practiceSession: { + enabled, + }, + }, + }).catch((error) => { + LoggerProxy.logger.error('Meeting:webinar#setPracticeSessionState failed', error); + throw error; + }); + }, + + /** + * update practice session status + * @param {object} payload + * @returns {void} + */ + updatePracticeSessionStatus(payload) { + this.set('practiceSessionEnabled', payload.enabled); + }, }); export default Webinar; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts b/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts index c64833dd0f6..d6b2dec374b 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts @@ -88,6 +88,11 @@ describe('plugin-meetings', () => { canShowStageView: null, canEnableStageView: null, canDisableStageView: null, + isPracticeSessionOn : null, + isPracticeSessionOff : null, + canStartPracticeSession: null, + canStopPracticeSession: null, + ...expected, }; @@ -181,7 +186,12 @@ describe('plugin-meetings', () => { 'canShowStageView', 'canEnableStageView', 'canDisableStageView', - ].forEach((key) => { + 'isPracticeSessionOn', + 'isPracticeSessionOff', + 'canStartPracticeSession', + 'canStopPracticeSession', + + ].forEach((key) => { it(`get and set for ${key} work as expected`, () => { const inMeetingActions = new InMeetingActions(); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 791f04e0c39..71a930a9253 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -9044,6 +9044,8 @@ describe('plugin-meetings', () => { }); it('listens to MEETING_CONTROLS_PRACTICE_SESSION_STATUS_UPDATED', async () => { + meeting.webinar.updatePracticeSessionStatus = sinon.stub(); + const state = {example: 'value'}; await meeting.locusInfo.emitScoped( @@ -9052,6 +9054,7 @@ describe('plugin-meetings', () => { {state} ); + assert.calledOnceWithExactly( meeting.webinar.updatePracticeSessionStatus, state); assert.calledWith( TriggerProxy.trigger, meeting, diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js b/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js index c5477c8b340..e79aca4e490 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js @@ -262,5 +262,100 @@ describe('plugin-meetings', () => { testParams(false); }); }); + + describe('#getAddMemberBody', () => { + it('returns the correct body with email address and roles', () => { + const options = { + invitee: { + emailAddress: 'test@example.com', + roles: ['role1', 'role2'], + }, + alertIfActive: true, + }; + + assert.deepEqual(MembersUtil.getAddMemberBody(options), { + invitees: [ + { + address: 'test@example.com', + roles: ['role1', 'role2'], + }, + ], + alertIfActive: true, + }); + }); + + it('returns the correct body with phone number and no roles', () => { + const options = { + invitee: { + phoneNumber: '1234567890', + }, + alertIfActive: false, + }; + + assert.deepEqual(MembersUtil.getAddMemberBody(options), { + invitees: [ + { + address: '1234567890', + }, + ], + alertIfActive: false, + }); + }); + + it('returns the correct body with fallback to email', () => { + const options = { + invitee: { + email: 'fallback@example.com', + }, + alertIfActive: true, + }; + + assert.deepEqual(MembersUtil.getAddMemberBody(options), { + invitees: [ + { + address: 'fallback@example.com', + }, + ], + alertIfActive: true, + }); + }); + + it('handles missing `alertIfActive` gracefully', () => { + const options = { + invitee: { + emailAddress: 'test@example.com', + roles: ['role1'], + }, + }; + + assert.deepEqual(MembersUtil.getAddMemberBody(options), { + invitees: [ + { + address: 'test@example.com', + roles: ['role1'], + }, + ], + alertIfActive: undefined, + }); + }); + + it('ignores roles if not provided', () => { + const options = { + invitee: { + emailAddress: 'test@example.com', + }, + alertIfActive: false, + }; + + assert.deepEqual(MembersUtil.getAddMemberBody(options), { + invitees: [ + { + address: 'test@example.com', + }, + ], + alertIfActive: false, + }); + }); + }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts index 8a06824220c..d466995c637 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts @@ -122,5 +122,52 @@ describe('plugin-meetings', () => { }); }); + describe("#setPracticeSessionState", () => { + [true, false].forEach((enabled) => { + it(`sends a PATCH request to ${enabled ? "enable" : "disable"} the practice session`, async () => { + const result = await webinar.setPracticeSessionState(enabled); + assert.calledOnce(webex.request); + assert.calledWith(webex.request, { + method: "PATCH", + uri: `${webinar.locusUrl}/controls`, + body: { + practiceSession: { enabled } + } + }); + assert.equal(result, "REQUEST_RETURN_VALUE", "should return the resolved value from the request"); + }); + }); + + it('handles API call failures gracefully', async () => { + webex.request.rejects(new Error('API_ERROR')); + const errorLogger = sinon.stub(LoggerProxy.logger, 'error'); + + try { + await webinar.setPracticeSessionState(true); + assert.fail('setPracticeSessionState should throw an error'); + } catch (error) { + assert.equal(error.message, 'API_ERROR', 'should throw the correct error'); + assert.calledOnce(errorLogger); + assert.calledWith(errorLogger, 'Meeting:webinar#setPracticeSessionState failed', sinon.match.instanceOf(Error)); + } + + errorLogger.restore(); + }); + }); + + describe('#updatePracticeSessionStatus', () => { + it('sets PS state true', () => { + webinar.updatePracticeSessionStatus({enabled: true}); + + assert.equal(webinar.practiceSessionEnabled, true); + }); + it('sets PS state true', () => { + webinar.updatePracticeSessionStatus({enabled: false}); + + assert.equal(webinar.practiceSessionEnabled, false); + }); + }); + + }) }) From 885e4bb02580a2b6bb7bbe0fe19926533d84d9ec Mon Sep 17 00:00:00 2001 From: Harshabh Mahant Date: Mon, 16 Dec 2024 18:58:02 +0530 Subject: [PATCH 26/30] feat: premise recording sdk support (#4021) --- .../@webex/plugin-meetings/src/constants.ts | 4 ++ .../src/meeting/in-meeting-actions.ts | 4 ++ .../plugin-meetings/src/meeting/index.ts | 4 ++ .../src/recording-controller/enums.ts | 7 +- .../src/recording-controller/index.ts | 21 ++++-- .../src/recording-controller/util.ts | 25 +++++-- .../unit/spec/meeting/in-meeting-actions.ts | 2 + .../unit/spec/recording-controller/index.js | 66 +++++++++++++++++-- .../unit/spec/recording-controller/util.js | 42 +++++++++++- .../webex-core/src/lib/webex-http-error.js | 9 +++ 10 files changed, 165 insertions(+), 19 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index 5231d3e0184..5d5b493835e 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -904,6 +904,10 @@ export const DISPLAY_HINTS = { RECORDING_CONTROL_PAUSE: 'RECORDING_CONTROL_PAUSE', RECORDING_CONTROL_STOP: 'RECORDING_CONTROL_STOP', RECORDING_CONTROL_RESUME: 'RECORDING_CONTROL_RESUME', + PREMISE_RECORDING_CONTROL_START: 'PREMISE_RECORDING_CONTROL_START', + PREMISE_RECORDING_CONTROL_PAUSE: 'PREMISE_RECORDING_CONTROL_PAUSE', + PREMISE_RECORDING_CONTROL_STOP: 'PREMISE_RECORDING_CONTROL_STOP', + PREMISE_RECORDING_CONTROL_RESUME: 'PREMISE_RECORDING_CONTROL_RESUME', LOCK_CONTROL_UNLOCK: 'LOCK_CONTROL_UNLOCK', LOCK_CONTROL_LOCK: 'LOCK_CONTROL_LOCK', LOCK_STATUS_LOCKED: 'LOCK_STATUS_LOCKED', diff --git a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts index f803251507c..6b6c80894f5 100644 --- a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts +++ b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts @@ -26,6 +26,7 @@ interface IInMeetingActions { canStartRecording?: boolean; canPauseRecording?: boolean; canResumeRecording?: boolean; + isPremiseRecordingEnabled?: boolean; canStopRecording?: boolean; canRaiseHand?: boolean; canLowerAllHands?: boolean; @@ -121,6 +122,8 @@ export default class InMeetingActions implements IInMeetingActions { canResumeRecording = null; + isPremiseRecordingEnabled = null; + canStopRecording = null; canSetMuteOnEntry = null; @@ -301,6 +304,7 @@ export default class InMeetingActions implements IInMeetingActions { canPauseRecording: this.canPauseRecording, canResumeRecording: this.canResumeRecording, canStopRecording: this.canStopRecording, + isPremiseRecordingEnabled: this.isPremiseRecordingEnabled, canRaiseHand: this.canRaiseHand, canLowerAllHands: this.canLowerAllHands, canLowerSomeoneElsesHand: this.canLowerSomeoneElsesHand, diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index ba985aef88f..f5311e002e3 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -3774,6 +3774,10 @@ export default class Meeting extends StatelessWebexPlugin { this.userDisplayHints, this.selfUserPolicies ), + isPremiseRecordingEnabled: RecordingUtil.isPremiseRecordingEnabled( + this.userDisplayHints, + this.selfUserPolicies + ), canRaiseHand: MeetingUtil.canUserRaiseHand(this.userDisplayHints), canLowerAllHands: MeetingUtil.canUserLowerAllHands(this.userDisplayHints), canLowerSomeoneElsesHand: MeetingUtil.canUserLowerSomeoneElsesHand(this.userDisplayHints), diff --git a/packages/@webex/plugin-meetings/src/recording-controller/enums.ts b/packages/@webex/plugin-meetings/src/recording-controller/enums.ts index a6089b59bde..92ebff556c4 100644 --- a/packages/@webex/plugin-meetings/src/recording-controller/enums.ts +++ b/packages/@webex/plugin-meetings/src/recording-controller/enums.ts @@ -1,8 +1,11 @@ -enum RecordingAction { +export enum RecordingAction { Start = 'Start', Stop = 'Stop', Pause = 'Pause', Resume = 'Resume', } -export default RecordingAction; +export enum RecordingType { + Premise = 'premise', + Cloud = 'cloud', +} diff --git a/packages/@webex/plugin-meetings/src/recording-controller/index.ts b/packages/@webex/plugin-meetings/src/recording-controller/index.ts index 5ffabd358be..e71f56db767 100644 --- a/packages/@webex/plugin-meetings/src/recording-controller/index.ts +++ b/packages/@webex/plugin-meetings/src/recording-controller/index.ts @@ -1,9 +1,9 @@ import PermissionError from '../common/errors/permission'; +import LoggerProxy from '../common/logs/logger-proxy'; import {CONTROLS, HTTP_VERBS, SELF_POLICY} from '../constants'; import MeetingRequest from '../meeting/request'; -import RecordingAction from './enums'; +import {RecordingAction, RecordingType} from './enums'; import Util from './util'; -import LoggerProxy from '../common/logs/logger-proxy'; /** * @description Recording manages the recording functionality of the meeting object, there should only be one instantation of recording per meeting @@ -228,11 +228,12 @@ export default class RecordingController { /** * @param {RecordingAction} action + * @param {RecordingType} recordingType * @private * @memberof RecordingController * @returns {Promise} */ - private recordingService(action: RecordingAction): Promise { + private recordingService(action: RecordingAction, recordingType: RecordingType): Promise { // @ts-ignore return this.request.request({ body: { @@ -242,6 +243,7 @@ export default class RecordingController { recording: { action: action.toLowerCase(), }, + recordingType, }, uri: `${this.serviceUrl}/loci/${this.locusId}/recording`, method: HTTP_VERBS.PUT, @@ -276,14 +278,25 @@ export default class RecordingController { * @returns {Promise} */ private recordingFacade(action: RecordingAction): Promise { + const isPremiseRecordingEnabled = Util.isPremiseRecordingEnabled( + this.displayHints, + this.selfUserPolicies + ); LoggerProxy.logger.log( `RecordingController:index#recordingFacade --> recording action [${action}]` ); + let recordingType: RecordingType; + if (isPremiseRecordingEnabled) { + recordingType = RecordingType.Premise; + } else { + recordingType = RecordingType.Cloud; + } + // assumes action is proper cased (i.e., Example) if (Util?.[`canUser${action}`](this.displayHints, this.selfUserPolicies)) { if (this.serviceUrl) { - return this.recordingService(action); + return this.recordingService(action, recordingType); } return this.recordingControls(action); diff --git a/packages/@webex/plugin-meetings/src/recording-controller/util.ts b/packages/@webex/plugin-meetings/src/recording-controller/util.ts index 807decd1aa6..88a242bde8d 100644 --- a/packages/@webex/plugin-meetings/src/recording-controller/util.ts +++ b/packages/@webex/plugin-meetings/src/recording-controller/util.ts @@ -1,33 +1,47 @@ import {DISPLAY_HINTS, SELF_POLICY} from '../constants'; -import RecordingAction from './enums'; +import {RecordingAction} from './enums'; import MeetingUtil from '../meeting/util'; const canUserStart = ( displayHints: Array, userPolicies: Record ): boolean => - displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_START) && + (displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_START) || + displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_START)) && MeetingUtil.selfSupportsFeature(SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD, userPolicies); const canUserPause = ( displayHints: Array, userPolicies: Record ): boolean => - displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_PAUSE) && + (displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_PAUSE) || + displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_PAUSE)) && MeetingUtil.selfSupportsFeature(SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD, userPolicies); const canUserResume = ( displayHints: Array, userPolicies: Record ): boolean => - displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_RESUME) && + (displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_RESUME) || + displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_RESUME)) && MeetingUtil.selfSupportsFeature(SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD, userPolicies); const canUserStop = ( displayHints: Array, userPolicies: Record ): boolean => - displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_STOP) && + (displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_STOP) || + displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_STOP)) && + MeetingUtil.selfSupportsFeature(SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD, userPolicies); + +const isPremiseRecordingEnabled = ( + displayHints: Array, + userPolicies: Record +): boolean => + (displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_START) || + displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_PAUSE) || + displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_STOP) || + displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_RESUME)) && MeetingUtil.selfSupportsFeature(SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD, userPolicies); const extractLocusId = (url: string) => { @@ -70,6 +84,7 @@ export default { canUserPause, canUserResume, canUserStop, + isPremiseRecordingEnabled, deriveRecordingStates, extractLocusId, }; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts b/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts index d6b2dec374b..bd673f7acb6 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts @@ -33,6 +33,7 @@ describe('plugin-meetings', () => { canStartManualCaption: null, canStopManualCaption: null, isManualCaptionActive: null, + isPremiseRecordingEnabled: null, isSaveTranscriptsEnabled: null, isWebexAssistantActive: null, canViewCaptionPanel: null, @@ -131,6 +132,7 @@ describe('plugin-meetings', () => { 'canStartManualCaption', 'canStopManualCaption', 'isManualCaptionActive', + 'isPremiseRecordingEnabled', 'isSaveTranscriptsEnabled', 'isWebexAssistantActive', 'canViewCaptionPanel', diff --git a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js index 5e45c719162..6fe2abda085 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js @@ -221,7 +221,21 @@ describe('plugin-meetings', () => { assert.calledWith(request.request, { uri: `test/loci/id/recording`, - body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'start'}}, + body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'start'}, recordingType: 'cloud'}, + method: HTTP_VERBS.PUT, + }); + + assert.deepEqual(result, request.request.firstCall.returnValue); + }); + + it('can start premise recording when the correct display hint is present', () => { + controller.setDisplayHints(['PREMISE_RECORDING_CONTROL_START']); + + const result = controller.startRecording(); + + assert.calledWith(request.request, { + uri: `test/loci/id/recording`, + body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'start'}, recordingType: 'premise'}, method: HTTP_VERBS.PUT, }); @@ -238,14 +252,28 @@ describe('plugin-meetings', () => { assert.isRejected(result); }); - it('can start recording when the correct display hint is present', () => { + it('can stop recording when the correct display hint is present', () => { controller.setDisplayHints(['RECORDING_CONTROL_STOP']); const result = controller.stopRecording(); assert.calledWith(request.request, { uri: `test/loci/id/recording`, - body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'stop'}}, + body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'stop'}, recordingType: 'cloud'}, + method: HTTP_VERBS.PUT, + }); + + assert.deepEqual(result, request.request.firstCall.returnValue); + }); + + it('can stop premise recording when the correct display hint is present', () => { + controller.setDisplayHints(['PREMISE_RECORDING_CONTROL_STOP']); + + const result = controller.stopRecording(); + + assert.calledWith(request.request, { + uri: `test/loci/id/recording`, + body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'stop'}, recordingType: 'premise'}, method: HTTP_VERBS.PUT, }); @@ -269,7 +297,21 @@ describe('plugin-meetings', () => { assert.calledWith(request.request, { uri: `test/loci/id/recording`, - body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'pause'}}, + body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'pause'}, recordingType: 'cloud'}, + method: HTTP_VERBS.PUT, + }); + + assert.deepEqual(result, request.request.firstCall.returnValue); + }); + + it('can pause premise recording when the correct display hint is present', () => { + controller.setDisplayHints(['PREMISE_RECORDING_CONTROL_PAUSE']); + + const result = controller.pauseRecording(); + + assert.calledWith(request.request, { + uri: `test/loci/id/recording`, + body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'pause'}, recordingType: 'premise'}, method: HTTP_VERBS.PUT, }); @@ -293,7 +335,21 @@ describe('plugin-meetings', () => { assert.calledWith(request.request, { uri: `test/loci/id/recording`, - body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'resume'}}, + body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'resume'}, recordingType: 'cloud'}, + method: HTTP_VERBS.PUT, + }); + + assert.deepEqual(result, request.request.firstCall.returnValue); + }); + + it('can resume premise recording when the correct display hint is present', () => { + controller.setDisplayHints(['PREMISE_RECORDING_CONTROL_RESUME']); + + const result = controller.resumeRecording(); + + assert.calledWith(request.request, { + uri: `test/loci/id/recording`, + body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'resume'}, recordingType: 'premise'}, method: HTTP_VERBS.PUT, }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/util.js b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/util.js index 2dcc54040a8..0358a7bf148 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/util.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/util.js @@ -1,5 +1,5 @@ import RecordingUtil from '@webex/plugin-meetings/src/recording-controller/util'; -import RecordingAction from '@webex/plugin-meetings/src/recording-controller/enums'; +import { RecordingAction } from '@webex/plugin-meetings/src/recording-controller/enums'; import {SELF_POLICY} from '@webex/plugin-meetings/src/constants'; import {assert} from 'chai'; @@ -29,6 +29,15 @@ describe('plugin-meetings', () => { ); }); + it('can start premise recording when the correct display hint is present', () => { + locusInfo.parsedLocus.info.userDisplayHints.push('PREMISE_RECORDING_CONTROL_START'); + + assert.equal( + RecordingUtil.canUserStart(locusInfo.parsedLocus.info.userDisplayHints), + true + ); + }); + it('can start recording when the correct display hint is present and the policy is true', () => { locusInfo.parsedLocus.info.userDisplayHints.push('RECORDING_CONTROL_START'); @@ -69,6 +78,15 @@ describe('plugin-meetings', () => { ); }); + it('can pause premise recording when the correct display hint is present', () => { + locusInfo.parsedLocus.info.userDisplayHints.push('PREMISE_RECORDING_CONTROL_PAUSE'); + + assert.equal( + RecordingUtil.canUserPause(locusInfo.parsedLocus.info.userDisplayHints), + true + ); + }); + it('can pause recording when the correct display hint is present and the policy is true', () => { locusInfo.parsedLocus.info.userDisplayHints.push('RECORDING_CONTROL_PAUSE'); @@ -109,6 +127,15 @@ describe('plugin-meetings', () => { ); }); + it('can stop premise recording when the correct display hint is present', () => { + locusInfo.parsedLocus.info.userDisplayHints.push('PREMISE_RECORDING_CONTROL_STOP'); + + assert.equal( + RecordingUtil.canUserStop(locusInfo.parsedLocus.info.userDisplayHints), + true + ); + }); + it('can stop recording when the correct display hint is present and the policy is true', () => { locusInfo.parsedLocus.info.userDisplayHints.push('RECORDING_CONTROL_STOP', { [SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD]: true, @@ -142,7 +169,7 @@ describe('plugin-meetings', () => { }); describe('canUserResume', () => { - it('can start recording when the correct display hint is present', () => { + it('can resume recording when the correct display hint is present', () => { locusInfo.parsedLocus.info.userDisplayHints.push('RECORDING_CONTROL_RESUME'); assert.equal( @@ -151,7 +178,16 @@ describe('plugin-meetings', () => { ); }); - it('can start recording when the correct display hint is present and the policy is true', () => { + it('can resume premise recording when the correct display hint is present', () => { + locusInfo.parsedLocus.info.userDisplayHints.push('PREMISE_RECORDING_CONTROL_RESUME'); + + assert.equal( + RecordingUtil.canUserResume(locusInfo.parsedLocus.info.userDisplayHints), + true + ); + }); + + it('can resume recording when the correct display hint is present and the policy is true', () => { locusInfo.parsedLocus.info.userDisplayHints.push('RECORDING_CONTROL_RESUME'); assert.equal( diff --git a/packages/@webex/webex-core/src/lib/webex-http-error.js b/packages/@webex/webex-core/src/lib/webex-http-error.js index 766fffe3da8..dbf4db970c5 100644 --- a/packages/@webex/webex-core/src/lib/webex-http-error.js +++ b/packages/@webex/webex-core/src/lib/webex-http-error.js @@ -22,6 +22,15 @@ export default class WebexHttpError extends HttpError { value: res.options, }); + Reflect.defineProperty(this, 'body', { + enumerable: false, + value: res.body, + }); + + if (this.body && this.body.errorCode) { + message += `\nerrorCode : ${this.body.errorCode}`; + } + if (this.options.url) { message += `\n${this.options.method} ${this.options.url}`; } else if (this.options.uri) { From 8278bd1847ed9c8d168c7b02c554c22aa0eb9658 Mon Sep 17 00:00:00 2001 From: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Date: Mon, 16 Dec 2024 19:23:47 +0530 Subject: [PATCH 27/30] fix(samples): remove presence from samples page as presence is not public (#4029) --- docs/index.html | 3 - docs/samples/browser-plugin-presence/app.js | 205 ---------- .../browser-plugin-presence/index.html | 96 ----- .../samples/browser-plugin-presence/style.css | 358 ------------------ docs/samples/index.html | 1 - 5 files changed, 663 deletions(-) delete mode 100644 docs/samples/browser-plugin-presence/app.js delete mode 100644 docs/samples/browser-plugin-presence/index.html delete mode 100644 docs/samples/browser-plugin-presence/style.css diff --git a/docs/index.html b/docs/index.html index e02f3127a57..744882a23cf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -82,8 +82,6 @@ Read Status Socket - - Presence
diff --git a/docs/samples/browser-plugin-presence/app.js b/docs/samples/browser-plugin-presence/app.js deleted file mode 100644 index c0b056d7200..00000000000 --- a/docs/samples/browser-plugin-presence/app.js +++ /dev/null @@ -1,205 +0,0 @@ -/* eslint-env browser */ - -/* global Webex */ - -/* eslint-disable no-console */ -/* eslint-disable require-jsdoc */ - -// Declare some globals that we'll need throughout. -let webex; -let enableProd = true; -let subscribedUserIds = []; - -const credentialsFormElm = document.querySelector('#credentials'); -const tokenElm = document.querySelector('#access-token'); -const saveElm = document.querySelector('#access-token-save'); -const authStatusElm = document.querySelector('#access-token-status'); -const selfPresenceElm = document.querySelector('#self-presence-status'); -const selfPresenceBtn = document.querySelector('#sd-get-self-presence'); -const setPresenceStatusElm = document.querySelector('#set-presence'); -const setPresenceTtl = document.querySelector('#presence-ttl'); -const setPresenceBtn = document.querySelector('#sd-set-self-presence'); -const getPresenceBtn = document.querySelector('#sd-get-user-presence'); -const getUserPresenceElm = document.querySelector('#get-user-presence'); -const userPresenceStatusElm = document.querySelector('#user-presence-status'); -const presenceNotifications = document.querySelector('#subscribe-presence-notifications'); -const usersToSubOrUnsub = document.querySelector('#subscribe-id'); -const subscribePresenceBtn = document.querySelector('#subscribe-presence'); -const unsubscribePresenceBtn = document.querySelector('#unsubscribe-presence'); -const subscribeNotificationBox = document.querySelector('#subscribe-presence-notifications'); - -// Store and Grab `access-token` from localstorage -if (localStorage.getItem('date') > new Date().getTime()) { - tokenElm.value = localStorage.getItem('access-token'); -} else { - localStorage.removeItem('access-token'); -} - -tokenElm.addEventListener('change', (event) => { - localStorage.setItem('access-token', event.target.value); - localStorage.setItem('date', new Date().getTime() + 12 * 60 * 60 * 1000); -}); - -function changeEnv() { - enableProd = !enableProd; - enableProduction.innerHTML = enableProd ? 'In Production' : 'In Integration'; -} - -function updateStatus(enabled) { - selfPresenceBtn.disabled = !enabled; - setPresenceBtn.disabled = !enabled; - getPresenceBtn.disabled = !enabled; - subscribePresenceBtn.disabled = !enabled; - unsubscribePresenceBtn.disabled = !enabled; -} - - -async function initWebex(e) { - e.preventDefault(); - console.log('Authentication#initWebex()'); - - tokenElm.disabled = true; - saveElm.disabled = true; - selfPresenceBtn.disabled = true; - setPresenceBtn.disabled = true; - getPresenceBtn.disabled = true; - subscribePresenceBtn.disabled = true; - unsubscribePresenceBtn.disabled = true; - - authStatusElm.innerText = 'initializing...'; - - const webexConfig = { - config: { - logger: { - level: 'debug', // set the desired log level - }, - meetings: { - reconnection: { - enabled: true, - }, - enableRtx: true, - }, - encryption: { - kmsInitialTimeout: 8000, - kmsMaxTimeout: 40000, - batcherMaxCalls: 30, - caroots: null, - }, - dss: {}, - }, - credentials: { - access_token: tokenElm.value - } - }; - - if (!enableProd) { - webexConfig.config.services = { - discovery: { - u2c: 'https://u2c-intb.ciscospark.com/u2c/api/v1', - hydra: 'https://apialpha.ciscospark.com/v1/', - }, - }; - } - - webex = window.webex = Webex.init(webexConfig); - - webex.once('ready', () => { - console.log('Authentication#initWebex() :: Webex Ready'); - authStatusElm.innerText = 'Webex is ready. Saved access token!'; - }); - - webex.messages.listen() - .then(() => { - updateStatus(true); - }) - .catch((err) => { - console.error(`error listening to messages: ${err}`); - }); -} - -credentialsFormElm.addEventListener('submit', initWebex); - - -function getSelfPresence() { - console.log('Presence enabled: ', webex.presence.isEnabled()); - webex.presence.get(webex.internal.device.userId) - .then((res) => { - selfPresenceElm.innerText = JSON.stringify(res, null, 2); - }) - .catch((error) => { - console.log('Error while fetching self presence status', error); - selfPresenceElm.innerText = 'Error while fetching self presence status'; - }); -} - -function setSelfPresence() { - const status = setPresenceStatusElm.value; - const ttl = setPresenceTtl.value; - webex.presence.setStatus(status, ttl) - .then(() => { - console.log('Set status for the user successfully'); - }) - .catch((error) => { - console.log('Error occurred while setting user\'s status', error); - }) -} - -function getUserPresence() { - const userId = getUserPresenceElm.value.trim(); - webex.presence.get(userId) - .then((response) => { - userPresenceStatusElm.innerText = JSON.stringify(response, null, 2); - }) - .catch((error) => { - console.log('Error occurred while trying to get user\'s presence', error); - }) -} - -function handlePresenceUpdate(payload) { - let value = subscribeNotificationBox.innerText; - value += '\n\n'; - value += JSON.stringify(payload.data, null, 2); - subscribeNotificationBox.innerText = value; -} - -function setupPresenceListener() { - webex.internal.mercury.on('event:apheleia.subscription_update', handlePresenceUpdate); -} - -function removePresenceListener() { - webex.internal.mercury.off('event:apheleia.subscription_update', handlePresenceUpdate); -} - -function subscribePresence() { - const ids = usersToSubOrUnsub.value.trim().split(','); - if (subscribedUserIds.length == 0) { - setupPresenceListener(); - } - webex.presence.subscribe(ids) - .then(() => { - console.log('successfully subscribed'); - ids.map((id) => subscribedUserIds.push(id)); - }) - .catch((error) => { - console.log('encountered error while subscribing', error); - }) -} - -function removeFromArray(A, B) { - return A.filter(element => !B.includes(element)); -} - -function unsubscribePresence() { - const ids = usersToSubOrUnsub.value.trim().split(','); - if (subscribedUserIds.length == 0) { - removePresenceListener(); - } - webex.presence.unsubscribe(ids) - .then(() => { - console.log('successfully unsubscribed'); - subscribedUserIds = removeFromArray(subscribedUserIds, ids); - }) - .catch((error) => { - console.log('encountered error while unsubscribing', error); - }) -} diff --git a/docs/samples/browser-plugin-presence/index.html b/docs/samples/browser-plugin-presence/index.html deleted file mode 100644 index 0a2a6f465b9..00000000000 --- a/docs/samples/browser-plugin-presence/index.html +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - Presence Kitchen Sink - - - - -
-
-
- - -
-

Webex - Presence

-

Use this kitchen sink to interact with the Webex Presence service

-
- - - -
-

Authentication

-
-

Note: Get an access token from our developer portal: https://developer.webex.com/docs/api/getting-started.

-

Note: Webex JS SDK must be initialized using a valid token.

- - -
-
- Credentials -
-

Initializes webex object and registers Webex JS SDK as a device.

- ( Click to change the environment ) - -
- - -
Not initialized
-
-
-
-
- - -
-

Presence Actions

-

Note: To get userIds for the below operations, use the People API in the developer portal to search names and get user's respective userId values.

-
-
- Get own user's presence status -
- -
-

-
-            Set Presence Status
-            
- - - -
- - Get a different user's presence status -
- - -
-

-            
- -
- Subscribe -
-

Input User IDs to subscribe for their respective presence statuses. Separate multiple IDs with a comma

- - - -
Presence Notifications
-
-
-
-
- -
-
-
- - - - - diff --git a/docs/samples/browser-plugin-presence/style.css b/docs/samples/browser-plugin-presence/style.css deleted file mode 100644 index e8bca7f3eff..00000000000 --- a/docs/samples/browser-plugin-presence/style.css +++ /dev/null @@ -1,358 +0,0 @@ -body { - margin: 0; - padding: 0; - background-color: #eee; - font-family: sans-serif; - font-size: 16px; -} - -/* Nice defaults */ -h2 { - font-weight: 500; - font-size: 2.2rem; - margin: 0 0 1rem 0; -} - -section { - background-color: #fff; - padding: 1rem; - margin-bottom: 1.5rem; - border-radius: 8px; -} - -select { - min-width: 300px; - margin-right: 1rem; -} - -label { - font-size: 1rem; - margin-left: 0.2rem; - white-space: nowrap; -} -label:first-child { - margin-left: 0; -} - -pre { - padding: 0.5rem; - background-color: #eee; - white-space: pre-wrap; -} - -code { - display: inline-block; - padding: 0.0625rem 0.25rem; - font-family: Consolas, Liberation Mono, Courier, monospace; - line-height: 1rem; - color: crimson; - font-size: 0.8rem; - background-color: #ededed; - border: 0.0625rem solid rgba(0, 0, 0, 0.12); - border-radius: 0.25rem; - box-sizing: border-box; -} - -fieldset { - margin: 0 0.5rem; - margin-bottom: 2rem; - border: 1px solid rgba(0, 0, 0, 0.1); -} -fieldset p { - padding: 1rem 0; - margin: 0; -} -fieldset legend { - font-weight: bold; - font-size: 1.1rem; -} - -button, input[type="button"] { - position: relative; - display: inline-block; - min-width: 4.5rem; - font-family: CiscoSansTT Regular, Helvetica Neue, Helvetica, sans-serif; - font-weight: 400; - text-align: center; - text-decoration: none; - cursor: pointer; - border: none; - font-size: 0.9rem; - line-height: 1.5rem; - border-radius: 1.125rem; - height: 2.25rem; - padding: 0.375rem 1.125rem; - color: var(--md-button-secondary-text-color, #000); - background-color: var(--md-button-secondary-bg-color, #dedede); - border-color: transparent; - transition: background-color 0.15s ease; -} - -button:hover, input[type="button"]:hover { - color: var(--md-button-secondary-text-color, #000); - background-color: var(--md-button-secondary-hover-bg-color, #ccc); -} - -button:active, input[type="button"]:active { - background-color: var(--md-button-bg-color, rgba(0, 0, 0, 0.3)); -} - -button:disabled, input[type="button"]:disabled { - color: var(--md-button-disabled-text-color, rgba(0, 0, 0, 0.2)); - fill: var(--md-button-disabled-text-color, rgba(0, 0, 0, 0.2)); - background-color: var(--md-button-disabled-bg-color, rgba(0, 0, 0, 0.04)); - pointer-events: none; - cursor: default; -} - -button.btn--green, input[type="button"].btn--green { - color: #fff; - background-color: #00ab50; - border-color: transparent; -} - -button.btn--green:disabled, input[type="button"].btn--green:disabled{ - opacity: .5; -} - -button.btn--red, input[type="button"].btn--red { - color: #fff; - color: var(--md-button-cancel-text-color, #fff); - background-color: #f7644a; - background-color: var(--md-button-cancel-bg-color, #f7644a); - border-color: transparent; -} - -.btn--red:disabled, -.btn--green:disabled { - opacity: .5; -} - -button.btn-code, input[type="button"].btn-code { - font-family: Consolas, Liberation Mono, Courier, monospace; - margin: 0.2rem 0; -} - -input[type=text], -input[type=password], -input[type=email] { - width: 100%; - margin-bottom: 1rem; - box-sizing: border-box; - border: 0.0625rem solid; - border-color: var(--md-input-outline-color, #ccc); - background-color: var(--md-input-background-color, #fff); - border-radius: 4px; - font-family: CiscoSansTT Regular, Helvetica Neue, Helvetica, sans-serif; - font-size: 1rem; - height: 2.25rem; - width: 100%; - padding: 0 1rem; - transition: box-shadow 0.15s ease-out; -} - -input[type=text]:focus, -input[type=password]:focus, -input[type=email]:focus { - border-color: transparent; - outline: none; - box-shadow: 0 0 4px 2px rgba(0, 160, 209, 0.75); - transition: box-shadow 0.15s ease-in; -} - -.btn-group { - display: flex; - margin: 0.5rem 0; - margin-bottom: 0; -} -.btn-group button { - flex: 1; - margin-right: 1rem; -} -.btn-group button:last-of-type { - margin-right: 0; -} - -/* Utilities */ -.flex { - display: flex; -} - -.flex--wrap { - flex-wrap: wrap; -} - -.flex--center { - justify-content: center; -} - -/* Margin utils */ -.u-m { - margin: 1rem !important; -} - -.u-mv { - margin-top: 1rem !important; - margin-bottom: 1rem !important; -} - -.u-mb { - margin-bottom: 1rem !important; -} - -.u-mt { - margin-top: 1rem !important; -} - -/* Padding Utils */ -.u-p { - padding: 1rem !important; -} - -.u-pv { - padding-top: 1rem !important; - padding-bottom: 1rem !important; -} - -.u-pb { - padding-bottom: 1rem !important; -} - -.u-pt { - padding-top: 1rem !important; -} - -/* Misc Styles */ -.note { - margin: 0.8rem 0; - background: rgba(232, 232, 232, 0.4); - padding: 1rem; - border-radius: 6px; - color: #555; -} - -.transcription { - display: flex; - flex-direction: column; -} - -.transcription textarea { - width: 100%; - min-height: 10rem; -} - -.device-type-label { - width: 125px; - display: inline-block; -} - -.context-info { - width: 200px; - display: inline-block; -} - -.meeting-list { - list-style-type: none; -} - -/* Container - Docs / Streams Fixed */ -.container { - overflow: auto; - height: 100vh; -} - -.docs { - max-width: 87.5rem; - margin-top: 1.5rem; -} - -.hidden { - display: none; -} - -th , td { - border:1px solid black; - padding: 5px; - font-size: small; -} - -table { - border-collapse: collapse; -} - -.styled-table { - border-collapse: collapse; - margin: 1.5625 0; - font-size: 0.9em; - font-family: sans-serif; - min-width: 25rem; - box-shadow: 0 0 1.25rem rgba(0, 0, 0, 0.15); -} - -.styled-table thead tr { - background-color: #009879; - color: #ffffff; - text-align: left; -} - -.styled-table th, -.styled-table td { - padding: 1.25rem 1.25rem; -} - -.styled-table tbody tr { - border-bottom: 0.063rem solid #dddddd; -} - -.styled-table tbody tr:nth-of-type(even) { - background-color: #f3f3f3; -} - -.styled-table tbody tr:last-of-type { - border-bottom: 0.125rem solid #009879; -} - -h2 { - color: #0052bf; - font-size: 1.5rem; - width: 100%; -} - -legend { - font-size: 1.15rem; - color: #1a73e8; -} - -.collapsible { - cursor: pointer; -} - -.section-content { - height: auto; - visibility: visible; -} - -.collapsed { - height: 0; - visibility: hidden; -} - -.text-color { - color: #555; -} - -input[type="string"], -select { - padding: 0 0.5rem; - height: 1.75rem; - border-radius: 4px; - box-sizing: border-box; - border: 0.0625rem solid; - border-color: var(--md-input-outline-color, #ccc); - background-color: var(--md-input-background-color, #fff); - font-family: CiscoSansTT Regular, Helvetica Neue, Helvetica, sans-serif; - font-size: 1rem; - transition: box-shadow 0.15s ease-out; - color: #555; - width: 10rem; -} \ No newline at end of file diff --git a/docs/samples/index.html b/docs/samples/index.html index 1491e74e6c5..54eabee35e6 100644 --- a/docs/samples/index.html +++ b/docs/samples/index.html @@ -7,7 +7,6 @@

Hosted Samples

From 5e322e875cea21473b00b79fbd404077e696d616 Mon Sep 17 00:00:00 2001 From: chrisadubois Date: Mon, 16 Dec 2024 07:30:44 -0800 Subject: [PATCH 28/30] feat(support): add issuedesc to metadata (#4028) --- packages/@webex/internal-plugin-support/src/support.js | 1 + .../internal-plugin-support/test/unit/spec/support.js | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/@webex/internal-plugin-support/src/support.js b/packages/@webex/internal-plugin-support/src/support.js index b5cb0824f19..21d1db01cda 100644 --- a/packages/@webex/internal-plugin-support/src/support.js +++ b/packages/@webex/internal-plugin-support/src/support.js @@ -135,6 +135,7 @@ const Support = WebexPlugin.extend({ 'surveySessionId', 'productAreaTag', 'issueTypeTag', + 'issueDescTag', 'locussessionid', 'autoupload', ] diff --git a/packages/@webex/internal-plugin-support/test/unit/spec/support.js b/packages/@webex/internal-plugin-support/test/unit/spec/support.js index dca8c01936f..5d0fc5a62bd 100644 --- a/packages/@webex/internal-plugin-support/test/unit/spec/support.js +++ b/packages/@webex/internal-plugin-support/test/unit/spec/support.js @@ -151,6 +151,14 @@ describe('plugin-support', () => { assert.equal(found?.value, autoupload); }); + + it('sends issuedesctag if specified in metadata', () => { + const issueDescTag = 'issueDescTag'; + const result = webex.internal.support._constructFileMetadata({issueDescTag}); + const found = result.find((attr) => attr.key === 'issueDescTag'); + + assert.equal(found?.value, issueDescTag); + }); }); describe('#submitLogs()', () => { From 42bd008bd09589af12dd1e0b4d8014a35c5fc1df Mon Sep 17 00:00:00 2001 From: robstax Date: Tue, 17 Dec 2024 13:08:03 -0500 Subject: [PATCH 29/30] feat(messaging): only create reaction hmac if doing encryption (#4032) Co-authored-by: rstachof --- .../src/conversation.js | 53 ++++++++++--------- .../test/unit/spec/conversation.js | 34 +++--------- 2 files changed, 34 insertions(+), 53 deletions(-) diff --git a/packages/@webex/internal-plugin-conversation/src/conversation.js b/packages/@webex/internal-plugin-conversation/src/conversation.js index a3fa8f89c85..ebdc02019d9 100644 --- a/packages/@webex/internal-plugin-conversation/src/conversation.js +++ b/packages/@webex/internal-plugin-conversation/src/conversation.js @@ -340,33 +340,36 @@ const Conversation = WebexPlugin.extend({ * @param {String} recipientId, * @returns {Promise} */ - addReaction(conversation, displayName, activity, recipientId) { - return this.createReactionHmac(displayName, activity).then((hmac) => { - const addReactionPayload = { - actor: {objectType: 'person', id: this.webex.internal.device.userId}, - target: { - id: conversation.id, - objectType: 'conversation', - }, - verb: 'add', - objectType: 'activity', - parent: { - type: 'reaction', - id: activity.id, - }, - object: { - objectType: 'reaction2', - displayName, - hmac, - }, - }; + async addReaction(conversation, displayName, activity, recipientId) { + let hmac; + if (this.config.includeEncryptionTransforms) { + hmac = await this.createReactionHmac(displayName, activity); + } - if (recipientId) { - addReactionPayload.recipients = {items: [{id: recipientId, objectType: 'person'}]}; - } + const addReactionPayload = { + actor: {objectType: 'person', id: this.webex.internal.device.userId}, + target: { + id: conversation.id, + objectType: 'conversation', + }, + verb: 'add', + objectType: 'activity', + parent: { + type: 'reaction', + id: activity.id, + }, + object: { + objectType: 'reaction2', + displayName, + hmac, + }, + }; - return this.sendReaction(conversation, addReactionPayload); - }); + if (recipientId) { + addReactionPayload.recipients = {items: [{id: recipientId, objectType: 'person'}]}; + } + + return this.sendReaction(conversation, addReactionPayload); }, /** diff --git a/packages/@webex/internal-plugin-conversation/test/unit/spec/conversation.js b/packages/@webex/internal-plugin-conversation/test/unit/spec/conversation.js index ee967bc2453..05a6f117352 100644 --- a/packages/@webex/internal-plugin-conversation/test/unit/spec/conversation.js +++ b/packages/@webex/internal-plugin-conversation/test/unit/spec/conversation.js @@ -41,49 +41,27 @@ describe('plugin-conversation', () => { const {conversation} = webex.internal; const recipientId = 'example-recipient-id'; const expected = {items: [{id: recipientId, objectType: 'person'}]}; + conversation.config.includeEncryptionTransforms = true; conversation.sendReaction = sinon.stub().returns(Promise.resolve()); conversation.createReactionHmac = sinon.stub().returns(Promise.resolve('hmac')); return conversation.addReaction({}, 'example-display-name', {}, recipientId).then(() => { + assert.called(conversation.createReactionHmac); assert.deepEqual(conversation.sendReaction.args[0][1].recipients, expected); }); }); - }); - - describe('deleteReaction()', () => { - it('should add recipients to the payload if provided', () => { - const {conversation} = webex.internal; - const recipientId = 'example-recipient-id'; - const expected = {items: [{id: recipientId, objectType: 'person'}]}; - conversation.sendReaction = sinon.stub().returns(Promise.resolve()); - - return conversation.deleteReaction({}, 'example-reaction-id', recipientId).then(() => { - assert.deepEqual(conversation.sendReaction.args[0][1].recipients, expected); - }); - }); - }); - describe('prepare()', () => { - it('should ammend activity recipients to the returned object', () => { - const {conversation} = webex.internal; - const activity = {recipients: 'example-recipients'}; - - return conversation.prepare(activity).then((results) => { - assert.deepEqual(results.recipients, activity.recipients); - }); - }); - }); - - describe('addReaction()', () => { - it('should add recipients to the payload if provided', () => { + it('will not call createReactionHmac if config prohibits', () => { const {conversation} = webex.internal; const recipientId = 'example-recipient-id'; const expected = {items: [{id: recipientId, objectType: 'person'}]}; + conversation.config.includeEncryptionTransforms = false; conversation.sendReaction = sinon.stub().returns(Promise.resolve()); - conversation.createReactionHmac = sinon.stub().returns(Promise.resolve('hmac')); + conversation.createReactionHmac = sinon.stub(); return conversation.addReaction({}, 'example-display-name', {}, recipientId).then(() => { assert.deepEqual(conversation.sendReaction.args[0][1].recipients, expected); + assert.notCalled(conversation.createReactionHmac); }); }); }); From 31f2bf3328a5649ebeeaa6c9ad11e87ef9d3930e Mon Sep 17 00:00:00 2001 From: rsarika <95286093+rsarika@users.noreply.github.com> Date: Fri, 20 Dec 2024 19:10:50 +0530 Subject: [PATCH 30/30] fix: end meeting for all getCurUserType type issue (#4038) --- .../@webex/plugin-meetings/src/meetings/index.ts | 6 ++++++ .../test/unit/spec/meetings/index.js | 14 +++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/meetings/index.ts b/packages/@webex/plugin-meetings/src/meetings/index.ts index dd0ade33a9f..e93b79bff93 100644 --- a/packages/@webex/plugin-meetings/src/meetings/index.ts +++ b/packages/@webex/plugin-meetings/src/meetings/index.ts @@ -155,6 +155,9 @@ export type BasicMeetingInformation = { }; meetingInfo: any; sessionCorrelationId: string; + roles: string[]; + getCurUserType: () => string | null; + callStateForMetrics: CallStateForMetrics; }; /** @@ -1143,6 +1146,9 @@ export default class Meetings extends WebexPlugin { sessionId: meeting.locusInfo?.fullState?.sessionId, }, }, + roles: meeting.roles, + callStateForMetrics: meeting.callStateForMetrics, + getCurUserType: meeting.getCurUserType, }); this.meetingCollection.delete(meeting.id); Trigger.trigger( diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js index 545a05fbd97..770605819be 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js @@ -131,9 +131,9 @@ describe('plugin-meetings', () => { logger, people: { _getMe: sinon.stub().resolves({ - type: 'validuser', + type: 'validuser', }), - } + }, }); startReachabilityStub = sinon.stub(webex.meetings, 'startReachability').resolves(); @@ -1985,6 +1985,8 @@ describe('plugin-meetings', () => { const meetingIds = { meetingId: meeting.id, correlationId: meeting.correlationId, + roles: meeting.roles, + callStateForMetrics: meeting.callStateForMetrics, }; webex.meetings.destroy(meeting, test1); @@ -2021,6 +2023,8 @@ describe('plugin-meetings', () => { assert.equal(deletedMeetingInfo.id, meetingIds.meetingId); assert.equal(deletedMeetingInfo.correlationId, meetingIds.correlationId); + assert.equal(deletedMeetingInfo.roles, meetingIds.roles); + assert.equal(deletedMeetingInfo.callStateForMetrics, meetingIds.callStateForMetrics); }); }); @@ -2092,7 +2096,7 @@ describe('plugin-meetings', () => { ); }); - const setup = ({me = { type: 'validuser'}, user} = {}) => { + const setup = ({me = {type: 'validuser'}, user} = {}) => { loggerProxySpy = sinon.spy(LoggerProxy.logger, 'error'); assert.deepEqual(webex.internal.services._getCatalog().getAllowedDomains(), []); @@ -2113,9 +2117,9 @@ describe('plugin-meetings', () => { it('should not call request.getMeetingPreferences if user is a guest', async () => { setup({me: {type: 'appuser'}}); - + await webex.meetings.fetchUserPreferredWebexSite(); - + assert.equal(webex.meetings.preferredWebexSite, ''); assert.deepEqual(webex.internal.services._getCatalog().getAllowedDomains(), []); assert.notCalled(webex.internal.services.getMeetingPreferences);