diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 09ad26be824..3818755bf01 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -32,7 +32,7 @@ This is for compliance purposes with FedRAMP program. - [ ] Tooling change - [ ] Internal code refactor -## The following scenarios where tested +## The following scenarios were tested < ENUMERATE TESTS PERFORMED, WHETHER MANUAL OR AUTOMATED > diff --git a/docs/samples/browser-plugin-meetings/app.js b/docs/samples/browser-plugin-meetings/app.js index 2e4db88b0eb..04e4d41dfd0 100644 --- a/docs/samples/browser-plugin-meetings/app.js +++ b/docs/samples/browser-plugin-meetings/app.js @@ -299,6 +299,7 @@ const passwordCaptchaStatusElm = document.querySelector('#password-captcha-statu const refreshCaptchaElm = document.querySelector('#meetings-join-captcha-refresh'); const verifyPasswordElm = document.querySelector('#btn-verify-password'); const displayMeetingStatusElm = document.querySelector('#display-meeting-status'); +const notes=document.querySelector('#notes'); const spaceIDError = `Using the space ID as a destination is no longer supported. Please refer to the migration guide to migrate to use the meeting ID or SIP address.`; const BNR = 'BNR'; const VBG = 'VBG'; @@ -381,6 +382,17 @@ createMeetingSelectElm.addEventListener('change', (event) => { } }); +createMeetingSelectElm.addEventListener('change', (event) => { + if (event.target.value === 'Others') { + notes.classList.remove('hidden'); + } + else { + notes.classList.add('hidden'); + + } +}); + + function createMeeting(e) { e.preventDefault(); @@ -427,6 +439,7 @@ function refreshCaptcha() { meetingsListElm.onclick = (e) => { selectedMeetingId = e.target.value; const meeting = webex.meetings.getAllMeetings()[selectedMeetingId]; + const selectedMeetingType = createMeetingSelectElm.options[createMeetingSelectElm.selectedIndex].innerText; if (meeting && meeting.passwordStatus === 'REQUIRED') { meetingsJoinPinElm.disabled = false; @@ -434,12 +447,18 @@ meetingsListElm.onclick = (e) => { document.getElementById('btn-join').disabled = true; document.getElementById('btn-join-media').disabled = true; } - else if (meeting && meeting.passwordStatus === 'UNKNOWN') { + else if (meeting && (meeting.passwordStatus === 'UNKNOWN' && selectedMeetingType === 'SIP URI')) { meetingsJoinPinElm.disabled = true; verifyPasswordElm.disabled = true; document.getElementById('btn-join').disabled = true; document.getElementById('btn-join-media').disabled = true; } + else if(meeting && (meeting.passwordStatus === 'UNKNOWN' && selectedMeetingType != 'SIP URI')) { + meetingsJoinPinElm.disabled = true; + verifyPasswordElm.disabled = true; + document.getElementById('btn-join').disabled = false; + document.getElementById('btn-join-media').disabled = false; + } else { meetingsJoinPinElm.disabled = true; verifyPasswordElm.disabled = true; @@ -1387,6 +1406,7 @@ async function handleVbg() { "bgImageUrl": blurVBGImageUrl, "bgVideoUrl": blurVBGVideoUrl, env: integrationEnv.checked ? 'int' : 'prod', + preventBackgroundThrottling: true, }); handleEffectsButton(toggleVbgBtn, VBG, effect); await localMedia.cameraStream.addEffect(effect); diff --git a/docs/samples/browser-plugin-meetings/index.html b/docs/samples/browser-plugin-meetings/index.html index 1df78750757..a994bf11ed5 100644 --- a/docs/samples/browser-plugin-meetings/index.html +++ b/docs/samples/browser-plugin-meetings/index.html @@ -338,14 +338,14 @@

+ diff --git a/docs/samples/calling/app.js b/docs/samples/calling/app.js index 1702f10f894..2555f593779 100644 --- a/docs/samples/calling/app.js +++ b/docs/samples/calling/app.js @@ -1301,6 +1301,10 @@ async function createCustomContact() { type: 'work', value: formData.get('phone') }], + emails: [{ + type: 'work', + value: formData.get('email') + }], contactType: 'CUSTOM', }; const res = await contacts.createContact(contact); diff --git a/packages/@webex/internal-plugin-device/src/config.js b/packages/@webex/internal-plugin-device/src/config.js index 8f37c6dc6f8..598c604231e 100644 --- a/packages/@webex/internal-plugin-device/src/config.js +++ b/packages/@webex/internal-plugin-device/src/config.js @@ -56,6 +56,12 @@ export default { * @type {boolean} */ ephemeralDeviceTTL: 30 * 60, + + /** + * energyForcast + * @type {boolean} + */ + energyForecast: false, }, /** diff --git a/packages/@webex/internal-plugin-device/src/device.js b/packages/@webex/internal-plugin-device/src/device.js index 65a28673b7c..da8ba42d7ea 100644 --- a/packages/@webex/internal-plugin-device/src/device.js +++ b/packages/@webex/internal-plugin-device/src/device.js @@ -306,6 +306,14 @@ const Device = WebexPlugin.extend({ */ isReachabilityChecked: ['boolean', false, false], + /** + * This property stores whether or not the next refresh or register request should request energy forecast data + * in order to prevent over fetching energy forecasts + * + * @type {boolean} + */ + energyForecastConfig: 'boolean', + /** * This property stores whether or not the current device is in a meeting * to prevent an unneeded timeout of a meeting due to inactivity. @@ -344,6 +352,15 @@ const Device = WebexPlugin.extend({ this.webex.trigger('meeting ended'); }, + /** + * Set the value of energy forecast config for the current registered device. + * @param {boolean} [energyForecastConfig=false] - fetch an energy forecast on the next refresh/register + * @returns {void} + */ + setEnergyForecastConfig(energyForecastConfig = false) { + this.energyForecastConfig = energyForecastConfig; + }, + // Registration method members /* eslint-disable require-jsdoc */ @@ -395,6 +412,11 @@ const Device = WebexPlugin.extend({ uri: this.url, body, headers, + qs: { + includeUpstreamServices: `all${ + this.config.energyForecast && this.energyForecastConfig ? ',energyforecast' : '' + }`, + }, }) .then((response) => this.processRegistrationSuccess(response)) .catch((reason) => { @@ -464,6 +486,11 @@ const Device = WebexPlugin.extend({ resource: 'devices', body, headers, + qs: { + includeUpstreamServices: `all${ + this.config.energyForecast && this.energyForecastConfig ? ',energyforecast' : '' + }`, + }, }) .catch((error) => { this.webex.internal.newMetrics.submitInternalEvent({ 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 9c00a8b18c1..53d781c268c 100644 --- a/packages/@webex/internal-plugin-device/test/unit/spec/device.js +++ b/packages/@webex/internal-plugin-device/test/unit/spec/device.js @@ -176,11 +176,14 @@ describe('plugin-device', () => { describe('#refresh()', () => { let requestSpy; - const setup = () => { + const setup = (config = {}) => { sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve()); sinon.stub(device, 'processRegistrationSuccess').callsFake(() => {}); requestSpy = sinon.spy(device, 'request'); device.config.defaults = {}; + Object.keys((config)).forEach((key) => { + device.config[key] = config[key]; + }); device.set('registered', true); }; @@ -207,15 +210,40 @@ describe('plugin-device', () => { assert.deepEqual(requestSpy.args[0][0].headers, {}); }); + + it('uses the energy forecast config to append upstream services to the outgoing call', async () => { + setup({energyForecast: true}); + device.setEnergyForecastConfig(true); + + await device.register(); + + assert.calledWith(requestSpy, sinon.match({ + qs: { includeUpstreamServices: 'all,energyforecast' } + })) + }); + + it('uses the energy forecast config to not append upstream services to the outgoing call', async () => { + setup({energyForecast: true}); + device.setEnergyForecastConfig(false); + + await device.register(); + + assert.calledWith(requestSpy, sinon.match({ + qs: { includeUpstreamServices: 'all' } + })) + }); }); describe('#register()', () => { - const setup = () => { + const setup = (config = {}) => { webex.internal.metrics.submitClientMetrics = sinon.stub(); sinon.stub(device, 'processRegistrationSuccess').callsFake(() => {}); device.config.defaults = {}; + Object.keys(config).forEach((key) => { + device.config[key] = config[key]; + }); device.set('registered', false); }; @@ -284,6 +312,42 @@ describe('plugin-device', () => { }); + it('uses the energy forecast config to append upstream services to the outgoing call', async () => { + setup({energyForecast: true}); + sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve()); + const spy = sinon.spy(device, 'request'); + device.setEnergyForecastConfig(true); + + await device.register(); + + assert.calledWith(spy, { + method: 'POST', + service: 'wdm', + resource: 'devices', + body: {}, + headers: {}, + qs: { includeUpstreamServices: 'all,energyforecast' } + } ) + }); + + it('uses the energy forecast config to not append upstream services to the outgoing call', async () => { + setup({energyForecast: true}); + sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve()); + const spy = sinon.spy(device, 'request'); + device.setEnergyForecastConfig(false); + + await device.register(); + + assert.calledWith(spy, { + method: 'POST', + service: 'wdm', + resource: 'devices', + body: {}, + headers: {}, + qs: { includeUpstreamServices: 'all' } + } ) + }); + }); describe('#processRegistrationSuccess()', () => { diff --git a/packages/@webex/internal-plugin-mercury/src/config.js b/packages/@webex/internal-plugin-mercury/src/config.js index 33d5db52868..a685b21ed44 100644 --- a/packages/@webex/internal-plugin-mercury/src/config.js +++ b/packages/@webex/internal-plugin-mercury/src/config.js @@ -30,5 +30,11 @@ export default { * @type {[type]} */ forceCloseDelay: process.env.MERCURY_FORCE_CLOSE_DELAY || 2000, + /** + * When logging out, use default reason which can trigger a reconnect, + * or set to something else, like `done (permanent)` to prevent reconnect + * @type {String} + */ + beforeLogoutOptionsCloseReason: process.env.MERCURY_LOGOUT_REASON || 'done (forced)', }, }; diff --git a/packages/@webex/internal-plugin-mercury/src/index.js b/packages/@webex/internal-plugin-mercury/src/index.js index 73aff3d62e4..693bd3b8fad 100644 --- a/packages/@webex/internal-plugin-mercury/src/index.js +++ b/packages/@webex/internal-plugin-mercury/src/index.js @@ -14,7 +14,7 @@ import config from './config'; registerInternalPlugin('mercury', Mercury, { config, onBeforeLogout() { - return this.disconnect(); + return this.logout(); }, }); diff --git a/packages/@webex/internal-plugin-mercury/src/mercury.js b/packages/@webex/internal-plugin-mercury/src/mercury.js index c74615ba33a..ed7b706130c 100644 --- a/packages/@webex/internal-plugin-mercury/src/mercury.js +++ b/packages/@webex/internal-plugin-mercury/src/mercury.js @@ -93,8 +93,17 @@ const Mercury = WebexPlugin.extend({ }); }, + logout() { + return this.disconnect( + this.config.beforeLogoutOptionsCloseReason && + !normalReconnectReasons.includes(this.config.beforeLogoutOptionsCloseReason) + ? {code: 1050, reason: this.config.beforeLogoutOptionsCloseReason} + : undefined + ); + }, + @oneFlight - disconnect() { + disconnect(options) { return new Promise((resolve) => { if (this.backoffCall) { this.logger.info(`${this.namespace}: aborting connection`); @@ -104,7 +113,7 @@ const Mercury = WebexPlugin.extend({ if (this.socket) { this.socket.removeAllListeners('message'); this.once('offline', resolve); - resolve(this.socket.close()); + resolve(this.socket.close(options || undefined)); } resolve(); @@ -446,6 +455,7 @@ const Mercury = WebexPlugin.extend({ // if (code == 1011 && reason !== ping error) metric: unexpected disconnect break; case 1000: + case 1050: // 1050 indicates logout form of closure, default to old behavior, use config reason defined by consumer to proceed with the permanent block if (normalReconnectReasons.includes(reason)) { this.logger.info(`${this.namespace}: socket disconnected; reconnecting`); this._emit('offline.transient', event); @@ -453,7 +463,9 @@ const Mercury = WebexPlugin.extend({ // metric: disconnect // if (reason === done forced) metric: force closure } else { - this.logger.info(`${this.namespace}: socket disconnected; will not reconnect`); + this.logger.info( + `${this.namespace}: socket disconnected; will not reconnect: ${event.reason}` + ); this._emit('offline.permanent', event); } break; diff --git a/packages/@webex/internal-plugin-mercury/src/socket/socket-base.js b/packages/@webex/internal-plugin-mercury/src/socket/socket-base.js index aaf953d4ac1..02bbe25d5a1 100644 --- a/packages/@webex/internal-plugin-mercury/src/socket/socket-base.js +++ b/packages/@webex/internal-plugin-mercury/src/socket/socket-base.js @@ -122,8 +122,15 @@ export default class Socket extends EventEmitter { } options = options || {}; - if (options.code && options.code !== 1000 && (options.code < 3000 || options.code > 4999)) { - reject(new Error('`options.code` must be 1000 or between 3000 and 4999 (inclusive)')); + if ( + options.code && + options.code !== 1000 && + options.code !== 1050 && + (options.code < 3000 || options.code > 4999) + ) { + reject( + new Error('`options.code` must be 1000 or 1050 or between 3000 and 4999 (inclusive)') + ); return; } @@ -137,10 +144,14 @@ export default class Socket extends EventEmitter { try { this.logger.info(`socket,${this._domain}: no close event received, forcing closure`); resolve( - this.onclose({ - code: 1000, - reason: 'Done (forced)', - }) + this.onclose( + options.code === 1050 + ? {code: 1050, reason: options.reason} + : { + code: 1000, + reason: 'Done (forced)', + } + ) ); } catch (error) { this.logger.warn(`socket,${this._domain}: force-close failed`, error); 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 adf4f527a61..6e772c301b9 100644 --- a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js +++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js @@ -503,6 +503,35 @@ describe('plugin-mercury', () => { }); }); + describe('#logout()', () => { + it('calls disconnect', () => { + sinon.stub(mercury, 'disconnect'); + mercury.logout(); + assert.called(mercury.disconnect); + }); + + it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 1050 for logout', () => { + sinon.stub(mercury, 'disconnect'); + mercury.config.beforeLogoutOptionsCloseReason = 'done (permanent)'; + mercury.logout(); + assert.calledWith(mercury.disconnect, {code: 1050, reason: 'done (permanent)'}); + }); + + it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 1050 for logout if the reason is different than standard', () => { + sinon.stub(mercury, 'disconnect'); + mercury.config.beforeLogoutOptionsCloseReason = 'test'; + mercury.logout(); + assert.calledWith(mercury.disconnect, {code: 1050, reason: 'test'}); + }); + + it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send undefined for logout if the reason is same as standard', () => { + sinon.stub(mercury, 'disconnect'); + mercury.config.beforeLogoutOptionsCloseReason = 'done (forced)'; + mercury.logout(); + assert.calledWith(mercury.disconnect, undefined); + }); + }); + describe('#disconnect()', () => { it('disconnects the WebSocket', () => mercury @@ -525,6 +554,27 @@ describe('plugin-mercury', () => { assert.isUndefined(mercury.mockWebSocket, 'Mercury does not have a mockWebSocket'); })); + it('disconnects the WebSocket with code 1050', () => + mercury + .connect() + .then(() => { + assert.isTrue(mercury.connected, 'Mercury is connected'); + assert.isFalse(mercury.connecting, 'Mercury is not connecting'); + const promise = mercury.disconnect(); + + mockWebSocket.emit('close', { + code: 1050, + reason: 'done (permanent)', + }); + + return promise; + }) + .then(() => { + assert.isFalse(mercury.connected, 'Mercury is not connected'); + assert.isFalse(mercury.connecting, 'Mercury is not connecting'); + assert.isUndefined(mercury.mockWebSocket, 'Mercury does not have a mockWebSocket'); + })); + it('stops emitting message events', () => { const spy = sinon.spy(); diff --git a/packages/@webex/internal-plugin-mercury/test/unit/spec/socket.js b/packages/@webex/internal-plugin-mercury/test/unit/spec/socket.js index b34f09bdc82..39b4f9f3da1 100644 --- a/packages/@webex/internal-plugin-mercury/test/unit/spec/socket.js +++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/socket.js @@ -452,7 +452,7 @@ describe('plugin-mercury', () => { Promise.all([ assert.isRejected( socket.close({code: 1001}), - /`options.code` must be 1000 or between 3000 and 4999 \(inclusive\)/ + /`options.code` must be 1000 or 1050 or between 3000 and 4999 \(inclusive\)/ ), socket.close({code: 1000}), ])); @@ -465,6 +465,14 @@ describe('plugin-mercury', () => { }) .then(() => assert.calledWith(mockWebSocket.close, 3001, 'Custom Normal'))); + it('accepts the logout reason', () => + socket + .close({ + code: 1050, + reason: 'done (permanent)', + }) + .then(() => assert.calledWith(mockWebSocket.close, 1050, 'done (permanent)'))); + it('can safely be called called multiple times', () => { const p1 = socket.close(); @@ -513,6 +521,84 @@ describe('plugin-mercury', () => { }); }); + it('signals closure if no close frame is received within the specified window, but uses the initial options as 1050 if specified by options call', () => { + const socket = new Socket(); + const promise = socket.open('ws://example.com', mockoptions); + + mockWebSocket.readyState = 1; + mockWebSocket.emit('open'); + mockWebSocket.emit('message', { + data: JSON.stringify({ + id: uuid.v4(), + data: { + eventType: 'mercury.buffer_state', + }, + }), + }); + + return promise.then(() => { + const spy = sinon.spy(); + + socket.on('close', spy); + mockWebSocket.close = () => + new Promise(() => { + /* eslint no-inline-comments: [0] */ + }); + mockWebSocket.removeAllListeners('close'); + + const promise = socket.close({code: 1050, reason: 'done (permanent)'}); + + clock.tick(mockoptions.forceCloseDelay); + + return promise.then(() => { + assert.called(spy); + assert.calledWith(spy, { + code: 1050, + reason: 'done (permanent)', + }); + }); + }); + }); + + it('signals closure if no close frame is received within the specified window, and uses default options as 1000 if the code is not 1050', () => { + const socket = new Socket(); + const promise = socket.open('ws://example.com', mockoptions); + + mockWebSocket.readyState = 1; + mockWebSocket.emit('open'); + mockWebSocket.emit('message', { + data: JSON.stringify({ + id: uuid.v4(), + data: { + eventType: 'mercury.buffer_state', + }, + }), + }); + + return promise.then(() => { + const spy = sinon.spy(); + + socket.on('close', spy); + mockWebSocket.close = () => + new Promise(() => { + /* eslint no-inline-comments: [0] */ + }); + mockWebSocket.removeAllListeners('close'); + + const promise = socket.close({code: 1000, reason: 'test'}); + + clock.tick(mockoptions.forceCloseDelay); + + return promise.then(() => { + assert.called(spy); + assert.calledWith(spy, { + code: 1000, + reason: 'Done (forced)', + }); + }); + }); + }); + it('cancels any outstanding ping/pong timers', () => { mockWebSocket.send = sinon.stub(); socket._ping.resetHistory(); @@ -618,6 +704,26 @@ describe('plugin-mercury', () => { } ); }); + + describe('when it receives close code 1050', () => { + it(`emits code 1050 for code 1050`, () => { + const code = 1050; + const reason = 'done (permanent)'; + const spy = sinon.spy(); + + socket.on('close', spy); + + mockWebSocket.emit('close', { + code, + reason, + }); + assert.called(spy); + assert.calledWith(spy, { + code, + reason, + }); + }); + }); }); describe('#onmessage()', () => { diff --git a/packages/@webex/internal-plugin-metrics/package.json b/packages/@webex/internal-plugin-metrics/package.json index 5b9ac214b8f..002feb20183 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.1406", + "@webex/event-dictionary-ts": "^1.0.1546", "@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/behavioral-metrics.ts b/packages/@webex/internal-plugin-metrics/src/behavioral-metrics.ts new file mode 100644 index 00000000000..1377a1083e5 --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/behavioral-metrics.ts @@ -0,0 +1,40 @@ +import {MetricEventProduct, MetricEventAgent, MetricEventVerb, EventPayload} from './metrics.types'; +import GenericMetrics from './generic-metrics'; + +/** + * @description Util class to handle Behavioral Metrics + * @export + * @class BehavioralMetrics + */ +export default class BehavioralMetrics extends GenericMetrics { + /** + * Submit a behavioral metric to our metrics endpoint. + * @param {MetricEventProduct} product the product from which the metric is being submitted, e.g. 'webex' web client, 'wxcc_desktop' + * @param {MetricEventAgent} agent the source of the action for this metric + * @param {string} target the 'thing' that this metric includes information about + * @param {MetricEventVerb} verb the action that this metric includes information about + * @param {EventPayload} payload information specific to this event. This should be flat, i.e. it should not include nested objects. + * @returns {Promise} + */ + public submitBehavioralEvent({ + product, + agent, + target, + verb, + payload, + }: { + product: MetricEventProduct; + agent: MetricEventAgent; + target: string; + verb: MetricEventVerb; + payload?: EventPayload; + }) { + const name = `${product}.${agent}.${target}.${verb}`; + const event = this.createTaggedEventObject({ + type: ['behavioral'], + name, + payload, + }); + this.submitEvent({kind: 'behavioral-events -> ', name, event}); + } +} diff --git a/packages/@webex/internal-plugin-metrics/src/behavioral/behavioral-metrics.ts b/packages/@webex/internal-plugin-metrics/src/behavioral/behavioral-metrics.ts deleted file mode 100644 index e6666678c56..00000000000 --- a/packages/@webex/internal-plugin-metrics/src/behavioral/behavioral-metrics.ts +++ /dev/null @@ -1,179 +0,0 @@ -import {merge} from 'lodash'; -import {BrowserDetection} from '@webex/common'; -import {StatelessWebexPlugin} from '@webex/webex-core'; -import {getOSNameInternal} from '../metrics'; -import {BEHAVIORAL_LOG_IDENTIFIER} from './config'; -import { - MetricEventProduct, - MetricEventAgent, - MetricEventVerb, - BehavioralEventContext, - BehavioralEvent, - BehavioralEventPayload, -} from '../metrics.types'; -import ClientMetricsBatcher from '../client-metrics-batcher'; - -const {getOSVersion, getBrowserName, getBrowserVersion} = BrowserDetection(); - -/** - * @description Util class to handle Behavioral Metrics - * @export - * @class BehavioralMetrics - */ -export default class BehavioralMetrics extends StatelessWebexPlugin { - // @ts-ignore - private clientMetricsBatcher: ClientMetricsBatcher; - private logger: any; // to avoid adding @ts-ignore everywhere - private device: any; - private version: string; - - /** - * Constructor - * @param {any[]} args - */ - constructor(...args) { - super(...args); - // @ts-ignore - this.logger = this.webex.logger; - // @ts-ignore - this.device = this.webex.internal.device; - // @ts-ignore - this.version = this.webex.version; - // @ts-ignore - this.clientMetricsBatcher = new ClientMetricsBatcher({}, {parent: this.webex}); - } - - /** - * Returns the deviceId from our registration with WDM. - * @returns {string} deviceId or empty string - */ - private getDeviceId(): string { - const {url} = this.device; - if (url && url.length !== 0) { - const n = url.lastIndexOf('/'); - if (n !== -1) { - return url.substring(n + 1); - } - } - - return ''; - } - - /** - * Returns the context object to be submitted with all behavioral metrics. - * @returns {BehavioralEventContext} - */ - private getContext(): BehavioralEventContext { - const context: BehavioralEventContext = { - app: { - version: this.version, - }, - device: { - id: this.getDeviceId(), - }, - locale: window.navigator.language, - os: { - name: getOSNameInternal(), - version: getOSVersion(), - }, - }; - - return context; - } - - /** - * Returns the default tags to be included with all behavioral metrics. - * @returns {BehavioralEventPayload} - */ - private getDefaultTags(): BehavioralEventPayload { - const tags = { - browser: getBrowserName(), - browserHeight: window.innerHeight, - browserVersion: getBrowserVersion(), - browserWidth: window.innerWidth, - domain: window.location.hostname, - inIframe: window.self !== window.top, - locale: window.navigator.language, - os: getOSNameInternal(), - }; - - return tags; - } - - /** - * Creates the object to send to our metrics endpoint for a behavioral event - * @param {MetricEventProduct} product - * @param {MetricEventAgent} agent - * @param {string} target - * @param {MetricEventVerb} verb - * @returns {BehavioralEventPayload} - */ - private createEventObject({ - product, - agent, - target, - verb, - payload, - }: { - product: MetricEventProduct; - agent: MetricEventAgent; - target: string; - verb: MetricEventVerb; - payload?: BehavioralEventPayload; - }): BehavioralEvent { - const metricName = `${product}.${agent}.${target}.${verb}`; - let allTags: BehavioralEventPayload = payload; - allTags = merge(allTags, this.getDefaultTags()); - - const event: BehavioralEvent = { - context: this.getContext(), - metricName, - tags: allTags, - timestamp: Date.now(), - type: ['behavioral'], - }; - - return event; - } - - /** - * Returns true once we're ready to submit behavioral metrics, after startup. - * @returns {boolean} true when deviceId is defined and non-empty - */ - public isReadyToSubmitBehavioralEvents(): boolean { - const deviceId = this.getDeviceId(); - - return deviceId && deviceId.length !== 0; - } - - /** - * Submit a behavioral metric to our metrics endpoint. - * @param {MetricEventProduct} product the product from which the metric is being submitted, e.g. 'webex' web client, 'wxcc_desktop' - * @param {MetricEventAgent} agent the source of the action for this metric - * @param {string} target the 'thing' that this metric includes information about - * @param {MetricEventVerb} verb the action that this metric includes information about - * @param {BehavioralEventPayload} payload information specific to this event. This should be flat, i.e. it should not include nested objects. - * @returns {Promise} - */ - public submitBehavioralEvent({ - product, - agent, - target, - verb, - payload, - }: { - product: MetricEventProduct; - agent: MetricEventAgent; - target: string; - verb: MetricEventVerb; - payload?: BehavioralEventPayload; - }) { - this.logger.log( - BEHAVIORAL_LOG_IDENTIFIER, - `BehavioralMetrics: @submitBehavioralEvent. Submit Behavioral event: ${product}.${agent}.${target}.${verb}` - ); - const behavioralEvent = this.createEventObject({product, agent, target, verb, payload}); - - return this.clientMetricsBatcher.request(behavioralEvent); - } -} diff --git a/packages/@webex/internal-plugin-metrics/src/behavioral/config.ts b/packages/@webex/internal-plugin-metrics/src/behavioral/config.ts deleted file mode 100644 index 4b5af5269c9..00000000000 --- a/packages/@webex/internal-plugin-metrics/src/behavioral/config.ts +++ /dev/null @@ -1,3 +0,0 @@ -/* eslint-disable import/prefer-default-export */ - -export const BEHAVIORAL_LOG_IDENTIFIER = 'behavioral-events -> '; diff --git a/packages/@webex/internal-plugin-metrics/src/business-metrics.ts b/packages/@webex/internal-plugin-metrics/src/business-metrics.ts new file mode 100644 index 00000000000..a86d7bc9651 --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/business-metrics.ts @@ -0,0 +1,30 @@ +import GenericMetrics from './generic-metrics'; +import {EventPayload} from './metrics.types'; + +/** + * @description Util class to handle Buisness Metrics + * @export + * @class BusinessMetrics + */ +export default class BusinessMetrics extends GenericMetrics { + /** + * Submit a buisness metric to our metrics endpoint. + * @param {string} name of the metric + * @param {EventPayload} user payload of the metric + * @returns {Promise} + */ + public submitBusinessEvent({name, payload}: {name: string; payload: EventPayload}) { + const event = { + type: ['business'], + eventPayload: { + key: name, + client_timestamp: Date.now(), + context: this.getContext(), + browserDetails: this.getBrowserDetails(), + value: payload, + }, + }; + + this.submitEvent({kind: 'buisness-events -> ', name, event}); + } +} 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 2da21fa487a..8a3abeff368 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 @@ -75,6 +75,7 @@ type GetIdentifiersOptions = { meeting?: any; mediaConnections?: any[]; correlationId?: string; + sessionCorrelationId?: string; preLoginId?: string; globalMeetingId?: string; webexConferenceIdStr?: string; @@ -285,19 +286,29 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { webexConferenceIdStr, globalMeetingId, preLoginId, + sessionCorrelationId, } = options; const identifiers: Event['event']['identifiers'] = { - correlationId: 'unknown', + correlationId: 'unknown', // concerned with setting this to unknown. This will fail diagnostic events parsing because it's not a uuid pattern }; if (meeting) { identifiers.correlationId = meeting.correlationId; + if (meeting.sessionCorrelationId) { + identifiers.sessionCorrelationId = meeting.sessionCorrelationId; + } + } + + if (sessionCorrelationId) { + identifiers.sessionCorrelationId = sessionCorrelationId; } if (correlationId) { identifiers.correlationId = correlationId; } + // TODO: should we use patterns.uuid to validate correlationId and session correlation id? they will fail the diagnostic events validation pipeline if improperly formatted + if (this.device) { const {device} = this; const {installationId} = device?.config || {}; @@ -455,6 +466,9 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { }, intervals: payload.intervals, callingServiceType: 'LOCUS', + meetingJoinInfo: { + clientSignallingProtocol: 'WebRTC', + }, sourceMetadata: { applicationSoftwareType: CLIENT_NAME, // @ts-ignore @@ -643,7 +657,13 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { options?: SubmitClientEventOptions; errors?: ClientEventPayloadError; }) { - const {meetingId, mediaConnections, globalMeetingId, webexConferenceIdStr} = options; + const { + meetingId, + mediaConnections, + globalMeetingId, + webexConferenceIdStr, + sessionCorrelationId, + } = options; // @ts-ignore const meeting = this.webex.meetings.meetingCollection.get(meetingId); @@ -670,6 +690,7 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { mediaConnections: meeting?.mediaConnections || mediaConnections, webexConferenceIdStr, globalMeetingId, + sessionCorrelationId, }); // create client event object @@ -711,11 +732,13 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { options?: SubmitClientEventOptions; errors?: ClientEventPayloadError; }) { - const {correlationId, globalMeetingId, webexConferenceIdStr, preLoginId} = options; + const {correlationId, globalMeetingId, webexConferenceIdStr, preLoginId, sessionCorrelationId} = + options; // grab identifiers const identifiers = this.getIdentifiers({ correlationId, + sessionCorrelationId, preLoginId, globalMeetingId, webexConferenceIdStr, diff --git a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.util.ts b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.util.ts index a0ba6e791eb..e125a706952 100644 --- a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.util.ts +++ b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.util.ts @@ -195,10 +195,12 @@ export const isBrowserMediaErrorName = (errorName: any) => { }; /** + * @param {Object} webex sdk instance * @param webClientDomain * @returns */ export const getBuildType = ( + webex, webClientDomain, markAsTestEvent = false ): Event['origin']['buildType'] => { @@ -207,6 +209,10 @@ export const getBuildType = ( return 'test'; } + if (webex.internal.metrics?.config?.caBuildType) { + return webex.internal.metrics.config.caBuildType; + } + if ( webClientDomain?.includes('localhost') || webClientDomain?.includes('127.0.0.1') || @@ -225,12 +231,19 @@ export const getBuildType = ( * @returns {Object} prepared item */ export const prepareDiagnosticMetricItem = (webex: any, item: any) => { + const buildType = getBuildType( + webex, + item.eventPayload?.event?.eventData?.webClientDomain, + item.eventPayload?.event?.eventData?.markAsTestEvent + ); + + // Set upgradeChannel to 'gold' if buildType is 'prod', otherwise to the buildType value + const upgradeChannel = buildType === 'prod' ? 'gold' : buildType; + const origin: Partial = { - buildType: getBuildType( - item.eventPayload?.event?.eventData?.webClientDomain, - item.eventPayload?.event?.eventData?.markAsTestEvent - ), + buildType, networkType: 'unknown', + upgradeChannel, }; // check event names and append latencies? diff --git a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/config.ts b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/config.ts index f3b7ea3b048..4faa788dc05 100644 --- a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/config.ts +++ b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/config.ts @@ -129,6 +129,7 @@ export const ERROR_DESCRIPTIONS = { ICE_AND_REACHABILITY_FAILED: 'ICEAndReachabilityFailed', SDP_OFFER_CREATION_ERROR: 'SdpOfferCreationError', SDP_OFFER_CREATION_ERROR_MISSING_CODEC: 'SdpOfferCreationErrorMissingCodec', + WDM_RESTRICTED_REGION: 'WdmRestrictedRegion', }; export const SERVICE_ERROR_CODES_TO_CLIENT_ERROR_CODES_MAP = { @@ -288,6 +289,12 @@ export const SERVICE_ERROR_CODES_TO_CLIENT_ERROR_CODES_MAP = { 100005: 4103, // Depracated because of an issue in the UCF Clients // If both email-hash and domain-hash are null or undefined. 100004: 4103, + + // ---- WDM ---- + // WDM_BLOCKED_ACCESS_BY_COUNTRY_CODE_BANNED_COUNTRY_ERROR_CODE + 4404002: 13000, + // WDM_BLOCKED_ACCESS_BY_COUNTRY_CODE_RESTRICTED_COUNTRY_ERROR_CODE + 4404003: 13000, }; export const CLIENT_ERROR_CODE_TO_ERROR_PAYLOAD: Record> = { @@ -687,6 +694,11 @@ export const CLIENT_ERROR_CODE_TO_ERROR_PAYLOAD: Record} + */ + protected submitEvent({kind, name, event}: {kind: string; name: string; event: object}) { + this.logger.log(kind, `@submitEvent. Submit event: ${name}`); + + return this.clientMetricsBatcher.request(event); + } + + /** + * Returns the deviceId from our registration with WDM. + * @returns {string} deviceId or empty string + */ + protected getDeviceId(): string { + if (this.deviceId === '') { + const {url} = this.device; + if (url && url.length !== 0) { + const n = url.lastIndexOf('/'); + if (n !== -1) { + this.deviceId = url.substring(n + 1); + } + } + } + + return this.deviceId; + } + + /** + * Returns the context object to be submitted with all metrics. + * @returns {DeviceContext} + */ + protected getContext(): DeviceContext { + return { + app: { + version: this.version, + }, + device: { + id: this.getDeviceId(), + }, + locale: window.navigator.language, + os: { + name: getOSNameInternal(), + version: getOSVersion(), + }, + }; + } + + /** + * Returns the browser details to be included with all metrics. + * @returns {object} + */ + protected getBrowserDetails(): object { + return { + browser: getBrowserName(), + browserHeight: window.innerHeight, + browserVersion: getBrowserVersion(), + browserWidth: window.innerWidth, + domain: window.location.hostname, + inIframe: window.self !== window.top, + locale: window.navigator.language, + os: getOSNameInternal(), + }; + } + + /** + * Returns true once we have the deviceId we need to submit behavioral/operational/buisness events + * @returns {boolean} + */ + public isReadyToSubmitEvents(): boolean { + const deviceId = this.getDeviceId(); + + return deviceId && deviceId.length !== 0; + } + + /** + * Creates the object to send to our metrics endpoint for a tagged event (i.e. behavoral or operational) + * @param {[MetricType]} list of event type (i.e. ['behavioral'], ['operational', 'behavioral']) + * @param {string} metric name + * @param {EventPayload} user payload + * @returns {EventPayload} + */ + protected createTaggedEventObject({ + type, + name, + payload, + }: { + type: [MetricType]; + name: string; + payload: EventPayload; + }): TaggedEvent { + let allTags: EventPayload = payload; + allTags = merge(allTags, this.getBrowserDetails()); + + const event = { + context: this.getContext(), + metricName: name, + tags: allTags, + timestamp: Date.now(), + type, + }; + + return event; + } +} diff --git a/packages/@webex/internal-plugin-metrics/src/index.ts b/packages/@webex/internal-plugin-metrics/src/index.ts index cd7c9ad21a8..551d7ca4453 100644 --- a/packages/@webex/internal-plugin-metrics/src/index.ts +++ b/packages/@webex/internal-plugin-metrics/src/index.ts @@ -22,7 +22,10 @@ import * as CALL_DIAGNOSTIC_CONFIG from './call-diagnostic/config'; import * as CallDiagnosticUtils from './call-diagnostic/call-diagnostic-metrics.util'; import CallDiagnosticMetrics from './call-diagnostic/call-diagnostic-metrics'; import CallDiagnosticLatencies from './call-diagnostic/call-diagnostic-metrics-latencies'; -import BehavioralMetrics from './behavioral/behavioral-metrics'; +import BehavioralMetrics from './behavioral-metrics'; +import OperationalMetrics from './operational-metrics'; +import BusinessMetrics from './business-metrics'; +import RtcMetrics from './rtcMetrics'; registerInternalPlugin('metrics', Metrics, { config, @@ -43,6 +46,9 @@ export { CallDiagnosticLatencies, CallDiagnosticMetrics, BehavioralMetrics, + OperationalMetrics, + BusinessMetrics, + RtcMetrics, }; export type { ClientEvent, diff --git a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts index f5a9bc5b2a5..d8a44836dda 100644 --- a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts +++ b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts @@ -61,6 +61,7 @@ export type SubmitClientEventOptions = { mediaConnections?: any[]; rawError?: any; correlationId?: string; + sessionCorrelationId?: string; preLoginId?: string; environment?: EnvironmentType; newEnvironmentType?: NewEnvironmentType; @@ -102,7 +103,7 @@ export interface ClientEvent { options?: SubmitClientEventOptions; } -export interface BehavioralEventContext { +export interface DeviceContext { app: {version: string}; device: {id: string}; locale: string; @@ -112,23 +113,36 @@ export interface BehavioralEventContext { }; } -export interface BehavioralEvent { - context: BehavioralEventContext; +export type MetricType = 'behavioral' | 'operational' | 'business'; + +type InternalEventPayload = string | number | boolean; +export type EventPayload = Record; +export type BehavioralEventPayload = EventPayload; // for compatibilty, can be remove after wxcc-desktop did change their imports. + +export interface BusinessEventPayload { metricName: string; - tags: Record; timestamp: number; - type: string[]; + context: DeviceContext; + browserDetails: EventPayload; + value: EventPayload; } -export type BehavioralEventPayload = BehavioralEvent['tags']; +export interface BusinessEvent { + type: string[]; + eventPayload: BusinessEventPayload; +} -export interface OperationalEvent { - // TODO: not implemented - name: never; - payload?: never; - options?: never; +export interface TaggedEvent { + context: DeviceContext; + metricName: string; + tags: EventPayload; + timestamp: number; + type: [MetricType]; } +export type BehavioralEvent = TaggedEvent; +export type OperationalEvent = TaggedEvent; + export interface FeatureEvent { // TODO: not implemented name: never; @@ -154,7 +168,8 @@ export type MetricEventNames = | InternalEvent['name'] | ClientEvent['name'] | BehavioralEvent['metricName'] - | OperationalEvent['name'] + | OperationalEvent['metricName'] + | BusinessEvent['eventPayload']['metricName'] | FeatureEvent['name'] | MediaQualityEvent['name']; @@ -190,7 +205,7 @@ export type SubmitBehavioralEvent = (args: { agent: MetricEventAgent; target: string; verb: MetricEventVerb; - payload?: BehavioralEventPayload; + payload?: EventPayload; }) => void; export type SubmitClientEvent = (args: { @@ -200,9 +215,8 @@ export type SubmitClientEvent = (args: { }) => Promise; export type SubmitOperationalEvent = (args: { - name: OperationalEvent['name']; - payload?: RecursivePartial; - options?: any; + name: OperationalEvent['metricName']; + payload: EventPayload; }) => void; export type SubmitMQE = (args: { diff --git a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts index 614b2645dd1..902ce86c948 100644 --- a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts @@ -6,7 +6,9 @@ import {WebexPlugin} from '@webex/webex-core'; import CallDiagnosticMetrics from './call-diagnostic/call-diagnostic-metrics'; -import BehavioralMetrics from './behavioral/behavioral-metrics'; +import BehavioralMetrics from './behavioral-metrics'; +import OperationalMetrics from './operational-metrics'; +import BusinessMetrics from './business-metrics'; import { RecursivePartial, MetricEventProduct, @@ -14,7 +16,7 @@ import { MetricEventVerb, ClientEvent, FeatureEvent, - BehavioralEventPayload, + EventPayload, OperationalEvent, MediaQualityEvent, InternalEvent, @@ -37,6 +39,9 @@ class Metrics extends WebexPlugin { // Helper classes to handle the different types of metrics callDiagnosticMetrics: CallDiagnosticMetrics; behavioralMetrics: BehavioralMetrics; + operationalMetrics: OperationalMetrics; + businessMetrics: BusinessMetrics; + isReady = false; /** * Constructor @@ -61,8 +66,7 @@ class Metrics extends WebexPlugin { this.webex.once('ready', () => { // @ts-ignore this.callDiagnosticMetrics = new CallDiagnosticMetrics({}, {parent: this.webex}); - // @ts-ignore - this.behavioralMetrics = new BehavioralMetrics({}, {parent: this.webex}); + this.isReady = true; }); } @@ -86,11 +90,61 @@ class Metrics extends WebexPlugin { } } + /** + * if webex metrics is ready, build behavioral metric backend if not already done. + */ + private lazyBuildBehavioralMetrics() { + if (this.isReady && !this.behavioralMetrics) { + // @ts-ignore + this.behavioralMetrics = new BehavioralMetrics({}, {parent: this.webex}); + } + } + + /** + * if webex metrics is ready, build operational metric backend if not already done. + */ + private lazyBuildOperationalMetrics() { + if (this.isReady && !this.operationalMetrics) { + // @ts-ignore + this.operationalMetrics = new OperationalMetrics({}, {parent: this.webex}); + } + } + + /** + * if webex metrics is ready, build business metric backend if not already done. + */ + private lazyBuildBusinessMetrics() { + if (this.isReady && !this.businessMetrics) { + // @ts-ignore + this.businessMetrics = new BusinessMetrics({}, {parent: this.webex}); + } + } + /** * @returns true once we have the deviceId we need to submit behavioral events to Amplitude */ isReadyToSubmitBehavioralEvents() { - return this.behavioralMetrics.isReadyToSubmitBehavioralEvents(); + this.lazyBuildBehavioralMetrics(); + + return this.behavioralMetrics?.isReadyToSubmitEvents() ?? false; + } + + /** + * @returns true once we have the deviceId we need to submit operational events + */ + isReadyToSubmitOperationalEvents() { + this.lazyBuildOperationalMetrics(); + + return this.operationalMetrics?.isReadyToSubmitEvents() ?? false; + } + + /** + * @returns true once we have the deviceId we need to submit buisness events + */ + isReadyToSubmitBusinessEvents() { + this.lazyBuildBusinessMetrics(); + + return this.businessMetrics?.isReadyToSubmitEvents() ?? false; } /** @@ -108,9 +162,9 @@ class Metrics extends WebexPlugin { agent: MetricEventAgent; target: string; verb: MetricEventVerb; - payload?: BehavioralEventPayload; + payload?: EventPayload; }) { - if (!this.behavioralMetrics) { + if (!this.isReady) { // @ts-ignore this.webex.logger.log( `NewMetrics: @submitBehavioralEvent. Attempted to submit before webex.ready: ${product}.${agent}.${target}.${verb}` @@ -119,6 +173,8 @@ class Metrics extends WebexPlugin { return Promise.resolve(); } + this.lazyBuildBehavioralMetrics(); + return this.behavioralMetrics.submitBehavioralEvent({product, agent, target, verb, payload}); } @@ -126,16 +182,38 @@ class Metrics extends WebexPlugin { * Operational event * @param args */ - submitOperationalEvent({ - name, - payload, - options, - }: { - name: OperationalEvent['name']; - payload?: RecursivePartial; - options?: any; - }) { - throw new Error('Not implemented.'); + submitOperationalEvent({name, payload}: {name: string; payload?: EventPayload}) { + if (!this.isReady) { + // @ts-ignore + this.webex.logger.log( + `NewMetrics: @submitOperationalEvent. Attempted to submit before webex.ready: ${name}` + ); + + return Promise.resolve(); + } + + this.lazyBuildOperationalMetrics(); + + return this.operationalMetrics.submitOperationalEvent({name, payload}); + } + + /** + * Buisness event + * @param args + */ + submitBusinessEvent({name, payload}: {name: string; payload: EventPayload}) { + if (!this.isReady) { + // @ts-ignore + this.webex.logger.log( + `NewMetrics: @submitBusinessEvent. Attempted to submit before webex.ready: ${name}` + ); + + return Promise.resolve(); + } + + this.lazyBuildBusinessMetrics(); + + return this.businessMetrics.submitBusinessEvent({name, payload}); } /** diff --git a/packages/@webex/internal-plugin-metrics/src/operational-metrics.ts b/packages/@webex/internal-plugin-metrics/src/operational-metrics.ts new file mode 100644 index 00000000000..1c520a2000e --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/operational-metrics.ts @@ -0,0 +1,24 @@ +import GenericMetrics from './generic-metrics'; +import {EventPayload} from './metrics.types'; + +/** + * @description Util class to handle Operational Metrics + * @export + * @class OperationalMetrics + */ +export default class OperationalMetrics extends GenericMetrics { + /** + * Submit an operational metric to our metrics endpoint. + * @param {string} name of the metric + * @param {EventPayload} user payload of the metric + * @returns {Promise} + */ + public submitOperationalEvent({name, payload}: {name: string; payload: EventPayload}) { + const event = this.createTaggedEventObject({ + type: ['operational'], + name, + payload, + }); + this.submitEvent({kind: 'operational-events -> ', name, event}); + } +} diff --git a/packages/@webex/plugin-meetings/src/rtcMetrics/constants.ts b/packages/@webex/internal-plugin-metrics/src/rtcMetrics/constants.ts similarity index 100% rename from packages/@webex/plugin-meetings/src/rtcMetrics/constants.ts rename to packages/@webex/internal-plugin-metrics/src/rtcMetrics/constants.ts diff --git a/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts b/packages/@webex/internal-plugin-metrics/src/rtcMetrics/index.ts similarity index 79% rename from packages/@webex/plugin-meetings/src/rtcMetrics/index.ts rename to packages/@webex/internal-plugin-metrics/src/rtcMetrics/index.ts index a86c3eb9f33..cc1f3f6b415 100644 --- a/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts +++ b/packages/@webex/internal-plugin-metrics/src/rtcMetrics/index.ts @@ -1,6 +1,6 @@ /* eslint-disable class-methods-use-this */ -import {CallDiagnosticUtils} from '@webex/internal-plugin-metrics'; import uuid from 'uuid'; +import * as CallDiagnosticUtils from '../call-diagnostic/call-diagnostic-metrics.util'; import RTC_METRICS from './constants'; const parseJsonPayload = (payload: any[]): any | null => { @@ -34,6 +34,8 @@ export default class RtcMetrics { connectionId: string; + shouldSendMetricsOnNextStatsReport: boolean; + /** * Initialize the interval. * @@ -47,9 +49,7 @@ export default class RtcMetrics { this.meetingId = meetingId; this.webex = webex; this.correlationId = correlationId; - this.setNewConnectionId(); - // Send the first set of metrics at 5 seconds in the case of a user leaving the call shortly after joining. - setTimeout(this.sendMetricsInQueue.bind(this), 5 * 1000); + this.resetConnection(); } /** @@ -64,6 +64,18 @@ export default class RtcMetrics { } } + /** + * Forces sending metrics when we get the next stats-report + * + * This is useful for cases when something important happens that affects the media connection, + * for example when we move from lobby into the meeting. + * + * @returns {void} + */ + public sendNextMetrics() { + this.shouldSendMetricsOnNextStatsReport = true; + } + /** * Add metrics items to the metrics queue. * @@ -79,6 +91,13 @@ export default class RtcMetrics { this.metricsQueue.push(data); + if (this.shouldSendMetricsOnNextStatsReport && data.name === 'stats-report') { + // this is the first useful set of data (WCME gives it to us after 5s), send it out immediately + // in case the user is unhappy and closes the browser early + this.sendMetricsInQueue(); + this.shouldSendMetricsOnNextStatsReport = false; + } + try { // If a connection fails, send the rest of the metrics in queue and get a new connection id. const parsedPayload = parseJsonPayload(data.payload); @@ -88,7 +107,7 @@ export default class RtcMetrics { parsedPayload.value === 'failed' ) { this.sendMetricsInQueue(); - this.setNewConnectionId(); + this.resetConnection(); } } catch (e) { console.error(e); @@ -130,8 +149,9 @@ export default class RtcMetrics { * * @returns {void} */ - private setNewConnectionId() { + private resetConnection() { this.connectionId = uuid.v4(); + this.shouldSendMetricsOnNextStatsReport = true; } /** diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/behavioral/behavioral-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/behavioral/behavioral-metrics.ts index 290b13d1f0e..73dc28455b5 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/behavioral/behavioral-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/behavioral/behavioral-metrics.ts @@ -74,6 +74,45 @@ describe('internal-plugin-metrics', () => { sinon.restore(); }); + describe('#sendEvent', () => { + it('should send correctly shaped behavioral event (check name building and internal tagged event building)', () => { + // For some reasons `jest` isn't available when testing form build server - so can't use `jest.fn()` here... + const requestCalls = []; + const request = function(arg) { requestCalls.push(arg) } + + behavioralMetrics.clientMetricsBatcher.request = request; + + assert.equal(requestCalls.length, 0) + behavioralMetrics.submitBehavioralEvent({ product: "webex", agent: "user", target: "foo", verb: "get", payload: {bar:"gee"} }) + assert.equal(requestCalls.length, 1) + assert.deepEqual(requestCalls[0], { + context: { + app: {version: 'webex-version'}, + device: {id: 'deviceId'}, + locale: 'language', + os: { + name: getOSNameInternal(), + version: getOSVersion(), + }, + }, + metricName: 'webex.user.foo.get', + tags: { + browser: getBrowserName(), + browserHeight: window.innerHeight, + browserVersion: getBrowserVersion(), + browserWidth: window.innerWidth, + domain: window.location.hostname, + inIframe: false, + locale: window.navigator.language, + os: getOSNameInternal(), + bar:"gee" + }, + timestamp: requestCalls[0].timestamp, // This is to bypass time check, which is correctly tested below. + type: ['behavioral'], + }); + }) + }) + describe('#getContext', () => { it('should build context correctly', () => { const res = behavioralMetrics.getContext(); @@ -96,7 +135,7 @@ describe('internal-plugin-metrics', () => { describe('#getDefaultTags', () => { it('should build tags correctly', () => { - const res = behavioralMetrics.getDefaultTags(); + const res = behavioralMetrics.getBrowserDetails(); assert.deepEqual(res, { browser: getBrowserName(), @@ -111,25 +150,27 @@ describe('internal-plugin-metrics', () => { }); }); - describe('#isReadyToSubmitBehavioralEvents', () => { + describe('#isReadyToSubmitEvents', () => { it('should return true when we have a deviceId, false when deviceId is empty or undefined', async () => { - assert.equal(true, behavioralMetrics.isReadyToSubmitBehavioralEvents()); + let deviceIdUrl = webex.internal.device.url; + // testing case w/o device id url first, as the internal deviceId cache would bypass that flow. webex.internal.device.url = ""; - assert.equal(false, behavioralMetrics.isReadyToSubmitBehavioralEvents()); + assert.equal(false, behavioralMetrics.isReadyToSubmitEvents()); delete webex.internal.device.url; - assert.equal(false, behavioralMetrics.isReadyToSubmitBehavioralEvents()); + assert.equal(false, behavioralMetrics.isReadyToSubmitEvents()); + + webex.internal.device.url = deviceIdUrl; + assert.equal(true, behavioralMetrics.isReadyToSubmitEvents()); }); }); describe('#createEventObject', () => { it('should build event object correctly', async () => { - const res = behavioralMetrics.createEventObject({ - product: 'webex', - agent: 'user', - target: 'target', - verb: 'create', + const res = behavioralMetrics.createTaggedEventObject({ + type:['behavioral'], + name:'webex.user.target.create', payload: tags, }); diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/business/business-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/business/business-metrics.ts new file mode 100644 index 00000000000..f0d30b24579 --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/business/business-metrics.ts @@ -0,0 +1,120 @@ +import sinon from 'sinon'; +import {assert} from '@webex/test-helper-chai'; +import {BrowserDetection} from '@webex/common'; +import {BusinessMetrics, config, getOSNameInternal} from '@webex/internal-plugin-metrics'; +import uuid from 'uuid'; + +//@ts-ignore +global.window = {location: {hostname: 'whatever'}, navigator: {language: 'language'}}; +process.env.NODE_ENV = 'test'; + +const {getOSVersion, getBrowserName, getBrowserVersion} = BrowserDetection(); + +describe('internal-plugin-metrics', () => { + describe('BusinessMetrics', () => { + let webex; + let now; + let businessMetrics: BusinessMetrics; + + const tags = {key: 'val'}; + + beforeEach(() => { + now = new Date(); + + webex = { + canAuthorize: true, + version: 'webex-version', + internal: { + services: { + get: () => 'locus-url', + }, + metrics: { + submitClientMetrics: sinon.stub(), + config: {...config.metrics}, + }, + newMetrics: {}, + device: { + userId: 'userId', + url: 'https://wdm-intb.ciscospark.com/wdm/api/v1/devices/deviceId', + orgId: 'orgId', + }, + }, + meetings: { + config: { + metrics: { + clientType: 'TEAMS_CLIENT', + subClientType: 'WEB_APP', + clientName: 'Cantina', + }, + }, + geoHintInfo: { + clientAddress: '1.3.4.5', + countryCode: 'UK', + }, + }, + credentials: { + isUnverifiedGuest: false, + }, + prepareFetchOptions: sinon.stub().callsFake((opts: any) => ({...opts, foo: 'bar'})), + request: sinon.stub().resolves({body: {}}), + logger: { + log: sinon.stub(), + error: sinon.stub(), + }, + }; + + sinon.createSandbox(); + sinon.useFakeTimers(now.getTime()); + businessMetrics = new BusinessMetrics({}, {parent: webex}); + sinon.stub(uuid, 'v4').returns('my-fake-id'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('#sendEvent', () => { + it('should send correctly shaped business event (check name building and internal tagged event building)', () => { + // For some reasons `jest` isn't available when testing form build server - so can't use `jest.fn()` here... + const requestCalls = []; + const request = function(arg) { requestCalls.push(arg) } + + businessMetrics.clientMetricsBatcher.request = request; + + assert.equal(requestCalls.length, 0) + businessMetrics.submitBusinessEvent({ name: "foobar", payload: {bar:"gee"} }) + assert.equal(requestCalls.length, 1) + assert.deepEqual(requestCalls[0], { + eventPayload: { + context: { + app: {version: 'webex-version'}, + device: {id: 'deviceId'}, + locale: 'language', + os: { + name: getOSNameInternal(), + version: getOSVersion(), + }, + }, + key: 'foobar', + browserDetails: { + browser: getBrowserName(), + browserHeight: window.innerHeight, + browserVersion: getBrowserVersion(), + browserWidth: window.innerWidth, + domain: window.location.hostname, + inIframe: false, + locale: window.navigator.language, + os: getOSNameInternal(), + }, + client_timestamp: requestCalls[0].eventPayload.client_timestamp, // This is to bypass time check, which is checked below. + value: { + bar: "gee" + } + }, + type: ['business'], + }); + assert.isNumber(requestCalls[0].eventPayload.client_timestamp) + }) + }) + }); +}); diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics-batcher.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics-batcher.ts index 37ff23f1d22..6ae22c73e3f 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics-batcher.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics-batcher.ts @@ -441,7 +441,7 @@ describe('plugin-metrics', () => { // item also gets assigned a delay property but the key is a Symbol and haven't been able to test that.. assert.deepEqual(calls.args[0].eventPayload, { event: 'my.event', - origin: {buildType: 'test', networkType: 'unknown'}, + origin: {buildType: 'test', networkType: 'unknown', upgradeChannel: 'test'}, }); assert.deepEqual(calls.args[0].type, ['diagnostic-event']); @@ -455,6 +455,7 @@ describe('plugin-metrics', () => { origin: { buildType: 'test', networkType: 'unknown', + upgradeChannel: 'test', }, }); assert.deepEqual(prepareDiagnosticMetricItemCalls[0].args[1].type, ['diagnostic-event']); 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 ea534d02cca..6a08680e0e8 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 @@ -1,7 +1,9 @@ import sinon from 'sinon'; +import bowser from 'bowser'; import {assert} from '@webex/test-helper-chai'; import {WebexHttpError} from '@webex/webex-core'; import {BrowserDetection} from '@webex/common'; +import window from 'global/window'; import { CallDiagnosticLatencies, CallDiagnosticMetrics, @@ -52,9 +54,17 @@ describe('internal-plugin-metrics', () => { callStateForMetrics: {loginType: 'fakeLoginType'}, }; + const fakeMeeting3 = { + ...fakeMeeting, + id: '3', + correlationId: 'correlationId3', + sessionCorrelationId: 'sessionCorrelationId3', + } + const fakeMeetings = { 1: fakeMeeting, 2: fakeMeeting2, + 3: fakeMeeting3, }; let webex; @@ -366,12 +376,101 @@ describe('internal-plugin-metrics', () => { }); }); + [undefined, null, '', false, 0].forEach((sessionCorrelationId) => { + it(`should build identifiers correctly and not add session correlation id if it is falsy: ${sessionCorrelationId}`, () => { + cd.device = { + ...cd.device, + config: {installationId: 'installationId'}, + }; + + const res = cd.getIdentifiers({ + mediaConnections: [ + {mediaAgentAlias: 'mediaAgentAlias', mediaAgentGroupId: 'mediaAgentGroupId'}, + ], + meeting: {...fakeMeeting, sessionCorrelationId}, + sessionCorrelationId: sessionCorrelationId as any, + }); + + assert.deepEqual(res, { + correlationId: 'correlationId', + deviceId: 'deviceUrl', + locusId: 'url', + locusStartTime: 'lastActive', + locusUrl: 'locus/url', + machineId: 'installationId', + mediaAgentAlias: 'mediaAgentAlias', + mediaAgentGroupId: 'mediaAgentGroupId', + orgId: 'orgId', + userId: 'userId', + }); + }); + }); + + it('should build identifiers correctly with sessionCorrelationID as a param', () => { + cd.device = { + ...cd.device, + config: {installationId: 'installationId'}, + }; + + const res = cd.getIdentifiers({ + mediaConnections: [ + {mediaAgentAlias: 'mediaAgentAlias', mediaAgentGroupId: 'mediaAgentGroupId'}, + ], + meeting: fakeMeeting, + sessionCorrelationId: 'sessionCorrelationId', + }); + + assert.deepEqual(res, { + correlationId: 'correlationId', + sessionCorrelationId: 'sessionCorrelationId', + deviceId: 'deviceUrl', + locusId: 'url', + locusStartTime: 'lastActive', + locusUrl: 'locus/url', + machineId: 'installationId', + mediaAgentAlias: 'mediaAgentAlias', + mediaAgentGroupId: 'mediaAgentGroupId', + orgId: 'orgId', + userId: 'userId', + }); + }); + + it('should build identifiers correctly with sessionCorrelationID as a param and a meeting with session correlation id, and the param should take precedence', () => { + cd.device = { + ...cd.device, + config: {installationId: 'installationId'}, + }; + + const res = cd.getIdentifiers({ + mediaConnections: [ + {mediaAgentAlias: 'mediaAgentAlias', mediaAgentGroupId: 'mediaAgentGroupId'}, + ], + meeting: {...fakeMeeting, sessionCorrelationId: 'sessionCorrelationId1'}, + sessionCorrelationId: 'sessionCorrelationId', + }); + + assert.deepEqual(res, { + correlationId: 'correlationId', + sessionCorrelationId: 'sessionCorrelationId', + deviceId: 'deviceUrl', + locusId: 'url', + locusStartTime: 'lastActive', + locusUrl: 'locus/url', + machineId: 'installationId', + mediaAgentAlias: 'mediaAgentAlias', + mediaAgentGroupId: 'mediaAgentGroupId', + orgId: 'orgId', + userId: 'userId', + }); + }); + it('should build identifiers correctly with a meeting that has meetingInfo with a webexConferenceIdStr and globalMeetingId, and that should take precedence over the options passed to it', () => { const res = cd.getIdentifiers({ mediaConnections: [ {mediaAgentAlias: 'mediaAgentAlias', mediaAgentGroupId: 'mediaAgentGroupId'}, ], webexConferenceIdStr: 'webexConferenceIdStr', + sessionCorrelationId: 'sessionCorrelationId', globalMeetingId: 'globalMeetingId', meeting: { ...fakeMeeting, @@ -386,6 +485,7 @@ describe('internal-plugin-metrics', () => { assert.deepEqual(res, { correlationId: 'correlationId', webexConferenceIdStr: 'webexConferenceIdStr1', + sessionCorrelationId: 'sessionCorrelationId', globalMeetingId: 'globalMeetingId1', deviceId: 'deviceUrl', locusId: 'url', @@ -464,6 +564,42 @@ describe('internal-plugin-metrics', () => { }); }); + it('should build identifiers correctly with a meeting that has sessionCorrelationId', () => { + const res = cd.getIdentifiers({ + mediaConnections: [ + {mediaAgentAlias: 'mediaAgentAlias', mediaAgentGroupId: 'mediaAgentGroupId'}, + ], + webexConferenceIdStr: 'webexConferenceIdStr', + globalMeetingId: 'globalMeetingId', + meeting: { + ...fakeMeeting, + sessionCorrelationId: 'sessionCorrelationId1', + meetingInfo: { + ...fakeMeeting.meetingInfo, + confIdStr: 'webexConferenceIdStr1', + meetingId: 'globalMeetingId1', + siteName: 'siteName1', + }, + }, + }); + + assert.deepEqual(res, { + correlationId: 'correlationId', + sessionCorrelationId: 'sessionCorrelationId1', + webexConferenceIdStr: 'webexConferenceIdStr1', + globalMeetingId: 'globalMeetingId1', + deviceId: 'deviceUrl', + locusId: 'url', + locusStartTime: 'lastActive', + locusUrl: 'locus/url', + mediaAgentAlias: 'mediaAgentAlias', + mediaAgentGroupId: 'mediaAgentGroupId', + orgId: 'orgId', + userId: 'userId', + webexSiteName: 'siteName1', + }); + }); + it('should build identifiers correctly given webexConferenceIdStr', () => { const res = cd.getIdentifiers({ correlationId: 'correlationId', @@ -510,6 +646,22 @@ describe('internal-plugin-metrics', () => { }); }); + it('should build identifiers correctly given sessionCorrelationId', () => { + const res = cd.getIdentifiers({ + correlationId: 'correlationId', + sessionCorrelationId: 'sessionCorrelationId', + }); + + assert.deepEqual(res, { + correlationId: 'correlationId', + sessionCorrelationId: 'sessionCorrelationId', + deviceId: 'deviceUrl', + locusUrl: 'locus-url', + orgId: 'orgId', + userId: 'userId', + }); + }); + it('should throw Error if correlationId is missing', () => { assert.throws(() => cd.getIdentifiers({ @@ -604,11 +756,13 @@ describe('internal-plugin-metrics', () => { options, }); + assert.called(getIdentifiersSpy); assert.calledWith(getIdentifiersSpy, { meeting: fakeMeeting, mediaConnections: [{mediaAgentAlias: 'alias', mediaAgentGroupId: '1'}], webexConferenceIdStr: undefined, globalMeetingId: undefined, + sessionCorrelationId: undefined, }); assert.notCalled(generateClientEventErrorPayloadSpy); assert.calledWith( @@ -717,6 +871,142 @@ describe('internal-plugin-metrics', () => { ]); }); + it('should submit client event successfully with meetingId which has a sessionCorrelationId', () => { + const prepareDiagnosticEventSpy = sinon.spy(cd, 'prepareDiagnosticEvent'); + const submitToCallDiagnosticsSpy = sinon.spy(cd, 'submitToCallDiagnostics'); + const generateClientEventErrorPayloadSpy = sinon.spy(cd, 'generateClientEventErrorPayload'); + const getIdentifiersSpy = sinon.spy(cd, 'getIdentifiers'); + const getSubServiceTypeSpy = sinon.spy(cd, 'getSubServiceType'); + sinon.stub(cd, 'getOrigin').returns({origin: 'fake-origin'}); + const validatorSpy = sinon.spy(cd, 'validator'); + const options = { + meetingId: fakeMeeting3.id, + mediaConnections: [{mediaAgentAlias: 'alias', mediaAgentGroupId: '1'}], + }; + + cd.submitClientEvent({ + name: 'client.alert.displayed', + options, + }); + + assert.called(getIdentifiersSpy); + assert.calledWith(getIdentifiersSpy, { + meeting: {...fakeMeeting3, sessionCorrelationId: 'sessionCorrelationId3'}, + mediaConnections: [{mediaAgentAlias: 'alias', mediaAgentGroupId: '1'}], + webexConferenceIdStr: undefined, + globalMeetingId: undefined, + sessionCorrelationId: undefined, + }); + assert.notCalled(generateClientEventErrorPayloadSpy); + assert.calledWith( + prepareDiagnosticEventSpy, + { + canProceed: true, + eventData: { + webClientDomain: 'whatever', + }, + identifiers: { + correlationId: 'correlationId3', + sessionCorrelationId: 'sessionCorrelationId3', + deviceId: 'deviceUrl', + locusId: 'url', + locusStartTime: 'lastActive', + locusUrl: 'locus/url', + mediaAgentAlias: 'alias', + mediaAgentGroupId: '1', + orgId: 'orgId', + userId: 'userId', + }, + loginType: 'login-ci', + name: 'client.alert.displayed', + userType: 'host', + isConvergedArchitectureEnabled: undefined, + webexSubServiceType: undefined, + }, + options + ); + assert.calledWith(submitToCallDiagnosticsSpy, { + event: { + canProceed: true, + eventData: { + webClientDomain: 'whatever', + }, + identifiers: { + correlationId: 'correlationId3', + sessionCorrelationId: 'sessionCorrelationId3', + deviceId: 'deviceUrl', + locusId: 'url', + locusStartTime: 'lastActive', + locusUrl: 'locus/url', + mediaAgentAlias: 'alias', + mediaAgentGroupId: '1', + orgId: 'orgId', + userId: 'userId', + }, + loginType: 'login-ci', + name: 'client.alert.displayed', + userType: 'host', + isConvergedArchitectureEnabled: undefined, + webexSubServiceType: undefined, + }, + eventId: 'my-fake-id', + origin: { + origin: 'fake-origin', + }, + originTime: { + sent: 'not_defined_yet', + triggered: now.toISOString(), + }, + senderCountryCode: 'UK', + version: 1, + }); + assert.calledWith(validatorSpy, { + type: 'ce', + event: { + event: { + canProceed: true, + eventData: { + webClientDomain: 'whatever', + }, + identifiers: { + correlationId: 'correlationId3', + sessionCorrelationId: 'sessionCorrelationId3', + deviceId: 'deviceUrl', + locusId: 'url', + locusStartTime: 'lastActive', + locusUrl: 'locus/url', + mediaAgentAlias: 'alias', + mediaAgentGroupId: '1', + orgId: 'orgId', + userId: 'userId', + }, + loginType: 'login-ci', + name: 'client.alert.displayed', + userType: 'host', + isConvergedArchitectureEnabled: undefined, + webexSubServiceType: undefined, + }, + eventId: 'my-fake-id', + origin: { + origin: 'fake-origin', + }, + originTime: { + sent: 'not_defined_yet', + triggered: now.toISOString(), + }, + senderCountryCode: 'UK', + version: 1, + }, + }); + + const webexLoggerLogCalls = webex.logger.log.getCalls(); + assert.deepEqual(webexLoggerLogCalls[1].args, [ + 'call-diagnostic-events -> ', + 'CallDiagnosticMetrics: @submitClientEvent. Submit Client Event CA event.', + `name: client.alert.displayed`, + ]); + }); + it('should log browser data, but only for the first call diagnostic event', () => { const prepareDiagnosticEventSpy = sinon.spy(cd, 'prepareDiagnosticEvent'); const submitToCallDiagnosticsSpy = sinon.spy(cd, 'submitToCallDiagnostics'); @@ -724,6 +1014,9 @@ describe('internal-plugin-metrics', () => { const getIdentifiersSpy = sinon.spy(cd, 'getIdentifiers'); const getSubServiceTypeSpy = sinon.spy(cd, 'getSubServiceType'); const validatorSpy = sinon.spy(cd, 'validator'); + sinon.stub(window.navigator, 'userAgent').get(() => userAgent); + sinon.stub(bowser, 'getParser').returns(userAgent); + const options = { meetingId: fakeMeeting.id, mediaConnections: [{mediaAgentAlias: 'alias', mediaAgentGroupId: '1'}], @@ -752,7 +1045,7 @@ describe('internal-plugin-metrics', () => { assert.deepEqual(webexLoggerLogCalls[2].args, [ 'call-diagnostic-events -> ', 'CallDiagnosticMetrics: @createClientEventObjectInMeeting => collected browser data', - '{"error":"unable to access window.navigator.userAgent"}', + `${JSON.stringify(userAgent)}`, ]); assert.deepEqual(webexLoggerLogCalls[3].args, [ @@ -762,7 +1055,7 @@ describe('internal-plugin-metrics', () => { ]); }); - it('should submit client event successfully with correlationId, webexConferenceIdStr and globalMeetingId', () => { + it('should submit client event successfully with correlationId, webexConferenceIdStr, sessionCorrelationId, and globalMeetingId', () => { const prepareDiagnosticEventSpy = sinon.spy(cd, 'prepareDiagnosticEvent'); const submitToCallDiagnosticsSpy = sinon.spy(cd, 'submitToCallDiagnostics'); const generateClientEventErrorPayloadSpy = sinon.spy(cd, 'generateClientEventErrorPayload'); @@ -773,6 +1066,7 @@ describe('internal-plugin-metrics', () => { correlationId: 'correlationId', webexConferenceIdStr: 'webexConferenceIdStr1', globalMeetingId: 'globalMeetingId1', + sessionCorrelationId: 'sessionCorrelationId1' }; cd.submitClientEvent({ @@ -784,6 +1078,7 @@ describe('internal-plugin-metrics', () => { correlationId: 'correlationId', webexConferenceIdStr: 'webexConferenceIdStr1', globalMeetingId: 'globalMeetingId1', + sessionCorrelationId: 'sessionCorrelationId1', preLoginId: undefined, }); @@ -798,6 +1093,7 @@ describe('internal-plugin-metrics', () => { identifiers: { correlationId: 'correlationId', webexConferenceIdStr: 'webexConferenceIdStr1', + sessionCorrelationId: 'sessionCorrelationId1', globalMeetingId: 'globalMeetingId1', deviceId: 'deviceUrl', locusUrl: 'locus-url', @@ -818,6 +1114,7 @@ describe('internal-plugin-metrics', () => { identifiers: { correlationId: 'correlationId', webexConferenceIdStr: 'webexConferenceIdStr1', + sessionCorrelationId: 'sessionCorrelationId1', globalMeetingId: 'globalMeetingId1', deviceId: 'deviceUrl', locusUrl: 'locus-url', @@ -863,6 +1160,7 @@ describe('internal-plugin-metrics', () => { webexConferenceIdStr: 'webexConferenceIdStr1', globalMeetingId: 'globalMeetingId1', preLoginId: 'myPreLoginId', + sessionCorrelationId: 'sessionCorrelationId1' }; cd.submitClientEvent({ @@ -875,6 +1173,7 @@ describe('internal-plugin-metrics', () => { webexConferenceIdStr: 'webexConferenceIdStr1', globalMeetingId: 'globalMeetingId1', preLoginId: 'myPreLoginId', + sessionCorrelationId: 'sessionCorrelationId1' }); assert.notCalled(generateClientEventErrorPayloadSpy); @@ -887,6 +1186,7 @@ describe('internal-plugin-metrics', () => { }, identifiers: { correlationId: 'correlationId', + sessionCorrelationId: 'sessionCorrelationId1', webexConferenceIdStr: 'webexConferenceIdStr1', globalMeetingId: 'globalMeetingId1', deviceId: 'deviceUrl', @@ -913,6 +1213,7 @@ describe('internal-plugin-metrics', () => { canProceed: true, identifiers: { correlationId: 'correlationId', + sessionCorrelationId: 'sessionCorrelationId1', userId: 'myPreLoginId', deviceId: 'deviceUrl', orgId: 'orgId', @@ -977,6 +1278,57 @@ describe('internal-plugin-metrics', () => { }); }); + it('should use meeting loginType if present and meetingId provided, with sessionCorrelationId', () => { + const submitToCallDiagnosticsSpy = sinon.spy(cd, 'submitToCallDiagnostics'); + sinon.stub(cd, 'getOrigin').returns({origin: 'fake-origin'}); + const options = { + meetingId: fakeMeeting2.id, + mediaConnections: [{mediaAgentAlias: 'alias', mediaAgentGroupId: '1'}], + sessionCorrelationId: 'sessionCorrelationId1' + }; + + cd.submitClientEvent({ + name: 'client.alert.displayed', + options, + }); + + assert.calledWith(submitToCallDiagnosticsSpy, { + event: { + canProceed: true, + eventData: { + webClientDomain: 'whatever', + }, + identifiers: { + correlationId: 'correlationId2', + sessionCorrelationId: 'sessionCorrelationId1', + deviceId: 'deviceUrl', + locusId: 'url', + locusStartTime: 'lastActive', + locusUrl: 'locus/url', + mediaAgentAlias: 'alias', + mediaAgentGroupId: '1', + orgId: 'orgId', + userId: 'userId', + }, + loginType: 'fakeLoginType', + name: 'client.alert.displayed', + userType: 'host', + isConvergedArchitectureEnabled: undefined, + webexSubServiceType: undefined, + }, + eventId: 'my-fake-id', + origin: { + origin: 'fake-origin', + }, + originTime: { + sent: 'not_defined_yet', + triggered: now.toISOString(), + }, + senderCountryCode: 'UK', + version: 1, + }); + }); + it('it should include errors if provided with meetingId', () => { sinon.stub(cd, 'getOrigin').returns({origin: 'fake-origin'}); const submitToCallDiagnosticsSpy = sinon.spy(cd, 'submitToCallDiagnostics'); @@ -1417,6 +1769,7 @@ describe('internal-plugin-metrics', () => { meetingId: fakeMeeting.id, webexConferenceIdStr: 'webexConferenceIdStr1', globalMeetingId: 'globalMeetingId1', + sessionCorrelationId: 'sessionCorrelationId1' }; cd.submitMQE({ @@ -1454,6 +1807,9 @@ describe('internal-plugin-metrics', () => { eventData: {webClientDomain: 'whatever'}, intervals: [{}], callingServiceType: 'LOCUS', + meetingJoinInfo: { + clientSignallingProtocol: 'WebRTC', + }, sourceMetadata: { applicationSoftwareType: 'webex-js-sdk', applicationSoftwareVersion: 'webex-version', @@ -1490,6 +1846,9 @@ describe('internal-plugin-metrics', () => { eventData: {webClientDomain: 'whatever'}, intervals: [{}], callingServiceType: 'LOCUS', + meetingJoinInfo: { + clientSignallingProtocol: 'WebRTC', + }, sourceMetadata: { applicationSoftwareType: 'webex-js-sdk', applicationSoftwareVersion: 'webex-version', @@ -1524,6 +1883,9 @@ describe('internal-plugin-metrics', () => { eventData: {webClientDomain: 'whatever'}, intervals: [{}], callingServiceType: 'LOCUS', + meetingJoinInfo: { + clientSignallingProtocol: 'WebRTC', + }, sourceMetadata: { applicationSoftwareType: 'webex-js-sdk', applicationSoftwareVersion: 'webex-version', @@ -2015,6 +2377,40 @@ describe('internal-plugin-metrics', () => { }); }); + it('should generate event error payload correctly for wdm error 4404002', () => { + const res = cd.generateClientEventErrorPayload({ + body: {errorCode: 4404002}, + message: 'Operation denied due to region restriction', + }); + assert.deepEqual(res, { + category: 'expected', + errorDescription: 'WdmRestrictedRegion', + fatal: true, + name: 'other', + shownToUser: false, + serviceErrorCode: 4404002, + errorCode: 13000, + rawErrorMessage: 'Operation denied due to region restriction', + }); + }); + + it('should generate event error payload correctly for wdm error 4404003', () => { + const res = cd.generateClientEventErrorPayload({ + body: {errorCode: 4404003}, + message: 'Operation denied due to region restriction', + }); + assert.deepEqual(res, { + category: 'expected', + errorDescription: 'WdmRestrictedRegion', + fatal: true, + name: 'other', + shownToUser: false, + serviceErrorCode: 4404003, + errorCode: 13000, + rawErrorMessage: 'Operation denied due to region restriction', + }); + }); + describe('httpStatusCode', () => { it('should include httpStatusCode for browser media errors', () => { const res = cd.generateClientEventErrorPayload({ @@ -2293,6 +2689,7 @@ describe('internal-plugin-metrics', () => { environment: 'meeting_evn', name: 'endpoint', networkType: 'unknown', + upgradeChannel: 'test', userAgent, }, originTime: { @@ -2376,11 +2773,11 @@ describe('internal-plugin-metrics', () => { // The method is called in beforeEach itself. We are just testing it here it('sets the received deviceInfo to call-diagnostics', () => { const webexLoggerLogCalls = webex.logger.log.getCalls(); - const device = { userId: 'userId', url: 'deviceUrl', orgId: 'orgId' }; + const device = {userId: 'userId', url: 'deviceUrl', orgId: 'orgId'}; assert.deepEqual(webexLoggerLogCalls[0].args, [ 'CallDiagnosticMetrics: @setDeviceInfo called', - device + device, ]); assert.deepEqual(cd.device, device); diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.util.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.util.ts index 0d18c9b417a..5a85c1ec019 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.util.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.util.ts @@ -235,6 +235,8 @@ describe('internal-plugin-metrics', () => { }); describe('getBuildType', () => { + const webex = {internal: {metrics: {config: {}}}}; + beforeEach(() => { process.env.NODE_ENV = 'production'; }); @@ -246,25 +248,33 @@ describe('internal-plugin-metrics', () => { ['https://web.webex.com', true, 'test'], ].forEach(([webClientDomain, markAsTestEvent, expected]) => { it(`returns expected result for ${webClientDomain}`, () => { - assert.deepEqual(getBuildType(webClientDomain, markAsTestEvent as any), expected); + assert.deepEqual(getBuildType(webex, webClientDomain, markAsTestEvent as any), expected); }); }); it('returns "test" for NODE_ENV "foo"', () => { process.env.NODE_ENV = 'foo'; - assert.deepEqual(getBuildType('production'), 'test'); + assert.deepEqual(getBuildType(webex, 'production'), 'test'); }); it('returns "test" for NODE_ENV "production" and markAsTestEvent = true', () => { process.env.NODE_ENV = 'production'; - assert.deepEqual(getBuildType('my.domain', true), 'test'); + assert.deepEqual(getBuildType(webex, 'my.domain', true), 'test'); + }); + + it('returns "test" for NODE_ENV "production" when webex.caBuildType = "test"', () => { + process.env.NODE_ENV = 'production'; + assert.deepEqual( + getBuildType({internal: {metrics: {config: {caBuildType: 'test'}}}}, 'my.domain'), + 'test' + ); }); }); describe('prepareDiagnosticMetricItem', () => { let webex: any; - const check = (eventName: string, expectedEvent: any) => { + const check = (eventName: string, expectedEvent: any, expectedUpgradeChannel: string) => { const eventPayload = {event: {name: eventName}}; const item = prepareDiagnosticMetricItem(webex, { eventPayload, @@ -276,6 +286,7 @@ describe('internal-plugin-metrics', () => { origin: { buildType: 'prod', networkType: 'unknown', + upgradeChannel: expectedUpgradeChannel }, event: {name: eventName, ...expectedEvent}, }, @@ -407,19 +418,19 @@ describe('internal-plugin-metrics', () => { ], ].forEach(([eventName, expectedEvent]) => { it(`returns expected result for ${eventName}`, () => { - check(eventName as string, expectedEvent); + check(eventName as string, expectedEvent, 'gold'); }); }); - it('getBuildType returns correct value', () => { + it('sets buildType and upgradeChannel correctly', () => { const item: any = { eventPayload: { event: { name: 'client.exit.app', eventData: { markAsTestEvent: true, - webClientDomain: 'https://web.webex.com' - } + webClientDomain: 'https://web.webex.com', + }, }, }, type: ['diagnostic-event'], @@ -428,11 +439,14 @@ describe('internal-plugin-metrics', () => { // just submit any event prepareDiagnosticMetricItem(webex, item); assert.deepEqual(item.eventPayload.origin.buildType, 'test'); + assert.deepEqual(item.eventPayload.origin.upgradeChannel, 'test'); delete item.eventPayload.origin.buildType; + delete item.eventPayload.origin.upgradeChannel; item.eventPayload.event.eventData.markAsTestEvent = false; prepareDiagnosticMetricItem(webex, item); assert.deepEqual(item.eventPayload.origin.buildType, 'prod'); + assert.deepEqual(item.eventPayload.origin.upgradeChannel, 'gold'); }); }); diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts index d300c91a39b..5ec40c27c85 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts @@ -73,6 +73,20 @@ describe('internal-plugin-metrics', () => { sinon.restore(); }) + it('lazy metrics backend initialization when checking if backend ready', () => { + assert.isUndefined(webex.internal.newMetrics.behavioralMetrics); + webex.internal.newMetrics.isReadyToSubmitBehavioralEvents(); + assert.isDefined(webex.internal.newMetrics.behavioralMetrics); + + assert.isUndefined(webex.internal.newMetrics.operationalMetrics); + webex.internal.newMetrics.isReadyToSubmitOperationalEvents(); + assert.isDefined(webex.internal.newMetrics.operationalMetrics); + + assert.isUndefined(webex.internal.newMetrics.businessMetrics) + webex.internal.newMetrics.isReadyToSubmitBusinessEvents(); + assert.isDefined(webex.internal.newMetrics.businessMetrics); + }) + it('submits Client Event successfully', () => { webex.internal.newMetrics.submitClientEvent({ name: 'client.alert.displayed', diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/operational/operational-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/operational/operational-metrics.ts new file mode 100644 index 00000000000..4e231552b61 --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/operational/operational-metrics.ts @@ -0,0 +1,115 @@ +import sinon from 'sinon'; +import {assert} from '@webex/test-helper-chai'; +import {BrowserDetection} from '@webex/common'; +import {OperationalMetrics, config, getOSNameInternal} from '@webex/internal-plugin-metrics'; +import uuid from 'uuid'; + +//@ts-ignore +global.window = {location: {hostname: 'whatever'}, navigator: {language: 'language'}}; +process.env.NODE_ENV = 'test'; + +const {getOSVersion, getBrowserName, getBrowserVersion} = BrowserDetection(); + +describe('internal-plugin-metrics', () => { + describe('OperationalMetrics', () => { + let webex; + let now; + let operationalMetrics: OperationalMetrics; + + const tags = {key: 'val'}; + + beforeEach(() => { + now = new Date(); + + webex = { + canAuthorize: true, + version: 'webex-version', + internal: { + services: { + get: () => 'locus-url', + }, + metrics: { + submitClientMetrics: sinon.stub(), + config: {...config.metrics}, + }, + newMetrics: {}, + device: { + userId: 'userId', + url: 'https://wdm-intb.ciscospark.com/wdm/api/v1/devices/deviceId', + orgId: 'orgId', + }, + }, + meetings: { + config: { + metrics: { + clientType: 'TEAMS_CLIENT', + subClientType: 'WEB_APP', + clientName: 'Cantina', + }, + }, + geoHintInfo: { + clientAddress: '1.3.4.5', + countryCode: 'UK', + }, + }, + credentials: { + isUnverifiedGuest: false, + }, + prepareFetchOptions: sinon.stub().callsFake((opts: any) => ({...opts, foo: 'bar'})), + request: sinon.stub().resolves({body: {}}), + logger: { + log: sinon.stub(), + error: sinon.stub(), + }, + }; + + sinon.createSandbox(); + sinon.useFakeTimers(now.getTime()); + operationalMetrics = new OperationalMetrics({}, {parent: webex}); + sinon.stub(uuid, 'v4').returns('my-fake-id'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('#sendEvent', () => { + it('should send correctly shaped operational event (check name building and internal tagged event building)', () => { + // For some reasons `jest` isn't available when testing form build server - so can't use `jest.fn()` here... + const requestCalls = []; + const request = function(arg) { requestCalls.push(arg) } + + operationalMetrics.clientMetricsBatcher.request = request; + + assert.equal(requestCalls.length, 0) + operationalMetrics.submitOperationalEvent({ name: "foobar", payload: {bar:"gee"} }) + assert.equal(requestCalls.length, 1) + assert.deepEqual(requestCalls[0], { + context: { + app: {version: 'webex-version'}, + device: {id: 'deviceId'}, + locale: 'language', + os: { + name: getOSNameInternal(), + version: getOSVersion(), + }, + }, + metricName: 'foobar', + tags: { + browser: getBrowserName(), + browserHeight: window.innerHeight, + browserVersion: getBrowserVersion(), + browserWidth: window.innerWidth, + domain: window.location.hostname, + inIframe: false, + locale: window.navigator.language, + os: getOSNameInternal(), + bar: "gee" + }, + timestamp: requestCalls[0].timestamp, // This is to bypass time check, which is correctly tested in behavioral-metrics tests. + type: ['operational'], + }); + }) + }) + }); +}); diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/prelogin-metrics-batcher.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/prelogin-metrics-batcher.ts index 1a5a0d2bf39..889ac210242 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/prelogin-metrics-batcher.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/prelogin-metrics-batcher.ts @@ -82,6 +82,7 @@ describe('internal-plugin-metrics', () => { origin: { buildType: 'test', networkType: 'unknown', + upgradeChannel: 'test', }, originTime: { sent: dateAfterBatcherWait.toISOString(), @@ -211,7 +212,7 @@ describe('internal-plugin-metrics', () => { // item also gets assigned a delay property but the key is a Symbol and haven't been able to test that.. assert.deepEqual(calls.args[0].eventPayload, { event: 'my.event', - origin: {buildType: 'test', networkType: 'unknown'}, + origin: {buildType: 'test', networkType: 'unknown', upgradeChannel: 'test'}, }); assert.deepEqual(calls.args[0].type, ['diagnostic-event']); @@ -225,6 +226,7 @@ describe('internal-plugin-metrics', () => { origin: { buildType: 'test', networkType: 'unknown', + upgradeChannel: 'test', }, }); assert.deepEqual(prepareDiagnosticMetricItemCalls[0].args[1].type, ['diagnostic-event']); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/rtcMetrics/index.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/rtcMetrics/index.ts similarity index 75% rename from packages/@webex/plugin-meetings/test/unit/spec/rtcMetrics/index.ts rename to packages/@webex/internal-plugin-metrics/test/unit/spec/rtcMetrics/index.ts index 7a0ff4eb3c4..12eebc0e31c 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/rtcMetrics/index.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/rtcMetrics/index.ts @@ -1,5 +1,5 @@ import 'jsdom-global/register'; -import RtcMetrics from '@webex/plugin-meetings/src/rtcMetrics'; +import RtcMetrics from '../../../../src/rtcMetrics'; import MockWebex from '@webex/test-helper-mock-webex'; import {assert} from '@webex/test-helper-chai'; import sinon from 'sinon'; @@ -25,6 +25,7 @@ describe('RtcMetrics', () => { beforeEach(() => { clock = sinon.useFakeTimers(); + window.setInterval = setInterval; webex = new MockWebex(); metrics = new RtcMetrics(webex, 'mock-meeting-id', 'mock-correlation-id'); anonymizeIpSpy = sandbox.spy(metrics, 'anonymizeIp'); @@ -120,4 +121,35 @@ describe('RtcMetrics', () => { metrics.addMetrics({ name: 'stats-report', payload: [STATS_WITH_IP] }); assert.calledOnce(anonymizeIpSpy); }) + + it('should send metrics on first stats-report', () => { + assert.callCount(webex.request, 0); + + metrics.addMetrics(FAKE_METRICS_ITEM); + assert.callCount(webex.request, 0); + + // first stats-report should trigger a call to webex.request + metrics.addMetrics({ name: 'stats-report', payload: [STATS_WITH_IP] }); + assert.callCount(webex.request, 1); + }); + + it('should send metrics on first stats-report after a new connection', () => { + assert.callCount(webex.request, 0); + + // first stats-report should trigger a call to webex.request + metrics.addMetrics({ name: 'stats-report', payload: [STATS_WITH_IP] }); + assert.callCount(webex.request, 1); + + // subsequent stats-report doesn't trigger it + metrics.addMetrics({ name: 'stats-report', payload: [STATS_WITH_IP] }); + assert.callCount(webex.request, 1); + + // now, simulate a failure - that triggers a new connection and upload of the metrics + metrics.addMetrics(FAILURE_METRICS_ITEM); + assert.callCount(webex.request, 2); + + // and another stats-report should trigger another upload of the metrics + metrics.addMetrics({ name: 'stats-report', payload: [STATS_WITH_IP] }); + assert.callCount(webex.request, 3); + }); }); diff --git a/packages/@webex/internal-plugin-presence/src/presence.js b/packages/@webex/internal-plugin-presence/src/presence.js index 7dce9dcd2b3..0d1440a8d2e 100644 --- a/packages/@webex/internal-plugin-presence/src/presence.js +++ b/packages/@webex/internal-plugin-presence/src/presence.js @@ -234,6 +234,7 @@ const Presence = WebexPlugin.extend({ body: { subject: this.webex.internal.device.userId, eventType: status, + label: this.webex.internal.device.userId, ttl, }, }) diff --git a/packages/@webex/internal-plugin-presence/test/integration/spec/presence.js b/packages/@webex/internal-plugin-presence/test/integration/spec/presence.js index f1cfc656eb9..2b7b0ac6fbb 100644 --- a/packages/@webex/internal-plugin-presence/test/integration/spec/presence.js +++ b/packages/@webex/internal-plugin-presence/test/integration/spec/presence.js @@ -206,8 +206,10 @@ describe.skip('plugin-presence', function () { spock.webex.internal.presence.setStatus('dnd', 1500).then((statusResponse) => { assert.property(statusResponse, 'subject'); assert.property(statusResponse, 'status'); + assert.property(statusResponse, 'label'); assert.equal(statusResponse.subject, spock.id); assert.equal(statusResponse.status, 'dnd'); + assert.equal(statusResponse.label, spock.id); })); }); }); diff --git a/packages/@webex/internal-plugin-presence/test/unit/spec/presence.js b/packages/@webex/internal-plugin-presence/test/unit/spec/presence.js index 105d3804afa..f94a375283e 100644 --- a/packages/@webex/internal-plugin-presence/test/unit/spec/presence.js +++ b/packages/@webex/internal-plugin-presence/test/unit/spec/presence.js @@ -64,6 +64,29 @@ describe.skip('plugin-presence', () => { describe('#setStatus()', () => { it('requires a status', () => assert.isRejected(webex.internal.presence.setStatus(), /A status is required/)); + + it('passes a label to the API', () => { + const testGuid = 'test-guid'; + + webex.internal.device.userId = testGuid; + + webex.request = function (options) { + return Promise.resolve({ + statusCode: 204, + body: [], + options, + }); + }; + sinon.spy(webex, 'request'); + + webex.internal.presence.setStatus('dnd'); + + assert.calledOnce(webex.request); + + const request = webex.request.getCall(0); + + assert.equal(request.args[0].body.label, testGuid); + }); }); }); }); diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json index b95c6dd1c99..8a91c6266bf 100644 --- a/packages/@webex/media-helpers/package.json +++ b/packages/@webex/media-helpers/package.json @@ -22,9 +22,9 @@ "deploy:npm": "yarn npm publish" }, "dependencies": { - "@webex/internal-media-core": "2.9.1", + "@webex/internal-media-core": "2.11.3", "@webex/ts-events": "^1.1.0", - "@webex/web-media-effects": "2.18.0" + "@webex/web-media-effects": "2.19.0" }, "browserify": { "transform": [ 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 916287f34d8..477b64e751d 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js @@ -103,14 +103,24 @@ const Authorization = WebexPlugin.extend({ this._verifySecurityToken(location.query); this._cleanUrl(location); + let preauthCatalogParams; + + const orgId = this._extractOrgIdFromCode(code); + + if (emailhash) { + preauthCatalogParams = {emailhash}; + } else if (orgId) { + preauthCatalogParams = {orgId}; + } + // Wait until nextTick in case `credentials` hasn't initialized yet process.nextTick(() => { this.webex.internal.services - .collectPreauthCatalog(emailhash ? {emailhash}: undefined) + .collectPreauthCatalog(preauthCatalogParams) .catch(() => Promise.resolve()) .then(() => this.requestAuthorizationCodeGrant({code, codeVerifier})) .catch((error) => { - this.logger.warn('authorization: failed initial authorization code grant request', error) + this.logger.warn('authorization: failed initial authorization code grant request', error); }) .then(() => { this.ready = true; @@ -230,6 +240,20 @@ const Authorization = WebexPlugin.extend({ }); }, + /** + * Extracts the orgId from the returned code from idbroker + * Description of how to parse the code can be found here: + * https://wiki.cisco.com/display/IDENTITY/Federated+Token+Validation + * @instance + * @memberof AuthorizationBrowserFirstParty + * @param {String} code + * @private + * @returns {String} + */ + _extractOrgIdFromCode(code) { + return code?.split('_')[2] || undefined; + }, + /** * Checks if the result of the login redirect contains an error string * @instance 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 fbc07485412..6069b45ac15 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 @@ -183,7 +183,7 @@ describe('plugin-authorization-browser-first-party', () => { }); it('collects the preauth catalog when emailhash is present in the state', async () => { - const code = 'auth code'; + const code = 'authcode_clusterid_theOrgId'; const webex = makeWebex( `http://example.com/?code=${code}&state=${base64.encode( JSON.stringify({emailhash: 'someemailhash'}) @@ -205,7 +205,7 @@ describe('plugin-authorization-browser-first-party', () => { }); it('collects the preauth catalog no emailhash is present in the state', async () => { - const code = 'auth code'; + const code = 'authcode_clusterid_theOrgId'; const webex = makeWebex( `http://example.com/?code=${code}` ); @@ -220,6 +220,26 @@ describe('plugin-authorization-browser-first-party', () => { await webex.authorization.when('change:ready'); + assert.calledOnce(requestAuthorizationCodeGrantStub); + assert.calledWith(requestAuthorizationCodeGrantStub, {code, codeVerifier: undefined}); + assert.calledOnce(collectPreauthCatalogStub); + assert.calledWith(collectPreauthCatalogStub, {orgId: 'theOrgId'}); + }); + + it('collects the preauth catalog with no emailhash and no orgId', async () => { + const code = 'authcode_clusterid'; + const webex = makeWebex(`http://example.com/?code=${code}`); + + const requestAuthorizationCodeGrantStub = sinon.stub( + Authorization.prototype, + 'requestAuthorizationCodeGrant' + ); + const collectPreauthCatalogStub = sinon + .stub(Services.prototype, 'collectPreauthCatalog') + .resolves(); + + await webex.authorization.when('change:ready'); + assert.calledOnce(requestAuthorizationCodeGrantStub); assert.calledWith(requestAuthorizationCodeGrantStub, {code, codeVerifier: undefined}); assert.calledOnce(collectPreauthCatalogStub); @@ -503,5 +523,46 @@ describe('plugin-authorization-browser-first-party', () => { assert.notInclude(href, 'csrf_token'); }); }); + + describe('#_extractOrgIdFromCode', () => { + it('extracts the orgId from the code', () => { + const webex = makeWebex(undefined, undefined, { + credentials: { + clientType: 'confidential', + }, + }); + + const code = 'authcode_clusterid_theOrgId'; + const orgId = webex.authorization._extractOrgIdFromCode(code); + + assert.equal(orgId, 'theOrgId'); + }); + + it('handles an invalid code', () => { + const webex = makeWebex(undefined, undefined, { + credentials: { + clientType: 'confidential', + }, + }); + + const code = 'authcode_clusterid_'; + const orgId = webex.authorization._extractOrgIdFromCode(code); + + assert.isUndefined(orgId); + }); + + it('handles an completely invalid code', () => { + const webex = makeWebex(undefined, undefined, { + credentials: { + clientType: 'confidential', + }, + }); + + const code = 'authcode'; + const orgId = webex.authorization._extractOrgIdFromCode(code); + + assert.isUndefined(orgId); + }) + }); }); }); diff --git a/packages/@webex/plugin-meetings/package.json b/packages/@webex/plugin-meetings/package.json index 02c7f99e073..9772e87d059 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.9.1", + "@webex/internal-media-core": "2.11.3", "@webex/internal-plugin-conversation": "workspace:*", "@webex/internal-plugin-device": "workspace:*", "@webex/internal-plugin-llm": "workspace:*", diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index 26fbfcd6c7b..424e831a597 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -879,6 +879,7 @@ export enum SELF_POLICY { SUPPORT_HDV = 'supportHDV', SUPPORT_PARTICIPANT_LIST = 'supportParticipantList', SUPPORT_VOIP = 'supportVoIP', + SUPPORT_POLLING_AND_QA = 'supportPollingAndQA', } export const DISPLAY_HINTS = { diff --git a/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts b/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts index 27079d8221b..0ba3e4d8178 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts @@ -428,11 +428,6 @@ SelfUtils.mutedByOthersChanged = (oldSelf, changedSelf) => { return false; } - // there is no need to trigger user update if no one muted user - if (changedSelf.selfIdentity === changedSelf.modifiedBy) { - return false; - } - return ( changedSelf.remoteMuted !== null && (oldSelf.remoteMuted !== changedSelf.remoteMuted || diff --git a/packages/@webex/plugin-meetings/src/media/index.ts b/packages/@webex/plugin-meetings/src/media/index.ts index 15d2376106e..28c014e4852 100644 --- a/packages/@webex/plugin-meetings/src/media/index.ts +++ b/packages/@webex/plugin-meetings/src/media/index.ts @@ -15,12 +15,12 @@ import { LocalSystemAudioStream, LocalMicrophoneStream, } from '@webex/media-helpers'; +import {RtcMetrics} from '@webex/internal-plugin-metrics'; import LoggerProxy from '../common/logs/logger-proxy'; import {MEDIA_TRACK_CONSTRAINT} from '../constants'; import Config from '../config'; import StaticConfig from '../common/config'; import BrowserDetection from '../common/browser-detection'; -import RtcMetrics from '../rtcMetrics'; const {isBrowser} = BrowserDetection(); @@ -104,9 +104,7 @@ Media.getDirection = (forceSendRecv: boolean, receive: boolean, send: boolean) = * * @param {boolean} isMultistream * @param {string} debugId string useful for debugging (will appear in media connection logs) - * @param {object} webex main `webex` object. * @param {string} meetingId id for the meeting using this connection - * @param {string} correlationId id used in requests to correlate to this session * @param {Object} options * @param {Object} [options.mediaProperties] contains mediaDirection and local tracks: * audioTrack, videoTrack, shareVideoTrack, and shareAudioTrack @@ -120,10 +118,9 @@ Media.getDirection = (forceSendRecv: boolean, receive: boolean, send: boolean) = Media.createMediaConnection = ( isMultistream: boolean, debugId: string, - webex: object, meetingId: string, - correlationId: string, options: { + rtcMetrics?: RtcMetrics; mediaProperties: { mediaDirection?: { receiveAudio: boolean; @@ -150,6 +147,7 @@ Media.createMediaConnection = ( } ) => { const { + rtcMetrics, mediaProperties, remoteQualityLevel, enableRtx, @@ -163,6 +161,19 @@ Media.createMediaConnection = ( // we might not have any TURN server if TURN discovery failed or wasn't done or // we might get an empty TURN url if we land on a video mesh node if (turnServerInfo?.url) { + if (!isBrowser('firefox')) { + let bareTurnServer = turnServerInfo.url; + bareTurnServer = bareTurnServer.replace('turns:', 'turn:'); + bareTurnServer = bareTurnServer.replace('443', '5004'); + + iceServers.push({ + urls: bareTurnServer, + username: turnServerInfo.username || '', + credential: turnServerInfo.password || '', + }); + } + + // TURN-TLS server iceServers.push({ urls: turnServerInfo.url, username: turnServerInfo.username || '', @@ -179,15 +190,13 @@ Media.createMediaConnection = ( config.bundlePolicy = bundlePolicy; } - const rtcMetrics = new RtcMetrics(webex, meetingId, correlationId); - return new MultistreamRoapMediaConnection( config, meetingId, /* the rtc metrics objects callbacks */ - (data) => rtcMetrics.addMetrics(data), - () => rtcMetrics.closeMetrics(), - () => rtcMetrics.sendMetricsInQueue() + (data) => rtcMetrics?.addMetrics(data), + () => rtcMetrics?.closeMetrics(), + () => rtcMetrics?.sendMetricsInQueue() ); } 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 3c6648b733c..161e90a9fbe 100644 --- a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts +++ b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts @@ -82,6 +82,7 @@ interface IInMeetingActions { supportHDV?: boolean; canShareWhiteBoard?: boolean; enforceVirtualBackground?: boolean; + canPollingAndQA?: boolean; } /** @@ -236,6 +237,7 @@ export default class InMeetingActions implements IInMeetingActions { canShareWhiteBoard = null; + canPollingAndQA = null; /** * Returns all meeting action options * @returns {Object} @@ -314,6 +316,7 @@ export default class InMeetingActions implements IInMeetingActions { supportHQV: this.supportHQV, supportHDV: this.supportHDV, canShareWhiteBoard: this.canShareWhiteBoard, + canPollingAndQA: this.canPollingAndQA, }); /** diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index e255e3f633b..c1d14b2dc48 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -10,6 +10,7 @@ import { ClientEventLeaveReason, CallDiagnosticUtils, CALL_DIAGNOSTIC_CONFIG, + RtcMetrics, } from '@webex/internal-plugin-metrics'; import {ClientEvent as RawClientEvent} from '@webex/event-dictionary-ts'; @@ -24,6 +25,8 @@ import { RoapMessage, StatsAnalyzer, StatsAnalyzerEventNames, + NetworkQualityEventNames, + NetworkQualityMonitor, } from '@webex/internal-media-core'; import { @@ -54,7 +57,6 @@ import { AddMediaFailed, } from '../common/errors/webex-errors'; -import NetworkQualityMonitor from '../networkQualityMonitor'; import LoggerProxy from '../common/logs/logger-proxy'; import EventsUtil from '../common/events/util'; import Trigger from '../common/events/trigger-proxy'; @@ -225,6 +227,7 @@ export type AddMediaOptions = { export type CallStateForMetrics = { correlationId?: string; + sessionCorrelationId?: string; joinTrigger?: string; loginType?: string; }; @@ -536,6 +539,7 @@ export default class Meeting extends StatelessWebexPlugin { id: string; isMultistream: boolean; locusUrl: string; + #isoLocalClientMeetingJoinTime?: string; mediaConnections: any[]; mediaId?: string; meetingFiniteStateMachine: any; @@ -695,6 +699,7 @@ export default class Meeting extends StatelessWebexPlugin { private connectionStateHandler?: ConnectionStateHandler; private iceCandidateErrors: Map; private iceCandidatesCount: number; + private rtcMetrics?: RtcMetrics; /** * @param {Object} attrs @@ -738,12 +743,29 @@ export default class Meeting extends StatelessWebexPlugin { */ this.callStateForMetrics = attrs.callStateForMetrics || {}; const correlationId = attrs.correlationId || attrs.callStateForMetrics?.correlationId; + const sessionCorrelationId = + attrs.sessionCorrelationId || attrs.callStateForMetrics?.sessionCorrelationId; + if (sessionCorrelationId) { + LoggerProxy.logger.log( + `Meetings:index#constructor --> Initializing the meeting object with session correlation id from app ${correlationId}` + ); + this.callStateForMetrics.sessionCorrelationId = sessionCorrelationId; + } else { + LoggerProxy.logger.log( + `Meetings:index#constructor --> No session correlation id supplied. None will be generated and this field will remain blank` + ); + // TODO: supply a session from the meetings instance + this.callStateForMetrics.sessionCorrelationId = ''; + } if (correlationId) { LoggerProxy.logger.log( `Meetings:index#constructor --> Initializing the meeting object with correlation id from app ${correlationId}` ); this.callStateForMetrics.correlationId = correlationId; } else { + LoggerProxy.logger.log( + `Meetings:index#constructor --> Initializing the meeting object with generated correlation id from sdk ${this.id}` + ); this.callStateForMetrics.correlationId = this.id; } /** @@ -1518,6 +1540,17 @@ export default class Meeting extends StatelessWebexPlugin { * @memberof Meeting */ this.iceCandidatesCount = 0; + + /** + * Start time of meeting as an ISO string + * based on browser time, so can only be used to compute durations client side + * undefined if meeting has not been joined, set once on meeting join, and not updated again + * @instance + * @type {string} + * @private + * @memberof Meeting + */ + this.#isoLocalClientMeetingJoinTime = undefined; } /** @@ -1566,6 +1599,31 @@ export default class Meeting extends StatelessWebexPlugin { this.callStateForMetrics.correlationId = correlationId; } + /** + * Getter - Returns callStateForMetrics.sessionCorrelationId + * @returns {string} + */ + get sessionCorrelationId() { + return this.callStateForMetrics.sessionCorrelationId; + } + + /** + * Setter - sets callStateForMetrics.sessionCorrelationId + * @param {string} sessionCorrelationId + */ + set sessionCorrelationId(sessionCorrelationId: string) { + this.callStateForMetrics.sessionCorrelationId = sessionCorrelationId; + } + + /** + * Getter - Returns isoLocalClientMeetingJoinTime + * This will be set once on meeting join, and not updated again + * @returns {string | undefined} + */ + get isoLocalClientMeetingJoinTime(): string | undefined { + return this.#isoLocalClientMeetingJoinTime; + } + /** * Set meeting info and trigger `MEETING_INFO_AVAILABLE` event * @param {any} info @@ -3155,6 +3213,7 @@ export default class Meeting extends StatelessWebexPlugin { options: {meetingId: this.id}, }); } + this.rtcMetrics?.sendNextMetrics(); this.updateLLMConnection(); }); @@ -3767,6 +3826,10 @@ export default class Meeting extends StatelessWebexPlugin { requiredPolicies: [SELF_POLICY.SUPPORT_CHAT], policies: this.selfUserPolicies, }), + canPollingAndQA: ControlsOptionsUtil.hasPolicies({ + requiredPolicies: [SELF_POLICY.SUPPORT_POLLING_AND_QA], + policies: this.selfUserPolicies, + }), canShareApplication: (ControlsOptionsUtil.hasHints({ requiredHints: [DISPLAY_HINTS.SHARE_APPLICATION], @@ -5228,6 +5291,11 @@ export default class Meeting extends StatelessWebexPlugin { this.meetingFiniteStateMachine.join(); this.setupLocusMediaRequest(); + // @ts-ignore + this.webex.internal.device.meetingStarted(); + + this.#isoLocalClientMeetingJoinTime = new Date().toISOString(); + LoggerProxy.logger.log('Meeting:index#join --> Success'); Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.JOIN_SUCCESS, { @@ -6306,14 +6374,17 @@ export default class Meeting extends StatelessWebexPlugin { * @returns {RoapMediaConnection | MultistreamRoapMediaConnection} */ private async createMediaConnection(turnServerInfo, bundlePolicy?: BundlePolicy) { + this.rtcMetrics = this.isMultistream + ? // @ts-ignore + new RtcMetrics(this.webex, this.id, this.correlationId) + : undefined; + const mc = Media.createMediaConnection( this.isMultistream, this.getMediaConnectionDebugId(), - // @ts-ignore - this.webex, this.id, - this.correlationId, { + rtcMetrics: this.rtcMetrics, mediaProperties: this.mediaProperties, remoteQualityLevel: this.mediaProperties.remoteQualityLevel, // @ts-ignore - config coming from registerPlugin @@ -6500,7 +6571,7 @@ export default class Meeting extends StatelessWebexPlugin { }); this.setupStatsAnalyzerEventHandlers(); this.networkQualityMonitor.on( - EVENT_TRIGGERS.NETWORK_QUALITY, + NetworkQualityEventNames.NETWORK_QUALITY, this.sendNetworkQualityEvent.bind(this) ); } @@ -6511,12 +6582,21 @@ export default class Meeting extends StatelessWebexPlugin { * * @private * @static + * @param {boolean} isAudioEnabled + * @param {boolean} isVideoEnabled * @returns {Promise} */ - private static async handleDeviceLogging(): Promise { - try { - const devices = await getDevices(); + private static async handleDeviceLogging(isAudioEnabled, isVideoEnabled): Promise { + try { + let devices = []; + if (isVideoEnabled && isAudioEnabled) { + devices = await getDevices(); + } else if (isVideoEnabled) { + devices = await getDevices(Media.DeviceKind.VIDEO_INPUT); + } else if (isAudioEnabled) { + devices = await getDevices(Media.DeviceKind.AUDIO_INPUT); + } MeetingUtil.handleDeviceLogging(devices); } catch { // getDevices may fail if we don't have browser permissions, that's ok, we still can have a media connection @@ -7009,7 +7089,7 @@ export default class Meeting extends StatelessWebexPlugin { ); if (audioEnabled || videoEnabled) { - await Meeting.handleDeviceLogging(); + await Meeting.handleDeviceLogging(audioEnabled, videoEnabled); } else { LoggerProxy.logger.info(`${LOG_HEADER} device logging not required`); } @@ -7022,6 +7102,7 @@ export default class Meeting extends StatelessWebexPlugin { await this.mediaProperties.getCurrentConnectionInfo(); // @ts-ignore const reachabilityStats = await this.webex.meetings.reachability.getReachabilityMetrics(); + const iceCandidateErrors = Object.fromEntries(this.iceCandidateErrors); Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.ADD_MEDIA_SUCCESS, { correlation_id: this.correlationId, @@ -7033,6 +7114,7 @@ export default class Meeting extends StatelessWebexPlugin { retriedWithTurnServer: this.addMediaData.retriedWithTurnServer, isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry, ...reachabilityStats, + ...iceCandidateErrors, iceCandidatesCount: this.iceCandidatesCount, }); // @ts-ignore @@ -8191,7 +8273,7 @@ export default class Meeting extends StatelessWebexPlugin { * @private * @memberof Meeting */ - private sendNetworkQualityEvent(res: any) { + private sendNetworkQualityEvent(res: {networkQualityScore: number; mediaType: string}) { Trigger.trigger( this, { diff --git a/packages/@webex/plugin-meetings/src/meeting/muteState.ts b/packages/@webex/plugin-meetings/src/meeting/muteState.ts index 1c3ed6ccbc2..e834514e732 100644 --- a/packages/@webex/plugin-meetings/src/meeting/muteState.ts +++ b/packages/@webex/plugin-meetings/src/meeting/muteState.ts @@ -379,7 +379,12 @@ export class MuteState { } if (muted !== undefined) { this.state.server.remoteMute = muted; - this.muteLocalStream(meeting, muted, 'remotelyMuted'); + + // We never want to unmute the local stream from a server remote mute update. + // Moderated unmute is handled by a different function. + if (muted) { + this.muteLocalStream(meeting, muted, 'remotelyMuted'); + } } } diff --git a/packages/@webex/plugin-meetings/src/meeting/util.ts b/packages/@webex/plugin-meetings/src/meeting/util.ts index 40159ddb8a9..a67fce6dd65 100644 --- a/packages/@webex/plugin-meetings/src/meeting/util.ts +++ b/packages/@webex/plugin-meetings/src/meeting/util.ts @@ -170,6 +170,8 @@ const MeetingUtil = { }, cleanUp: (meeting) => { + meeting.getWebexObject().internal.device.meetingEnded(); + meeting.breakouts.cleanUp(); meeting.simultaneousInterpretation.cleanUp(); meeting.locusMediaRequest = undefined; diff --git a/packages/@webex/plugin-meetings/src/meetings/index.ts b/packages/@webex/plugin-meetings/src/meetings/index.ts index 52481a6aee8..a9b79c52364 100644 --- a/packages/@webex/plugin-meetings/src/meetings/index.ts +++ b/packages/@webex/plugin-meetings/src/meetings/index.ts @@ -765,7 +765,7 @@ export default class Meetings extends WebexPlugin { return Promise.all([ this.fetchUserPreferredWebexSite(), this.getGeoHint(), - this.startReachability().catch((error) => { + this.startReachability('registration').catch((error) => { LoggerProxy.logger.error(`Meetings:index#register --> GDM error, ${error.message}`); }), // @ts-ignore @@ -967,12 +967,13 @@ export default class Meetings extends WebexPlugin { /** * initializes and starts gathering reachability for Meetings + * @param {string} trigger - explains the reason for starting reachability * @returns {Promise} * @public * @memberof Meetings */ - startReachability() { - return this.getReachability().gatherReachability(); + startReachability(trigger = 'client') { + return this.getReachability().gatherReachability(trigger); } /** @@ -1080,6 +1081,7 @@ export default class Meetings extends WebexPlugin { * @param {CallStateForMetrics} callStateForMetrics - information about call state for metrics * @param {Object} [meetingInfo] - Pre-fetched complete meeting info * @param {String} [meetingLookupUrl] - meeting info prefetch url + * @param {string} sessionCorrelationId - the optional specified sessionCorrelationId (callStateForMetrics.sessionCorrelationId) can be provided instead * @returns {Promise} A new Meeting. * @public * @memberof Meetings @@ -1093,7 +1095,8 @@ export default class Meetings extends WebexPlugin { failOnMissingMeetingInfo = false, callStateForMetrics: CallStateForMetrics = undefined, meetingInfo = undefined, - meetingLookupUrl = undefined + meetingLookupUrl = undefined, + sessionCorrelationId: string = undefined ) { // Validate meeting information based on the provided destination and // type. This must be performed prior to determining if the meeting is @@ -1104,6 +1107,10 @@ export default class Meetings extends WebexPlugin { callStateForMetrics = {...(callStateForMetrics || {}), correlationId}; } + if (sessionCorrelationId) { + callStateForMetrics = {...(callStateForMetrics || {}), sessionCorrelationId}; + } + return ( this.meetingInfo .fetchInfoOptions(destination, type) diff --git a/packages/@webex/plugin-meetings/src/networkQualityMonitor/index.ts b/packages/@webex/plugin-meetings/src/networkQualityMonitor/index.ts deleted file mode 100644 index ee77feeb153..00000000000 --- a/packages/@webex/plugin-meetings/src/networkQualityMonitor/index.ts +++ /dev/null @@ -1,211 +0,0 @@ -import EventsScope from '../common/events/events-scope'; -import {EVENT_TRIGGERS} from '../constants'; - -/** - * Meeting - network quality event - * Emitted on each interval of retrieving stats Analyzer data - * @event network:quality - * @type {Object} - * @property {string} mediaType {video|audio} - * @property {number} networkQualityScore - value determined in determineUplinkNetworkQuality - * @memberof NetworkQualityMonitor - */ -/** - * NetworkQualityMonitor class that will emit events based on detected quality - * - * @class NetworkQualityMonitor - * @extends {EventsScope} - */ -export default class NetworkQualityMonitor extends EventsScope { - config: any; - frequencyTypes: any; - indicatorTypes: any; - mediaType: any; - networkQualityScore: any; - networkQualityStatus: any; - - /** - * Creates a new instance of NetworkQualityMonitor - * @constructor - * @public - * @param {Object} config - * @property {Object} indicatorTypes - network properties used to evaluate network quality used as constants - * @property {Object} frequencyTypes - frequency properties used as constants {uplink|send} {downlink|receive} - * @property {number} networkQualityScore - 0|1 1 is acceptable 0 is bad/unknown - * @property {Object} networkQualityStatus - hash object based on indicatorTypes and frequencyTypes - * @property {string} mediaType - audio|video - */ - constructor(config: any) { - super(); - this.config = config; - this.indicatorTypes = Object.freeze({ - PACKETLOSS: 'packetLoss', - LATENCY: 'latency', - JITTER: 'jitter', - }); - this.frequencyTypes = Object.freeze({ - UPLINK: 'uplink', - DOWNLINK: 'downlink', - }); - this.networkQualityScore = 1; - this.networkQualityStatus = { - [this.frequencyTypes.UPLINK]: {}, - }; - this.mediaType = null; - } - - /** - * emits NETWORK_QUALITY event on meeting with payload of media type and uplinkNetworkQuality score - * - * @memberof NetworkQualityMonitor - * @returns {void} - */ - emitNetworkQuality() { - this.emit( - { - file: 'networkQualityMonitor', - function: 'emitNetworkQuality', - }, - EVENT_TRIGGERS.NETWORK_QUALITY, - { - mediaType: this.mediaType, - networkQualityScore: this.networkQualityScore, - } - ); - } - - /** - * invokes emitNetworkQuality method resets values back to default - * @returns {void} - * @memberof NetworkQualityMonitor - */ - updateNetworkQualityStatus() { - this.emitNetworkQuality(); - - // reset values - this.networkQualityScore = 1; - this.mediaType = null; - } - - /** - * filter data to determine uplink network quality, invoked on same interval as stats analyzer remote-inbout-rtp - * @param {Object} configObj - * @param {string} configObj.mediaType {audio|video} - * @param {RTCStats} configObj.remoteRtpResults RTC stats remote obj - * @param {Object} configObj.statsAnalyzerCurrentStats statsResults - * @returns {void} - * @public - * @memberof NetworkQualityMonitor - */ - public determineUplinkNetworkQuality({ - mediaType, - remoteRtpResults, - statsAnalyzerCurrentStats, - }: { - mediaType: string; - remoteRtpResults: any; - statsAnalyzerCurrentStats: object; - }) { - const roundTripTimeInMilliseconds = remoteRtpResults.roundTripTime * 1000; - const jitterInMilliseconds = remoteRtpResults.jitter * 1000; - const {currentPacketLossRatio} = statsAnalyzerCurrentStats[mediaType].send; - - this.mediaType = mediaType; - - const {JITTER, PACKETLOSS, LATENCY} = this.indicatorTypes; - const {UPLINK} = this.frequencyTypes; - - /** - * determines if packetLoss ratio is over threshold set in config - * sets networkQualityScore to 0 if over threshold - * @returns {boolean} - */ - const determinePacketLoss = () => { - if (currentPacketLossRatio > this.config.videoPacketLossRatioThreshold) { - this.networkQualityScore = 0; - - return false; - } - - return true; - }; - - /** - * determines if round trip time value is over threshold set in config - * sets networkQualityScore to 0 if over threshold - * @returns {boolean} - */ - const determineLatency = () => { - if (roundTripTimeInMilliseconds > this.config.rttThreshold) { - this.networkQualityScore = 0; - - return false; - } - - return true; - }; - - /** - * determines if jitter value is over threshold in config - * sets networkQualityScore to 0 if over threshold - * @returns {boolean} - */ - const deterMineJitter = () => { - if (jitterInMilliseconds > this.config.jitterThreshold) { - this.networkQualityScore = 0; - - return false; - } - - return true; - }; - - /** - * returns null if val is specifically undefined - * @param {(number|undefined)} value - * @returns {(number|null)} - */ - const determineIfUndefined = (value: number | undefined) => - typeof value === 'undefined' ? null : value; - - if (!this.networkQualityStatus[UPLINK][mediaType]) { - this.networkQualityStatus[UPLINK][mediaType] = {}; - } - - /** - * Values for some browsers specifically Safari will be undefined we explicitly set to null - * https://bugs.webkit.org/show_bug.cgi?id=206645 - * https://bugs.webkit.org/show_bug.cgi?id=212668 - */ - // PACKET LOSS - this.networkQualityStatus[UPLINK][mediaType][PACKETLOSS] = { - acceptable: determinePacketLoss(), - value: determineIfUndefined(currentPacketLossRatio), - }; - - // LATENCY measured in Round trip time - this.networkQualityStatus[UPLINK][mediaType][LATENCY] = { - acceptable: determineLatency(), - value: determineIfUndefined(remoteRtpResults.roundTripTime), - }; - - // JITTER - this.networkQualityStatus[UPLINK][mediaType][JITTER] = { - acceptable: deterMineJitter(), - value: determineIfUndefined(remoteRtpResults.jitter), - }; - - this.updateNetworkQualityStatus(); - } - - /** - * Get the current status of network quaility object - networkQualityStatus - * @returns {Object} - * @public - */ - get networkQualityStats() { - const {UPLINK} = this.frequencyTypes; - - return this.networkQualityStatus[UPLINK]; - } -} diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts index 7f67e44ff74..7b6868f04f0 100644 --- a/packages/@webex/plugin-meetings/src/reachability/index.ts +++ b/packages/@webex/plugin-meetings/src/reachability/index.ts @@ -93,6 +93,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}}; + protected lastTrigger?: string; + /** * Creates an instance of Reachability. * @param {object} webex @@ -114,18 +116,50 @@ export default class Reachability extends EventsScope { this.clusterReachability = {}; } + /** + * Fetches the list of media clusters from the backend + * @param {boolean} isRetry + * @private + * @returns {Promise<{clusters: ClusterList, joinCookie: any}>} + */ + async getClusters(isRetry = false): Promise<{clusters: ClusterList; joinCookie: any}> { + try { + const {clusters, joinCookie} = await this.reachabilityRequest.getClusters( + MeetingUtil.getIpVersion(this.webex) + ); + + return {clusters, joinCookie}; + } catch (error) { + if (isRetry) { + throw error; + } + + LoggerProxy.logger.error( + `Reachability:index#getClusters --> Failed with error: ${error}, retrying...` + ); + + return this.getClusters(true); + } + } + /** * Gets a list of media clusters from the backend and performs reachability checks on all the clusters + * @param {string} trigger - explains the reason for starting reachability * @returns {Promise} reachability results * @public * @memberof Reachability */ - public async gatherReachability(): Promise { + public async gatherReachability(trigger: string): Promise { // Fetch clusters and measure latency try { - const {clusters, joinCookie} = await this.reachabilityRequest.getClusters( - MeetingUtil.getIpVersion(this.webex) - ); + 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 + // @ts-ignore + this.webex.internal.device.ipNetworkDetector.detect(); + + const {clusters, joinCookie} = await this.getClusters(); // @ts-ignore await this.webex.boundedStorage.put( @@ -513,6 +547,17 @@ export default class Reachability extends EventsScope { tcp: this.getStatistics(results, 'tcp', false), xtls: this.getStatistics(results, 'xtls', false), }, + ipver: { + // @ts-ignore + firstIpV4: this.webex.internal.device.ipNetworkDetector.firstIpV4, + // @ts-ignore + firstIpV6: this.webex.internal.device.ipNetworkDetector.firstIpV6, + // @ts-ignore + firstMdns: this.webex.internal.device.ipNetworkDetector.firstMdns, + // @ts-ignore + totalTime: this.webex.internal.device.ipNetworkDetector.totalTime, + }, + trigger: this.lastTrigger, }; Metrics.sendBehavioralMetric( BEHAVIORAL_METRICS.REACHABILITY_COMPLETED, diff --git a/packages/@webex/plugin-meetings/src/reconnection-manager/index.ts b/packages/@webex/plugin-meetings/src/reconnection-manager/index.ts index ea2c47f05f4..55ca4a764c6 100644 --- a/packages/@webex/plugin-meetings/src/reconnection-manager/index.ts +++ b/packages/@webex/plugin-meetings/src/reconnection-manager/index.ts @@ -342,7 +342,7 @@ export default class ReconnectionManager { } try { - await this.webex.meetings.startReachability(); + await this.webex.meetings.startReachability('reconnection'); } catch (err) { LoggerProxy.logger.info( 'ReconnectionManager:index#reconnect --> Reachability failed, continuing with reconnection attempt, err: ', diff --git a/packages/@webex/plugin-meetings/test/integration/spec/converged-space-meetings.js b/packages/@webex/plugin-meetings/test/integration/spec/converged-space-meetings.js index 76981c3c60e..7206ec01b83 100644 --- a/packages/@webex/plugin-meetings/test/integration/spec/converged-space-meetings.js +++ b/packages/@webex/plugin-meetings/test/integration/spec/converged-space-meetings.js @@ -1,5 +1,5 @@ -import { config } from 'dotenv'; import 'jsdom-global/register'; +import {config} from 'dotenv'; import {assert} from '@webex/test-helper-chai'; import {skipInNode} from '@webex/test-helper-mocha'; import BrowserDetection from '@webex/plugin-meetings/dist/common/browser-detection'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/breakouts/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/breakouts/index.ts index bdc6e8da972..ac4c2e18321 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/breakouts/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/breakouts/index.ts @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import {assert, expect} from '@webex/test-helper-chai'; import Breakouts from '@webex/plugin-meetings/src/breakouts'; import LoggerProxy from '@webex/plugin-meetings/src/common/logs/logger-proxy'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/interceptors/locusRetry.ts b/packages/@webex/plugin-meetings/test/unit/spec/interceptors/locusRetry.ts index ac94b6e5c39..c4853cfc187 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/interceptors/locusRetry.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/interceptors/locusRetry.ts @@ -3,6 +3,7 @@ */ /* eslint-disable camelcase */ +import 'jsdom-global/register'; import {assert} from '@webex/test-helper-chai'; import { expect } from "@webex/test-helper-chai"; import MockWebex from '@webex/test-helper-mock-webex'; @@ -13,7 +14,7 @@ import sinon from 'sinon'; describe('plugin-meetings', () => { describe('Interceptors', () => { - describe('LocusRetryStatusInterceptor', () => { + describe('LocusRetryStatusInterceptor', () => { let interceptor, webex; beforeEach(() => { webex = new MockWebex({ @@ -24,7 +25,7 @@ describe('plugin-meetings', () => { interceptor = Reflect.apply(LocusRetryStatusInterceptor.create, { sessionId: 'mock-webex_uuid', }, []); - }); + }); describe('#onResponseError', () => { const options = { method: 'POST', @@ -41,7 +42,7 @@ describe('plugin-meetings', () => { headers: { trackingid: 'test', 'retry-after': 1000, - }, + }, uri: `https://locus-test.webex.com/locus/api/v1/loci/call`, }, body: { @@ -54,7 +55,7 @@ describe('plugin-meetings', () => { headers: { trackingid: 'test', 'retry-after': 1000, - }, + }, uri: `https://locus-test.webex.com/locus/api/v1/loci/call`, }, body: { @@ -73,7 +74,7 @@ describe('plugin-meetings', () => { return interceptor.onResponseError(options, reason2).then(() => { expect(handleRetryStub.calledWith(options, 1000)).to.be.true; - + }); }); }); @@ -92,7 +93,7 @@ describe('plugin-meetings', () => { it('returns the correct resolved value when the request is successful', () => { const mockResponse = 'mock response' interceptor.webex.request = sinon.stub().returns(Promise.resolve(mockResponse)); - + return interceptor.handleRetryRequestLocusServiceError(options, retryAfterTime) .then((response) => { expect(response).to.equal(mockResponse); @@ -101,9 +102,9 @@ describe('plugin-meetings', () => { it('rejects the promise when the request is unsuccessful', () => { const rejectionReason = 'Service Unavaialble after retry'; - + interceptor.webex.request = sinon.stub().returns(Promise.reject(rejectionReason)); - + return interceptor.handleRetryRequestLocusServiceError(options, retryAfterTime) .catch((error) => { expect(error).to.equal(rejectionReason); @@ -114,10 +115,10 @@ describe('plugin-meetings', () => { let clock; clock = sinon.useFakeTimers(); const mockResponse = 'mock response' - + interceptor.webex.request = sinon.stub().returns(Promise.resolve(mockResponse)); const promise = interceptor.handleRetryRequestLocusServiceError(options, retryAfterTime); - + clock.tick(retryAfterTime); return promise.then(() => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js index c0656946711..9ad22a69acb 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js @@ -345,37 +345,39 @@ describe('plugin-meetings', () => { }); describe('mutedByOthersChanged', () => { - it('throws an error if changedSelf is not provided', function() { - assert.throws(() => SelfUtils.mutedByOthersChanged({}, null), 'New self must be defined to determine if self was muted by others.'); - }); - - it('return false when oldSelf is not defined', function() { - assert.equal(SelfUtils.mutedByOthersChanged(null, { remoteMuted: false }), false); + it('throws an error if changedSelf is not provided', function () { + assert.throws( + () => SelfUtils.mutedByOthersChanged({}, null), + 'New self must be defined to determine if self was muted by others.' + ); }); - it('should return true when remoteMuted is true on entry', function() { - assert.equal(SelfUtils.mutedByOthersChanged(null, { remoteMuted: true }), true); + it('return false when oldSelf is not defined', function () { + assert.equal(SelfUtils.mutedByOthersChanged(null, {remoteMuted: false}), false); }); - it('should return false when selfIdentity and modifiedBy are the same', function() { - assert.equal(SelfUtils.mutedByOthersChanged( - { remoteMuted: false }, - { remoteMuted: true, selfIdentity: 'user1', modifiedBy: 'user1' } - ), false); + it('should return true when remoteMuted is true on entry', function () { + assert.equal(SelfUtils.mutedByOthersChanged(null, {remoteMuted: true}), true); }); - it('should return true when remoteMuted values are different', function() { - assert.equal(SelfUtils.mutedByOthersChanged( - { remoteMuted: false }, - { remoteMuted: true, selfIdentity: 'user1', modifiedBy: 'user2' } - ), true); + it('should return true when remoteMuted values are different', function () { + assert.equal( + SelfUtils.mutedByOthersChanged( + {remoteMuted: false}, + {remoteMuted: true, selfIdentity: 'user1', modifiedBy: 'user2'} + ), + true + ); }); - it('should return true when remoteMuted is true and unmuteAllowed has changed', function() { - assert.equal(SelfUtils.mutedByOthersChanged( - { remoteMuted: true, unmuteAllowed: false }, - { remoteMuted: true, unmuteAllowed: true, selfIdentity: 'user1', modifiedBy: 'user2' } - ), true); + it('should return true when remoteMuted is true and unmuteAllowed has changed', function () { + assert.equal( + SelfUtils.mutedByOthersChanged( + {remoteMuted: true, unmuteAllowed: false}, + {remoteMuted: true, unmuteAllowed: true, selfIdentity: 'user1', modifiedBy: 'user2'} + ), + true + ); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/media/MediaConnectionAwaiter.ts b/packages/@webex/plugin-meetings/test/unit/spec/media/MediaConnectionAwaiter.ts index e4e554e9961..7813f7d30f1 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/media/MediaConnectionAwaiter.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/media/MediaConnectionAwaiter.ts @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import {assert} from '@webex/test-helper-chai'; import sinon from 'sinon'; import {ConnectionState, MediaConnectionEventNames} from '@webex/internal-media-core'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts index 4c5e7acea08..8378ebb9131 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts @@ -1,54 +1,53 @@ +import 'jsdom-global/register'; import * as InternalMediaCoreModule from '@webex/internal-media-core'; import Media from '@webex/plugin-meetings/src/media/index'; import {assert} from '@webex/test-helper-chai'; import sinon from 'sinon'; import StaticConfig from '@webex/plugin-meetings/src/common/config'; -import MockWebex from '@webex/test-helper-mock-webex'; describe('createMediaConnection', () => { let clock; beforeEach(() => { clock = sinon.useFakeTimers(); }); - const webex = MockWebex(); const fakeRoapMediaConnection = { id: 'roap media connection', }; const fakeTrack = { - id: 'any fake track' - } + id: 'any fake track', + }; const fakeAudioStream = { outputStream: { getTracks: () => { return [fakeTrack]; - } - } + }, + }, }; const fakeVideoStream = { outputStream: { getTracks: () => { return [fakeTrack]; - } - } + }, + }, }; const fakeShareVideoStream = { outputStream: { getTracks: () => { return [fakeTrack]; - } - } + }, + }, }; const fakeShareAudioStream = { outputStream: { getTracks: () => { return [fakeTrack]; - } - } + }, + }, }; afterEach(() => { sinon.restore(); - clock.uninstall() + clock.uninstall(); }); it('creates a RoapMediaConnection when multistream is disabled', () => { @@ -61,7 +60,7 @@ describe('createMediaConnection', () => { const ENABLE_EXTMAP = false; const ENABLE_RTX = true; - Media.createMediaConnection(false, 'some debug id', webex, 'meetingId', 'correlationId', { + Media.createMediaConnection(false, 'some debug id', 'meetingId', { mediaProperties: { mediaDirection: { sendAudio: false, @@ -80,7 +79,7 @@ describe('createMediaConnection', () => { enableRtx: ENABLE_RTX, enableExtmap: ENABLE_EXTMAP, turnServerInfo: { - url: 'turn server url', + url: 'turns:turn-server-url:443?transport=tcp', username: 'turn username', password: 'turn password', }, @@ -91,7 +90,12 @@ describe('createMediaConnection', () => { { iceServers: [ { - urls: 'turn server url', + urls: 'turn:turn-server-url:5004?transport=tcp', + username: 'turn username', + credential: 'turn password', + }, + { + urls: 'turns:turn-server-url:443?transport=tcp', username: 'turn username', credential: 'turn password', }, @@ -134,7 +138,13 @@ describe('createMediaConnection', () => { .stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection') .returns(fakeRoapMediaConnection); - Media.createMediaConnection(true, 'some debug id', webex, 'meeting id', 'correlationId', { + const rtcMetrics = { + addMetrics: sinon.stub(), + closeMetrics: sinon.stub(), + sendMetricsInQueue: sinon.stub(), + }; + + Media.createMediaConnection(true, 'some debug id', 'meeting id', { mediaProperties: { mediaDirection: { sendAudio: true, @@ -145,8 +155,9 @@ describe('createMediaConnection', () => { receiveShare: true, }, }, + rtcMetrics, turnServerInfo: { - url: 'turn server url', + url: 'turns:turn-server-url:443?transport=tcp', username: 'turn username', password: 'turn password', }, @@ -158,7 +169,12 @@ describe('createMediaConnection', () => { { iceServers: [ { - urls: 'turn server url', + urls: 'turn:turn-server-url:5004?transport=tcp', + username: 'turn username', + credential: 'turn password', + }, + { + urls: 'turns:turn-server-url:443?transport=tcp', username: 'turn username', credential: 'turn password', }, @@ -167,18 +183,42 @@ describe('createMediaConnection', () => { }, 'meeting id' ); + + // check if rtcMetrics callbacks are configured correctly + const addMetricsCallback = multistreamRoapMediaConnectionConstructorStub.getCalls()[0].args[2]; + const closeMetricsCallback = multistreamRoapMediaConnectionConstructorStub.getCalls()[0].args[3]; + const sendMetricsInQueueCallback = multistreamRoapMediaConnectionConstructorStub.getCalls()[0].args[4]; + + assert.isFunction(addMetricsCallback); + assert.isFunction(closeMetricsCallback); + assert.isFunction(sendMetricsInQueueCallback); + + const fakeMetricsData = {id: 'metrics data'}; + + addMetricsCallback(fakeMetricsData); + assert.calledOnceWithExactly(rtcMetrics.addMetrics, fakeMetricsData); + + closeMetricsCallback(); + assert.calledOnce(rtcMetrics.closeMetrics); + + sendMetricsInQueueCallback(); + assert.calledOnce(rtcMetrics.sendMetricsInQueue); + }); [ {testCase: 'turnServerInfo is undefined', turnServerInfo: undefined}, - {testCase: 'turnServerInfo.url is empty string', turnServerInfo: {url: '', username: 'turn username', password: 'turn password'}}, + { + testCase: 'turnServerInfo.url is empty string', + turnServerInfo: {url: '', username: 'turn username', password: 'turn password'}, + }, ].forEach(({testCase, turnServerInfo}) => { it(`passes empty ICE servers array to MultistreamRoapMediaConnection if ${testCase} (multistream enabled)`, () => { const multistreamRoapMediaConnectionConstructorStub = sinon .stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection') .returns(fakeRoapMediaConnection); - Media.createMediaConnection(true, 'debug string', webex, 'meeting id', 'correlationId', { + Media.createMediaConnection(true, 'debug string', 'meeting id', { mediaProperties: { mediaDirection: { sendAudio: true, @@ -198,7 +238,7 @@ describe('createMediaConnection', () => { iceServers: [], }, 'meeting id' - ); + ); }); }); @@ -207,7 +247,7 @@ describe('createMediaConnection', () => { .stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection') .returns(fakeRoapMediaConnection); - Media.createMediaConnection(true, 'debug string', webex, 'meeting id', 'correlationId', { + Media.createMediaConnection(true, 'debug string', 'meeting id', { mediaProperties: { mediaDirection: { sendAudio: true, @@ -232,7 +272,10 @@ describe('createMediaConnection', () => { [ {testCase: 'turnServerInfo is undefined', turnServerInfo: undefined}, - {testCase: 'turnServerInfo.url is empty string', turnServerInfo: {url: '', username: 'turn username', password: 'turn password'}}, + { + testCase: 'turnServerInfo.url is empty string', + turnServerInfo: {url: '', username: 'turn username', password: 'turn password'}, + }, ].forEach(({testCase, turnServerInfo}) => { it(`passes empty ICE servers array to RoapMediaConnection if ${testCase} (multistream disabled)`, () => { const roapMediaConnectionConstructorStub = sinon @@ -244,7 +287,7 @@ describe('createMediaConnection', () => { const ENABLE_EXTMAP = false; const ENABLE_RTX = true; - Media.createMediaConnection(false, 'some debug id', webex, 'meeting id', 'correlationId', { + Media.createMediaConnection(false, 'some debug id', 'meeting id', { mediaProperties: { mediaDirection: { sendAudio: true, diff --git a/packages/@webex/plugin-meetings/test/unit/spec/media/properties.ts b/packages/@webex/plugin-meetings/test/unit/spec/media/properties.ts index f96118d90db..1507ec8708e 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/media/properties.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/media/properties.ts @@ -1,8 +1,8 @@ +import 'jsdom-global/register'; import {assert} from '@webex/test-helper-chai'; import sinon from 'sinon'; import {ConnectionState} from '@webex/internal-media-core'; import MediaProperties from '@webex/plugin-meetings/src/media/properties'; -import testUtils from '../../../utils/testUtils'; import {Defer} from '@webex/common'; import MediaConnectionAwaiter from '../../../../src/media/MediaConnectionAwaiter'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting-info/meetinginfov2.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting-info/meetinginfov2.js index df94e5be38d..6beb9ae9ffe 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting-info/meetinginfov2.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting-info/meetinginfov2.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. */ - +import 'jsdom-global/register'; import {assert} from '@webex/test-helper-chai'; import sinon from 'sinon'; import MockWebex from '@webex/test-helper-mock-webex'; @@ -23,7 +23,6 @@ import MeetingInfoUtil from '@webex/plugin-meetings/src/meeting-info/utilv2'; import Metrics from '@webex/plugin-meetings/src/metrics'; import BEHAVIORAL_METRICS from '@webex/plugin-meetings/src/metrics/constants'; import {forEach} from 'lodash'; -import { request } from 'express'; describe('plugin-meetings', () => { const conversation = { @@ -433,7 +432,7 @@ describe('plugin-meetings', () => { assert.deepEqual(submitInternalEventCalls[1].args[0], { name: 'internal.client.meetinginfo.response', }); - + assert.deepEqual(submitClientEventCalls[1].args[0], { name: 'client.meetinginfo.response', payload: { @@ -484,9 +483,9 @@ describe('plugin-meetings', () => { requestResponse.body.confIdStr = confIdStr; } const extraParams = {mtid: 'm9fe0afd8c435e892afcce9ea25b97046', joinTXId: 'TSmrX61wNF'} - + webex.request.resolves(requestResponse); - + const result = await meetingInfo.fetchMeetingInfo( '1234323', DESTINATION_TYPE.MEETING_ID, @@ -497,7 +496,7 @@ describe('plugin-meetings', () => { extraParams, {meetingId, sendCAevents} ); - + assert.calledWith(webex.request, { method: 'POST', service: WBXAPPAPI_SERVICE, @@ -515,7 +514,7 @@ describe('plugin-meetings', () => { Metrics.sendBehavioralMetric, BEHAVIORAL_METRICS.FETCH_MEETING_INFO_V1_SUCCESS ); - + const submitInternalEventCalls = webex.internal.newMetrics.submitInternalEvent.getCalls(); const submitClientEventCalls = webex.internal.newMetrics.submitClientEvent.getCalls(); @@ -529,7 +528,7 @@ describe('plugin-meetings', () => { meetingId, } }); - + assert.deepEqual(submitInternalEventCalls[1].args[0], { name: 'internal.client.meetinginfo.response', }); @@ -591,7 +590,7 @@ describe('plugin-meetings', () => { const submitInternalEventCalls = webex.internal.newMetrics.submitInternalEvent.getCalls(); const submitClientEventCalls = webex.internal.newMetrics.submitClientEvent.getCalls(); - + assert.deepEqual(submitInternalEventCalls[0].args[0], { name: 'internal.client.meetinginfo.request', }); @@ -601,7 +600,7 @@ describe('plugin-meetings', () => { meetingId: 'meetingId', } }); - + assert.deepEqual(submitInternalEventCalls[1].args[0], { name: 'internal.client.meetinginfo.response', }); @@ -629,7 +628,7 @@ describe('plugin-meetings', () => { it(`should not send CA metric if meetingId is not provided disregarding if sendCAevents is ${sendCAevents}`, async () => { const message = 'a message'; const meetingInfoData = 'meeting info'; - + webex.request = sinon.stub().rejects({ statusCode: 403, body: {message, code: 403102, data: {meetingInfo: meetingInfoData}}, diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting-info/request.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting-info/request.js index 3d2d2bd7bc3..f3dd2e0c384 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting-info/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting-info/request.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. */ - +import 'jsdom-global/register'; import {assert} from '@webex/test-helper-chai'; import sinon from 'sinon'; import MockWebex from '@webex/test-helper-mock-webex'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/connectionStateHandler.ts b/packages/@webex/plugin-meetings/test/unit/spec/meeting/connectionStateHandler.ts index a4f0460be35..b0278e370ad 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/connectionStateHandler.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/connectionStateHandler.ts @@ -1,3 +1,4 @@ +import 'jsdom-global/register' import sinon from 'sinon'; import {assert} from '@webex/test-helper-chai'; import { 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 1ae094cdb0c..9fe27480635 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 @@ -78,6 +78,7 @@ describe('plugin-meetings', () => { supportHDV: null, canShareWhiteBoard: null, enforceVirtualBackground: null, + canPollingAndQA: null, ...expected, }; @@ -161,6 +162,7 @@ describe('plugin-meetings', () => { 'supportHDV', 'canShareWhiteBoard', 'enforceVirtualBackground', + 'canPollingAndQA', ].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 24e352c815f..f9bc7b813dc 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -5,6 +5,7 @@ import 'jsdom-global/register'; import {cloneDeep, forEach, isEqual, isUndefined} from 'lodash'; import sinon from 'sinon'; import * as InternalMediaCoreModule from '@webex/internal-media-core'; +import * as RemoteMediaManagerModule from '@webex/plugin-meetings/src/multistream/remoteMediaManager'; import StateMachine from 'javascript-state-machine'; import uuid from 'uuid'; import {assert, expect} from '@webex/test-helper-chai'; @@ -305,7 +306,7 @@ describe('plugin-meetings', () => { assert.equal(meeting.resource, uuid2); assert.equal(meeting.deviceUrl, uuid3); assert.equal(meeting.correlationId, correlationId); - assert.deepEqual(meeting.callStateForMetrics, {correlationId}); + assert.deepEqual(meeting.callStateForMetrics, {correlationId, sessionCorrelationId: ''}); assert.deepEqual(meeting.meetingInfo, {}); assert.instanceOf(meeting.members, Members); assert.calledOnceWithExactly( @@ -329,6 +330,7 @@ describe('plugin-meetings', () => { assert.isNull(meeting.partner); assert.isNull(meeting.type); assert.isNull(meeting.owner); + assert.isUndefined(meeting.isoLocalClientMeetingJoinTime); assert.isNull(meeting.hostId); assert.isNull(meeting.policy); assert.instanceOf(meeting.meetingRequest, MeetingRequest); @@ -373,7 +375,7 @@ describe('plugin-meetings', () => { } ); assert.equal(newMeeting.correlationId, newMeeting.id); - assert.deepEqual(newMeeting.callStateForMetrics, {correlationId: newMeeting.id}); + assert.deepEqual(newMeeting.callStateForMetrics, {correlationId: newMeeting.id, sessionCorrelationId: ''}); }); it('correlationId can be provided in callStateForMetrics', () => { @@ -400,6 +402,37 @@ describe('plugin-meetings', () => { correlationId: uuid4, joinTrigger: 'fake-join-trigger', loginType: 'fake-login-type', + sessionCorrelationId: '', + }); + }); + + it('sessionCorrelationId can be provided in callStateForMetrics', () => { + const newMeeting = new Meeting( + { + userId: uuid1, + resource: uuid2, + deviceUrl: uuid3, + locus: {url: url1}, + destination: testDestination, + destinationType: DESTINATION_TYPE.MEETING_ID, + callStateForMetrics: { + correlationId: uuid4, + sessionCorrelationId: uuid1, + joinTrigger: 'fake-join-trigger', + loginType: 'fake-login-type', + }, + }, + { + parent: webex, + } + ); + assert.exists(newMeeting.sessionCorrelationId); + assert.equal(newMeeting.sessionCorrelationId, uuid1); + assert.deepEqual(newMeeting.callStateForMetrics, { + correlationId: uuid4, + sessionCorrelationId: uuid1, + joinTrigger: 'fake-join-trigger', + loginType: 'fake-login-type', }); }); @@ -625,7 +658,9 @@ describe('plugin-meetings', () => { beforeEach(() => { meeting.join = sinon.stub().returns(Promise.resolve(fakeJoinResult)); - addMediaInternalStub = sinon.stub(meeting, 'addMediaInternal').returns(Promise.resolve(test4)); + addMediaInternalStub = sinon + .stub(meeting, 'addMediaInternal') + .returns(Promise.resolve(test4)); webex.meetings.reachability.getReachabilityResults.resolves(fakeReachabilityResults); @@ -656,12 +691,21 @@ describe('plugin-meetings', () => { meeting, fakeJoinResult ); - assert.calledOnceWithExactly(meeting.addMediaInternal, sinon.match.any, fakeTurnServerInfo, false, mediaOptions); + assert.calledOnceWithExactly( + meeting.addMediaInternal, + sinon.match.any, + fakeTurnServerInfo, + false, + mediaOptions + ); assert.deepEqual(result, {join: fakeJoinResult, media: test4}); // resets joinWithMediaRetryInfo - assert.deepEqual(meeting.joinWithMediaRetryInfo, {isRetry: false, prevJoinResponse: undefined}); + assert.deepEqual(meeting.joinWithMediaRetryInfo, { + isRetry: false, + prevJoinResponse: undefined, + }); }); it("should not call handleTurnDiscoveryHttpResponse if we don't send a TURN discovery request with join", async () => { @@ -681,7 +725,13 @@ describe('plugin-meetings', () => { assert.calledOnceWithExactly(generateTurnDiscoveryRequestMessageStub, meeting, true); assert.notCalled(handleTurnDiscoveryHttpResponseStub); assert.notCalled(abortTurnDiscoveryStub); - assert.calledOnceWithExactly(meeting.addMediaInternal, sinon.match.any, undefined, false, mediaOptions); + assert.calledOnceWithExactly( + meeting.addMediaInternal, + sinon.match.any, + undefined, + false, + mediaOptions + ); assert.deepEqual(result, {join: fakeJoinResult, media: test4}); assert.equal(meeting.turnServerUsed, false); @@ -711,7 +761,13 @@ describe('plugin-meetings', () => { fakeJoinResult ); assert.calledOnceWithExactly(abortTurnDiscoveryStub); - assert.calledOnceWithExactly(meeting.addMediaInternal, sinon.match.any, undefined, false, mediaOptions); + assert.calledOnceWithExactly( + meeting.addMediaInternal, + sinon.match.any, + undefined, + false, + mediaOptions + ); assert.deepEqual(result, {join: fakeJoinResult, media: test4}); }); @@ -758,12 +814,20 @@ describe('plugin-meetings', () => { ); // resets joinWithMediaRetryInfo - assert.deepEqual(meeting.joinWithMediaRetryInfo, {isRetry: false, prevJoinResponse: undefined}); + assert.deepEqual(meeting.joinWithMediaRetryInfo, { + isRetry: false, + prevJoinResponse: undefined, + }); }); it('should resolve if join() fails the first time but succeeds the second time', async () => { const error = new Error('fake'); - meeting.join = sinon.stub().onFirstCall().returns(Promise.reject(error)).onSecondCall().returns(Promise.resolve(fakeJoinResult)); + meeting.join = sinon + .stub() + .onFirstCall() + .returns(Promise.reject(error)) + .onSecondCall() + .returns(Promise.resolve(fakeJoinResult)); const leaveStub = sinon.stub(meeting, 'leave').resolves(); const result = await meeting.joinWithMedia({ @@ -795,7 +859,10 @@ describe('plugin-meetings', () => { assert.deepEqual(result, {join: fakeJoinResult, media: test4}); // resets joinWithMediaRetryInfo - assert.deepEqual(meeting.joinWithMediaRetryInfo, {isRetry: false, prevJoinResponse: undefined}); + assert.deepEqual(meeting.joinWithMediaRetryInfo, { + isRetry: false, + prevJoinResponse: undefined, + }); }); it('should fail if called with allowMediaInLobby:false', async () => { @@ -828,7 +895,6 @@ describe('plugin-meetings', () => { reason: 'joinWithMedia failure', }); - // Behavioral metric is sent on both calls of joinWithMedia assert.calledTwice(Metrics.sendBehavioralMetric); assert.calledWith( @@ -1068,12 +1134,15 @@ describe('plugin-meetings', () => { const addMediaError = new Error('fake addMedia error'); addMediaError.name = 'SdpOfferCreationError'; - meeting.addMediaInternal.rejects(addMediaError) + meeting.addMediaInternal.rejects(addMediaError); - await assert.isRejected(meeting.joinWithMedia({ - joinOptions, - mediaOptions, - }), addMediaError); + await assert.isRejected( + meeting.joinWithMedia({ + joinOptions, + mediaOptions, + }), + addMediaError + ); // check that only 1 attempt was done assert.calledOnce(meeting.join); @@ -1339,10 +1408,7 @@ describe('plugin-meetings', () => { it('should trigger meeting:caption-received event', () => { meeting.voiceaListenerCallbacks[VOICEAEVENTS.NEW_CAPTION]({}); - assert.calledWith( - meeting.trigger, - EVENT_TRIGGERS.MEETING_CAPTION_RECEIVED - ); + assert.calledWith(meeting.trigger, EVENT_TRIGGERS.MEETING_CAPTION_RECEIVED); }); it('should trigger meeting:receiveTranscription:started event', () => { @@ -1355,10 +1421,7 @@ describe('plugin-meetings', () => { it('should trigger meeting:caption-received event', () => { meeting.voiceaListenerCallbacks[VOICEAEVENTS.NEW_CAPTION]({}); - assert.calledWith( - meeting.trigger, - EVENT_TRIGGERS.MEETING_CAPTION_RECEIVED - ); + assert.calledWith(meeting.trigger, EVENT_TRIGGERS.MEETING_CAPTION_RECEIVED); }); }); @@ -1513,11 +1576,7 @@ describe('plugin-meetings', () => { it('turns off llm online, emits transcription connected events', () => { meeting.handleLLMOnline(); - assert.calledOnceWithExactly( - webex.internal.llm.off, - 'online', - meeting.handleLLMOnline - ); + assert.calledOnceWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline); assert.calledWith( TriggerProxy.trigger, sinon.match.instanceOf(Meeting), @@ -1559,6 +1618,10 @@ describe('plugin-meetings', () => { sandbox.stub(MeetingUtil, 'joinMeeting').returns(Promise.resolve(joinMeetingResult)); }); + afterEach(() => { + assert.exists(meeting.isoLocalClientMeetingJoinTime); + }); + it('should join the meeting and return promise', async () => { const join = meeting.join({pstnAudioType: 'dial-in'}); meeting.config.enableAutomaticLLM = true; @@ -1577,13 +1640,10 @@ describe('plugin-meetings', () => { const result = await join; assert.calledOnce(MeetingUtil.joinMeeting); + assert.calledOnce(webex.internal.device.meetingStarted); assert.calledOnce(meeting.setLocus); assert.equal(result, joinMeetingResult); - assert.calledWith( - webex.internal.llm.on, - 'online', - meeting.handleLLMOnline - ); + assert.calledWith(webex.internal.llm.on, 'online', meeting.handleLLMOnline); }); [true, false].forEach((enableMultistream) => { @@ -1906,7 +1966,9 @@ describe('plugin-meetings', () => { }; meeting.mediaProperties.setMediaDirection = sinon.stub().returns(true); meeting.mediaProperties.waitForMediaConnectionConnected = sinon.stub().resolves(); - meeting.mediaProperties.getCurrentConnectionInfo = sinon.stub().resolves({connectionType: 'udp', selectedCandidatePairChanges: 2, numTransports: 1}); + meeting.mediaProperties.getCurrentConnectionInfo = sinon + .stub() + .resolves({connectionType: 'udp', selectedCandidatePairChanges: 2, numTransports: 1}); meeting.audio = muteStateStub; meeting.video = muteStateStub; sinon.stub(Media, 'createMediaConnection').returns(fakeMediaConnection); @@ -1920,7 +1982,12 @@ describe('plugin-meetings', () => { // normally the first Roap message we send is creating confluence, so mock LocusMediaRequest.isConfluenceCreated() // to return false the first time it's called and true the 2nd time, to simulate how it would happen for real meeting.locusMediaRequest = { - isConfluenceCreated: sinon.stub().onFirstCall().returns(false).onSecondCall().returns(true) + isConfluenceCreated: sinon + .stub() + .onFirstCall() + .returns(false) + .onSecondCall() + .returns(true), }; }); @@ -2376,9 +2443,7 @@ describe('plugin-meetings', () => { Media.createMediaConnection, false, meeting.getMediaConnectionDebugId(), - webex, meeting.id, - meeting.correlationId, sinon.match({turnServerInfo: undefined}) ); assert.calledOnce(meeting.setMercuryListener); @@ -2420,6 +2485,42 @@ describe('plugin-meetings', () => { checkWorking({allowMediaInLobby: true}); }); + it('should create rtcMetrics and pass them to Media.createMediaConnection()', async () => { + const setIntervalOriginal = window.setInterval; + window.setInterval = sinon.stub().returns(1); + + // setup the minimum mocks required for multistream connection + fakeMediaConnection.createSendSlot = sinon.stub().returns({ + publishStream: sinon.stub(), + unpublishStream: sinon.stub(), + setNamedMediaGroups: sinon.stub(), + }); + sinon.stub(RemoteMediaManagerModule, 'RemoteMediaManager').returns({ + start: sinon.stub().resolves(), + on: sinon.stub(), + logAllReceiveSlots: sinon.stub(), + }); + + meeting.meetingState = 'ACTIVE'; + meeting.isMultistream = true; + + await meeting.addMedia({ + mediaSettings: {}, + }); + + // check that rtcMetrics was passed to Media.createMediaConnection + assert.calledOnce(Media.createMediaConnection); + assert.calledWith( + Media.createMediaConnection, + true, + meeting.getMediaConnectionDebugId(), + meeting.id, + sinon.match.hasNested('rtcMetrics.webex', webex) + ); + + window.setInterval = setIntervalOriginal; + }); + it('should pass the turn server info to the peer connection', async () => { const FAKE_TURN_URL = 'turns:webex.com:3478'; const FAKE_TURN_USER = 'some-turn-username'; @@ -2449,9 +2550,7 @@ describe('plugin-meetings', () => { Media.createMediaConnection, false, meeting.getMediaConnectionDebugId(), - webex, meeting.id, - meeting.correlationId, sinon.match({ turnServerInfo: { url: FAKE_TURN_URL, @@ -2485,7 +2584,6 @@ describe('plugin-meetings', () => { it('should reject if waitForMediaConnectionConnected() rejects after turn server retry', async () => { const FAKE_ERROR = {fatal: true}; const getErrorPayloadForClientErrorCodeStub = - (webex.internal.newMetrics.callDiagnosticMetrics.getErrorPayloadForClientErrorCode = sinon.stub().returns(FAKE_ERROR)); webex.meetings.reachability = { @@ -2690,9 +2788,16 @@ describe('plugin-meetings', () => { it('should resolve if waitForMediaConnectionConnected() rejects the first time but resolves the second time', async () => { const FAKE_ERROR = {fatal: true}; webex.meetings.reachability = { - isWebexMediaBackendUnreachable: sinon.stub().onCall(0).rejects().onCall(1).resolves(true).onCall(2).resolves(false), + isWebexMediaBackendUnreachable: sinon + .stub() + .onCall(0) + .rejects() + .onCall(1) + .resolves(true) + .onCall(2) + .resolves(false), getReachabilityMetrics: sinon.stub().resolves({}), - } + }; const getErrorPayloadForClientErrorCodeStub = (webex.internal.newMetrics.callDiagnosticMetrics.getErrorPayloadForClientErrorCode = sinon.stub().returns(FAKE_ERROR)); @@ -2988,6 +3093,8 @@ describe('plugin-meetings', () => { }), }; meeting.iceCandidatesCount = 3; + meeting.iceCandidateErrors.set('701_error', 3); + meeting.iceCandidateErrors.set('701_turn_host_lookup_received_error', 1); await meeting.addMedia({ mediaSettings: {}, @@ -3009,6 +3116,8 @@ describe('plugin-meetings', () => { someReachabilityMetric1: 'some value1', someReachabilityMetric2: 'some value2', iceCandidatesCount: 3, + '701_error': 3, + '701_turn_host_lookup_received_error': 1, } ); @@ -3362,9 +3471,7 @@ describe('plugin-meetings', () => { Media.createMediaConnection, false, meeting.getMediaConnectionDebugId(), - webex, meeting.id, - meeting.correlationId, sinon.match({ turnServerInfo: { url: FAKE_TURN_URL, @@ -3445,9 +3552,9 @@ describe('plugin-meetings', () => { .returns(clientErrorCode); meeting.meetingState = 'ACTIVE'; - meeting.mediaProperties.waitForMediaConnectionConnected.rejects( - {iceConnected: false} - ); + meeting.mediaProperties.waitForMediaConnectionConnected.rejects({ + iceConnected: false, + }); let errorThrown = false; @@ -3562,12 +3669,18 @@ describe('plugin-meetings', () => { meeting.meetingState = 'ACTIVE'; meeting.selfUrl = 'selfUrl'; meeting.mediaProperties.waitForMediaConnectionConnected = sinon.stub().resolves(); - meeting.mediaProperties.getCurrentConnectionInfo = sinon.stub().resolves({connectionType: 'udp', selectedCandidatePairChanges: 2, numTransports: 1}); + meeting.mediaProperties.getCurrentConnectionInfo = sinon + .stub() + .resolves({connectionType: 'udp', selectedCandidatePairChanges: 2, numTransports: 1}); meeting.setMercuryListener = sinon.stub(); meeting.locusInfo.onFullLocus = sinon.stub(); meeting.webex.meetings.geoHintInfo = {regionCode: 'EU', countryCode: 'UK'}; meeting.roap.doTurnDiscovery = sinon.stub().resolves({ - turnServerInfo: {url: 'turn-url', username: 'turn user', password: 'turn password'}, + turnServerInfo: { + url: 'turns:turn-server-url:443?transport=tcp', + username: 'turn user', + password: 'turn password', + }, turnDiscoverySkippedReason: 'reachability', }); meeting.deferSDPAnswer = new Defer(); @@ -3580,7 +3693,18 @@ describe('plugin-meetings', () => { // setup things that are expected to be the same across all the tests and are actually irrelevant for these tests expectedDebugId = `MC-${meeting.id.substring(0, 4)}`; expectedMediaConnectionConfig = { - iceServers: [{urls: 'turn-url', username: 'turn user', credential: 'turn password'}], + iceServers: [ + { + urls: 'turn:turn-server-url:5004?transport=tcp', + username: 'turn user', + credential: 'turn password', + }, + { + urls: 'turns:turn-server-url:443?transport=tcp', + username: 'turn user', + credential: 'turn password', + }, + ], skipInactiveTransceivers: false, requireH264: true, sdpMunging: { @@ -3665,13 +3789,11 @@ describe('plugin-meetings', () => { // that's being tested in these tests) meeting.webex.meetings.registered = true; meeting.webex.internal.device.config = {}; - sinon - .stub(MeetingUtil, 'joinMeeting') - .resolves({ - id: 'fake locus from mocked join request', - locusUrl: 'fake locus url', - mediaId: 'fake media id', - }); + sinon.stub(MeetingUtil, 'joinMeeting').resolves({ + id: 'fake locus from mocked join request', + locusUrl: 'fake locus url', + mediaId: 'fake media id', + }); await meeting.join({enableMultistream: isMultistream}); }); @@ -3701,7 +3823,8 @@ describe('plugin-meetings', () => { for (let idx = 0; idx < roapMediaConnectionToCheck.on.callCount; idx += 1) { if ( - roapMediaConnectionToCheck.on.getCall(idx).args[0] === MediaConnectionEventNames.ROAP_MESSAGE_TO_SEND + roapMediaConnectionToCheck.on.getCall(idx).args[0] === + MediaConnectionEventNames.ROAP_MESSAGE_TO_SEND ) { return roapMediaConnectionToCheck.on.getCall(idx).args[1]; } @@ -4148,13 +4271,13 @@ describe('plugin-meetings', () => { await meeting.addMedia({ localStreams: {microphone: fakeMicrophoneStream}, audioEnabled: false, - videoEnabled: false + videoEnabled: false, }); await simulateRoapOffer(); await simulateRoapOk(); assert.notCalled(handleDeviceLoggingSpy); - }) + }); it('addMedia() works correctly when media is disabled with no streams to publish', async () => { await meeting.addMedia({audioEnabled: false}); @@ -4189,6 +4312,20 @@ describe('plugin-meetings', () => { assert.calledTwice(locusMediaRequestStub); }); + it('addMedia() works correctly when media is disabled with no streams to publish', async () => { + 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); + }); + + 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); + }); + it('addMedia() works correctly when video is disabled with no streams to publish', async () => { await meeting.addMedia({videoEnabled: false}); await simulateRoapOffer(); @@ -4255,6 +4392,14 @@ 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); + }); + describe('publishStreams()/unpublishStreams() calls', () => { [ {mediaEnabled: true, expected: {direction: 'sendrecv', localMuteSentValue: false}}, @@ -6589,14 +6734,14 @@ describe('plugin-meetings', () => { beforeEach(() => { sandbox = sinon.createSandbox(); meeting.statsAnalyzer = { - stopAnalyzer: sinon.stub().returns(Promise.resolve()) + stopAnalyzer: sinon.stub().returns(Promise.resolve()), }; meeting.reconnectionManager = { - cleanUp: sinon.stub() + cleanUp: sinon.stub(), }; - meeting.cleanupLocalStreams=sinon.stub(); + meeting.cleanupLocalStreams = sinon.stub(); meeting.closeRemoteStreams = sinon.stub().returns(Promise.resolve()); meeting.closePeerConnections = sinon.stub().returns(Promise.resolve()); meeting.unsetRemoteStreams = sinon.stub(); @@ -6678,7 +6823,6 @@ describe('plugin-meetings', () => { 'SELF_OBSERVING' ); - // Verify that the event handler behaves as expected expect(meeting.statsAnalyzer.stopAnalyzer.calledOnce).to.be.true; expect(meeting.closeRemoteStreams.calledOnce).to.be.true; @@ -6690,11 +6834,13 @@ describe('plugin-meetings', () => { expect(meeting.unsetPeerConnections.calledOnce).to.be.true; expect(meeting.reconnectionManager.cleanUp.calledOnce).to.be.true; expect(meeting.mediaProperties.setMediaDirection.calledOnce).to.be.true; - expect(meeting.addMedia.calledOnceWithExactly({ - audioEnabled: false, - videoEnabled: false, - shareVideoEnabled: true - })).to.be.true; + expect( + meeting.addMedia.calledOnceWithExactly({ + audioEnabled: false, + videoEnabled: false, + shareVideoEnabled: true, + }) + ).to.be.true; await testUtils.flushPromises(); assert.equal(meeting.isMoveToInProgress, false); }); @@ -6809,33 +6955,36 @@ describe('plugin-meetings', () => { describe('#setCorrelationId', () => { it('should set the correlationId and return undefined', () => { assert.equal(meeting.correlationId, correlationId); - assert.deepEqual(meeting.callStateForMetrics, {correlationId}); + assert.deepEqual(meeting.callStateForMetrics, {correlationId, sessionCorrelationId: ''}); meeting.setCorrelationId(uuid1); assert.equal(meeting.correlationId, uuid1); - assert.deepEqual(meeting.callStateForMetrics, {correlationId: uuid1}); + assert.deepEqual(meeting.callStateForMetrics, {correlationId: uuid1, sessionCorrelationId: ''}); }); }); describe('#updateCallStateForMetrics', () => { it('should update the callState, overriding existing values', () => { - assert.deepEqual(meeting.callStateForMetrics, {correlationId}); + assert.deepEqual(meeting.callStateForMetrics, {correlationId, sessionCorrelationId: ''}); meeting.updateCallStateForMetrics({ correlationId: uuid1, + sessionCorrelationId: uuid3, joinTrigger: 'jt', loginType: 'lt', }); assert.deepEqual(meeting.callStateForMetrics, { correlationId: uuid1, + sessionCorrelationId: uuid3, joinTrigger: 'jt', loginType: 'lt', }); }); it('should update the callState, keeping non-supplied values', () => { - assert.deepEqual(meeting.callStateForMetrics, {correlationId}); + assert.deepEqual(meeting.callStateForMetrics, {correlationId, sessionCorrelationId: ''}); meeting.updateCallStateForMetrics({joinTrigger: 'jt', loginType: 'lt'}); assert.deepEqual(meeting.callStateForMetrics, { correlationId, + sessionCorrelationId: '', joinTrigger: 'jt', loginType: 'lt', }); @@ -7482,9 +7631,11 @@ describe('plugin-meetings', () => { getTracks: () => [{id: 'track', addEventListener: sinon.stub()}], }; const simulateConnectionStateChange = (newState) => { - meeting.mediaProperties.webrtcMediaConnection.getConnectionState = sinon.stub().returns(newState); + meeting.mediaProperties.webrtcMediaConnection.getConnectionState = sinon + .stub() + .returns(newState); eventListeners[MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED](); - } + }; beforeEach(() => { eventListeners = {}; @@ -7506,7 +7657,9 @@ describe('plugin-meetings', () => { assert.isFunction(eventListeners[MediaConnectionEventNames.ROAP_FAILURE]); assert.isFunction(eventListeners[MediaConnectionEventNames.ROAP_MESSAGE_TO_SEND]); assert.isFunction(eventListeners[MediaConnectionEventNames.REMOTE_TRACK_ADDED]); - assert.isFunction(eventListeners[MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED]); + assert.isFunction( + eventListeners[MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED] + ); assert.isFunction(eventListeners[MediaConnectionEventNames.ICE_CONNECTION_STATE_CHANGED]); assert.isFunction(eventListeners[MediaConnectionEventNames.ICE_CANDIDATE]); assert.isFunction(eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]); @@ -7569,28 +7722,44 @@ describe('plugin-meetings', () => { }); it('should not collect skipped ice candidates error', () => { - eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({error: { errorCode: 600, errorText: 'Address not associated with the desired network interface.' }}); + eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({ + error: { + errorCode: 600, + errorText: 'Address not associated with the desired network interface.', + }, + }); assert.equal(meeting.iceCandidateErrors.size, 0); }); it('should collect valid ice candidates error', () => { - eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({error: { errorCode: 701, errorText: '' }}); + eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({ + error: {errorCode: 701, errorText: ''}, + }); assert.equal(meeting.iceCandidateErrors.size, 1); assert.equal(meeting.iceCandidateErrors.has('701_'), true); }); it('should increment counter if same valid ice candidates error collected', () => { - eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({error: { errorCode: 701, errorText: '' }}); + eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({ + error: {errorCode: 701, errorText: ''}, + }); - eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({error: { errorCode: 701, errorText: 'STUN host lookup received error.' }}); - eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({error: { errorCode: 701, errorText: 'STUN host lookup received error.' }}); + eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({ + error: {errorCode: 701, errorText: 'STUN host lookup received error.'}, + }); + eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({ + error: {errorCode: 701, errorText: 'STUN host lookup received error.'}, + }); assert.equal(meeting.iceCandidateErrors.size, 2); assert.equal(meeting.iceCandidateErrors.has('701_'), true); assert.equal(meeting.iceCandidateErrors.get('701_'), 1); - assert.equal(meeting.iceCandidateErrors.has('701_stun_host_lookup_received_error'), true); + assert.equal( + meeting.iceCandidateErrors.has('701_stun_host_lookup_received_error'), + true + ); assert.equal(meeting.iceCandidateErrors.get('701_stun_host_lookup_received_error'), 2); }); }); @@ -8263,8 +8432,12 @@ describe('plugin-meetings', () => { }); it('registers for audio and video source count changed', () => { - assert.isFunction(eventListeners[MediaConnectionEventNames.VIDEO_SOURCES_COUNT_CHANGED]); - assert.isFunction(eventListeners[MediaConnectionEventNames.AUDIO_SOURCES_COUNT_CHANGED]); + assert.isFunction( + eventListeners[MediaConnectionEventNames.VIDEO_SOURCES_COUNT_CHANGED] + ); + assert.isFunction( + eventListeners[MediaConnectionEventNames.AUDIO_SOURCES_COUNT_CHANGED] + ); }); it('forwards the VIDEO_SOURCES_COUNT_CHANGED event as "media:remoteVideoSourceCountChanged"', () => { @@ -8374,6 +8547,9 @@ describe('plugin-meetings', () => { it('listens to the self admitted guest event', (done) => { meeting.stopKeepAlive = sinon.stub(); meeting.updateLLMConnection = sinon.stub(); + meeting.rtcMetrics = { + sendNextMetrics: sinon.stub(), + }; meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ADMITTED_GUEST', test1); assert.calledOnceWithExactly(meeting.stopKeepAlive); assert.calledThrice(TriggerProxy.trigger); @@ -8385,6 +8561,8 @@ describe('plugin-meetings', () => { {payload: test1} ); assert.calledOnce(meeting.updateLLMConnection); + assert.calledOnceWithExactly(meeting.rtcMetrics.sendNextMetrics); + done(); }); @@ -9690,6 +9868,11 @@ describe('plugin-meetings', () => { requiredDisplayHints: [], requiredPolicies: [SELF_POLICY.SUPPORT_ANNOTATION], }, + { + actionName: 'canPollingAndQA', + requiredDisplayHints: [], + requiredPolicies: [SELF_POLICY.SUPPORT_POLLING_AND_QA], + }, ], ({ actionName, 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 b20d76c20ef..e0e12e728e5 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/locusMediaRequest.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/locusMediaRequest.ts @@ -1,6 +1,7 @@ +import 'jsdom-global/register'; import sinon from 'sinon'; import {assert} from '@webex/test-helper-chai'; -import { cloneDeep, defer } from 'lodash'; +import { cloneDeep } from 'lodash'; import MockWebex from '@webex/test-helper-mock-webex'; import Meetings from '@webex/plugin-meetings'; @@ -495,4 +496,4 @@ describe('LocusMediaRequest.send()', () => { }); }); -}) \ No newline at end of file +}) 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 217a265a708..7af0df9e88b 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js @@ -113,6 +113,30 @@ describe('plugin-meetings', () => { assert.isTrue(audio.isRemotelyMuted()); }); + it('does not locally unmute on a server unmute', async () => { + const setServerMutedSpy = meeting.mediaProperties.audioStream.setServerMuted; + + // simulate remote mute + audio.handleServerRemoteMuteUpdate(meeting, true, true); + + assert.isTrue(audio.isRemotelyMuted()); + assert.isTrue(audio.isLocallyMuted()); + + // mutes local + assert.calledOnceWithExactly(setServerMutedSpy, true, 'remotelyMuted'); + + setServerMutedSpy.resetHistory(); + + // simulate remote unmute + audio.handleServerRemoteMuteUpdate(meeting, false, true); + + assert.isFalse(audio.isRemotelyMuted()); + assert.isTrue(audio.isLocallyMuted()); + + // does not unmute local + assert.notCalled(setServerMutedSpy); + }); + it('does local audio unmute if localAudioUnmuteRequired is received', async () => { // first we need to have the local stream user muted meeting.mediaProperties.audioStream.userMuted = true; 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 8d9e844e18c..5e53406d31c 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/request.js @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import sinon from 'sinon'; import {assert} from '@webex/test-helper-chai'; import MockWebex from '@webex/test-helper-mock-webex'; 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 f02c372960a..f418a256d06 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import sinon from 'sinon'; import {assert} from '@webex/test-helper-chai'; import Meetings from '@webex/plugin-meetings'; @@ -71,6 +72,7 @@ describe('plugin-meetings', () => { assert.calledOnce(meeting.updateLLMConnection); assert.calledOnce(meeting.breakouts.cleanUp); assert.calledOnce(meeting.simultaneousInterpretation.cleanUp); + assert.calledOnce(webex.internal.device.meetingEnded); }); it('do clean up on meeting object with LLM disabled', async () => { @@ -87,6 +89,7 @@ describe('plugin-meetings', () => { assert.notCalled(meeting.updateLLMConnection); assert.calledOnce(meeting.breakouts.cleanUp); assert.calledOnce(meeting.simultaneousInterpretation.cleanUp); + assert.calledOnce(webex.internal.device.meetingEnded); }); it('do clean up on meeting object with no config', async () => { @@ -102,6 +105,7 @@ describe('plugin-meetings', () => { assert.notCalled(meeting.updateLLMConnection); assert.calledOnce(meeting.breakouts.cleanUp); assert.calledOnce(meeting.simultaneousInterpretation.cleanUp); + assert.calledOnce(webex.internal.device.meetingEnded); }); }); 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 9775a52bb97..21629c5f398 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js @@ -79,6 +79,7 @@ describe('plugin-meetings', () => { let locusInfo; let services; let catalog; + let startReachabilityStub; describe('meetings index', () => { beforeEach(() => { @@ -129,9 +130,7 @@ describe('plugin-meetings', () => { logger, }); - Object.assign(webex.meetings, { - startReachability: sinon.stub().returns(Promise.resolve()), - }); + startReachabilityStub = sinon.stub(webex.meetings, 'startReachability').resolves(); Object.assign(webex.internal, { llm: {on: sinon.stub()}, @@ -197,6 +196,34 @@ describe('plugin-meetings', () => { assert.calledOnce(MeetingsUtil.checkH264Support); }); + describe('#startReachability', () => { + let gatherReachabilitySpy; + let fakeResult = {id: 'fake-result'}; + + beforeEach(() => { + startReachabilityStub.restore(); + gatherReachabilitySpy = sinon + .stub(webex.meetings.getReachability(), 'gatherReachability') + .resolves(fakeResult); + }); + + it('should gather reachability with default trigger value', async () => { + const result = await webex.meetings.startReachability(); + + assert.calledOnceWithExactly(gatherReachabilitySpy, 'client'); + assert.equal(result, fakeResult); + }); + + it('should gather reachability and pass custom trigger value', async () => { + const trigger = 'custom-trigger'; + + const result = await webex.meetings.startReachability(trigger); + + assert.calledOnceWithExactly(gatherReachabilitySpy, trigger); + assert.equal(result, fakeResult); + }); + }); + describe('#_toggleUnifiedMeetings', () => { it('should have toggleUnifiedMeetings', () => { assert.equal(typeof webex.meetings._toggleUnifiedMeetings, 'function'); @@ -726,7 +753,9 @@ describe('plugin-meetings', () => { const FAKE_USE_RANDOM_DELAY = true; const correlationId = 'my-correlationId'; + const sessionCorrelationId = 'my-session-correlationId'; const callStateForMetrics = { + sessionCorrelationId: 'my-session-correlationId2', correlationId: 'my-correlationId2', joinTrigger: 'my-join-trigger', loginType: 'my-login-type', @@ -742,11 +771,15 @@ describe('plugin-meetings', () => { {}, correlationId, true, - callStateForMetrics + callStateForMetrics, + undefined, + undefined, + sessionCorrelationId ); assert.calledOnceWithExactly(fakeMeeting.setCallStateForMetrics, { ...callStateForMetrics, correlationId, + sessionCorrelationId, }); }); @@ -787,13 +820,14 @@ describe('plugin-meetings', () => { undefined, meetingInfo, 'meetingLookupURL', + sessionCorrelationId ], [ test1, test2, FAKE_USE_RANDOM_DELAY, {}, - {correlationId}, + {correlationId, sessionCorrelationId}, true, meetingInfo, 'meetingLookupURL', @@ -1692,6 +1726,7 @@ describe('plugin-meetings', () => { const expectedMeetingData = { correlationId: 'my-correlationId', callStateForMetrics: { + sessionCorrelationId: '', correlationId: 'my-correlationId', joinTrigger: 'my-join-trigger', loginType: 'my-login-type', 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 833d95629a1..e1fe2b3606c 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/request.js @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import sinon from 'sinon'; import chai from 'chai'; import uuid from 'uuid'; @@ -131,7 +132,7 @@ describe('plugin-meetings', () => { locusUrl: url1, memberIds: ['1', '2'], }; - + await membersRequest.admitMember(options) checkRequest({ diff --git a/packages/@webex/plugin-meetings/test/unit/spec/multistream/mediaRequestManager.ts b/packages/@webex/plugin-meetings/test/unit/spec/multistream/mediaRequestManager.ts index 3f0c70170f5..229a6625410 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/multistream/mediaRequestManager.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/multistream/mediaRequestManager.ts @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import {MediaRequestManager} from '@webex/plugin-meetings/src/multistream/mediaRequestManager'; import {ReceiveSlot} from '@webex/plugin-meetings/src/multistream/receiveSlot'; import sinon from 'sinon'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/multistream/receiveSlot.ts b/packages/@webex/plugin-meetings/test/unit/spec/multistream/receiveSlot.ts index 88fa907ee4f..860173568d6 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/multistream/receiveSlot.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/multistream/receiveSlot.ts @@ -1,4 +1,5 @@ /* eslint-disable require-jsdoc */ +import 'jsdom-global/register'; import EventEmitter from 'events'; import {MediaType, ReceiveSlotEvents as WcmeReceiveSlotEvents} from '@webex/internal-media-core'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/multistream/receiveSlotManager.ts b/packages/@webex/plugin-meetings/test/unit/spec/multistream/receiveSlotManager.ts index ded0488f8f4..6ee1128e86b 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/multistream/receiveSlotManager.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/multistream/receiveSlotManager.ts @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import sinon from 'sinon'; import {assert} from '@webex/test-helper-chai'; import {MediaType} from '@webex/internal-media-core'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMedia.ts b/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMedia.ts index 2bb059e27ff..be5b51042a0 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMedia.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMedia.ts @@ -1,4 +1,5 @@ /* eslint-disable require-jsdoc */ +import 'jsdom-global/register'; import EventEmitter from 'events'; import {MediaType} from '@webex/internal-media-core'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMediaGroup.ts b/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMediaGroup.ts index cbde1e3968c..4eb3bc71ed5 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMediaGroup.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMediaGroup.ts @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import EventEmitter from 'events'; import {MediaType} from '@webex/internal-media-core'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMediaManager.ts b/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMediaManager.ts index 5a36879de61..b37c34df752 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMediaManager.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/multistream/remoteMediaManager.ts @@ -1,4 +1,5 @@ /* eslint-disable require-jsdoc */ +import 'jsdom-global/register'; import EventEmitter from 'events'; import {MediaType} from '@webex/internal-media-core'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/multistream/sendSlotManager.ts b/packages/@webex/plugin-meetings/test/unit/spec/multistream/sendSlotManager.ts index 6f74035285b..f4ccf80f7c9 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/multistream/sendSlotManager.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/multistream/sendSlotManager.ts @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import SendSlotManager from '@webex/plugin-meetings/src/multistream/sendSlotManager'; import { LocalStream, MediaType, MultistreamRoapMediaConnection } from "@webex/internal-media-core"; import {expect} from '@webex/test-helper-chai'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/networkQualityMonitor/index.js b/packages/@webex/plugin-meetings/test/unit/spec/networkQualityMonitor/index.js deleted file mode 100644 index 762effbb645..00000000000 --- a/packages/@webex/plugin-meetings/test/unit/spec/networkQualityMonitor/index.js +++ /dev/null @@ -1,99 +0,0 @@ -import 'jsdom-global/register'; -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import sinon from 'sinon'; - -import NetworkQualityMonitor from '../../../../src/networkQualityMonitor'; -import {EVENT_TRIGGERS} from '../../../../src/constants'; - -const {assert} = chai; - -chai.use(chaiAsPromised); -sinon.assert.expose(chai.assert, {prefix: ''}); - -// eslint-disable-next-line mocha/no-exclusive-tests -describe('plugin-meetings', () => { - describe('NetworkQualityMonitor', () => { - let networkQualityMonitor; - let sandBoxEmitSpy; - - const initialConfig = { - videoPacketLossRatioThreshold: 9, - rttThreshold: 500, - jitterThreshold: 500, - }; - - const configObject = { - mediaType: 'video-send', - remoteRtpResults: { - id: 'RTCRemoteInboundRtpVideoStream_2411086660', - timestamp: 1624472676193.79, - type: 'remote-inbound-rtp', - ssrc: 2411086660, - kind: 'video', - transportId: 'RTCTransport_1_1', - codecId: 'RTCCodec_1_Outbound_102', - jitter: 0.004, - packetsLost: 8, - localId: 'RTCOutboundRTPVideoStream_2411086660', - roundTripTime: 0.648, - fractionLost: 0, - totalRoundTripTime: 3.554, - roundTripTimeMeasurements: 14, - }, - statsAnalyzerCurrentStats: { - 'audio-send': { - send: { - currentPacketLossRatio: 8, - }, - }, - 'video-send': { - send: { - currentPacketLossRatio: 10, - }, - }, - }, - }; - - const sandbox = sinon.createSandbox(); - - beforeEach(() => { - networkQualityMonitor = new NetworkQualityMonitor(initialConfig); - sandbox.spy(networkQualityMonitor, 'updateNetworkQualityStatus'); - sandBoxEmitSpy = sandbox.spy(networkQualityMonitor, 'emit'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('should trigger updateNetworkQualityStatus when determineUplinkNetworkQuality has finished', async () => { - await networkQualityMonitor.determineUplinkNetworkQuality(configObject); - - assert.calledOnce(networkQualityMonitor.updateNetworkQualityStatus); - }); - - it('should emit a network quality judgement event with the proper payload', async () => { - await networkQualityMonitor.determineUplinkNetworkQuality(configObject); - assert( - sandBoxEmitSpy.calledWith( - sinon.match({ - file: 'networkQualityMonitor', - function: 'emitNetworkQuality', - }), - sinon.match(EVENT_TRIGGERS.NETWORK_QUALITY), - sinon.match({ - mediaType: 'video-send', - networkQualityScore: 0, - }) - ) - ); - }); - - it('should reset to default values after determineUplinkNetworkQuality call stack is complete', async () => { - await networkQualityMonitor.determineUplinkNetworkQuality(configObject); - assert.isNull(networkQualityMonitor.mediaType); - assert.deepEqual(networkQualityMonitor.networkQualityScore, 1); - }); - }); -}); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/personal-meeting-room/personal-meeting-room.js b/packages/@webex/plugin-meetings/test/unit/spec/personal-meeting-room/personal-meeting-room.js index e87df2a397c..43fa8eb17c2 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/personal-meeting-room/personal-meeting-room.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/personal-meeting-room/personal-meeting-room.js @@ -1,7 +1,6 @@ /*! * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. */ - import 'jsdom-global/register'; import {assert} from '@webex/test-helper-chai'; import sinon from 'sinon'; 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 8be4ce92bb2..29b9ae371ec 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts @@ -4,10 +4,9 @@ import sinon from 'sinon'; import EventEmitter from 'events'; import testUtils from '../../../utils/testUtils'; import Reachability, { - ReachabilityResults, ReachabilityResultsForBackend, } from '@webex/plugin-meetings/src/reachability/'; -import { ClusterNode } from '../../../../src/reachability/request'; +import {ClusterNode} from '../../../../src/reachability/request'; import MeetingUtil from '@webex/plugin-meetings/src/meeting/util'; import * as ClusterReachabilityModule from '@webex/plugin-meetings/src/reachability/clusterReachability'; import Metrics from '@webex/plugin-meetings/src/metrics'; @@ -145,7 +144,6 @@ describe('isAnyPublicClusterReachable', () => { }); }); - describe('isWebexMediaBackendUnreachable', () => { let webex; @@ -486,6 +484,16 @@ describe('gatherReachability', () => { JSON.stringify({old: 'joinCookie'}) ); + webex.internal.device.ipNetworkDetector = { + supportsIpV4: false, + supportsIpV6: false, + firstIpV4: -1, + firstIpV6: -1, + firstMdns: -1, + totalTime: -1, + detect: sinon.stub().resolves(), + }; + clock = sinon.useFakeTimers(); mockClusterReachabilityInstances = {}; @@ -498,6 +506,11 @@ describe('gatherReachability', () => { mockClusterReachabilityInstances[id] = mockInstance; return mockInstance; }); + + webex.config.meetings.experimental = { + enableTcpReachability: false, + enableTlsReachability: false, + }; }); afterEach(() => { @@ -1035,6 +1048,16 @@ describe('gatherReachability', () => { enableTlsReachability: true, }; + // the metrics related to ipver and trigger are not tested in these tests and are all the same, so setting them up here + const expectedMetricsFull = { + ...expectedMetrics, + ipver_firstIpV4: -1, + ipver_firstIpV6: -1, + ipver_firstMdns: -1, + ipver_totalTime: -1, + trigger: 'test', + }; + const receivedEvents = { done: 0, firstResultAvailable: { @@ -1064,7 +1087,7 @@ describe('gatherReachability', () => { reachability.reachabilityRequest.getClusters = sinon.stub().returns(mockGetClustersResult); - const resultPromise = reachability.gatherReachability(); + const resultPromise = reachability.gatherReachability('test'); await testUtils.flushPromises(); @@ -1119,11 +1142,122 @@ describe('gatherReachability', () => { assert.calledWith( Metrics.sendBehavioralMetric, 'js_sdk_reachability_completed', - expectedMetrics + expectedMetricsFull ); }) ); + it('sends the trigger parameter in the metrics', async () => { + const reachability = new TestReachability(webex); + + const mockGetClustersResult = { + clusters: { + clusterA: { + udp: ['udp-url'], + tcp: [], + xtls: [], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'id'}, + }; + + reachability.reachabilityRequest.getClusters = sinon.stub().returns(mockGetClustersResult); + + const resultPromise = reachability.gatherReachability('some trigger'); + + // let it time out + await testUtils.flushPromises(); + clock.tick(15000); + await resultPromise; + + // check the metric contains the right trigger value + assert.calledWith( + Metrics.sendBehavioralMetric, + 'js_sdk_reachability_completed', + sinon.match({trigger: 'some trigger'}) + ); + }); + + it(`starts ip network version detection and includes the results in the metrics`, async () => { + webex.config.meetings.experimental = { + enableTcpReachability: true, + enableTlsReachability: true, + }; + webex.internal.device.ipNetworkDetector = { + supportsIpV4: true, + supportsIpV6: true, + firstIpV4: 10, + firstIpV6: 20, + firstMdns: 30, + totalTime: 40, + detect: sinon.stub().resolves(), + }; + + const receivedEvents = { + done: 0, + }; + + const reachability = new Reachability(webex); + + reachability.on('reachability:done', () => { + receivedEvents.done += 1; + }); + + // simulate having just 1 cluster, we don't need more for this test + reachability.reachabilityRequest.getClusters = sinon.stub().returns({ + clusters: { + publicCluster: { + udp: ['udp-url'], + tcp: [], + xtls: [], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'id'}, + }); + + const resultPromise = reachability.gatherReachability('test'); + + await testUtils.flushPromises(); + + // trigger mock result events from ClusterReachability instance + mockClusterReachabilityInstances['publicCluster'].emitFakeResult('udp', { + result: 'reachable', + clientMediaIPs: ['1.2.3.4'], + latencyInMilliseconds: 100, + }); + + await resultPromise; + + // check events emitted by Reachability class + assert.equal(receivedEvents['done'], 1); + + // and that ip network detection was started + assert.calledOnceWithExactly(webex.internal.device.ipNetworkDetector.detect); + + // finally, check the metrics - they should contain values from ipNetworkDetector + assert.calledWith(Metrics.sendBehavioralMetric, 'js_sdk_reachability_completed', { + vmn_udp_min: -1, + vmn_udp_max: -1, + vmn_udp_average: -1, + public_udp_min: 100, + public_udp_max: 100, + public_udp_average: 100, + public_tcp_min: -1, + public_tcp_max: -1, + public_tcp_average: -1, + public_xtls_min: -1, + public_xtls_max: -1, + public_xtls_average: -1, + ipver_firstIpV4: webex.internal.device.ipNetworkDetector.firstIpV4, + ipver_firstIpV6: webex.internal.device.ipNetworkDetector.firstIpV6, + ipver_firstMdns: webex.internal.device.ipNetworkDetector.firstMdns, + ipver_totalTime: webex.internal.device.ipNetworkDetector.totalTime, + trigger: 'test', + }); + }); + it('keeps updating reachability results after the 3s public cloud timeout expires', async () => { webex.config.meetings.experimental = { enableTcpReachability: true, @@ -1152,7 +1286,7 @@ describe('gatherReachability', () => { reachability.reachabilityRequest.getClusters = sinon.stub().returns(mockGetClustersResult); - const resultPromise = reachability.gatherReachability(); + const resultPromise = reachability.gatherReachability('test'); await testUtils.flushPromises(); @@ -1245,7 +1379,7 @@ describe('gatherReachability', () => { reachability.reachabilityRequest.getClusters = sinon.stub().returns(mockGetClustersResult); - const resultPromise = reachability.gatherReachability(); + const resultPromise = reachability.gatherReachability('test'); await testUtils.flushPromises(); @@ -1286,7 +1420,7 @@ describe('gatherReachability', () => { reachability.reachabilityRequest.getClusters = sinon.stub().throws(); - const result = await reachability.gatherReachability(); + const result = await reachability.gatherReachability('test'); assert.empty(result); @@ -1304,7 +1438,7 @@ describe('gatherReachability', () => { reachability.reachabilityRequest.getClusters = sinon.stub().returns(getClustersResult); (reachability as any).performReachabilityChecks = sinon.stub().throws(); - const result = await reachability.gatherReachability(); + const result = await reachability.gatherReachability('test'); assert.empty(result); @@ -1339,7 +1473,7 @@ describe('gatherReachability', () => { reachability.reachabilityRequest.getClusters = sinon.stub().returns(getClustersResult); - const promise = reachability.gatherReachability(); + const promise = reachability.gatherReachability('test'); await simulateTimeout(); await promise; @@ -1385,7 +1519,7 @@ describe('gatherReachability', () => { reachability.reachabilityRequest.getClusters = sinon.stub().returns(getClustersResult); - const promise = reachability.gatherReachability(); + const promise = reachability.gatherReachability('test'); await simulateTimeout(); await promise; @@ -1419,7 +1553,7 @@ describe('gatherReachability', () => { reachability.reachabilityRequest.getClusters = sinon.stub().returns(getClustersResult); - const promise = reachability.gatherReachability(); + const promise = reachability.gatherReachability('test'); await simulateTimeout(); await promise; @@ -1454,7 +1588,7 @@ describe('gatherReachability', () => { reachability.reachabilityRequest.getClusters = sinon.stub().returns(getClustersResult); - const promise = reachability.gatherReachability(); + const promise = reachability.gatherReachability('test'); await simulateTimeout(); await promise; @@ -1466,6 +1600,70 @@ describe('gatherReachability', () => { xtls: [], // empty list because TLS is disabled in config }); }); + + it('retry of getClusters is succesfull', async () => { + webex.config.meetings.experimental = { + enableTcpReachability: true, + enableTlsReachability: false, + }; + + const getClustersResult = { + clusters: { + 'cluster name': { + udp: ['testUDP1', 'testUDP2'], + tcp: ['testTCP1', 'testTCP2'], + xtls: ['testXTLS1', 'testXTLS2'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'id'}, + }; + + const reachability = new Reachability(webex); + + let getClustersCallCount = 0; + + reachability.reachabilityRequest.getClusters = sinon.stub().callsFake(() => { + getClustersCallCount++; + + if (getClustersCallCount == 1) { + throw new Error('fake error'); + } + + return getClustersResult; + }); + + const promise = reachability.gatherReachability('test'); + + await simulateTimeout(); + await promise; + + assert.equal(getClustersCallCount, 2); + + assert.calledOnce(clusterReachabilityCtorStub); + }); + + it('two failed calls to getClusters', async () => { + const reachability = new Reachability(webex); + + let getClustersCallCount = 0; + + reachability.reachabilityRequest.getClusters = sinon.stub().callsFake(() => { + getClustersCallCount++; + + throw new Error('fake error'); + }); + + const promise = reachability.gatherReachability('test'); + + await simulateTimeout(); + + await promise; + + assert.equal(getClustersCallCount, 2); + + assert.neverCalledWith(clusterReachabilityCtorStub); + }); }); 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 e13a48dbce9..372c398b4b4 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import sinon from 'sinon'; import {assert} from '@webex/test-helper-chai'; import MockWebex from '@webex/test-helper-mock-webex'; 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 155f27c96be..70cb8df3bbd 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/roap/request.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/roap/request.ts @@ -1,3 +1,4 @@ +import 'jsdom-global/register'; import sinon from 'sinon'; import {assert} from '@webex/test-helper-chai'; import MockWebex from '@webex/test-helper-mock-webex'; diff --git a/packages/@webex/plugin-presence/src/presence.ts b/packages/@webex/plugin-presence/src/presence.ts index 55df070a032..f7181f3c653 100644 --- a/packages/@webex/plugin-presence/src/presence.ts +++ b/packages/@webex/plugin-presence/src/presence.ts @@ -238,6 +238,7 @@ const Presence: IPresence = WebexPlugin.extend({ body: { subject: this.webex.internal.device.userId, eventType: status, + label: this.webex.internal.device.userId, ttl, }, }) diff --git a/packages/@webex/plugin-presence/test/integration/spec/presence.ts b/packages/@webex/plugin-presence/test/integration/spec/presence.ts index d767e253d23..bd2635817af 100644 --- a/packages/@webex/plugin-presence/test/integration/spec/presence.ts +++ b/packages/@webex/plugin-presence/test/integration/spec/presence.ts @@ -203,8 +203,10 @@ describe.skip('plugin-presence', function () { spock.webex.presence.setStatus('dnd', 1500).then((statusResponse) => { assert.property(statusResponse, 'subject'); assert.property(statusResponse, 'status'); + assert.property(statusResponse, 'label'); assert.equal(statusResponse.subject, spock.id); assert.equal(statusResponse.status, 'dnd'); + assert.equal(statusResponse.subject, spock.id); })); }); }); diff --git a/packages/@webex/plugin-presence/test/unit/spec/presence.ts b/packages/@webex/plugin-presence/test/unit/spec/presence.ts index ac3b8e3baa5..137af9d2bc3 100644 --- a/packages/@webex/plugin-presence/test/unit/spec/presence.ts +++ b/packages/@webex/plugin-presence/test/unit/spec/presence.ts @@ -61,6 +61,29 @@ describe('plugin-presence', () => { describe('#setStatus()', () => { it('requires a status', () => assert.isRejected(webex.presence.setStatus(), /A status is required/)); + + it('passes a label to the API', () => { + const testGuid = 'test-guid'; + + webex.internal.device.userId = testGuid; + + webex.request = function (options) { + return Promise.resolve({ + statusCode: 204, + body: [], + options, + }); + }; + sinon.spy(webex, 'request'); + + webex.presence.setStatus('dnd'); + + assert.calledOnce(webex.request); + + const request = webex.request.getCall(0); + + assert.equal(request.args[0].body.label, testGuid); + }); }); }); }); diff --git a/packages/@webex/test-helper-mock-webex/src/index.js b/packages/@webex/test-helper-mock-webex/src/index.js index bfd92df62c3..255afe3742f 100644 --- a/packages/@webex/test-helper-mock-webex/src/index.js +++ b/packages/@webex/test-helper-mock-webex/src/index.js @@ -268,6 +268,8 @@ function makeWebex(options) { get: sinon.stub(), }, }, + meetingEnded: sinon.stub(), + meetingStarted: sinon.stub(), registered: true, register: sinon.stub().returns(Promise.resolve()), ipNetworkDetector: { diff --git a/packages/@webex/webex-core/src/index.js b/packages/@webex/webex-core/src/index.js index a3da486372a..7d4c1b9f6bc 100644 --- a/packages/@webex/webex-core/src/index.js +++ b/packages/@webex/webex-core/src/index.js @@ -25,6 +25,7 @@ export { Services, ServiceHost, ServiceUrl, + HostMapInterceptor, } from './lib/services'; export { diff --git a/packages/@webex/webex-core/src/lib/services/index.js b/packages/@webex/webex-core/src/lib/services/index.js index d18760f11c8..8f73c306e7e 100644 --- a/packages/@webex/webex-core/src/lib/services/index.js +++ b/packages/@webex/webex-core/src/lib/services/index.js @@ -18,6 +18,7 @@ registerInternalPlugin('services', Services, { export {constants}; export {default as ServiceInterceptor} from './interceptors/service'; export {default as ServerErrorInterceptor} from './interceptors/server-error'; +export {default as HostMapInterceptor} from './interceptors/hostmap'; export {default as Services} from './services'; export {default as ServiceCatalog} from './service-catalog'; export {default as ServiceRegistry} from './service-registry'; diff --git a/packages/@webex/webex-core/src/lib/services/interceptors/hostmap.js b/packages/@webex/webex-core/src/lib/services/interceptors/hostmap.js new file mode 100644 index 00000000000..0cf4370b7cb --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services/interceptors/hostmap.js @@ -0,0 +1,36 @@ +/*! + * Copyright (c) 2015-2024 Cisco Systems, Inc. See LICENSE file. + */ + +import {Interceptor} from '@webex/http-core'; + +/** + * This interceptor replaces the host in the request uri with the host from the hostmap + * It will attempt to do this for every request, but not all URIs will be in the hostmap + * URIs with hosts that are not in the hostmap will be left unchanged + */ +export default class HostMapInterceptor extends Interceptor { + /** + * @returns {HostMapInterceptor} + */ + static create() { + return new HostMapInterceptor({webex: this}); + } + + /** + * @see Interceptor#onRequest + * @param {Object} options + * @returns {Object} + */ + onRequest(options) { + if (options.uri) { + try { + options.uri = this.webex.internal.services.replaceHostFromHostmap(options.uri); + } catch (error) { + /* empty */ + } + } + + return options; + } +} diff --git a/packages/@webex/webex-core/src/lib/services/services.js b/packages/@webex/webex-core/src/lib/services/services.js index cbc2972bc6f..bc3dc67e4c6 100644 --- a/packages/@webex/webex-core/src/lib/services/services.js +++ b/packages/@webex/webex-core/src/lib/services/services.js @@ -675,6 +675,33 @@ const Services = WebexPlugin.extend({ }); }, + /** + * Looks up the hostname in the host catalog + * and replaces it with the first host if it finds it + * @param {string} uri + * @returns {string} uri with the host replaced + */ + replaceHostFromHostmap(uri) { + const url = new URL(uri); + const hostCatalog = this._hostCatalog; + + if (!hostCatalog) { + return uri; + } + + const host = hostCatalog[url.host]; + + if (host && host[0]) { + const newHost = host[0].host; + + url.host = newHost; + + return url.toString(); + } + + return uri; + }, + /** * @private * Organize a received hostmap from a service diff --git a/packages/@webex/webex-core/src/webex-core.js b/packages/@webex/webex-core/src/webex-core.js index c9f33cdeda6..a8c52cf56ca 100644 --- a/packages/@webex/webex-core/src/webex-core.js +++ b/packages/@webex/webex-core/src/webex-core.js @@ -31,6 +31,7 @@ import WebexUserAgentInterceptor from './interceptors/webex-user-agent'; import RateLimitInterceptor from './interceptors/rate-limit'; import EmbargoInterceptor from './interceptors/embargo'; import DefaultOptionsInterceptor from './interceptors/default-options'; +import HostMapInterceptor from './lib/services/interceptors/hostmap'; import config from './config'; import {makeWebexStore} from './lib/storage'; import mixinWebexCorePlugins from './lib/webex-core-plugin-mixin'; @@ -71,6 +72,7 @@ const interceptors = { NetworkTimingInterceptor: NetworkTimingInterceptor.create, EmbargoInterceptor: EmbargoInterceptor.create, DefaultOptionsInterceptor: DefaultOptionsInterceptor.create, + HostMapInterceptor: HostMapInterceptor.create, }; const preInterceptors = [ diff --git a/packages/@webex/webex-core/test/unit/spec/services/interceptors/hostmap.js b/packages/@webex/webex-core/test/unit/spec/services/interceptors/hostmap.js new file mode 100644 index 00000000000..46ff05fd178 --- /dev/null +++ b/packages/@webex/webex-core/test/unit/spec/services/interceptors/hostmap.js @@ -0,0 +1,79 @@ +/*! + * Copyright (c) 2015-2024 Cisco Systems, Inc. See LICENSE file. + */ + +/* eslint-disable camelcase */ + +import sinon from 'sinon'; +import {assert} from '@webex/test-helper-chai'; +import MockWebex from '@webex/test-helper-mock-webex'; +import {HostMapInterceptor, config, Credentials} from '@webex/webex-core'; +import {cloneDeep} from 'lodash'; + +describe('webex-core', () => { + describe('Interceptors', () => { + describe('HostMapInterceptor', () => { + let interceptor, webex; + + beforeEach(() => { + webex = new MockWebex({ + children: { + credentials: Credentials, + }, + config: cloneDeep(config), + request: sinon.spy(), + }); + + webex.internal.services = { + replaceHostFromHostmap: sinon.stub().returns('http://replaceduri.com'), + } + + interceptor = Reflect.apply(HostMapInterceptor.create, webex, []); + }); + + describe('#onRequest', () => { + it('calls replaceHostFromHostmap if options.uri is defined', () => { + const options = { + uri: 'http://example.com', + }; + + interceptor.onRequest(options); + + sinon.assert.calledWith( + webex.internal.services.replaceHostFromHostmap, + 'http://example.com' + ); + + assert.equal(options.uri, 'http://replaceduri.com'); + }); + + it('does not call replaceHostFromHostmap if options.uri is not defined', () => { + const options = {}; + + interceptor.onRequest(options); + + sinon.assert.notCalled(webex.internal.services.replaceHostFromHostmap); + + assert.isUndefined(options.uri); + }); + + it('does not modify options.uri if replaceHostFromHostmap throws an error', () => { + const options = { + uri: 'http://example.com', + }; + + webex.internal.services.replaceHostFromHostmap.throws(new Error('replaceHostFromHostmap error')); + + interceptor.onRequest(options); + + sinon.assert.calledWith( + webex.internal.services.replaceHostFromHostmap, + 'http://example.com' + ); + + assert.equal(options.uri, 'http://example.com'); + }); + }); + }); + }); +}); diff --git a/packages/@webex/webex-core/test/unit/spec/services/services.js b/packages/@webex/webex-core/test/unit/spec/services/services.js index 80f63b5c5ba..9e13ee09310 100644 --- a/packages/@webex/webex-core/test/unit/spec/services/services.js +++ b/packages/@webex/webex-core/test/unit/spec/services/services.js @@ -290,6 +290,61 @@ describe('webex-core', () => { }); }); + describe('replaceHostFromHostmap', () => { + it('returns the same uri if the hostmap is not set', () => { + services._hostCatalog = null; + + const uri = 'http://example.com'; + + assert.equal(services.replaceHostFromHostmap(uri), uri); + }); + + it('returns the same uri if the hostmap does not contain the host', () => { + services._hostCatalog = { + 'not-example.com': [ + { + host: 'example-1.com', + ttl: -1, + priority: 5, + id: '0:0:0:example', + }, + ], + }; + + const uri = 'http://example.com'; + + assert.equal(services.replaceHostFromHostmap(uri), uri); + }); + + it('returns the original uri if the hostmap has no hosts for the host', () => { + + services._hostCatalog = { + 'example.com': [], + }; + + const uri = 'http://example.com'; + + assert.equal(services.replaceHostFromHostmap(uri), uri); + }); + + it('returns the replaces the host in the uri with the host from the hostmap', () => { + services._hostCatalog = { + 'example.com': [ + { + host: 'example-1.com', + ttl: -1, + priority: 5, + id: '0:0:0:example', + }, + ], + }; + + const uri = 'http://example.com/somepath'; + + assert.equal(services.replaceHostFromHostmap(uri), 'http://example-1.com/somepath'); + }); + }); + describe('#_formatReceivedHostmap()', () => { let serviceHostmap; let formattedHM; diff --git a/packages/calling/package.json b/packages/calling/package.json index d149f8f9b62..b5684f56853 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.9.1", + "@webex/internal-media-core": "2.11.3", "@webex/media-helpers": "workspace:*", "async-mutex": "0.4.0", "buffer": "6.0.3", diff --git a/packages/calling/src/CallHistory/CallHistory.test.ts b/packages/calling/src/CallHistory/CallHistory.test.ts index 5efa59d50b8..dadfbf0541a 100644 --- a/packages/calling/src/CallHistory/CallHistory.test.ts +++ b/packages/calling/src/CallHistory/CallHistory.test.ts @@ -3,7 +3,7 @@ /* eslint-disable @typescript-eslint/no-shadow */ import {LOGGER} from '../Logger/types'; import {getTestUtilsWebex} from '../common/testUtil'; -import {HTTP_METHODS, SORT, SORT_BY, WebexRequestPayload} from '../common/types'; +import {CALLING_BACKEND, HTTP_METHODS, SORT, SORT_BY, WebexRequestPayload} from '../common/types'; import {CallHistory, createCallHistoryClient} from './CallHistory'; import {ICallHistory} from './types'; import { @@ -16,6 +16,10 @@ import { janusSetReadStateUrl, ERROR_DETAILS_401, ERROR_DETAILS_400, + MOCK_LINES_API_CALL_RESPONSE, + MOCK_LINES_API_CALL_RESPONSE_WITH_NO_LINEDATA, + MOCK_CALL_HISTORY_WITH_UCM_LINE_NUMBER, + MOCK_CALL_HISTORY_WITHOUT_UCM_LINE_NUMBER, } from './callHistoryFixtures'; import { COMMON_EVENT_KEYS, @@ -247,4 +251,113 @@ describe('Call history tests', () => { ); }); }); + + describe('fetchUCMLinesData test', () => { + it('verify successful UCM lines API case', async () => { + const ucmLinesAPIPayload = (MOCK_LINES_API_CALL_RESPONSE); + + webex.request.mockResolvedValue(ucmLinesAPIPayload); + const response = await callHistory['fetchUCMLinesData'](); + + expect(response.statusCode).toBe(200); + expect(response.message).toBe('SUCCESS'); + }); + + it('verify bad request failed UCM lines API case', async () => { + const failurePayload = { + statusCode: 400, + }; + const ucmLinesAPIPayload = (failurePayload); + + webex.request.mockRejectedValue(ucmLinesAPIPayload); + const response = await callHistory['fetchUCMLinesData'](); + + expect(response).toStrictEqual(ERROR_DETAILS_400); + expect(response.data.error).toEqual(ERROR_DETAILS_400.data.error); + expect(response.statusCode).toBe(400); + expect(response.message).toBe('FAILURE'); + expect(serviceErrorCodeHandlerSpy).toHaveBeenCalledWith( + {statusCode: 400}, + {file: 'CallHistory', method: 'fetchLinesData'} + ); + }); + + it('should call fetchUCMLinesData when calling backend is UCM and userSessions contain valid cucmDN', async () => { + jest.spyOn(utils, 'getCallingBackEnd').mockReturnValue(CALLING_BACKEND.UCM); + // Since fetchUCMLinesData is a private method, TypeScript restricts direct access to it. + // To bypass this restriction, we are using 'as any' to access and invoke the method for testing purposes. + const fetchUCMLinesDataSpy = jest + .spyOn(callHistory as any, 'fetchUCMLinesData') + .mockResolvedValue(MOCK_LINES_API_CALL_RESPONSE); + + const mockCallHistoryPayload = ( + (MOCK_CALL_HISTORY_WITH_UCM_LINE_NUMBER) + ); + webex.request.mockResolvedValue(mockCallHistoryPayload); + + const response = await callHistory.getCallHistoryData(7, 10, SORT.DEFAULT, SORT_BY.DEFAULT); + + expect(fetchUCMLinesDataSpy).toHaveBeenCalledTimes(1); + + expect(response.statusCode).toBe(200); + expect( + response.data.userSessions && response.data.userSessions[0].self.ucmLineNumber + ).toEqual(1); + }); + + it('should fetchUCMLinesData but not assign ucmLineNumber when UCM backend has no line data', async () => { + jest.spyOn(utils, 'getCallingBackEnd').mockReturnValue(CALLING_BACKEND.UCM); + + // Since fetchUCMLinesData is a private method, TypeScript restricts direct access to it. + // To bypass this restriction, we are using 'as any' to access and invoke the method for testing purposes. + const fetchUCMLinesDataSpy = jest + .spyOn(callHistory as any, 'fetchUCMLinesData') + .mockResolvedValue(MOCK_LINES_API_CALL_RESPONSE_WITH_NO_LINEDATA); + + const mockCallHistoryPayload = ( + (MOCK_CALL_HISTORY_WITHOUT_UCM_LINE_NUMBER) + ); + webex.request.mockResolvedValue(mockCallHistoryPayload); + + const response = await callHistory.getCallHistoryData(7, 10, SORT.DEFAULT, SORT_BY.DEFAULT); + + expect(fetchUCMLinesDataSpy).toHaveBeenCalledTimes(1); + + expect(response.statusCode).toBe(200); + expect(response.data.userSessions && response.data.userSessions[0].self.cucmDN).toBeDefined(); + expect( + response.data.userSessions && response.data.userSessions[0].self.ucmLineNumber + ).toEqual(undefined); + }); + + it('should not call fetchUCMLinesData when calling backend is UCM but no valid cucmDN is present', async () => { + jest.spyOn(utils, 'getCallingBackEnd').mockReturnValue(CALLING_BACKEND.UCM); + // Since fetchUCMLinesData is a private method, TypeScript restricts direct access to it. + // To bypass this restriction, we are using 'as any' to access and invoke the method for testing purposes. + const fetchUCMLinesDataSpy = jest + .spyOn(callHistory as any, 'fetchUCMLinesData') + .mockResolvedValue({}); + + const callHistoryPayload = (mockCallHistoryBody); + webex.request.mockResolvedValue(callHistoryPayload); + + await callHistory.getCallHistoryData(7, 10, SORT.DEFAULT, SORT_BY.DEFAULT); + + expect(fetchUCMLinesDataSpy).not.toHaveBeenCalled(); + }); + + it('should not call fetchUCMLinesData when calling backend is not UCM', async () => { + jest.spyOn(utils, 'getCallingBackEnd').mockReturnValue(CALLING_BACKEND.WXC); + // Since fetchUCMLinesData is a private method, TypeScript restricts direct access to it. + // To bypass this restriction, we are using 'as any' to access and invoke the method for testing purposes. + const fetchUCMLinesDataSpy = jest + .spyOn(callHistory as any, 'fetchUCMLinesData') + .mockResolvedValue({}); + + const callHistoryPayload = (mockCallHistoryBody); + webex.request.mockResolvedValue(callHistoryPayload); + await callHistory.getCallHistoryData(7, 10, SORT.DEFAULT, SORT_BY.DEFAULT); + expect(fetchUCMLinesDataSpy).not.toHaveBeenCalled(); // Check that fetchUCMLinesData was not called + }); + }); }); diff --git a/packages/calling/src/CallHistory/CallHistory.ts b/packages/calling/src/CallHistory/CallHistory.ts index 4afab6d6722..dc9f6fa551b 100644 --- a/packages/calling/src/CallHistory/CallHistory.ts +++ b/packages/calling/src/CallHistory/CallHistory.ts @@ -2,15 +2,23 @@ /* eslint-disable no-underscore-dangle */ import SDKConnector from '../SDKConnector'; import {ISDKConnector, WebexSDK} from '../SDKConnector/types'; -import {ALLOWED_SERVICES, HTTP_METHODS, WebexRequestPayload, SORT, SORT_BY} from '../common/types'; +import { + ALLOWED_SERVICES, + HTTP_METHODS, + WebexRequestPayload, + SORT, + SORT_BY, + CALLING_BACKEND, +} from '../common/types'; import { ICallHistory, JanusResponseEvent, LoggerInterface, UpdateMissedCallsResponse, + UCMLinesResponse, } from './types'; import log from '../Logger'; -import {serviceErrorCodeHandler} from '../common/Utils'; +import {serviceErrorCodeHandler, getVgActionEndpoint, getCallingBackEnd} from '../common/Utils'; import { APPLICATION_JSON, CALL_HISTORY_FILE, @@ -21,6 +29,12 @@ import { NUMBER_OF_DAYS, UPDATE_MISSED_CALLS_ENDPOINT, SET_READ_STATE_SUCCESS_MESSAGE, + VERSION_1, + UNIFIED_COMMUNICATIONS, + CONFIG, + PEOPLE, + LINES, + ORG_ID, } from './constants'; import {STATUS_CODE, SUCCESS_MESSAGE, USER_SESSIONS} from '../common/constants'; import { @@ -32,6 +46,7 @@ import { EndTimeSessionId, CallSessionViewedEvent, SanitizedEndTimeAndSessionId, + UCMLinesApiResponse, } from '../Events/types'; import {Eventing} from '../Events/impl'; /** @@ -128,6 +143,43 @@ export class CallHistory extends Eventing implements ICal ); } } + // Check the calling backend + const callingBackend = getCallingBackEnd(this.webex); + if (callingBackend === CALLING_BACKEND.UCM) { + // Check if userSessions exist and the length is greater than 0 + if (this.userSessions[USER_SESSIONS] && this.userSessions[USER_SESSIONS].length > 0) { + // Check if cucmDN exists and is valid in any of the userSessions + const hasCucmDN = this.userSessions[USER_SESSIONS].some( + (session: UserSession) => session.self.cucmDN && session.self.cucmDN.length > 0 + ); + // If any user session has cucmDN, proceed to fetch line data + if (hasCucmDN) { + // Fetch the Lines data + const ucmLinesResponse = await this.fetchUCMLinesData(); + + // Check if the Lines API response was successful + if (ucmLinesResponse.statusCode === 200 && ucmLinesResponse.data.lines?.devices) { + const ucmLinesData = ucmLinesResponse.data.lines.devices; + + // Iterate over user sessions and match with Lines data + this.userSessions[USER_SESSIONS].forEach((session: UserSession) => { + const cucmDN = session.self.cucmDN; + + if (cucmDN) { + ucmLinesData.forEach((device) => { + device.lines.forEach((line) => { + if (line.dnorpattern === cucmDN) { + session.self.ucmLineNumber = line.index; // Assign the ucmLineNumber + } + }); + }); + } + }); + } + } + } + } + const responseDetails = { statusCode: this.userSessions[STATUS_CODE], data: { @@ -202,6 +254,45 @@ export class CallHistory extends Eventing implements ICal } } + /** + * Function to display the UCM Lines API response. + * @returns {Promise} Resolves to an object of type {@link UCMLinesResponse}.Response details with success or error status. + */ + private async fetchUCMLinesData(): Promise { + const loggerContext = { + file: CALL_HISTORY_FILE, + method: 'fetchLinesData', + }; + const vgEndpoint = getVgActionEndpoint(this.webex, CALLING_BACKEND.UCM); + const userId = this.webex.internal.device.userId; + const orgId = this.webex.internal.device.orgId; + const linesURIForUCM = `${vgEndpoint}/${VERSION_1}/${UNIFIED_COMMUNICATIONS}/${CONFIG}/${PEOPLE}/${userId}/${LINES}?${ORG_ID}=${orgId}`; + + try { + const response = await this.webex.request({ + uri: `${linesURIForUCM}`, + method: HTTP_METHODS.GET, + }); + + const ucmLineDetails: UCMLinesResponse = { + statusCode: Number(response.statusCode), + data: { + lines: response.body as UCMLinesApiResponse, + }, + message: SUCCESS_MESSAGE, + }; + + log.info(`Line details fetched successfully`, loggerContext); + + return ucmLineDetails; + } catch (err: unknown) { + const errorInfo = err as WebexRequestPayload; + const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + + return errorStatus; + } + } + handleSessionEvents = async (event?: CallSessionEvent) => { if (event && event.data.userSessions.userSessions) { this.emit(COMMON_EVENT_KEYS.CALL_HISTORY_USER_SESSION_INFO, event as CallSessionEvent); diff --git a/packages/calling/src/CallHistory/callHistoryFixtures.ts b/packages/calling/src/CallHistory/callHistoryFixtures.ts index ef30a1e8219..b7901a6dcc6 100644 --- a/packages/calling/src/CallHistory/callHistoryFixtures.ts +++ b/packages/calling/src/CallHistory/callHistoryFixtures.ts @@ -5,7 +5,7 @@ import { SessionType, CallSessionViewedEvent, } from '../Events/types'; -import {UpdateMissedCallsResponse} from './types'; +import {UCMLinesResponse, UpdateMissedCallsResponse} from './types'; export const sortedCallHistory = { body: { @@ -287,6 +287,200 @@ export const mockCallHistoryBody = { }, }; +/** + * MOCK_CALL_HISTORY_WITH_UCM_LINE_NUMBER simulates a call history response where the session contains + * both cucmDN and ucmLineNumber data. This implies that the cucmDN was successfully matched with the UCM lines data. + */ +export const MOCK_CALL_HISTORY_WITH_UCM_LINE_NUMBER = { + body: { + statusCode: 200, + userSessions: [ + { + id: '123456', + durationSecs: 438, + self: { + id: 'fd2e1234', + name: 'Mark', + cucmDN: '1001', + ucmLineNumber: 1, + incomingCallProtocols: [], + callbackInfo: { + callbackAddress: 'test@cisco.com', + callbackType: 'EMAIL', + }, + lookUpInfo: { + lookupLink: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/98765', + type: 'CONVERSATION', + }, + }, + url: 'https://janus-a.wbx2.com/janus/api/v1/history/userSessions/654321', + sessionId: '123456', + sessionType: 'SPARK', + startTime: '2022-08-22T10:45:21.565Z', + endTime: '2022-08-22T10:53:01.624Z', + direction: 'OUTGOING', + disposition: 'INITIATED', + other: { + id: '100001', + name: 'test', + isPrivate: false, + callbackAddress: '89998888', + }, + durationSeconds: 438, + joinedDurationSeconds: 457, + participantCount: 2, + links: { + locusUrl: 'https://locus-a.wbx2.com/locus/api/v1/loci/786765', + conversationUrl: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/55443322', + callbackAddress: '01010101', + }, + isDeleted: false, + isPMR: false, + correlationIds: ['008899'], + }, + { + id: '20191817', + durationSecs: 438, + self: { + id: '12131415', + name: 'Mark', + cucmDN: '1002', + ucmLineNumber: 2, + incomingCallProtocols: [], + callbackInfo: { + callbackAddress: 'test@cisco.com', + callbackType: 'EMAIL', + }, + lookUpInfo: { + lookupLink: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/21314151', + type: 'CONVERSATION', + }, + }, + url: 'https://janus-a.wbx2.com/janus/api/v1/history/userSessions/100101102', + sessionId: '20191817', + sessionType: 'SPARK', + startTime: '2022-08-30T10:45:21.565Z', + endTime: '2022-08-30T10:53:01.624Z', + direction: 'OUTGOING', + disposition: 'INITIATED', + other: { + id: '301302303', + name: 'test', + isPrivate: false, + callbackAddress: '401402403', + }, + durationSeconds: 438, + joinedDurationSeconds: 457, + participantCount: 2, + links: { + locusUrl: 'https://locus-a.wbx2.com/locus/api/v1/loci/501502503', + conversationUrl: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/601602603', + callbackAddress: '801802803', + }, + isDeleted: false, + isPMR: false, + correlationIds: ['901902903'], + }, + ], + }, +}; + +/** + * MOCK_CALL_HISTORY_WITHOUT_UCM_LINE_NUMBER simulates a call history response where the session contains + * cucmDN, but no ucmLineNumber is present. This implies that the cucmDN was not matched with any UCM lines data. + */ +export const MOCK_CALL_HISTORY_WITHOUT_UCM_LINE_NUMBER = { + body: { + statusCode: 200, + userSessions: [ + { + id: '123456', + durationSecs: 438, + self: { + id: 'fd2e1234', + name: 'Mark', + cucmDN: '1001', + incomingCallProtocols: [], + callbackInfo: { + callbackAddress: 'test@cisco.com', + callbackType: 'EMAIL', + }, + lookUpInfo: { + lookupLink: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/98765', + type: 'CONVERSATION', + }, + }, + url: 'https://janus-a.wbx2.com/janus/api/v1/history/userSessions/654321', + sessionId: '123456', + sessionType: 'SPARK', + startTime: '2022-08-22T10:45:21.565Z', + endTime: '2022-08-22T10:53:01.624Z', + direction: 'OUTGOING', + disposition: 'INITIATED', + other: { + id: '100001', + name: 'test', + isPrivate: false, + callbackAddress: '89998888', + }, + durationSeconds: 438, + joinedDurationSeconds: 457, + participantCount: 2, + links: { + locusUrl: 'https://locus-a.wbx2.com/locus/api/v1/loci/786765', + conversationUrl: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/55443322', + callbackAddress: '01010101', + }, + isDeleted: false, + isPMR: false, + correlationIds: ['008899'], + }, + { + id: '20191817', + durationSecs: 438, + self: { + id: '12131415', + name: 'Mark', + cucmDN: '1002', + incomingCallProtocols: [], + callbackInfo: { + callbackAddress: 'test@cisco.com', + callbackType: 'EMAIL', + }, + lookUpInfo: { + lookupLink: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/21314151', + type: 'CONVERSATION', + }, + }, + url: 'https://janus-a.wbx2.com/janus/api/v1/history/userSessions/100101102', + sessionId: '20191817', + sessionType: 'SPARK', + startTime: '2022-08-30T10:45:21.565Z', + endTime: '2022-08-30T10:53:01.624Z', + direction: 'OUTGOING', + disposition: 'INITIATED', + other: { + id: '301302303', + name: 'test', + isPrivate: false, + callbackAddress: '401402403', + }, + durationSeconds: 438, + joinedDurationSeconds: 457, + participantCount: 2, + links: { + locusUrl: 'https://locus-a.wbx2.com/locus/api/v1/loci/501502503', + conversationUrl: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/601602603', + callbackAddress: '801802803', + }, + isDeleted: false, + isPMR: false, + correlationIds: ['901902903'], + }, + ], + }, +}; + const WEBEX_CALL_SESSION = { id: 'd74d19cc-6aa7-f341-6012-aec433cc6f8d', durationSecs: 438, @@ -439,3 +633,43 @@ export const ERROR_DETAILS_400 = { }, message: 'FAILURE', }; + +/* + * MOCK_LINES_API_CALL_RESPONSE simulates a successful response from the UCM lines API. + */ +export const MOCK_LINES_API_CALL_RESPONSE: UCMLinesResponse = { + statusCode: 200, + data: { + lines: { + devices: [ + { + name: 'CSFheliosucm01', + model: 503, + lines: [ + { + dnorpattern: '+14928000001', + index: 1, + label: '', + }, + { + dnorpattern: '+14928000003', + index: 2, + label: '', + }, + ], + }, + ], + }, + }, + message: 'SUCCESS', +}; + +/** + * MOCK_LINES_API_CALL_RESPONSE_WITH_NO_LINEDATA simulates a successful UCM lines API response + * where no line data is present. The `lines` field is empty, indicating no devices or lines available. + */ +export const MOCK_LINES_API_CALL_RESPONSE_WITH_NO_LINEDATA: UCMLinesResponse = { + statusCode: 200, + data: {}, + message: 'SUCCESS', +}; diff --git a/packages/calling/src/CallHistory/constants.ts b/packages/calling/src/CallHistory/constants.ts index ac222e6edc3..f22c1d04e86 100644 --- a/packages/calling/src/CallHistory/constants.ts +++ b/packages/calling/src/CallHistory/constants.ts @@ -1,13 +1,19 @@ export const APPLICATION_JSON = 'application/json'; export const CALL_HISTORY_FILE = 'CallHistory'; export const CONTENT_TYPE = 'Content-Type'; +export const CONFIG = 'config'; export const FROM_DATE = '?from'; export const HISTORY = 'history'; export const LIMIT = 50; +export const LINES = 'lines'; export const NUMBER_OF_DAYS = 10; +export const ORG_ID = 'orgId'; +export const PEOPLE = 'people'; export const RESPONSE_MESSAGE = 'responseMessage'; -export const UPDATE_MISSED_CALLS_ENDPOINT = 'setReadState'; export const SET_READ_STATE_SUCCESS_MESSAGE = 'Missed calls are read by the user.'; export const SUCCESS_MESSAGE = 'SUCCESS'; export const STATUS_CODE = 'statusCode'; export const USER_SESSIONS = 'userSessions'; +export const UPDATE_MISSED_CALLS_ENDPOINT = 'setReadState'; +export const UNIFIED_COMMUNICATIONS = 'uc'; +export const VERSION_1 = 'v1'; diff --git a/packages/calling/src/CallHistory/types.ts b/packages/calling/src/CallHistory/types.ts index ca0ca3f333e..60be740f665 100644 --- a/packages/calling/src/CallHistory/types.ts +++ b/packages/calling/src/CallHistory/types.ts @@ -1,5 +1,10 @@ import {Eventing} from '../Events/impl'; -import {CallHistoryEventTypes, EndTimeSessionId, UserSession} from '../Events/types'; +import { + CallHistoryEventTypes, + EndTimeSessionId, + UserSession, + UCMLinesApiResponse, +} from '../Events/types'; import {LOGGER} from '../Logger/types'; import {SORT, SORT_BY} from '../common/types'; @@ -25,6 +30,15 @@ export type UpdateMissedCallsResponse = { message: string | null; }; +export type UCMLinesResponse = { + statusCode: number; + data: { + lines?: UCMLinesApiResponse; + error?: string; + }; + message: string | null; +}; + /** * Interface for CallHistory Client. * This encompasses a set of APIs designed to facilitate the retrieval of recent Call History Record. diff --git a/packages/calling/src/CallSettings/UcmBackendConnector.test.ts b/packages/calling/src/CallSettings/UcmBackendConnector.test.ts index 45df2f651c0..7b3409e57b1 100644 --- a/packages/calling/src/CallSettings/UcmBackendConnector.test.ts +++ b/packages/calling/src/CallSettings/UcmBackendConnector.test.ts @@ -1,10 +1,15 @@ import {LOGGER} from '../Logger/types'; import * as utils from '../common/Utils'; -import {FAILURE_MESSAGE, SUCCESS_MESSAGE, UCM_CONNECTOR_FILE} from '../common/constants'; +import { + FAILURE_MESSAGE, + SUCCESS_MESSAGE, + UCM_CONNECTOR_FILE, + WEBEX_API_CONFIG_INT_URL, +} from '../common/constants'; import {getTestUtilsWebex} from '../common/testUtil'; import {HTTP_METHODS, WebexRequestPayload} from '../common/types'; import {UcmBackendConnector} from './UcmBackendConnector'; -import {CF_ENDPOINT, ORG_ENDPOINT, PEOPLE_ENDPOINT, WEBEX_APIS_INT_URL} from './constants'; +import {CF_ENDPOINT, ORG_ENDPOINT, PEOPLE_ENDPOINT} from './constants'; import {CallForwardAlwaysSetting, CallForwardingSettingsUCM, IUcmBackendConnector} from './types'; describe('Call Settings Client Tests for UcmBackendConnector', () => { @@ -44,7 +49,7 @@ describe('Call Settings Client Tests for UcmBackendConnector', () => { }, }; - const callForwardingUri = `${WEBEX_APIS_INT_URL}/${PEOPLE_ENDPOINT}/${userId}/${CF_ENDPOINT.toLowerCase()}?${ORG_ENDPOINT}=${orgId}`; + const callForwardingUri = `${WEBEX_API_CONFIG_INT_URL}/${PEOPLE_ENDPOINT}/${userId}/${CF_ENDPOINT.toLowerCase()}?${ORG_ENDPOINT}=${orgId}`; beforeAll(() => { callSettingsClient = new UcmBackendConnector(webex, {level: LOGGER.INFO}, false); diff --git a/packages/calling/src/CallSettings/UcmBackendConnector.ts b/packages/calling/src/CallSettings/UcmBackendConnector.ts index cbf6fd7e8f2..3aab3e44ee6 100644 --- a/packages/calling/src/CallSettings/UcmBackendConnector.ts +++ b/packages/calling/src/CallSettings/UcmBackendConnector.ts @@ -8,15 +8,11 @@ import { SUCCESS_MESSAGE, UCM_CONNECTOR_FILE, VOICEMAIL, + WEBEX_API_CONFIG_INT_URL, + WEBEX_API_CONFIG_PROD_URL, } from '../common/constants'; import {HTTP_METHODS, WebexRequestPayload} from '../common/types'; -import { - CF_ENDPOINT, - ORG_ENDPOINT, - PEOPLE_ENDPOINT, - WEBEX_APIS_INT_URL, - WEBEX_APIS_PROD_URL, -} from './constants'; +import {CF_ENDPOINT, ORG_ENDPOINT, PEOPLE_ENDPOINT} from './constants'; import { CallForwardAlwaysSetting, CallForwardingSettingsUCM, @@ -132,7 +128,9 @@ export class UcmBackendConnector implements IUcmBackendConnector { method: this.getCallForwardAlwaysSetting.name, }; - const webexApisUrl = this.useProdWebexApis ? WEBEX_APIS_PROD_URL : WEBEX_APIS_INT_URL; + const webexApisUrl = this.useProdWebexApis + ? WEBEX_API_CONFIG_PROD_URL + : WEBEX_API_CONFIG_INT_URL; try { if (directoryNumber) { diff --git a/packages/calling/src/CallSettings/constants.ts b/packages/calling/src/CallSettings/constants.ts index a869a5703ce..95912f9fac4 100644 --- a/packages/calling/src/CallSettings/constants.ts +++ b/packages/calling/src/CallSettings/constants.ts @@ -7,5 +7,3 @@ export const CF_ENDPOINT = 'features/callForwarding'; export const VM_ENDPOINT = 'features/voicemail'; export const CALL_WAITING_ENDPOINT = 'CallWaiting'; export const XSI_VERSION = 'v2.0'; -export const WEBEX_APIS_INT_URL = 'https://integration.webexapis.com/v1/uc/config'; -export const WEBEX_APIS_PROD_URL = 'https://webexapis.com/v1/uc/config'; diff --git a/packages/calling/src/CallingClient/CallingClient.test.ts b/packages/calling/src/CallingClient/CallingClient.test.ts index 6432f0f0642..d39aad1ee23 100644 --- a/packages/calling/src/CallingClient/CallingClient.test.ts +++ b/packages/calling/src/CallingClient/CallingClient.test.ts @@ -41,6 +41,7 @@ import { mockCatalogUSInt, mockCatalogUS, mockCatalogEUInt, + mockUSServiceHosts, } from './callingClientFixtures'; import Line from './line'; import {filterMobiusUris} from '../common/Utils'; @@ -67,6 +68,40 @@ describe('CallingClient Tests', () => { }); } + describe('CallingClient pick Mobius cluster using Service Host Tests', () => { + afterAll(() => { + callManager.removeAllListeners(); + webex.internal.services['_serviceUrls']['mobius'] = + 'https://mobius.aintgen-a-1.int.infra.webex.com/api/v1'; + webex.internal.services['_hostCatalog'] = mockCatalogUS; + }); + + it('should set mobiusServiceHost correctly when URL is valid', async () => { + webex.internal.services._hostCatalog = mockCatalogEU; + webex.internal.services['_serviceUrls']['mobius'] = + 'https://mobius-eu-central-1.prod.infra.webex.com/api/v1'; + const urlSpy = jest.spyOn(window, 'URL').mockImplementation((url) => new window.URL(url)); + const callingClient = await createClient(webex, {logger: {level: LOGGER.INFO}}); + + expect(urlSpy).toHaveBeenCalledWith( + 'https://mobius-eu-central-1.prod.infra.webex.com/api/v1' + ); + + expect(callingClient['mobiusClusters']).toStrictEqual(mockEUServiceHosts); + + urlSpy.mockRestore(); + }); + + it('should use default mobius service host when Service URL is invalid', async () => { + webex.internal.services._hostCatalog = mockCatalogUS; + webex.internal.services._serviceUrls.mobius = 'invalid-url'; + + const callingClient = await createClient(webex, {logger: {level: LOGGER.INFO}}); + + expect(callingClient['mobiusClusters']).toStrictEqual(mockUSServiceHosts); + }); + }); + describe('ServiceData tests', () => { let callingClient: ICallingClient | undefined; diff --git a/packages/calling/src/CallingClient/CallingClient.ts b/packages/calling/src/CallingClient/CallingClient.ts index 5b7dba62f97..c14822aa0e7 100644 --- a/packages/calling/src/CallingClient/CallingClient.ts +++ b/packages/calling/src/CallingClient/CallingClient.ts @@ -106,6 +106,7 @@ export class CallingClient extends Eventing implements : {indicator: ServiceIndicator.CALLING, domain: ''}; const logLevel = this.sdkConfig?.logger?.level ? this.sdkConfig.logger.level : LOGGER.ERROR; + log.setLogger(logLevel, CALLING_CLIENT_FILE); validateServiceData(serviceData); this.callManager = getCallManager(this.webex, serviceData.indicator); @@ -115,7 +116,18 @@ export class CallingClient extends Eventing implements this.primaryMobiusUris = []; this.backupMobiusUris = []; + let mobiusServiceHost = ''; + try { + mobiusServiceHost = new URL(this.webex.internal.services._serviceUrls.mobius).host; + } catch (error) { + log.warn(`Failed to parse mobius service URL`, { + file: CALLING_CLIENT_FILE, + method: this.constructor.name, + }); + } + this.mobiusClusters = + (mobiusServiceHost && this.webex.internal.services._hostCatalog[mobiusServiceHost]) || this.webex.internal.services._hostCatalog[MOBIUS_US_PROD] || this.webex.internal.services._hostCatalog[MOBIUS_EU_PROD] || this.webex.internal.services._hostCatalog[MOBIUS_US_INT] || @@ -124,8 +136,6 @@ export class CallingClient extends Eventing implements this.registerSessionsListener(); - log.setLogger(logLevel, CALLING_CLIENT_FILE); - this.registerCallsClearedListener(); } diff --git a/packages/calling/src/CallingClient/calling/CallerId/types.ts b/packages/calling/src/CallingClient/calling/CallerId/types.ts index aefdfd25298..7fcf796f514 100644 --- a/packages/calling/src/CallingClient/calling/CallerId/types.ts +++ b/packages/calling/src/CallingClient/calling/CallerId/types.ts @@ -1,5 +1,5 @@ import {CallerIdInfo} from '../../../Events/types'; -import {DisplayInformation, PhoneNumber} from '../../../common/types'; +import {DisplayInformation} from '../../../common/types'; export type EmailType = { primary: boolean; @@ -18,31 +18,6 @@ export type PhotoType = { value: string; }; -export type ResourceType = { - userName: string; - emails: Array; - name: { - givenName: string; - familyName: string; - }; - phoneNumbers: Array; - entitlements: Array; - id: string; - photos: Array; - displayName: string; - active: boolean; - sipAddresses: Array; -}; - -/* The scim response has many fields , dropping few of them as they are not to be consumed by us */ -export type scimResponseBody = { - totalResults: number; - itemsPerPage: number; - startIndex: number; - schemas: Array; - Resources: Array; -}; - /** * Represents the interface for fetching caller ID details. */ diff --git a/packages/calling/src/CallingClient/calling/call.test.ts b/packages/calling/src/CallingClient/calling/call.test.ts index ce43512f806..c95fe05c9f4 100644 --- a/packages/calling/src/CallingClient/calling/call.test.ts +++ b/packages/calling/src/CallingClient/calling/call.test.ts @@ -109,6 +109,7 @@ describe('Call Tests', () => { sdpMunging: { convertPort9to0: true, addContentSlides: false, + copyClineToSessionLevel: true, }, }; @@ -717,32 +718,6 @@ describe('Call Tests', () => { {file: 'call', method: 'updateMedia'} ); }); - - describe('#addSessionConnection', () => { - let call; - - beforeEach(() => { - call = callManager.createCall(dest, CallDirection.INBOUND, deviceId, mockLineId); - }); - - it('should copy the c-line from media level to the session level', () => { - const sdp = `v=0\r\no=- 2890844526 2890842807 IN IP4 192.0.2.3\r\ns=-\r\nt=0 0\r\nm=audio 49170 RTP/AVP 0\r\nc=IN IP4 203.0.113.1\r\na=rtpmap:0 PCMU/8000`; - - const expectedSdp = `v=0\r\no=- 2890844526 2890842807 IN IP4 192.0.2.3\r\ns=-\r\nc=IN IP4 203.0.113.1\r\nt=0 0\r\nm=audio 49170 RTP/AVP 0\r\nc=IN IP4 203.0.113.1\r\na=rtpmap:0 PCMU/8000`; - - const result = call.addSessionConnection(sdp); - expect(result).toBe(expectedSdp); - }); - - it('should handle multiple media sections correctly', () => { - const sdp = `v=0\r\no=- 2890844526 2890842807 IN IP4 192.0.2.3\r\ns=-\r\nt=0 0\r\nm=audio 49170 RTP/AVP 0\r\nc=IN IP4 203.0.113.1\r\na=rtpmap:0 PCMU/8000\r\nm=video 51372 RTP/AVP 31\r\nc=IN IP4 203.0.113.2\r\na=rtpmap:31 H261/90000`; - - const expectedSdp = `v=0\r\no=- 2890844526 2890842807 IN IP4 192.0.2.3\r\ns=-\r\nc=IN IP4 203.0.113.1\r\nt=0 0\r\nm=audio 49170 RTP/AVP 0\r\nc=IN IP4 203.0.113.1\r\na=rtpmap:0 PCMU/8000\r\nm=video 51372 RTP/AVP 31\r\nc=IN IP4 203.0.113.2\r\na=rtpmap:31 H261/90000`; - - const result = call.addSessionConnection(sdp); - expect(result).toBe(expectedSdp); - }); - }); }); describe('State Machine handler tests', () => { @@ -1980,6 +1955,108 @@ describe('State Machine handler tests', () => { method: 'handleCallHold', }); }); + + describe('Call event timers tests', () => { + let callManager; + beforeEach(() => { + jest.useFakeTimers(); + callManager = getCallManager(webex, defaultServiceIndicator); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('times out if the next event is not received - 60 seconds timeout', async () => { + const statusPayload = ({ + statusCode: 200, + body: mockStatusBody, + }); + const dummyEvent = { + type: 'E_SEND_CALL_SETUP', + data: undefined as any, + }; + const logSpy = jest.spyOn(log, 'warn'); + const emitSpy = jest.spyOn(call, 'emit'); + const deleteSpy = jest.spyOn(call as any, 'delete'); + callManager.callCollection = {}; + + webex.request.mockReturnValue(statusPayload); + + // handleOutgoingCallSetup is asynchronous + await call.sendCallStateMachineEvt(dummyEvent as CallEvent); + expect(call['callStateMachine'].state.value).toBe('S_SEND_CALL_SETUP'); + + dummyEvent.type = 'E_RECV_CALL_PROGRESS'; + call.sendCallStateMachineEvt(dummyEvent as CallEvent); + expect(call['callStateMachine'].state.value).toBe('S_RECV_CALL_PROGRESS'); + + // Media setup for the call + dummyEvent.type = 'E_SEND_ROAP_OFFER'; + call.sendMediaStateMachineEvt(dummyEvent as RoapEvent); + + dummyEvent.data = { + seq: 1, + messageType: 'OFFER', + sdp: 'sdp', + }; + call.sendMediaStateMachineEvt(dummyEvent as RoapEvent); + + dummyEvent.type = 'E_RECV_ROAP_ANSWER'; + call.sendMediaStateMachineEvt(dummyEvent as RoapEvent); + + const dummyOkEvent = { + type: 'E_ROAP_OK', + data: { + received: false, + message: { + seq: 1, + messageType: 'OK', + }, + }, + }; + call.sendMediaStateMachineEvt(dummyOkEvent as RoapEvent); + dummyEvent.type = 'E_RECV_ROAP_OFFER_REQUEST'; + call.sendMediaStateMachineEvt(dummyEvent as RoapEvent); + dummyEvent.type = 'E_SEND_ROAP_OFFER'; + call.sendMediaStateMachineEvt(dummyEvent as RoapEvent); + dummyEvent.type = 'E_RECV_ROAP_ANSWER'; + logSpy.mockClear(); + jest.advanceTimersByTime(60000); + expect(logSpy.mock.calls[0][0]).toBe('Call timed out'); + expect(emitSpy).toHaveBeenCalledWith(CALL_EVENT_KEYS.DISCONNECT, call.getCorrelationId()); + expect(deleteSpy).toHaveBeenCalledTimes(1); + expect(callManager.callCollection).toStrictEqual({}); + }); + + it('times out if the next event is not received - 10 seconds timeout', async () => { + const statusPayload = ({ + statusCode: 200, + body: mockStatusBody, + }); + const dummyEvent = { + type: 'E_SEND_CALL_SETUP', + data: undefined as any, + }; + callManager.callCollection = {}; + const call = callManager.createCall(dest, CallDirection.OUTBOUND, deviceId, mockLineId); + const emitSpy = jest.spyOn(call, 'emit'); + const deleteSpy = jest.spyOn(call as any, 'delete'); + const logSpy = jest.spyOn(log, 'warn'); + webex.request.mockReturnValue(statusPayload); + expect(Object.keys(callManager.callCollection)[0]).toBe(call.getCorrelationId()); + + // handleOutgoingCallSetup is asynchronous + await call.sendCallStateMachineEvt(dummyEvent as CallEvent); + expect(call['callStateMachine'].state.value).toBe('S_SEND_CALL_SETUP'); + logSpy.mockClear(); + jest.advanceTimersByTime(10000); + expect(logSpy.mock.calls[0][0]).toBe('Call timed out'); + expect(emitSpy).toHaveBeenCalledWith(CALL_EVENT_KEYS.DISCONNECT, call.getCorrelationId()); + expect(deleteSpy).toHaveBeenCalledTimes(1); + expect(callManager.callCollection).toStrictEqual({}); + }); + }); }); describe('Supplementary Services tests', () => { diff --git a/packages/calling/src/CallingClient/calling/call.ts b/packages/calling/src/CallingClient/calling/call.ts index 9fc68949cba..57c9f75eaee 100644 --- a/packages/calling/src/CallingClient/calling/call.ts +++ b/packages/calling/src/CallingClient/calling/call.ts @@ -282,6 +282,12 @@ export class Call extends Eventing implements ICall { /* CALL SETUP */ S_RECV_CALL_SETUP: { + after: { + 10000: { + target: 'S_CALL_CLEARED', + actions: ['triggerTimeout'], + }, + }, on: { E_SEND_CALL_ALERTING: { target: 'S_SEND_CALL_PROGRESS', @@ -302,6 +308,12 @@ export class Call extends Eventing implements ICall { }, }, S_SEND_CALL_SETUP: { + after: { + 10000: { + target: 'S_CALL_CLEARED', + actions: ['triggerTimeout'], + }, + }, on: { E_RECV_CALL_PROGRESS: { target: 'S_RECV_CALL_PROGRESS', @@ -328,6 +340,12 @@ export class Call extends Eventing implements ICall { /* CALL_PROGRESS */ S_RECV_CALL_PROGRESS: { + after: { + 60000: { + target: 'S_CALL_CLEARED', + actions: ['triggerTimeout'], + }, + }, on: { E_RECV_CALL_CONNECT: { target: 'S_RECV_CALL_CONNECT', @@ -353,6 +371,12 @@ export class Call extends Eventing implements ICall { }, }, S_SEND_CALL_PROGRESS: { + after: { + 60000: { + target: 'S_CALL_CLEARED', + actions: ['triggerTimeout'], + }, + }, on: { E_SEND_CALL_CONNECT: { target: 'S_SEND_CALL_CONNECT', @@ -375,6 +399,12 @@ export class Call extends Eventing implements ICall { /* CALL_CONNECT */ S_RECV_CALL_CONNECT: { + after: { + 10000: { + target: 'S_CALL_CLEARED', + actions: ['triggerTimeout'], + }, + }, on: { E_CALL_ESTABLISHED: { target: 'S_CALL_ESTABLISHED', @@ -395,6 +425,12 @@ export class Call extends Eventing implements ICall { }, }, S_SEND_CALL_CONNECT: { + after: { + 10000: { + target: 'S_CALL_CLEARED', + actions: ['triggerTimeout'], + }, + }, on: { E_CALL_ESTABLISHED: { target: 'S_CALL_ESTABLISHED', @@ -606,6 +642,10 @@ export class Call extends Eventing implements ICall { * @param event */ unknownState: (context, event: CallEvent) => this.handleUnknownState(event), + /** + * + */ + triggerTimeout: () => this.handleTimeout(), }, } ); @@ -1904,6 +1944,7 @@ export class Call extends Eventing implements ICall { sdpMunging: { convertPort9to0: true, addContentSlides: false, + copyClineToSessionLevel: true, }, }, { @@ -2375,24 +2416,6 @@ export class Call extends Eventing implements ICall { }); } - /* istanbul ignore next */ - /** - * Copy SDP's c-line to session level from media level. - * SPARK-522437 - */ - private addSessionConnection(sdp: string): string { - const lines: string[] = sdp.split(/\r\n|\r|\n/); - const mIndex: number = lines.findIndex((line) => line.startsWith('m=')); - const tIndex: number = lines.findIndex((line) => line.startsWith('t=')); - - if (mIndex !== -1 && mIndex < lines.length - 1 && lines[mIndex + 1].startsWith('c=')) { - const cLine: string = lines[mIndex + 1]; - lines.splice(tIndex, 0, cLine); - } - - return lines.join('\r\n'); - } - /* istanbul ignore next */ /** * Setup a listener for roap events emitted by the media sdk. @@ -2413,10 +2436,6 @@ export class Call extends Eventing implements ICall { method: this.mediaRoapEventsListener.name, }); - if (event.roapMessage?.sdp) { - event.roapMessage.sdp = this.addSessionConnection(event.roapMessage.sdp); - } - switch (event.roapMessage.messageType) { case RoapScenario.OK: { const mediaOk = { @@ -2815,6 +2834,26 @@ export class Call extends Eventing implements ICall { getCallRtpStats(): Promise { return this.getCallStats(); } + + /** + * Handle timeout for the missed events + * @param expectedStates - An array of next expected states + * @param errorMessage - Error message to be emitted if the call is not in the expected state in expected time + */ + private async handleTimeout() { + log.warn(`Call timed out`, { + file: CALL_FILE, + method: 'handleTimeout', + }); + this.deleteCb(this.getCorrelationId()); + this.emit(CALL_EVENT_KEYS.DISCONNECT, this.getCorrelationId()); + const response = await this.delete(); + + log.log(`handleTimeout: Response code: ${response.statusCode}`, { + file: CALL_FILE, + method: this.handleTimeout.name, + }); + } } /** diff --git a/packages/calling/src/CallingClient/constants.ts b/packages/calling/src/CallingClient/constants.ts index b395a335a5d..c3b8cc87fc4 100644 --- a/packages/calling/src/CallingClient/constants.ts +++ b/packages/calling/src/CallingClient/constants.ts @@ -43,9 +43,7 @@ export const DUMMY_METRICS = { }; export const DUMMY_MOBIUS_URL = 'https://mobius.aintgen-a-1.int.infra.webex.com/api/v1'; export const FETCH_NAME = /^[a-zA-Z ]+/; -export const IDENTITY_BROKER = 'https://identitybts.webex.com/'; export const IP_ENDPOINT = 'myip'; -export const IDENTITY_ENDPOINT_RESOURCE = 'identity'; export const INITIAL_SEQ_NUMBER = 1; export const MEDIA_ENDPOINT_RESOURCE = 'media'; export const NETWORK_FLAP_TIMEOUT = 2000; @@ -54,8 +52,6 @@ export const CALL_TRANSFER_SERVICE = 'calltransfer'; export const HOLD_ENDPOINT = 'hold'; export const TRANSFER_ENDPOINT = 'commit'; export const RESUME_ENDPOINT = 'resume'; -export const SCIM_ENDPOINT_RESOURCE = 'scim'; -export const SCIM_USER_FILTER = 'v1/Users?filter='; export const SPARK_USER_AGENT = 'spark-user-agent'; export const REGISTER_RETRY_TIMEOUT = 10000; export const SUPPLEMENTARY_SERVICES_TIMEOUT = 10000; @@ -111,6 +107,7 @@ export const SEC_TO_MSEC_MFACTOR = 1000; export const MINUTES_TO_SEC_MFACTOR = 60; export const REG_RANDOM_T_FACTOR_UPPER_LIMIT = 10000; export const REG_TRY_BACKUP_TIMER_VAL_IN_SEC = 1200; +export const REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC = 114; export const REG_FAILBACK_429_MAX_RETRIES = 5; export const REGISTER_UTIL = 'registerDevice'; export const GET_MOBIUS_SERVERS_UTIL = 'getMobiusServers'; diff --git a/packages/calling/src/CallingClient/registration/register.test.ts b/packages/calling/src/CallingClient/registration/register.test.ts index 0f0da477562..8283b073b86 100644 --- a/packages/calling/src/CallingClient/registration/register.test.ts +++ b/packages/calling/src/CallingClient/registration/register.test.ts @@ -22,6 +22,7 @@ import { KEEPALIVE_UTIL, MINUTES_TO_SEC_MFACTOR, REGISTRATION_FILE, + REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC, REG_TRY_BACKUP_TIMER_VAL_IN_SEC, SEC_TO_MSEC_MFACTOR, } from '../constants'; @@ -64,6 +65,17 @@ describe('Registration Tests', () => { }, }; + const ccMockResponse = { + ...mockResponse, + body: { + ...mockResponse.body, + serviceData: { + domain: '', + indicator: 'contactcenter', + }, + }, + }; + const failurePayload = ({ statusCode: 500, body: mockPostResponse, @@ -85,15 +97,19 @@ describe('Registration Tests', () => { let restoreSpy; let postRegistrationSpy; - beforeEach(() => { + const setupRegistration = (mockServiceData) => { const mutex = new Mutex(); - reg = createRegistration(webex, MockServiceData, mutex, lineEmitter, LOGGER.INFO); + reg = createRegistration(webex, mockServiceData, mutex, lineEmitter, LOGGER.INFO); reg.setMobiusServers(mobiusUris.primary, mobiusUris.backup); jest.clearAllMocks(); restartSpy = jest.spyOn(reg, 'restartRegistration'); failbackRetry429Spy = jest.spyOn(reg, FAILBACK_429_RETRY_UTIL); restoreSpy = jest.spyOn(reg, 'restorePreviousRegistration'); postRegistrationSpy = jest.spyOn(reg, 'postRegistration'); + }; + + beforeEach(() => { + setupRegistration(MockServiceData); }); afterEach(() => { @@ -218,6 +234,36 @@ describe('Registration Tests', () => { expect(reg.getActiveMobiusUrl()).toEqual(mobiusUris.backup[0]); }); + it('cc: verify unreachable primary with reachable backup server', async () => { + setupRegistration({...MockServiceData, indicator: ServiceIndicator.CONTACT_CENTER}); + + jest.useFakeTimers(); + webex.request + .mockRejectedValueOnce(failurePayload) + .mockRejectedValueOnce(failurePayload) + .mockResolvedValueOnce(successPayload); + + expect(reg.getStatus()).toEqual(RegistrationStatus.IDLE); + await reg.triggerRegistration(); + jest.advanceTimersByTime(REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC * SEC_TO_MSEC_MFACTOR); + await flushPromises(); + + expect(webex.request).toBeCalledTimes(3); + expect(webex.request).toBeCalledWith({ + ...ccMockResponse, + method: 'POST', + uri: `${mobiusUris.primary[0]}device`, + }); + expect(webex.request).toBeCalledWith({ + ...ccMockResponse, + method: 'POST', + uri: `${mobiusUris.backup[0]}device`, + }); + expect(reg.getStatus()).toEqual(RegistrationStatus.ACTIVE); + /* Active Url must match with the backup url as per the test */ + expect(reg.getActiveMobiusUrl()).toEqual(mobiusUris.backup[0]); + }); + it('verify unreachable primary and backup servers', async () => { jest.useFakeTimers(); // try the primary twice and register successfully with backup servers @@ -444,15 +490,14 @@ describe('Registration Tests', () => { file: REGISTRATION_FILE, method: 'startKeepaliveTimer', }; - const mockKeepAliveBody = {device: mockPostResponse.device}; - beforeEach(async () => { + const beforeEachSetupForKeepalive = async () => { postRegistrationSpy.mockResolvedValueOnce(successPayload); jest.useFakeTimers(); await reg.triggerRegistration(); expect(reg.getStatus()).toBe(RegistrationStatus.ACTIVE); - }); + }; afterEach(() => { jest.clearAllTimers(); @@ -471,6 +516,7 @@ describe('Registration Tests', () => { }); it('verify successful keep-alive cases', async () => { + await beforeEachSetupForKeepalive(); const keepAlivePayload = ({ statusCode: 200, body: mockKeepAliveBody, @@ -487,6 +533,7 @@ describe('Registration Tests', () => { }); it('verify failure keep-alive cases: Retry Success', async () => { + await beforeEachSetupForKeepalive(); const failurePayload = ({ statusCode: 503, body: mockKeepAliveBody, @@ -517,6 +564,7 @@ describe('Registration Tests', () => { }); it('verify failure keep-alive cases: Restore failure', async () => { + await beforeEachSetupForKeepalive(); const restoreSpy = jest.spyOn(reg, 'restorePreviousRegistration'); const restartRegSpy = jest.spyOn(reg, 'restartRegistration'); const reconnectSpy = jest.spyOn(reg, 'reconnectOnFailure'); @@ -565,6 +613,7 @@ describe('Registration Tests', () => { }); it('verify failure keep-alive cases: Restore Success', async () => { + await beforeEachSetupForKeepalive(); const restoreSpy = jest.spyOn(reg, 'restorePreviousRegistration'); const restartRegSpy = jest.spyOn(reg, 'restartRegistration'); const reconnectSpy = jest.spyOn(reg, 'reconnectOnFailure'); @@ -616,6 +665,7 @@ describe('Registration Tests', () => { }); it('verify failure followed by recovery of keepalive', async () => { + await beforeEachSetupForKeepalive(); const failurePayload = ({ statusCode: 503, body: mockKeepAliveBody, @@ -647,7 +697,53 @@ describe('Registration Tests', () => { expect(reg.keepaliveTimer).toBe(timer); }); + it('cc: verify failover to backup server after 4 keep alive failure with primary server', async () => { + // Register with contact center service + setupRegistration({...MockServiceData, indicator: ServiceIndicator.CONTACT_CENTER}); + await beforeEachSetupForKeepalive(); + + const failurePayload = ({ + statusCode: 503, + body: mockKeepAliveBody, + }); + const successPayload = ({ + statusCode: 200, + body: mockKeepAliveBody, + }); + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + jest + .spyOn(reg, 'postKeepAlive') + .mockRejectedValueOnce(failurePayload) + .mockRejectedValueOnce(failurePayload) + .mockRejectedValueOnce(failurePayload) + .mockRejectedValueOnce(failurePayload) + .mockResolvedValue(successPayload); + + expect(reg.getStatus()).toBe(RegistrationStatus.ACTIVE); + + const timer = reg.keepaliveTimer; + + jest.advanceTimersByTime(5 * mockPostResponse.keepaliveInterval * SEC_TO_MSEC_MFACTOR); + await flushPromises(); + + expect(clearIntervalSpy).toBeCalledOnceWith(timer); + expect(reg.getStatus()).toBe(RegistrationStatus.INACTIVE); + expect(reg.keepaliveTimer).not.toBe(timer); + + webex.request.mockRejectedValueOnce(failurePayload).mockResolvedValue(successPayload); + + jest.advanceTimersByTime(REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC * SEC_TO_MSEC_MFACTOR); + await flushPromises(); + + /* Active Url must match with the backup url as per the test */ + expect(reg.getActiveMobiusUrl()).toEqual(mobiusUris.backup[0]); + expect(reg.getStatus()).toBe(RegistrationStatus.ACTIVE); + }); + it('verify final error for keep-alive', async () => { + await beforeEachSetupForKeepalive(); const restoreSpy = jest.spyOn(reg, 'restorePreviousRegistration'); const restartRegSpy = jest.spyOn(reg, 'restartRegistration'); const reconnectSpy = jest.spyOn(reg, 'reconnectOnFailure'); @@ -686,6 +782,7 @@ describe('Registration Tests', () => { }); it('verify failure keep-alive case with active call present: Restore Success after call ends', async () => { + await beforeEachSetupForKeepalive(); const restoreSpy = jest.spyOn(reg, 'restorePreviousRegistration'); const restartRegSpy = jest.spyOn(reg, 'restartRegistration'); const reconnectSpy = jest.spyOn(reg, 'reconnectOnFailure'); diff --git a/packages/calling/src/CallingClient/registration/register.ts b/packages/calling/src/CallingClient/registration/register.ts index 862f827300e..297c8fef62e 100644 --- a/packages/calling/src/CallingClient/registration/register.ts +++ b/packages/calling/src/CallingClient/registration/register.ts @@ -17,6 +17,7 @@ import { IDeviceInfo, RegistrationStatus, ServiceData, + ServiceIndicator, WebexRequestPayload, } from '../../common/types'; import {ISDKConnector, WebexSDK} from '../../SDKConnector/types'; @@ -39,6 +40,7 @@ import { DEFAULT_REHOMING_INTERVAL_MIN, DEFAULT_REHOMING_INTERVAL_MAX, DEFAULT_KEEPALIVE_INTERVAL, + REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC, } from '../constants'; import {LINE_EVENTS, LineEmitterCallback} from '../line/types'; import {LineError} from '../../Errors/catalog/LineError'; @@ -73,6 +75,7 @@ export class Registration implements IRegistration { private backupMobiusUris: string[]; private registerRetry = false; private reconnectPending = false; + private isCCFlow = false; /** */ @@ -85,6 +88,8 @@ export class Registration implements IRegistration { ) { this.sdkConnector = SDKConnector; this.serviceData = serviceData; + this.isCCFlow = serviceData.indicator === ServiceIndicator.CONTACT_CENTER; + if (!this.sdkConnector.getWebex()) { SDKConnector.setWebex(webex); } @@ -257,8 +262,12 @@ export class Registration implements IRegistration { let interval = this.getRegRetryInterval(attempt); - if (timeElapsed + interval > REG_TRY_BACKUP_TIMER_VAL_IN_SEC) { - const excessVal = timeElapsed + interval - REG_TRY_BACKUP_TIMER_VAL_IN_SEC; + const TIMER_THRESHOLD = this.isCCFlow + ? REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC + : REG_TRY_BACKUP_TIMER_VAL_IN_SEC; + + if (timeElapsed + interval > TIMER_THRESHOLD) { + const excessVal = timeElapsed + interval - TIMER_THRESHOLD; interval -= excessVal; } @@ -681,13 +690,15 @@ export class Registration implements IRegistration { private startKeepaliveTimer(url: string, interval: number) { let keepAliveRetryCount = 0; this.clearKeepaliveTimer(); + const RETRY_COUNT_THRESHOLD = this.isCCFlow ? 4 : 5; + this.keepaliveTimer = setInterval(async () => { const logContext = { file: REGISTRATION_FILE, method: this.startKeepaliveTimer.name, }; await this.mutex.runExclusive(async () => { - if (this.isDeviceRegistered() && keepAliveRetryCount < 5) { + if (this.isDeviceRegistered() && keepAliveRetryCount < RETRY_COUNT_THRESHOLD) { try { const res = await this.postKeepAlive(url); log.info(`Sent Keepalive, status: ${res.statusCode}`, logContext); @@ -720,7 +731,7 @@ export class Registration implements IRegistration { {method: this.startKeepaliveTimer.name, file: REGISTRATION_FILE} ); - if (abort || keepAliveRetryCount >= 5) { + if (abort || keepAliveRetryCount >= RETRY_COUNT_THRESHOLD) { this.setStatus(RegistrationStatus.INACTIVE); this.clearKeepaliveTimer(); this.clearFailbackTimer(); diff --git a/packages/calling/src/Contacts/ContactsClient.test.ts b/packages/calling/src/Contacts/ContactsClient.test.ts index ee1068f02eb..b882d31979e 100644 --- a/packages/calling/src/Contacts/ContactsClient.test.ts +++ b/packages/calling/src/Contacts/ContactsClient.test.ts @@ -1,9 +1,16 @@ -import {HTTP_METHODS, WebexRequestPayload} from '../common/types'; +import {HTTP_METHODS, SCIMListResponse, WebexRequestPayload} from '../common/types'; import {getTestUtilsWebex} from '../common/testUtil'; import {LOGGER} from '../Logger/types'; import {Contact, ContactResponse, IContacts} from './types'; import {createContactsClient} from './ContactsClient'; -import {FAILURE_MESSAGE, SUCCESS_MESSAGE} from '../common/constants'; +import { + FAILURE_MESSAGE, + IDENTITY_ENDPOINT_RESOURCE, + SCIM_ENDPOINT_RESOURCE, + SCIM_USER_FILTER, + SUCCESS_MESSAGE, + WEBEX_API_BTS, +} from '../common/constants'; import log from '../Logger'; import { CONTACTS_FILE, @@ -24,12 +31,12 @@ import { mockContactResponseBodyOne, mockCountry, mockDisplayNameOne, - mockDSSResponse, mockEmail, mockFirstName, mockLastName, mockNumber1, mockNumber2, + mockSCIMListResponse, mockSipAddress, mockState, mockStreet, @@ -42,6 +49,8 @@ import { mockContactGroupListOne, mockContactGroupListTwo, mockAvatarURL, + mockSCIMMinListResponse, + mockContactMinimum, } from './contactFixtures'; describe('ContactClient Tests', () => { @@ -51,6 +60,7 @@ describe('ContactClient Tests', () => { // eslint-disable-next-line no-underscore-dangle const contactServiceUrl = `${webex.internal.services._serviceUrls.contactsService}/${ENCRYPT_FILTER}/${USERS}/${CONTACT_FILTER}`; + const scimUrl = `${WEBEX_API_BTS}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}id%20eq%20%22801bb994-343b-4f6b-97ae-d13c91d4b877%22`; // eslint-disable-next-line no-underscore-dangle const contactServiceGroupUrl = `${webex.internal.services._serviceUrls.contactsService}/${ENCRYPT_FILTER}/${USERS}/${GROUP_FILTER}`; const serviceErrorCodeHandlerSpy = jest.spyOn(utils, 'serviceErrorCodeHandler'); @@ -90,6 +100,8 @@ describe('ContactClient Tests', () => { expectedMessage: string; expectedStatusCode: number; decryptTextList: Array; + cloudContactPresent?: boolean; + scimResponse?: SCIMListResponse; }[] = [ { name: 'Success case 1: fetch contacts using get contacts api, custom and cloud contact present', @@ -118,6 +130,8 @@ describe('ContactClient Tests', () => { mockSipAddress, mockGroupName, ], + cloudContactPresent: true, + scimResponse: mockSCIMListResponse, }, { name: 'Success case 2: fetch contacts using get contacts api, single custom contact with mandatory details present', @@ -202,7 +216,10 @@ describe('ContactClient Tests', () => { codeObj.decryptTextList.forEach((text) => { webex.internal.encryption.decryptText.mockResolvedValueOnce(text); }); - webex.internal.dss.lookup.mockResolvedValueOnce(mockDSSResponse); + + if (codeObj.scimResponse) { + webex.request.mockResolvedValueOnce(mockSCIMListResponse); + } } else { respPayload['message'] = FAILURE_MESSAGE; respPayload['data'] = codeObj.payloadData; @@ -211,10 +228,34 @@ describe('ContactClient Tests', () => { const contactsResponse = await contactClient.getContacts(); - expect(webex.request).toBeCalledOnceWith({ - uri: contactServiceUrl, - method: HTTP_METHODS.GET, - }); + if (codeObj.inputStatusCode === 200) { + if (codeObj.cloudContactPresent) { + expect(webex.request).toBeCalledTimes(2); + } else { + expect(webex.request).toBeCalledTimes(1); + } + expect(webex.request).toHaveBeenNthCalledWith(1, { + uri: contactServiceUrl, + method: HTTP_METHODS.GET, + }); + + if (codeObj.cloudContactPresent) { + expect(webex.request).toHaveBeenNthCalledWith(2, { + uri: scimUrl, + method: HTTP_METHODS.GET, + headers: { + 'cisco-device-url': + 'https://wdm-intb.ciscospark.com/wdm/api/v1/devices/c5ae3b86-1bb7-40f1-a6a9-c296ee7e61d5', + 'spark-user-agent': 'webex-calling/beta', + }, + }); + } + } else { + expect(webex.request).toBeCalledOnceWith({ + uri: contactServiceUrl, + method: HTTP_METHODS.GET, + }); + } expect(contactsResponse).toEqual({ data: expect.any(Object), @@ -563,7 +604,8 @@ describe('ContactClient Tests', () => { webex.request .mockResolvedValueOnce(successResponsePayloadGroup) - .mockResolvedValueOnce(successResponsePayload); + .mockResolvedValueOnce(successResponsePayload) + .mockResolvedValueOnce(mockSCIMListResponse); webex.internal.encryption.encryptText.mockResolvedValueOnce('Encrypted group name'); @@ -581,14 +623,13 @@ describe('ContactClient Tests', () => { expect(res.statusCode).toEqual(400); expect(res.data.error).toEqual('contactId is required for contactType:CLOUD.'); - webex.internal.dss.lookup.mockResolvedValueOnce(mockDSSResponse); contact.contactId = mockContactResponse.contactId; res = await contactClient.createContact(contact); expect(res.statusCode).toEqual(201); expect(res.data.contact?.contactId).toBe(mockContactResponse.contactId); - expect(webex.request).toBeCalledTimes(2); + expect(webex.request).toBeCalledTimes(3); expect(webex.request).toHaveBeenNthCalledWith(1, { method: HTTP_METHODS.POST, uri: contactServiceGroupUrl, @@ -610,6 +651,15 @@ describe('ContactClient Tests', () => { groups: ['1561977e-3443-4ccf-a591-69686275d7d2'], }, }); + expect(webex.request).toHaveBeenNthCalledWith(3, { + uri: scimUrl, + method: HTTP_METHODS.GET, + headers: { + 'cisco-device-url': + 'https://wdm-intb.ciscospark.com/wdm/api/v1/devices/c5ae3b86-1bb7-40f1-a6a9-c296ee7e61d5', + 'spark-user-agent': 'webex-calling/beta', + }, + }); }); it('create a contact - service unavailable', async () => { @@ -677,4 +727,81 @@ describe('ContactClient Tests', () => { expect(contactClient['contacts']).toEqual(mockContactListOne); }); + + it('test resolveContacts function for a minimal contact with few details', () => { + const contact = contactClient['resolveCloudContacts']( + {userId: mockContactMinimum}, + mockSCIMMinListResponse.body + ); + + expect(contact).toEqual([ + { + avatarURL: '', + avatarUrlDomain: undefined, + contactId: 'userId', + contactType: 'CLOUD', + department: undefined, + displayName: undefined, + emails: undefined, + encryptionKeyUrl: 'kms://cisco.com/keys/dcf18f9d-155e-44ff-ad61-c8a69b7103ab', + firstName: undefined, + groups: ['1561977e-3443-4ccf-a591-69686275d7d2'], + lastName: undefined, + manager: undefined, + ownerId: 'ownerId', + phoneNumbers: undefined, + sipAddresses: undefined, + resolved: true, + }, + ]); + }); + + it("test resolveContacts function when contactsDataMap list doesn't match resolved list", () => { + const mockContact = { + firstName: 'Jane', + lastName: 'Doe', + contactId: 'janeDoe', + }; + + const contact = contactClient['resolveCloudContacts']( + {userId: mockContactMinimum, janeDoe: mockContact}, + mockSCIMMinListResponse.body + ); + + expect(contact).toEqual([ + { + firstName: 'Jane', + lastName: 'Doe', + contactId: 'janeDoe', + resolved: false, + }, + { + avatarURL: '', + avatarUrlDomain: undefined, + contactId: 'userId', + contactType: 'CLOUD', + department: undefined, + displayName: undefined, + emails: undefined, + encryptionKeyUrl: 'kms://cisco.com/keys/dcf18f9d-155e-44ff-ad61-c8a69b7103ab', + firstName: undefined, + groups: ['1561977e-3443-4ccf-a591-69686275d7d2'], + lastName: undefined, + manager: undefined, + ownerId: 'ownerId', + phoneNumbers: undefined, + sipAddresses: undefined, + resolved: true, + }, + ]); + }); + + it('test resolveContacts function encountering an error', () => { + const contact = contactClient['resolveCloudContacts']( + {userId: mockContactMinimum}, + mockSCIMMinListResponse + ); + + expect(contact).toEqual(null); + }); }); diff --git a/packages/calling/src/Contacts/ContactsClient.ts b/packages/calling/src/Contacts/ContactsClient.ts index d701f7d8f0e..0245c7461e3 100644 --- a/packages/calling/src/Contacts/ContactsClient.ts +++ b/packages/calling/src/Contacts/ContactsClient.ts @@ -1,6 +1,12 @@ /* eslint-disable no-await-in-loop */ -import {FAILURE_MESSAGE, STATUS_CODE, SUCCESS_MESSAGE} from '../common/constants'; -import {HTTP_METHODS, WebexRequestPayload, ContactDetail} from '../common/types'; +import { + FAILURE_MESSAGE, + SCIM_ENTERPRISE_USER, + SCIM_WEBEXIDENTITY_USER, + STATUS_CODE, + SUCCESS_MESSAGE, +} from '../common/constants'; +import {HTTP_METHODS, WebexRequestPayload, ContactDetail, SCIMListResponse} from '../common/types'; import {LoggerInterface} from '../Voicemail/types'; import {ISDKConnector, WebexSDK} from '../SDKConnector/types'; import SDKConnector from '../SDKConnector'; @@ -13,6 +19,8 @@ import { DEFAULT_GROUP_NAME, ENCRYPT_FILTER, GROUP_FILTER, + OR, + SCIM_ID_FILTER, USERS, encryptedFields, } from './constants'; @@ -27,10 +35,7 @@ import { GroupType, } from './types'; -import {serviceErrorCodeHandler} from '../common/Utils'; -import Logger from '../Logger'; -import ExtendedError from '../Errors/catalog/ExtendedError'; -import {ERROR_TYPE} from '../Errors/types'; +import {scimQuery, serviceErrorCodeHandler} from '../common/Utils'; /** * `ContactsClient` module is designed to offer a set of APIs for retrieving and updating contacts and groups from the contacts-service. @@ -66,7 +71,6 @@ export class ContactsClient implements IContacts { } this.webex = this.sdkConnector.getWebex(); - this.webex.internal.dss.register(); this.encryptionKeyUrl = ''; this.groups = undefined; @@ -253,75 +257,73 @@ export class ContactsClient implements IContacts { return decryptedContact; } - /** - * Fetches contacts from DSS. - */ - private async fetchContactFromDSS( + private resolveCloudContacts( contactsDataMap: ContactIdContactInfo, - id: string - ): Promise { - try { - const contact = await this.webex.internal.dss.lookup({id, shouldBatch: true}); - - const contactId = contact.identity; - const {displayName, emails, phoneNumbers, sipAddresses, photos} = contact; - const {department, firstName, identityManager, jobTitle, lastName} = contact.additionalInfo; - const manager = - identityManager && identityManager.displayName ? identityManager.displayName : undefined; - const {contactType, avatarUrlDomain, encryptionKeyUrl, ownerId, groups} = - contactsDataMap[contactId]; - let avatarURL = ''; - - if (photos.length) { - avatarURL = photos[0].value; - } - const addedPhoneNumbers = contactsDataMap[contactId].phoneNumbers; - - if (addedPhoneNumbers) { - const decryptedPhoneNumbers = await this.decryptContactDetail( - encryptionKeyUrl, - addedPhoneNumbers - ); + inputList: SCIMListResponse + ): Contact[] | null { + const loggerContext = { + file: CONTACTS_FILE, + method: 'resolveCloudContacts', + }; + const finalContactList: Contact[] = []; + const resolvedList: string[] = []; - decryptedPhoneNumbers.forEach((number) => phoneNumbers.push(number)); - } + try { + inputList.Resources.forEach((item) => { + resolvedList.push(item.id); + }); - const addedSipAddresses = contactsDataMap[contactId].sipAddresses; + Object.values(contactsDataMap).forEach((item) => { + const isResolved = resolvedList.some((listItem) => listItem === item.contactId); + if (!isResolved) { + finalContactList.push({...item, resolved: false}); + } + }); - if (addedSipAddresses) { - const decryptedSipAddresses = await this.decryptContactDetail( + for (let n = 0; n < inputList.Resources.length; n += 1) { + const filteredContact = inputList.Resources[n]; + const {displayName, emails, phoneNumbers, photos} = filteredContact; + let sipAddresses; + if (filteredContact[SCIM_WEBEXIDENTITY_USER]) { + sipAddresses = filteredContact[SCIM_WEBEXIDENTITY_USER].sipAddresses; + } + const firstName = filteredContact.name?.givenName; + const lastName = filteredContact.name?.familyName; + const manager = filteredContact[SCIM_ENTERPRISE_USER]?.manager?.displayName; + const department = filteredContact[SCIM_ENTERPRISE_USER]?.department; + const avatarURL = photos?.length ? photos[0].value : ''; + + const {contactType, avatarUrlDomain, encryptionKeyUrl, ownerId, groups} = + contactsDataMap[inputList.Resources[n].id]; + + const cloudContact = { + avatarUrlDomain, + avatarURL, + contactId: inputList.Resources[n].id, + contactType, + department, + displayName, + emails, encryptionKeyUrl, - addedSipAddresses - ); - - decryptedSipAddresses.forEach((address) => sipAddresses.push(address)); + firstName, + groups, + lastName, + manager, + ownerId, + phoneNumbers, + sipAddresses, + resolved: true, + }; + + finalContactList.push(cloudContact); } - - const cloudContact = { - avatarUrlDomain, - avatarURL, - contactId, - contactType, - department, - displayName, - emails, - encryptionKeyUrl, - firstName, - groups, - lastName, - manager, - ownerId, - phoneNumbers, - sipAddresses, - title: jobTitle, - }; - - return cloudContact; } catch (error: any) { - Logger.error(new ExtendedError(error.message, {}, ERROR_TYPE.DEFAULT), {}); + log.warn('Error occurred while parsing resolved contacts', loggerContext); return null; } + + return finalContactList; } /** @@ -334,7 +336,7 @@ export class ContactsClient implements IContacts { }; const contactList: Contact[] = []; - const contactsDataMap: ContactIdContactInfo = {}; + const cloudContactsMap: ContactIdContactInfo = {}; try { const response = await this.webex.request({ @@ -351,19 +353,27 @@ export class ContactsClient implements IContacts { const {contacts, groups} = responseBody; - for (let i = 0; i < contacts.length; i += 1) { - const contact = contacts[i]; - + contacts.map(async (contact) => { if (contact.contactType === ContactType.CUSTOM) { const decryptedContact = await this.decryptContact(contact); contactList.push(decryptedContact); } else if (contact.contactType === ContactType.CLOUD && contact.contactId) { - contactsDataMap[contact.contactId] = contact; - const contactDetails = await this.fetchContactFromDSS(contactsDataMap, contact.contactId); - if (contactDetails) { - contactList.push(contactDetails); - } + cloudContactsMap[contact.contactId] = contact; + } + }); + + // Resolve cloud contacts + if (Object.keys(cloudContactsMap).length) { + const contactIdList = Object.keys(cloudContactsMap); + const query = contactIdList.map((item) => `${SCIM_ID_FILTER} "${item}"`).join(OR); + const result = await scimQuery(query); + const resolvedContacts = this.resolveCloudContacts( + cloudContactsMap, + result.body as SCIMListResponse + ); + if (resolvedContacts) { + resolvedContacts.map((item) => contactList.push(item)); } } @@ -699,13 +709,14 @@ export class ContactsClient implements IContacts { }; if (contact.contactType === ContactType.CLOUD && newContact.contactId) { - const decryptedContact = await this.fetchContactFromDSS( + const query = `${SCIM_ID_FILTER} "${newContact.contactId}"`; + const res = await scimQuery(query); + const resolvedContact = this.resolveCloudContacts( Object.fromEntries([[newContact.contactId, newContact]]) as ContactIdContactInfo, - newContact.contactId + res.body as SCIMListResponse ); - - if (decryptedContact) { - this.contacts?.push(decryptedContact); + if (resolvedContact) { + this.contacts?.push(resolvedContact[0]); } } else { this.contacts?.push(contact); diff --git a/packages/calling/src/Contacts/constants.ts b/packages/calling/src/Contacts/constants.ts index 8b69f44686b..705ceda4381 100644 --- a/packages/calling/src/Contacts/constants.ts +++ b/packages/calling/src/Contacts/constants.ts @@ -5,6 +5,8 @@ export const ENCRYPT_FILTER = 'encrypt'; export const USERS = 'Users'; export const DEFAULT_GROUP_NAME = 'Other contacts'; export const CONTACTS_SCHEMA = 'urn:cisco:codev:identity:contact:core:1.0'; +export const SCIM_ID_FILTER = 'id eq'; +export const OR = ' or '; export enum encryptedFields { ADDRESS_INFO = 'addressInfo', diff --git a/packages/calling/src/Contacts/contactFixtures.ts b/packages/calling/src/Contacts/contactFixtures.ts index afe591a8f83..4295a607411 100644 --- a/packages/calling/src/Contacts/contactFixtures.ts +++ b/packages/calling/src/Contacts/contactFixtures.ts @@ -272,39 +272,213 @@ export const mockContactGroupListTwo = [ }, ]; -export const mockDSSResponse = { - additionalInfo: { - created: '2022-08-05T02:51:46.055Z', - department: '123029217', - extLinkedAccts: [{providerID: 'cisco.webex.com', accountGUID: '500802287', status: 'active'}], - firstName: 'Emily', - identityManager: { - displayName: 'Robert Langdon', - managerId: '9d0fce00-95b2-435f-99d1-b6b44759fbdc', +export const mockContactMinimum = { + contactId: 'userId', + contactType: 'CLOUD', + encryptionKeyUrl: 'kms://cisco.com/keys/dcf18f9d-155e-44ff-ad61-c8a69b7103ab', + groups: ['1561977e-3443-4ccf-a591-69686275d7d2'], + ownerId: 'ownerId', +}; + +export const scimUserMinimum = { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + id: 'userId', + userName: 'userName', + userType: 'user', +}; + +const scimUser1 = { + schemas: [ + 'urn:ietf:params:scim:schemas:core:2.0:User', + 'urn:scim:schemas:extension:cisco:webexidentity:2.0:User', + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User', + ], + id: 'userId', + userName: 'johndoe@cisco.com', + active: true, + name: { + familyName: 'Doe', + givenName: 'John', + }, + displayName: 'John Doe', + emails: [ + { + value: 'johndoe@cisco.com', + type: 'work', + primary: true, + }, + ], + userType: 'user', + phoneNumbers: [ + { + value: '+91 22 1234 5678', + type: 'work', + }, + ], + photos: [ + { + value: 'photoUrl', + type: 'photo', + }, + { + value: 'thumbnailURL', + type: 'thumbnail', + }, + ], + addresses: [ + { + type: 'work', + streetAddress: 'Street', + locality: 'BANGALORE', + region: 'KARNATAKA', + postalCode: '560103', + country: 'IN', + }, + ], + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User': { + department: '123029016', + manager: { + value: 'userId', + displayName: 'Jane Smith', + $ref: 'scimUrl', }, - jobTitle: 'Software Engineer', - lastName: 'Nagakawa', - modified: '2023-03-03T13:37:03.196Z', - nickName: 'Emily', - userName: 'emikawa2@cisco.com', }, - displayName: 'Emily Nagakawa', - emails: [{value: 'emikawa2@cisco.com'}], - entityProviderType: 'CI_USER', - identity: '801bb994-343b-4f6b-97ae-d13c91d4b877', - orgId: '1eb65fdf-9643-417f-9974-ad72cae0e10f', + 'urn:scim:schemas:extension:cisco:webexidentity:2.0:User': { + isTeamsOnJabberEnabled: false, + isUCCallOnJabberEnabled: false, + licenseID: ['license'], + userSettings: ['setting'], + userPreferences: ['preferences'], + sipAddresses: [ + { + value: 'johndoe@cisco.call.ciscospark.com', + type: 'cloud-calling', + }, + { + value: 'johndoe@cisco.calls.webex.com', + type: 'cloud-calling', + primary: true, + }, + { + value: 'johndoe@cisco.webex.com', + type: 'personal-room', + }, + ], + meta: { + organizationId: 'orgId', + }, + userNameType: 'email', + }, + meta: { + resourceType: 'User', + location: 'scimUrl', + version: 'W/"16629124099"', + created: '2019-12-24T02:01:42.803Z', + lastModified: '2024-08-21T14:22:55.987Z', + }, +}; + +const scimUser2NoPhoto = { + schemas: [ + 'urn:ietf:params:scim:schemas:core:2.0:User', + 'urn:scim:schemas:extension:cisco:webexidentity:2.0:User', + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User', + ], + id: 'userId', + userName: 'janedoe@cisco.com', + active: true, + name: { + familyName: 'Doe', + givenName: 'Jane', + }, + displayName: 'Jane Doe', + emails: [ + { + value: 'janedoe@cisco.com', + type: 'work', + primary: true, + }, + ], + userType: 'user', phoneNumbers: [ - {type: 'mobile', value: '+1 835 648 8750'}, - {type: 'work', value: '+1 791 723 8825'}, + { + value: '+91 22 1234 5678', + type: 'work', + }, ], - photos: [{value: 'avatar-prod-us-east-2.webexcontent.com'}], - sipAddresses: [ - {type: 'cloud-calling', value: 'emikawa2@cisco.call.ciscospark.com', primary: true}, - {type: 'personal-room', value: 'emikawa2@cisco.webex.com', primary: false}, - {type: 'enterprise', value: 'emikawa2@cisco.com', primary: true}, - {type: 'personal-room', value: '25762555827@cisco.webex.com', primary: false}, + addresses: [ + { + type: 'work', + streetAddress: 'Street', + locality: 'BANGALORE', + region: 'KARNATAKA', + postalCode: '560103', + country: 'IN', + }, ], - type: 'PERSON', + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User': { + department: '123029016', + manager: { + value: 'userId', + displayName: 'John Smith', + $ref: 'scimUrl', + }, + }, + 'urn:scim:schemas:extension:cisco:webexidentity:2.0:User': { + isTeamsOnJabberEnabled: false, + isUCCallOnJabberEnabled: false, + licenseID: ['license'], + userSettings: ['setting'], + userPreferences: ['preferences'], + sipAddresses: [ + { + value: 'janedoe@cisco.call.ciscospark.com', + type: 'cloud-calling', + }, + { + value: 'janedoe@cisco.calls.webex.com', + type: 'cloud-calling', + primary: true, + }, + { + value: 'janedoe@cisco.webex.com', + type: 'personal-room', + }, + ], + meta: { + organizationId: 'orgId', + }, + userNameType: 'email', + }, + meta: { + resourceType: 'User', + location: 'scimUrl', + version: 'W/"16629124099"', + created: '2019-12-24T02:01:42.803Z', + lastModified: '2024-08-21T14:22:55.987Z', + }, +}; + +export const mockSCIMListResponse = { + statusCode: 200, + body: { + schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], + totalResults: 2, + itemsPerPage: 2, + startIndex: 1, + Resources: [scimUser1, scimUser2NoPhoto], + }, +}; + +export const mockSCIMMinListResponse = { + statusCode: 200, + body: { + schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], + totalResults: 1, + itemsPerPage: 1, + startIndex: 1, + Resources: [scimUserMinimum], + }, }; export const mockKmsKey = { diff --git a/packages/calling/src/Contacts/types.ts b/packages/calling/src/Contacts/types.ts index 2c9d2e16fbf..8a0b01b3568 100644 --- a/packages/calling/src/Contacts/types.ts +++ b/packages/calling/src/Contacts/types.ts @@ -1,19 +1,11 @@ import {ISDKConnector} from '../SDKConnector/types'; -import {ContactDetail} from '../common/types'; +import {Address, PhoneNumber, URIAddress} from '../common/types'; export enum ContactType { CUSTOM = 'CUSTOM', CLOUD = 'CLOUD', } -export type AddressType = { - city?: string; - country?: string; - state?: string; - street?: string; - zipCode?: string; -}; - /** * `Contact` object is used to represent a contact. */ @@ -21,7 +13,7 @@ export type Contact = { /** * This represents the complete address of the contact. */ - addressInfo?: AddressType; + addressInfo?: Address; /** * This represents the URL of the avatar of the contact. */ @@ -37,7 +29,7 @@ export type Contact = { /** * Unique identifier of the contact. */ - contactId?: string; + contactId: string; /** * Indicates the type of the contact, can be `CLOUD` or `CUSTOM`. */ @@ -49,11 +41,11 @@ export type Contact = { /** * This represents the display name of the contact. */ - displayName: string; + displayName?: string; /** * This represents the array of different email addresses of the contact. */ - emails?: ContactDetail[]; + emails?: URIAddress[]; /** * This is encrypted key url of the contact used for encryption. */ @@ -85,7 +77,7 @@ export type Contact = { /** * This represents the array of different phone numbers of the contact. */ - phoneNumbers?: ContactDetail[]; + phoneNumbers?: PhoneNumber[]; /** * Primary contact method as set by the contact. */ @@ -97,11 +89,11 @@ export type Contact = { /** * This represents the array of different sip addresses of the contact. */ - sipAddresses?: ContactDetail[]; + sipAddresses?: URIAddress[]; /** - * This represents the job title of the contact. + * This field indicates whether the contact was resolved successfully. */ - title?: string; + resolved: boolean; }; export enum GroupType { diff --git a/packages/calling/src/Events/types.ts b/packages/calling/src/Events/types.ts index 17c8c51bf5d..c0ed077a021 100644 --- a/packages/calling/src/Events/types.ts +++ b/packages/calling/src/Events/types.ts @@ -79,6 +79,8 @@ export type CallRecordSelf = { id: string; name?: string; phoneNumber?: string; + cucmDN?: string; + ucmLineNumber?: number; }; export type CallRecordListOther = { @@ -242,32 +244,38 @@ enum CALL_STATE { REMOTE_HELD = 'remoteheld', CONNECTED = 'connected', } + type eventType = string; + type callProgressData = { alerting: boolean; inbandROAP: boolean; }; + export type CallerIdInfo = { 'x-broadworks-remote-party-info'?: string; 'p-asserted-identity'?: string; from?: string; }; + type callId = string; type deviceId = string; type correlationId = string; type callUrl = string; type causecode = number; type cause = string; + type eventData = { callerId: CallerIdInfo; callState: CALL_STATE; }; + type midCallServiceData = { eventType: eventType; eventData: eventData; }; -type midCallService = Array; +type midCallService = Array; interface BaseMessage { eventType: eventType; correlationId: correlationId; @@ -275,19 +283,18 @@ interface BaseMessage { callId: callId; callUrl: callUrl; } - export interface CallSetupMessage extends BaseMessage { callerId: CallerIdInfo; trackingId: string; alertType: string; } - interface CallProgressMessage extends BaseMessage { callProgressData: callProgressData; callerId: CallerIdInfo; } export const WEBSOCKET_SCOPE = 'mobius'; + export enum WEBSOCKET_KEYS { CALL_PROGRESS = 'callprogress', CALL_CONNECTED = 'callconnected', @@ -368,7 +375,24 @@ export type EndTimeSessionId = { endTime: string; sessionId: string; }; + export type SanitizedEndTimeAndSessionId = { endTime: number; sessionId: string; }; + +export type UCMLine = { + dnorpattern: string; + index: number; + label: string | null; +}; + +export type UCMDevice = { + name: string; + model: number; + lines: UCMLine[]; +}; + +export type UCMLinesApiResponse = { + devices: UCMDevice[]; +}; diff --git a/packages/calling/src/Metrics/index.ts b/packages/calling/src/Metrics/index.ts index 2f60c64bb57..6e2ef686c08 100644 --- a/packages/calling/src/Metrics/index.ts +++ b/packages/calling/src/Metrics/index.ts @@ -285,7 +285,10 @@ class MetricManager implements IMetricManager { }, fields: { device_url: this.deviceInfo?.device?.clientDeviceUri, - calling_sdk_version: process.env.CALLING_SDK_VERSION || VERSION, + calling_sdk_version: + typeof process !== 'undefined' && process.env.CALLING_SDK_VERSION + ? process.env.CALLING_SDK_VERSION + : VERSION, }, type, }; @@ -303,7 +306,10 @@ class MetricManager implements IMetricManager { }, fields: { device_url: this.deviceInfo?.device?.clientDeviceUri, - calling_sdk_version: process.env.CALLING_SDK_VERSION || VERSION, + calling_sdk_version: + typeof process !== 'undefined' && process.env.CALLING_SDK_VERSION + ? process.env.CALLING_SDK_VERSION + : VERSION, }, type, }; diff --git a/packages/calling/src/SDKConnector/types.ts b/packages/calling/src/SDKConnector/types.ts index 3f5760d518b..68f92869c5c 100644 --- a/packages/calling/src/SDKConnector/types.ts +++ b/packages/calling/src/SDKConnector/types.ts @@ -1,11 +1,4 @@ -import { - DSSLookupResponse, - KmsKey, - KmsResourceObject, - LookupOptions, - PeopleListResponse, - WebexRequestPayload, -} from '../common/types'; +import {KmsKey, KmsResourceObject, PeopleListResponse, WebexRequestPayload} from '../common/types'; /* eslint-disable no-shadow */ type Listener = (e: string, data?: unknown) => void; @@ -82,10 +75,6 @@ export interface WebexSDK { }; }; }; - dss: { - lookup: (options: LookupOptions) => Promise; - register: () => Promise; - }; encryption: { decryptText: (encryptionKeyUrl: string, encryptedData?: string) => Promise; encryptText: (encryptionKeyUrl: string, text?: string) => Promise; @@ -109,7 +98,6 @@ export interface WebexSDK { mercuryApi: string; 'ucmgmt-gateway': string; contactsService: string; - directorySearch: string; }; fetchClientRegionInfo: () => Promise; }; diff --git a/packages/calling/src/common/Utils.test.ts b/packages/calling/src/common/Utils.test.ts index e480b3497b9..06b70c2c098 100644 --- a/packages/calling/src/common/Utils.test.ts +++ b/packages/calling/src/common/Utils.test.ts @@ -8,6 +8,7 @@ import { getSamplePeopleListResponse, getSampleRawAndParsedMediaStats, getMobiusDiscoveryResponse, + getSampleMinimumScimResponse, } from './testUtil'; import { CallDirection, @@ -20,15 +21,7 @@ import { RegistrationStatus, } from './types'; import log from '../Logger'; -import { - CALL_FILE, - DUMMY_METRICS, - UTILS_FILE, - IDENTITY_ENDPOINT_RESOURCE, - SCIM_ENDPOINT_RESOURCE, - SCIM_USER_FILTER, - REGISTER_UTIL, -} from '../CallingClient/constants'; +import {CALL_FILE, DUMMY_METRICS, UTILS_FILE, REGISTER_UTIL} from '../CallingClient/constants'; import { CALL_ERROR_CODE, ERROR_CODE, @@ -54,7 +47,13 @@ import { getAscVoicemailListJsonWXC, getDescVoicemailListJsonWXC, } from '../Voicemail/voicemailFixture'; -import {INFER_ID_CONSTANT} from './constants'; +import { + IDENTITY_ENDPOINT_RESOURCE, + INFER_ID_CONSTANT, + SCIM_ENDPOINT_RESOURCE, + SCIM_USER_FILTER, + WEBEX_API_BTS, +} from './constants'; import {CALL_EVENT_KEYS} from '../Events/types'; const mockSubmitRegistrationMetric = jest.fn(); @@ -915,7 +914,7 @@ describe('Voicemail Sorting Tests', () => { }); describe('resolveContact tests', () => { - const scimUrl = `${webex.internal.services._serviceUrls.identity}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}`; + const scimUrl = `${WEBEX_API_BTS}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}`; it('Invalid CallingPartyInfo', () => { const callingPartyInfo = {} as CallingPartyInfo; @@ -1051,6 +1050,29 @@ describe('resolveContact tests', () => { }); }); + it('Resolve with minimal response from SCIM', () => { + const callingPartyInfo = {} as CallingPartyInfo; + const scimResponse = getSampleMinimumScimResponse(); + + // scimResponse.Resources[0].photos = []; + const webexSpy = jest.spyOn(webex, 'request').mockResolvedValue({ + statusCode: 200, + body: scimResponse, + }); + + callingPartyInfo.userExternalId = {$: 'userExternalId'}; + resolveContact(callingPartyInfo).then((displayInfo) => { + expect(displayInfo?.name).toBeUndefined(); + expect(displayInfo?.num).toBeUndefined(); + expect(displayInfo?.avatarSrc).toStrictEqual('unknown'); + expect(displayInfo?.id).toStrictEqual(getSampleMinimumScimResponse().Resources[0].id); + + const query = scimUrl + encodeURIComponent(`id eq "${callingPartyInfo.userExternalId?.$}"`); + + expect(webexSpy).toBeCalledOnceWith(expect.objectContaining({uri: query})); + }); + }); + it('Resolve by name', () => { const callingPartyInfo = {} as CallingPartyInfo; const webexSpy = jest diff --git a/packages/calling/src/common/Utils.ts b/packages/calling/src/common/Utils.ts index d2a3d5b4763..4c2d8441dfe 100644 --- a/packages/calling/src/common/Utils.ts +++ b/packages/calling/src/common/Utils.ts @@ -27,7 +27,6 @@ import { LineErrorObject, } from '../Errors/types'; import { - ALLOWED_SERVICES, CALLING_BACKEND, CorrelationId, DecodeType, @@ -36,6 +35,7 @@ import { IDeviceInfo, MobiusServers, RegistrationStatus, + SCIMListResponse, SORT, ServiceData, ServiceIndicator, @@ -52,7 +52,6 @@ import { CISCO_DEVICE_URL, CODEC_ID, DUMMY_METRICS, - IDENTITY_ENDPOINT_RESOURCE, INBOUND_CODEC_MATCH, INBOUND_RTP, JITTER_BUFFER_DELAY, @@ -75,8 +74,6 @@ import { RTC_ICE_CANDIDATE_PAIR, RTP_RX_STAT, RTP_TX_STAT, - SCIM_ENDPOINT_RESOURCE, - SCIM_USER_FILTER, SELECTED_CANDIDATE_PAIR_ID, SPARK_USER_AGENT, TARGET_BIT_RATE, @@ -88,7 +85,11 @@ import { URL_ENDPOINT, UTILS_FILE, } from '../CallingClient/constants'; -import {JanusResponseEvent, UpdateMissedCallsResponse} from '../CallHistory/types'; +import { + JanusResponseEvent, + UCMLinesResponse, + UpdateMissedCallsResponse, +} from '../CallHistory/types'; import { VoicemailResponseEvent, MessageInfo, @@ -113,9 +114,13 @@ import { NATIVE_WEBEX_TEAMS_CALLING, NATIVE_SIP_CALL_TO_UCM, BW_XSI_ENDPOINT_VERSION, + IDENTITY_ENDPOINT_RESOURCE, + SCIM_ENDPOINT_RESOURCE, + SCIM_USER_FILTER, + WEBEX_API_PROD, + WEBEX_API_BTS, } from './constants'; import {Model, WebexSDK} from '../SDKConnector/types'; -import {scimResponseBody} from '../CallingClient/calling/CallerId/types'; import SDKConnector from '../SDKConnector'; import {CallSettingResponse} from '../CallSettings/types'; import {ContactResponse} from '../Contacts/types'; @@ -690,6 +695,7 @@ export async function serviceErrorCodeHandler( | CallSettingResponse | ContactResponse | UpdateMissedCallsResponse + | UCMLinesResponse > { const errorCode = Number(err.statusCode); const failureMessage = 'FAILURE'; @@ -1177,7 +1183,7 @@ export function getSortedVoicemailList( * @param filter - A filter for the query. * @returns - Promise. */ -async function scimQuery(filter: string) { +export async function scimQuery(filter: string) { log.info(`Starting resolution for filter:- ${filter}`, { file: UTILS_FILE, method: 'scimQuery', @@ -1185,7 +1191,10 @@ async function scimQuery(filter: string) { const sdkConnector = SDKConnector; const webex = sdkConnector.getWebex(); - const scimUrl = `${webex.internal.services._serviceUrls.identity}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}`; + const isProd = !webex.internal.device.url.includes('-int'); + const webexHost = isProd ? WEBEX_API_PROD : WEBEX_API_BTS; + + const scimUrl = `${webexHost}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}`; const query = scimUrl + encodeURIComponent(filter); return (webex.request({ @@ -1195,7 +1204,6 @@ async function scimQuery(filter: string) { [CISCO_DEVICE_URL]: webex.internal.device.url, [SPARK_USER_AGENT]: CALLING_USER_AGENT, }, - service: ALLOWED_SERVICES.MOBIUS, })); } @@ -1211,7 +1219,7 @@ export async function resolveCallerIdDisplay(filter: string) { try { const response = await scimQuery(filter); - resolution = response.body as scimResponseBody; + resolution = response.body as SCIMListResponse; log.info(`Number of records found for this user :- ${resolution.totalResults}`, { file: UTILS_FILE, @@ -1236,12 +1244,12 @@ export async function resolveCallerIdDisplay(filter: string) { /* Pick only the primary number OR 2nd preference Work */ const numberObj = - scimResource.phoneNumbers.find((num) => num.primary) || - scimResource.phoneNumbers.find((num) => num.type.toLowerCase() === 'work'); + scimResource.phoneNumbers?.find((num) => num.primary) || + scimResource.phoneNumbers?.find((num) => num.type.toLowerCase() === 'work'); if (numberObj) { displayResult.num = numberObj.value; - } else if (scimResource.phoneNumbers.length > 0) { + } else if (scimResource.phoneNumbers && scimResource.phoneNumbers.length > 0) { /* When no primary number exists OR PA-ID/From failed to populate, we take the first number */ log.info('Failure to resolve caller information. Setting number as caller ID', { file: UTILS_FILE, diff --git a/packages/calling/src/common/constants.ts b/packages/calling/src/common/constants.ts index 92d37654651..a71f3de888a 100644 --- a/packages/calling/src/common/constants.ts +++ b/packages/calling/src/common/constants.ts @@ -4,6 +4,7 @@ export const BINARY = 'binary'; export const CONTENT = 'content'; export const DEVICES = 'devices'; export const FAILURE_MESSAGE = 'FAILURE'; +export const IDENTITY_ENDPOINT_RESOURCE = 'identity'; export const ITEMS = 'items'; export const KEY = 'key'; export const OBJECT = 'object'; @@ -12,6 +13,8 @@ export const RAW_REQUEST = 'rawRequest'; export const RESPONSE = 'response'; export const RESPONSE_DATA = 'responseData'; export const RESPONSE_MESSAGE = 'responseMessage'; +export const SCIM_ENDPOINT_RESOURCE = 'scim'; +export const SCIM_USER_FILTER = 'v2/Users?filter='; export const SETTINGS = 'settings'; export const STATUS_CODE = 'statusCode'; export const SUCCESS_MESSAGE = 'SUCCESS'; @@ -39,3 +42,9 @@ export const BW_XSI_URL = 'broadworksXsiActionsUrl'; export const WEBEX_CALLING_CONNECTOR_FILE = 'WxCallBackendConnector'; export const UCM_CONNECTOR_FILE = 'UcmBackendConnector'; export const VOICEMAIL = 'VOICEMAIL'; +export const SCIM_WEBEXIDENTITY_USER = 'urn:scim:schemas:extension:cisco:webexidentity:2.0:User'; +export const SCIM_ENTERPRISE_USER = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'; +export const WEBEX_API_PROD = 'https://webexapis.com'; +export const WEBEX_API_BTS = 'https://integration.webexapis.com'; +export const WEBEX_API_CONFIG_INT_URL = `https://${WEBEX_API_BTS}/v1/uc/config`; +export const WEBEX_API_CONFIG_PROD_URL = `https://${WEBEX_API_PROD}/v1/uc/config`; diff --git a/packages/calling/src/common/testUtil.ts b/packages/calling/src/common/testUtil.ts index 8dd11ab0909..dff60f0e25f 100644 --- a/packages/calling/src/common/testUtil.ts +++ b/packages/calling/src/common/testUtil.ts @@ -51,10 +51,6 @@ export function getTestUtilsWebex() { }, }, }, - dss: { - lookup: jest.fn(), - register: jest.fn(), - }, encryption: { decryptText: jest.fn(), encryptText: jest.fn(), @@ -78,7 +74,6 @@ export function getTestUtilsWebex() { mercuryApi: 'https://mercury-api-intb.ciscospark.com/v1', 'ucmgmt-gateway': 'https://gw.telemetry.int-ucmgmt.cisco.com', contactsService: 'https://contacts-service-a.wbx2.com/contact/api/v1', - directorySearch: 'https://directory-search-a.wbx2.com/direcory-search/api/v1/', }, fetchClientRegionInfo: jest.fn(), }, @@ -265,6 +260,32 @@ export const getSampleScimResponse = () => { }; }; +export const getSampleMinimumScimResponse = () => { + return { + totalResults: '1', + itemsPerPage: '1', + startIndex: '1', + schemas: ['urn:scim:schemas:core:1.0'], + Resources: [ + { + userName: 'atlas.test.wxcwebrtc+user8@gmail.com', + id: 'userExternalId', + meta: { + created: '2022-03-16T16:13:53.847Z', + lastModified: '2022-05-31T14:39:12.782Z', + lastLoginTime: '2022-05-31T14:39:12.780Z', + version: 'W/"66025591113"', + location: + 'https://identitybts.webex.com/identity/scim/1704d30d-a131-4bc7-9449-948487643793/v1/Users/652fe0c7-05ce-4acd-8bda-9a080830187f', + organizationID: '1704d30d-a131-4bc7-9449-948487643793', + creator: '97fe25e3-d3e8-400e-856b-5b0cd5b0c790', + modifier: '8c7abf2f-0c8e-49cf-b8e4-693d4ec7daee', + }, + }, + ], + }; +}; + /** * Returns a sample people list response object. */ diff --git a/packages/calling/src/common/types.ts b/packages/calling/src/common/types.ts index 5a592b6b959..02605e6a1f0 100644 --- a/packages/calling/src/common/types.ts +++ b/packages/calling/src/common/types.ts @@ -1,3 +1,5 @@ +import {SCIM_ENTERPRISE_USER, SCIM_WEBEXIDENTITY_USER} from './constants'; + export type MobiusDeviceId = string; export type MobiusDeviceUri = string; export type SettingEnabled = boolean; @@ -30,14 +32,13 @@ export enum CALLING_BACKEND { export type DeviceList = unknown; export type CallId = string; // guid; export type CorrelationId = string; -export type SipAddress = string; export enum CallType { URI = 'uri', TEL = 'tel', } export type CallDetails = { type: CallType; - address: SipAddress; // sip address + address: string; // sip address }; export type CallDestination = CallDetails; @@ -181,32 +182,11 @@ export type ContactDetail = { value: string; }; -export interface LookupOptions { - id: string; - shouldBatch: boolean; -} - -export type DSSLookupResponse = { - additionalInfo: { - department: string; - firstName: string; - identityManager: { - managerId: string; - displayName: string; - }; - jobTitle: string; - lastName: string; - }; - displayName: string; - emails: ContactDetail[]; - entityProviderType: string; - identity: string; - orgId: string; - phoneNumbers: ContactDetail[]; - photos: ContactDetail[]; - sipAddresses: ContactDetail[]; +export interface URIAddress { + value: string; type: string; -}; + primary?: boolean; +} export type KmsKey = { uri: string; @@ -222,3 +202,59 @@ export type KmsResourceObject = { keyUris: string[]; authorizationUris: string[]; }; + +export interface Name { + familyName: string; + givenName: string; +} + +export interface Address { + city?: string; + country?: string; + state?: string; + street?: string; + zipCode?: string; +} + +interface WebexIdentityMeta { + organizationId: string; +} +interface WebexIdentityUser { + sipAddresses?: URIAddress[]; + meta?: WebexIdentityMeta; +} + +interface Manager { + value: string; + displayName: string; + $ref: string; +} + +interface EnterpriseUser { + department?: string; + manager?: Manager; +} + +interface Resource { + schemas: string[]; + id: string; + userName: string; + active?: boolean; + name?: Name; + displayName?: string; + emails?: URIAddress[]; + userType: string; + phoneNumbers?: PhoneNumber[]; + photos?: ContactDetail[]; + addresses?: Address[]; + [SCIM_WEBEXIDENTITY_USER]?: WebexIdentityUser; + [SCIM_ENTERPRISE_USER]?: EnterpriseUser; +} + +export interface SCIMListResponse { + schemas: string[]; + totalResults: number; + itemsPerPage: number; + startIndex: number; + Resources: Resource[]; +} diff --git a/packages/webex/src/calling.js b/packages/webex/src/calling.js index c7af8455cfd..fb91d3b466d 100644 --- a/packages/webex/src/calling.js +++ b/packages/webex/src/calling.js @@ -5,7 +5,6 @@ import EventEmitter from 'events'; require('@webex/internal-plugin-device'); require('@webex/internal-plugin-mercury'); require('@webex/internal-plugin-encryption'); -require('@webex/internal-plugin-dss'); const merge = require('lodash/merge'); const WebexCore = require('@webex/webex-core').default; diff --git a/yarn.lock b/yarn.lock index 2527d5aca6e..579647c5faa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2035,21 +2035,21 @@ __metadata: linkType: hard "@babel/runtime-corejs2@npm:^7.25.0": - version: 7.25.4 - resolution: "@babel/runtime-corejs2@npm:7.25.4" + version: 7.25.6 + resolution: "@babel/runtime-corejs2@npm:7.25.6" dependencies: core-js: ^2.6.12 regenerator-runtime: ^0.14.0 - checksum: 5c543575e3eef559a81657e696ee7c2b71da159905b59eaf1b9b9a2efbd8c5259edb4f79d5a86c97326fa47a7dc447f60133e00ca4977ac5244fefdf34fc5921 + checksum: afd5406391d7e41ac291ae40c549dc8da734ec8d160b560081b4153b4063409b7402e5c469bcebd0789aaeaca740d3fe088cbe45be3a4c7341e3a2b3f48fdd98 languageName: node linkType: hard -"@babel/runtime@npm:^7.18.9, @babel/runtime@npm:^7.25.0": - version: 7.25.4 - resolution: "@babel/runtime@npm:7.25.4" +"@babel/runtime@npm:^7.18.9, @babel/runtime@npm:^7.25.6": + version: 7.25.6 + resolution: "@babel/runtime@npm:7.25.6" dependencies: regenerator-runtime: ^0.14.0 - checksum: 5c2aab03788e77f1f959d7e6ce714c299adfc9b14fb6295c2a17eb7cad0dd9c2ebfb2d25265f507f68c43d5055c5cd6f71df02feb6502cea44b68432d78bcbbe + checksum: ee1a69d3ac7802803f5ee6a96e652b78b8addc28c6a38c725a4ad7d61a059d9e6cb9f6550ed2f63cce67a1bd82e0b1ef66a1079d895be6bfb536a5cfbd9ccc32 languageName: node linkType: hard @@ -5931,11 +5931,11 @@ __metadata: linkType: hard "@types/node@npm:^20.14.1": - version: 20.16.1 - resolution: "@types/node@npm:20.16.1" + version: 20.16.5 + resolution: "@types/node@npm:20.16.5" dependencies: undici-types: ~6.19.2 - checksum: 2b8f30f416f5c1851ffa8a13ef6c464a5e355edfd763713c22813a7839f6419a64e27925f9e89c972513d78432263179332f0bffb273d16498233bfdf495d096 + checksum: f38b7bd8c4993dcf38943afa2ffdd7dfd18fc94f8f3f28d0c1045a10d39871a6cc1b8f8d3bf0c7ed848457d0e1d283482f6ca125579c13fed1b7575d23e8e8f5 languageName: node linkType: hard @@ -7493,7 +7493,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.9.1 + "@webex/internal-media-core": 2.11.3 "@webex/media-helpers": "workspace:*" async-mutex: 0.4.0 buffer: 6.0.3 @@ -7692,17 +7692,17 @@ __metadata: languageName: unknown linkType: soft -"@webex/event-dictionary-ts@npm:^1.0.1406": - version: 1.0.1406 - resolution: "@webex/event-dictionary-ts@npm:1.0.1406" +"@webex/event-dictionary-ts@npm:^1.0.1546": + version: 1.0.1546 + resolution: "@webex/event-dictionary-ts@npm:1.0.1546" dependencies: - amf-client-js: "npm:^5.2.6" - json-schema-to-typescript: "npm:^12.0.0" - minimist: "npm:^1.2.8" - ramldt2jsonschema: "npm:^1.2.3" - shelljs: "npm:^0.8.5" - webapi-parser: "npm:^0.5.0" - checksum: 2188a6368758001a0d839e934f4131100a40f16911a931abf0be61e7a98b001c4fe2c3108b7bd657f6d1f768c3c4aedeea9480296939ca4a7837db264df2a589 + amf-client-js: ^5.2.6 + json-schema-to-typescript: ^12.0.0 + minimist: ^1.2.8 + ramldt2jsonschema: ^1.2.3 + shelljs: ^0.8.5 + webapi-parser: ^0.5.0 + checksum: d938300584c5dcdeb5924a072c20aac85e9826c9631961e604c0e1dd577172f5d11a34c0b52dbb49f86386a80ecb842446a368172e5e64c273d06903f5843fa4 languageName: node linkType: hard @@ -7784,21 +7784,22 @@ __metadata: languageName: unknown linkType: soft -"@webex/internal-media-core@npm:2.9.1": - version: 2.9.1 - resolution: "@webex/internal-media-core@npm:2.9.1" +"@webex/internal-media-core@npm:2.11.3": + version: 2.11.3 + resolution: "@webex/internal-media-core@npm:2.11.3" 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.23.1 + "@webex/web-client-media-engine": 3.24.2 events: ^3.3.0 typed-emitter: ^2.1.0 uuid: ^8.3.2 webrtc-adapter: ^8.1.2 xstate: ^4.30.6 - checksum: 358a8b43ae55d6272fa07ca6e075cceb07a29d464c7e494ec130cd918848595eecd8e52ff12407790051af92b5e7d9612fdb4d7c6cf7a11231ce3bb390bd10f3 + checksum: b95c917890c98ded1346d093656a8c54cb4ae7c0a8a93ccccaf39e72913f3c2c8a53829d257eb5ac74a087ee2c4583c37ed3752acfbab179e6533761eb9a5ed0 languageName: node linkType: hard @@ -8199,7 +8200,7 @@ __metadata: "@webex/common": "workspace:*" "@webex/common-timers": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/event-dictionary-ts": ^1.0.1406 + "@webex/event-dictionary-ts": ^1.0.1546 "@webex/internal-plugin-metrics": "workspace:*" "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" @@ -8465,10 +8466,10 @@ __metadata: languageName: unknown linkType: soft -"@webex/json-multistream@npm:2.1.3": - version: 2.1.3 - resolution: "@webex/json-multistream@npm:2.1.3" - checksum: bf95a540c0509b15798cb9ead0f31518e79766ae1633119a83f40e22d175f6cf98338c24246c204185591932a08f9ede7bd3d8d65f46f7c212e57103295200e4 +"@webex/json-multistream@npm:2.1.6": + version: 2.1.6 + resolution: "@webex/json-multistream@npm:2.1.6" + checksum: 18cd8e24151c88fc563c6224cc358c9e2e3cda78d80baddba8dd58aa3e79bf4d78ff12613b27cad5a0242856e84e3c6001e12916e404a68398c68e5439e5154b languageName: node linkType: hard @@ -8557,13 +8558,13 @@ __metadata: "@babel/preset-typescript": 7.22.11 "@webex/babel-config-legacy": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.9.1 + "@webex/internal-media-core": 2.11.3 "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" "@webex/test-helper-chai": "workspace:*" "@webex/test-helper-mock-webex": "workspace:*" "@webex/ts-events": ^1.1.0 - "@webex/web-media-effects": 2.18.0 + "@webex/web-media-effects": 2.19.0 eslint: ^8.24.0 jsdom-global: 3.0.2 sinon: ^9.2.4 @@ -8793,7 +8794,7 @@ __metadata: "@webex/babel-config-legacy": "workspace:*" "@webex/common": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.9.1 + "@webex/internal-media-core": 2.11.3 "@webex/internal-plugin-conversation": "workspace:*" "@webex/internal-plugin-device": "workspace:*" "@webex/internal-plugin-llm": "workspace:*" @@ -9085,13 +9086,13 @@ __metadata: languageName: unknown linkType: soft -"@webex/rtcstats@npm:^1.3.2": - version: 1.3.3 - resolution: "@webex/rtcstats@npm:1.3.3" +"@webex/rtcstats@npm:^1.5.0": + version: 1.5.0 + resolution: "@webex/rtcstats@npm:1.5.0" dependencies: "@types/node": ^20.14.1 uuid: ^8.3.2 - checksum: 7ac73b2f6bf8bf44bfaff7a5e904b175509632b80bf4cbdb09c5570c940d7e02445ba0c95f713067b03770eee49162d964b98a8d1e05f42e3f80b5bfd503352c + checksum: d7af2b4be63a146de7eca11bbfa6478e3b4504c2d9df971315ca9ac07026c83813742ef9dcd019cbb38a9a74aebcd569a03436e1c1c35e087efcf9e682ddf8ff languageName: node linkType: hard @@ -9496,41 +9497,41 @@ __metadata: languageName: node linkType: hard -"@webex/web-client-media-engine@npm:3.23.1": - version: 3.23.1 - resolution: "@webex/web-client-media-engine@npm:3.23.1" +"@webex/web-client-media-engine@npm:3.24.2": + version: 3.24.2 + resolution: "@webex/web-client-media-engine@npm:3.24.2" dependencies: - "@webex/json-multistream": 2.1.3 - "@webex/rtcstats": ^1.3.2 + "@webex/json-multistream": 2.1.6 + "@webex/rtcstats": ^1.5.0 "@webex/ts-events": ^1.0.1 "@webex/ts-sdp": 1.7.0 "@webex/web-capabilities": ^1.4.0 - "@webex/web-media-effects": ^2.15.6 - "@webex/webrtc-core": 2.10.0 + "@webex/web-media-effects": 2.18.1 + "@webex/webrtc-core": 2.10.3 async: ^3.2.4 js-logger: ^1.6.1 typed-emitter: ^2.1.0 uuid: ^8.3.2 - checksum: e1a502725ac0ad588891e4ec0e73ffa5242d44fc612161a4c09d9f5f3a31734f22be28c8bf41838d3ae779e5f5689933f0b8deeb325c755499d3648d6e8035a5 + checksum: 26ac75ffcb519a11b3a9f2b601b13b85c4c4e4b685503da0e752d03137af65d22472329f45fbd08de138d93ebd92295645f98c9b879617c838afee61c0589df7 languageName: node linkType: hard -"@webex/web-media-effects@npm:2.18.0": - version: 2.18.0 - resolution: "@webex/web-media-effects@npm:2.18.0" +"@webex/web-media-effects@npm:2.18.1": + version: 2.18.1 + resolution: "@webex/web-media-effects@npm:2.18.1" dependencies: - "@webex/ladon-ts": "npm:^4.3.0" - events: "npm:^3.3.0" - js-logger: "npm:^1.6.1" - typed-emitter: "npm:^1.4.0" - uuid: "npm:^9.0.1" - checksum: d2b4bcdcc2af87c0bed9c753fa06a34ac663e703c7a1e0bbf542558e23667b9a15b3c4ca594c9b4c8af1a28445f0afd7b4e622c411b0a8c567c84997f2fe44c2 + "@webex/ladon-ts": ^4.3.0 + events: ^3.3.0 + js-logger: ^1.6.1 + typed-emitter: ^1.4.0 + uuid: ^9.0.1 + checksum: c60bf96a9d3efc579101de05dce26c9b03b7d1b8629b02294408e5cb3bc17e50ceed7c33bfdd78248894ce3ab728702549cb103cc3e9c886aaa702531d026c62 languageName: node linkType: hard -"@webex/web-media-effects@npm:^2.15.6": - version: 2.20.8 - resolution: "@webex/web-media-effects@npm:2.20.8" +"@webex/web-media-effects@npm:2.19.0": + version: 2.19.0 + resolution: "@webex/web-media-effects@npm:2.19.0" dependencies: "@webex/ladon-ts": ^4.3.0 events: ^3.3.0 @@ -9538,7 +9539,7 @@ __metadata: typed-emitter: ^1.4.0 uuid: ^9.0.1 worker-timers: ^8.0.2 - checksum: 97d51ff86bcb7880f18b3615dcd745df826758bb93cfd1c076fd60357ff57c2dd00b7ba4a609b0c71fa5b7caf39f5443c0fc371f1ed4486fa2aafe42a4b50d0d + checksum: 04c100b8eb01fbe05cc5ee1b1898c54f2ef537a9e367c7693767f6fb1df1d085c00ba91ee42d7bb9e9df55baeccd43d4ea979dcd486223a4dc3a82c61769494f languageName: node linkType: hard @@ -9632,18 +9633,18 @@ __metadata: languageName: unknown linkType: soft -"@webex/webrtc-core@npm:2.10.0": - version: 2.10.0 - resolution: "@webex/webrtc-core@npm:2.10.0" +"@webex/webrtc-core@npm:2.10.3": + version: 2.10.3 + resolution: "@webex/webrtc-core@npm:2.10.3" dependencies: "@webex/ts-events": ^1.1.0 "@webex/web-capabilities": ^1.1.0 - "@webex/web-media-effects": ^2.15.6 + "@webex/web-media-effects": 2.18.1 events: ^3.3.0 js-logger: ^1.6.1 typed-emitter: ^2.1.0 webrtc-adapter: ^8.1.2 - checksum: f166a1b04e9f2c4a288174960fe04bcf8ccf23485ecbdf2a18e09a3c65fe836cc0e97370864b806115c7701e116a1873fd79021c799e04fcbb44e1ef4dbc4c17 + checksum: 9b945ffb0046082967317704d139e2f5d68b4db56bae3fc0942aaa5a9c53740edd56617596873373cb3b3ccae376b6fc5eefcff1cb293c017c0b5ace49ad11e3 languageName: node linkType: hard @@ -17050,13 +17051,13 @@ __metadata: languageName: node linkType: hard -"fast-unique-numbers@npm:^9.0.8": - version: 9.0.8 - resolution: "fast-unique-numbers@npm:9.0.8" +"fast-unique-numbers@npm:^9.0.9": + version: 9.0.9 + resolution: "fast-unique-numbers@npm:9.0.9" dependencies: - "@babel/runtime": ^7.25.0 - tslib: ^2.6.3 - checksum: 27840ed4ada274f6391cc81a977ab068ef0931a835711415794010ff8775f1ba2ffc4ae890b00b28fdee2e54a929efec6cf9e154ae958f55d16d55cc4023f6cc + "@babel/runtime": ^7.25.6 + tslib: ^2.7.0 + checksum: 58481531260a91d57859a631378609368a5c3828a36da2e833f1b82f205eec7fe698df76151af84a4bbb1bf911ed9659b79e646d418462d7981e0e24e48f6394 languageName: node linkType: hard @@ -31225,7 +31226,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.6.3": +"tslib@npm:^2.7.0": version: 2.7.0 resolution: "tslib@npm:2.7.0" checksum: 1606d5c89f88d466889def78653f3aab0f88692e80bb2066d090ca6112ae250ec1cfa9dbfaab0d17b60da15a4186e8ec4d893801c67896b277c17374e36e1d28 @@ -33388,37 +33389,37 @@ __metadata: languageName: node linkType: hard -"worker-timers-broker@npm:^7.1.1": - version: 7.1.1 - resolution: "worker-timers-broker@npm:7.1.1" +"worker-timers-broker@npm:^7.1.2": + version: 7.1.2 + resolution: "worker-timers-broker@npm:7.1.2" dependencies: - "@babel/runtime": ^7.25.0 - fast-unique-numbers: ^9.0.8 - tslib: ^2.6.3 - worker-timers-worker: ^8.0.3 - checksum: baa4a7bf49efb8ade9be7cb7865ac27e8030b278d7698ce70a09972ab92a5a7a3e6aaf5dbb3ebecc2d9f94562f10643dada43159382b40a2b9d257f82f473071 + "@babel/runtime": ^7.25.6 + fast-unique-numbers: ^9.0.9 + tslib: ^2.7.0 + worker-timers-worker: ^8.0.4 + checksum: ec2deb097662ef2331cdf4681023fe970504cd30b58cbf13ceab0741d05fd21a49e518c73f03b20a01e2684d9b8d6d59787aed82d05e615be99c8dc0229623c4 languageName: node linkType: hard -"worker-timers-worker@npm:^8.0.3": - version: 8.0.3 - resolution: "worker-timers-worker@npm:8.0.3" +"worker-timers-worker@npm:^8.0.4": + version: 8.0.4 + resolution: "worker-timers-worker@npm:8.0.4" dependencies: - "@babel/runtime": ^7.25.0 - tslib: ^2.6.3 - checksum: af06dc1df2eb5b45ed5d0b5d56de621411f9354633eb9d4368effa00c856301d02c83c632c77645f2753167f0168136d17c2b8cbf986b041ba5b08cf8c10ad83 + "@babel/runtime": ^7.25.6 + tslib: ^2.7.0 + checksum: fd59d4c947895efd036e46cd8d4c288b228256f7bac24ff5b83c682ef44e53584ce8bc4f0525eee469be00dbf1f89e9266682d0297df7478704996087a7553b2 languageName: node linkType: hard "worker-timers@npm:^8.0.2": - version: 8.0.4 - resolution: "worker-timers@npm:8.0.4" + version: 8.0.5 + resolution: "worker-timers@npm:8.0.5" dependencies: - "@babel/runtime": ^7.25.0 - tslib: ^2.6.3 - worker-timers-broker: ^7.1.1 - worker-timers-worker: ^8.0.3 - checksum: 51bcc64f01ea143ac644b88d10b211afd0dd05991ae2a747066f25456f9e84eaa48e530c01d71998a1bc2a00303d8ea40ea97f070bea5e7ec84890140f7f1cb6 + "@babel/runtime": ^7.25.6 + tslib: ^2.7.0 + worker-timers-broker: ^7.1.2 + worker-timers-worker: ^8.0.4 + checksum: e8c00e33e5af252a37472c0fc1bc3c3b26cdd174e179ab1b301364b880db7e560f6f3eb82a4438970fe113c1e9a4855569df0ef61edc6d5de613a51789587ef4 languageName: node linkType: hard