diff --git a/docs/samples/browser-plugin-meetings/app.js b/docs/samples/browser-plugin-meetings/app.js index 04e4d41dfd0..e0aa78e1158 100644 --- a/docs/samples/browser-plugin-meetings/app.js +++ b/docs/samples/browser-plugin-meetings/app.js @@ -375,7 +375,7 @@ function collectMeetings() { createMeetingSelectElm.addEventListener('change', (event) => { if (event.target.value === 'CONVERSATION_URL') { - createMeetingActionElm.innerText = 'Create Adhoc Meeting'; + createMeetingActionElm.innerText = 'Create Adhoc Meeting using conversation URL (INTERNAL-USE ONLY)'; } else { createMeetingActionElm.innerText = 'Create Meeting'; diff --git a/docs/samples/calling/index.html b/docs/samples/calling/index.html index af36f1ea19a..d78988b5473 100644 --- a/docs/samples/calling/index.html +++ b/docs/samples/calling/index.html @@ -49,9 +49,9 @@

Authentication

Advanced Settings

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

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

diff --git a/packages/@webex/internal-plugin-device/src/device.js b/packages/@webex/internal-plugin-device/src/device.js index 585827006e8..14eb5635acb 100644 --- a/packages/@webex/internal-plugin-device/src/device.js +++ b/packages/@webex/internal-plugin-device/src/device.js @@ -2,6 +2,7 @@ import {deprecated, oneFlight} from '@webex/common'; import {persist, waitForValue, WebexPlugin} from '@webex/webex-core'; import {safeSetTimeout} from '@webex/common-timers'; +import {orderBy} from 'lodash'; import METRICS from './metrics'; import {FEATURE_COLLECTION_NAMES, DEVICE_EVENT_REGISTRATION_SUCCESS} from './constants'; @@ -439,6 +440,74 @@ const Device = WebexPlugin.extend({ }); }); }, + /** + * Fetches the web devices and deletes the third of them which are not recent devices in use + * @returns {Promise} + */ + deleteDevices() { + // Fetch devices with a GET request + return this.request({ + method: 'GET', + service: 'wdm', + resource: 'devices', + }) + .then((response) => { + const {devices} = response.body; + + const {deviceType} = this._getBody(); + + // Filter devices of type deviceType + const webDevices = devices.filter((item) => item.deviceType === deviceType); + + const sortedDevices = orderBy(webDevices, [(item) => new Date(item.modificationTime)]); + + // If there are more than two devices, delete the last third + if (sortedDevices.length > 2) { + const totalItems = sortedDevices.length; + const countToDelete = Math.ceil(totalItems / 3); + const urlsToDelete = sortedDevices.slice(0, countToDelete).map((item) => item.url); + + return Promise.race( + urlsToDelete.map((url) => { + return this.request({ + uri: url, + method: 'DELETE', + }); + }) + ); + } + + return Promise.resolve(); + }) + .catch((error) => { + this.logger.error('Failed to retrieve devices:', error); + + return Promise.reject(error); + }); + }, + + /** + * Registers and when fails deletes devices + */ + @oneFlight + @waitForValue('@') + register(deviceRegistrationOptions = {}) { + return this._registerInternal(deviceRegistrationOptions).catch((error) => { + if (error?.body?.message === 'User has excessive device registrations') { + return this.deleteDevices().then(() => { + return this._registerInternal(deviceRegistrationOptions); + }); + } + throw error; + }); + }, + + _getBody() { + return { + ...(this.config.defaults.body ? this.config.defaults.body : {}), + ...(this.config.body ? this.config.body : {}), + }; + }, /** * Register or refresh a device depending on the current device state. Device @@ -451,7 +520,7 @@ const Device = WebexPlugin.extend({ */ @oneFlight @waitForValue('@') - register(deviceRegistrationOptions = {}) { + _registerInternal(deviceRegistrationOptions = {}) { this.logger.info('device: registering'); this.webex.internal.newMetrics.callDiagnosticMetrics.setDeviceInfo(this); @@ -466,10 +535,7 @@ const Device = WebexPlugin.extend({ } // Merge body configurations, overriding defaults. - const body = { - ...(this.config.defaults.body ? this.config.defaults.body : {}), - ...(this.config.body ? this.config.body : {}), - }; + const body = this._getBody(); // Merge header configurations, overriding defaults. const headers = { @@ -527,7 +593,6 @@ const Device = WebexPlugin.extend({ }); }); }, - /** * Unregister the current registered device if available. Unregistering a * device utilizes the services plugin to send the request to the **WDM** diff --git a/packages/@webex/internal-plugin-device/test/unit/spec/device.js b/packages/@webex/internal-plugin-device/test/unit/spec/device.js index 754a48706f1..b2da5312b1a 100644 --- a/packages/@webex/internal-plugin-device/test/unit/spec/device.js +++ b/packages/@webex/internal-plugin-device/test/unit/spec/device.js @@ -357,6 +357,68 @@ describe('plugin-device', () => { }); }); + describe('deleteDevices()', () => { + const setup = (deviceType) => { + device.config.defaults = {body: {deviceType}}; + }; + ['WEB', 'WEBCLIENT'].forEach(deviceType => { + it(`should delete correct number of devices for ${deviceType}`, async () => { + setup(deviceType); + const response = { + body: { + devices: [ + {url: 'url3', modificationTime: '2023-10-03T10:00:00Z', deviceType}, + {url: 'url4', modificationTime: '2023-10-04T10:00:00Z', deviceType: 'notweb'}, + {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType}, + {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType}, + {url: 'url5', modificationTime: '2023-10-00T10:00:00Z', deviceType}, + {url: 'url6', modificationTime: '2023-09-50T10:00:00Z', deviceType}, + {url: 'url7', modificationTime: '2023-09-30T10:00:00Z', deviceType}, + {url: 'url8', modificationTime: '2023-08-30T10:00:00Z', deviceType}, + ] + } + }; + const requestStub = sinon.stub(device, 'request'); + requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response); + requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves(); + + await device.deleteDevices(); + + const expectedDeletions = ['url8', 'url7', 'url1']; + + expectedDeletions.forEach(url => { + assert(requestStub.calledWith(sinon.match({uri: url, method: 'DELETE'}))); + }); + + const notDeletedUrls = ['url2', 'url3', 'url5', 'url6', 'url4']; + notDeletedUrls.forEach(url => { + assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'}))); + }); + });}); + + it('does not delete when there are just 2 devices', async () => { + setup('WEB'); + const response = { + body: { + devices: [ + {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'WEB'}, + {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType: 'WEB'}, + ] + } + }; + + const requestStub = sinon.stub(device, 'request'); + requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response); + requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves(); + + await device.deleteDevices(); + const notDeletedUrls = ['url1', 'url2']; + notDeletedUrls.forEach(url => { + assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'}))); + }); + }); + }); + describe('#register()', () => { const setup = (config = {}) => { webex.internal.metrics.submitClientMetrics = sinon.stub(); @@ -386,6 +448,40 @@ describe('plugin-device', () => { }); }); + it('calls delete devices when errors with User has excessive device registrations', async () => { + setup(); + const deleteDeviceSpy = sinon.stub(device, 'deleteDevices').callsFake(() => Promise.resolve()); + const registerStub = sinon.stub(device, '_registerInternal'); + + registerStub.onFirstCall().rejects({body: {message: 'User has excessive device registrations'}}); + registerStub.onSecondCall().callsFake(() => Promise.resolve({exampleKey: 'example response value',})); + + const result = await device.register(); + + assert.calledOnce(deleteDeviceSpy); + + assert.equal(registerStub.callCount, 2); + + assert.deepEqual(result, {exampleKey: 'example response value'}); + }); + + it('does not call delete devices when some other error', async () => { + setup(); + + const deleteDeviceSpy = sinon.stub(device, 'deleteDevices').callsFake(() => Promise.resolve()); + const registerStub = sinon.stub(device, '_registerInternal').rejects(new Error('some error')); + + try { + await device.register({deleteFlag: true}); + } catch (error) { + assert.notCalled(deleteDeviceSpy); + + assert.equal(registerStub.callCount, 1); + + assert.match(error.message, /some error/, 'Expected error message not matched'); + } + }); + it('checks that submitInternalEvent gets called with internal.register.device.response on error', async () => { setup(); sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve()); diff --git a/packages/@webex/internal-plugin-support/src/config.js b/packages/@webex/internal-plugin-support/src/config.js index c85a0aa5cca..8fcd1226b2a 100644 --- a/packages/@webex/internal-plugin-support/src/config.js +++ b/packages/@webex/internal-plugin-support/src/config.js @@ -17,5 +17,6 @@ export default { appType: '', appVersion: '', languageCode: '', + incrementalLogs: false, }, }; diff --git a/packages/@webex/internal-plugin-support/src/support.js b/packages/@webex/internal-plugin-support/src/support.js index f41d380637e..b5cb0824f19 100644 --- a/packages/@webex/internal-plugin-support/src/support.js +++ b/packages/@webex/internal-plugin-support/src/support.js @@ -107,6 +107,10 @@ const Support = WebexPlugin.extend({ return this.webex.upload(options); }) .then((body) => { + if (this.config.incrementalLogs) { + this.webex.logger.clearBuffers(); + } + if (userId && !body.userId) { body.userId = userId; } diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json index 8a91c6266bf..2f74b136b3a 100644 --- a/packages/@webex/media-helpers/package.json +++ b/packages/@webex/media-helpers/package.json @@ -22,7 +22,7 @@ "deploy:npm": "yarn npm publish" }, "dependencies": { - "@webex/internal-media-core": "2.11.3", + "@webex/internal-media-core": "2.12.2", "@webex/ts-events": "^1.1.0", "@webex/web-media-effects": "2.19.0" }, diff --git a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js index cc65a772edd..91be8a7ce34 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js @@ -22,6 +22,16 @@ const lodash = require('lodash'); const OAUTH2_CSRF_TOKEN = 'oauth2-csrf-token'; const OAUTH2_CODE_VERIFIER = 'oauth2-code-verifier'; +/** + * Authorization plugin events + */ +export const Events = { + /** + * QR code login events + */ + qRCodeLogin: 'qRCodeLogin', +}; + /** * Browser support for OAuth2. Automatically parses the URL query for an * authorization code @@ -67,17 +77,49 @@ const Authorization = WebexPlugin.extend({ namespace: 'Credentials', + /** + * EventEmitter for authorization events + * @instance + * @memberof AuthorizationBrowserFirstParty + * @type {EventEmitter} + * @public + */ + eventEmitter: new EventEmitter(), /** - * Stores the interval ID for QR code polling + * Stores the timer ID for QR code polling * @instance * @memberof AuthorizationBrowserFirstParty * @type {?number} * @private */ - pollingRequest: null, + pollingTimer: null, + /** + * Stores the expiration timer ID for QR code polling + * @instance + * @memberof AuthorizationBrowserFirstParty + * @type {?number} + * @private + */ + pollingExpirationTimer: null, - eventEmitter: new EventEmitter(), + /** + * Monotonically increasing id to identify the current polling request + * @instance + * @memberof AuthorizationBrowserFirstParty + * @type {number} + * @private + */ + pollingId: 0, + + /** + * Identifier for the current polling request + * @instance + * @memberof AuthorizationBrowserFirstParty + * @type {?number} + * @private + */ + currentPollingId: null, /** * Initializer @@ -253,6 +295,30 @@ const Authorization = WebexPlugin.extend({ }); }, + /** + * Generate a QR code URL to launch the Webex app when scanning with the camera + * @instance + * @memberof AuthorizationBrowserFirstParty + * @param {String} verificationUrl + * @returns {String} + */ + _generateQRCodeVerificationUrl(verificationUrl) { + const baseUrl = 'https://web.webex.com/deviceAuth'; + const urlParams = new URLSearchParams(new URL(verificationUrl).search); + const userCode = urlParams.get('userCode'); + + if (userCode) { + const {services} = this.webex.internal; + const oauthHelperUrl = services.get('oauth-helper'); + const newVerificationUrl = new URL(baseUrl); + newVerificationUrl.searchParams.set('usercode', userCode); + newVerificationUrl.searchParams.set('oauthhelper', oauthHelperUrl); + return newVerificationUrl.toString(); + } else { + return verificationUrl; + } + }, + /** * Get an OAuth Login URL for QRCode. Generate QR code based on the returned URL. * @instance @@ -260,8 +326,8 @@ const Authorization = WebexPlugin.extend({ * @emits #qRCodeLogin */ initQRCodeLogin() { - if (this.pollingRequest) { - this.eventEmitter.emit('qRCodeLogin', { + if (this.pollingTimer) { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'getUserCodeFailure', data: {message: 'There is already a polling request'}, }); @@ -285,19 +351,20 @@ const Authorization = WebexPlugin.extend({ }) .then((res) => { const {user_code, verification_uri, verification_uri_complete} = res.body; - this.eventEmitter.emit('qRCodeLogin', { + const verificationUriComplete = this._generateQRCodeVerificationUrl(verification_uri_complete); + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'getUserCodeSuccess', userData: { userCode: user_code, verificationUri: verification_uri, - verificationUriComplete: verification_uri_complete, - } + verificationUriComplete, + }, }); // if device authorization success, then start to poll server to check whether the user has completed authorization this._startQRCodePolling(res.body); }) .catch((res) => { - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'getUserCodeFailure', data: res.body, }); @@ -313,30 +380,36 @@ const Authorization = WebexPlugin.extend({ */ _startQRCodePolling(options = {}) { if (!options.device_code) { - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'authorizationFailure', data: {message: 'A deviceCode is required'}, }); return; } - if (this.pollingRequest) { - this.eventEmitter.emit('qRCodeLogin', { + if (this.pollingTimer) { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'authorizationFailure', data: {message: 'There is already a polling request'}, }); return; } - const {device_code: deviceCode, interval = 2, expires_in: expiresIn = 300} = options; + const {device_code: deviceCode, expires_in: expiresIn = 300} = options; + let interval = options.interval ?? 2; - let attempts = 0; - const maxAttempts = expiresIn / interval; + this.pollingExpirationTimer = setTimeout(() => { + this.cancelQRCodePolling(false); + this.eventEmitter.emit(Events.qRCodeLogin, { + eventType: 'authorizationFailure', + data: {message: 'Authorization timed out'}, + }); + }, expiresIn * 1000); - this.pollingRequest = setInterval(() => { - attempts += 1; + const polling = () => { + this.pollingId += 1; + this.currentPollingId = this.pollingId; - const currentAttempts = attempts; this.webex .request({ method: 'POST', @@ -354,43 +427,50 @@ const Authorization = WebexPlugin.extend({ }, }) .then((res) => { - if (this.pollingRequest === null) return; + // if the pollingId has changed, it means that the polling request has been canceled + if (this.currentPollingId !== this.pollingId) return; - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'authorizationSuccess', data: res.body, }); this.cancelQRCodePolling(); }) .catch((res) => { - if (this.pollingRequest === null) return; + // if the pollingId has changed, it means that the polling request has been canceled + if (this.currentPollingId !== this.pollingId) return; - if (currentAttempts >= maxAttempts) { - this.eventEmitter.emit('qRCodeLogin', { - eventType: 'authorizationFailure', - data: {message: 'Authorization timed out'} - }); - this.cancelQRCodePolling(); + // When server sends 400 status code with message 'slow_down', it means that last request happened too soon. + // So, skip one interval and then poll again. + if (res.statusCode === 400 && res.body.message === 'slow_down') { + schedulePolling(interval * 2); return; } + // if the statusCode is 428 which means that the authorization request is still pending // as the end user hasn't yet completed the user-interaction steps. So keep polling. if (res.statusCode === 428) { - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'authorizationPending', - data: res.body + data: res.body, }); + schedulePolling(interval); return; } this.cancelQRCodePolling(); - this.eventEmitter.emit('qRCodeLogin', { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'authorizationFailure', - data: res.body + data: res.body, }); }); - }, interval * 1000); + }; + + const schedulePolling = (interval) => + (this.pollingTimer = setTimeout(polling, interval * 1000)); + + schedulePolling(interval); }, /** @@ -399,14 +479,19 @@ const Authorization = WebexPlugin.extend({ * @memberof AuthorizationBrowserFirstParty * @returns {void} */ - cancelQRCodePolling() { - if (this.pollingRequest) { - clearInterval(this.pollingRequest); - this.eventEmitter.emit('qRCodeLogin', { + cancelQRCodePolling(withCancelEvent = true) { + if (this.pollingTimer && withCancelEvent) { + this.eventEmitter.emit(Events.qRCodeLogin, { eventType: 'pollingCanceled', }); - this.pollingRequest = null; } + + this.currentPollingId = null; + + clearTimeout(this.pollingExpirationTimer); + this.pollingExpirationTimer = null; + clearTimeout(this.pollingTimer); + this.pollingTimer = null; }, /** diff --git a/packages/@webex/plugin-authorization-browser-first-party/src/index.js b/packages/@webex/plugin-authorization-browser-first-party/src/index.js index 02ca4c2bbe8..4870be6d0ed 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/src/index.js +++ b/packages/@webex/plugin-authorization-browser-first-party/src/index.js @@ -14,5 +14,5 @@ registerPlugin('authorization', Authorization, { proxies, }); -export {default} from './authorization'; +export {default, Events} from './authorization'; export {default as config} from './config'; diff --git a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js index 5bf294485fb..b08af627998 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js @@ -18,7 +18,6 @@ import Authorization from '@webex/plugin-authorization-browser-first-party'; // Necessary to require lodash this way in order to stub the method const lodash = require('lodash'); - describe('plugin-authorization-browser-first-party', () => { describe('Authorization', () => { function makeWebex( @@ -187,14 +186,16 @@ describe('plugin-authorization-browser-first-party', () => { const webex = makeWebex( `http://example.com/?code=${code}&state=${base64.encode( JSON.stringify({emailhash: 'someemailhash'}) - )}`, + )}` ); const requestAuthorizationCodeGrantStub = sinon.stub( Authorization.prototype, 'requestAuthorizationCodeGrant' ); - const collectPreauthCatalogStub = sinon.stub(Services.prototype, 'collectPreauthCatalog').resolves(); + const collectPreauthCatalogStub = sinon + .stub(Services.prototype, 'collectPreauthCatalog') + .resolves(); await webex.authorization.when('change:ready'); @@ -206,9 +207,7 @@ describe('plugin-authorization-browser-first-party', () => { it('collects the preauth catalog no emailhash is present in the state', async () => { const code = 'authcode_clusterid_theOrgId'; - const webex = makeWebex( - `http://example.com/?code=${code}` - ); + const webex = makeWebex(`http://example.com/?code=${code}`); const requestAuthorizationCodeGrantStub = sinon.stub( Authorization.prototype, @@ -271,12 +270,13 @@ describe('plugin-authorization-browser-first-party', () => { it('throws a grant error', () => { let err = null; try { - makeWebex('http://127.0.0.1:8000/?error=invalid_scope&error_description=The%20requested%20scope%20is%20invalid.'); - } - catch (e) { + makeWebex( + 'http://127.0.0.1:8000/?error=invalid_scope&error_description=The%20requested%20scope%20is%20invalid.' + ); + } catch (e) { err = e; } - expect(err?.message).toBe('Cannot convert object to primitive value') + expect(err?.message).toBe('Cannot convert object to primitive value'); }); }); @@ -443,17 +443,49 @@ describe('plugin-authorization-browser-first-party', () => { }); }); + describe('#_generateQRCodeVerificationUrl()', () => { + it('should generate a QR code URL when a userCode is present', () => { + const verificationUrl = 'https://example.com/verify?userCode=123456'; + const oauthHelperUrl = 'https://oauth-helper-a.wbx2.com/helperservice/v1'; + const expectedUrl = 'https://web.webex.com/deviceAuth?usercode=123456&oauthhelper=https%3A%2F%2Foauth-helper-a.wbx2.com%2Fhelperservice%2Fv1'; + + const webex = makeWebex('http://example.com'); + + const oauthHelperSpy = sinon.stub(webex.internal.services, 'get').returns(oauthHelperUrl); + const result = webex.authorization._generateQRCodeVerificationUrl(verificationUrl); + + assert.calledOnce(oauthHelperSpy); + assert.calledWithExactly(oauthHelperSpy, 'oauth-helper'); + assert.equal(result, expectedUrl); + + oauthHelperSpy.restore(); + }); + + it('should return the original verificationUrl when userCode is not present', () => { + const verificationUrl = 'https://example.com/verify'; + const webex = makeWebex('http://example.com'); + + const oauthHelperSpy = sinon.stub(webex.internal.services, 'get'); + const result = webex.authorization._generateQRCodeVerificationUrl(verificationUrl); + + assert.notCalled(oauthHelperSpy); + assert.equal(result, verificationUrl); + + oauthHelperSpy.restore(); + }); + }); + describe('#initQRCodeLogin()', () => { it('should prevent concurrent request if there is already a polling request', async () => { const webex = makeWebex('http://example.com'); - - webex.authorization.pollingRequest = 1; + + webex.authorization.pollingTimer = 1; const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); webex.authorization.initQRCodeLogin(); - + assert.calledOnce(emitSpy); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure'); - webex.authorization.pollingRequest = null; + assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure'); + webex.authorization.pollingTimer = null; }); it('should send correct request parameters to the API', async () => { @@ -461,31 +493,33 @@ describe('plugin-authorization-browser-first-party', () => { const testClientId = 'test-client-id'; const testScope = 'test-scope'; const sampleData = { - device_code: "test123", + device_code: 'test123', expires_in: 300, - user_code: "421175", - verification_uri: "http://example.com", - verification_uri_complete: "http://example.com", - interval: 2 + user_code: '421175', + verification_uri: 'http://example.com', + verification_uri_complete: 'http://example.com', + interval: 2, }; const webex = makeWebex('http://example.com', undefined, undefined, { credentials: { client_id: testClientId, scope: testScope, - } + }, }); webex.request.onFirstCall().resolves({statusCode: 200, body: sampleData}); sinon.spy(webex.authorization, '_startQRCodePolling'); + sinon.spy(webex.authorization, '_generateQRCodeVerificationUrl'); const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); webex.authorization.initQRCodeLogin(); clock.tick(2000); - await clock.runAllAsync() + await clock.runAllAsync(); assert.calledTwice(webex.request); assert.calledOnce(webex.authorization._startQRCodePolling); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeSuccess'); + assert.calledOnce(webex.authorization._generateQRCodeVerificationUrl); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeSuccess'); const request = webex.request.getCall(0); @@ -498,18 +532,18 @@ describe('plugin-authorization-browser-first-party', () => { const clock = sinon.useFakeTimers(); const webex = makeWebex('http://example.com'); const sampleData = { - device_code: "test123", + device_code: 'test123', expires_in: 300, - user_code: "421175", - verification_uri: "http://example.com", - verification_uri_complete: "http://example.com", - interval: 2 + user_code: '421175', + verification_uri: 'http://example.com', + verification_uri_complete: 'http://example.com', + interval: 2, }; webex.request.resolves().resolves({statusCode: 200, body: sampleData}); webex.authorization.initQRCodeLogin(); clock.tick(2000); - await clock.runAllAsync() + await clock.runAllAsync(); const request = webex.request.getCall(0); assert.equal(request.args[0].method, 'POST'); @@ -517,7 +551,7 @@ describe('plugin-authorization-browser-first-party', () => { assert.equal(request.args[0].resource, '/actions/device/authorize'); clock.restore(); }); - + it('should emit getUserCodeFailure event', async () => { const clock = sinon.useFakeTimers(); const webex = makeWebex('http://example.com'); @@ -526,7 +560,7 @@ describe('plugin-authorization-browser-first-party', () => { webex.authorization.initQRCodeLogin(); - await clock.runAllAsync() + await clock.runAllAsync(); assert.calledOnce(emitSpy); assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure'); @@ -537,15 +571,15 @@ describe('plugin-authorization-browser-first-party', () => { describe('#_startQRCodePolling()', () => { it('requires a deviceCode', () => { const webex = makeWebex('http://example.com'); - + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); webex.authorization._startQRCodePolling({}); assert.calledOnce(emitSpy); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure'); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure'); }); - + it('should send correct request parameters to the API', async () => { const clock = sinon.useFakeTimers(); const testClientId = 'test-client-id'; @@ -560,7 +594,7 @@ describe('plugin-authorization-browser-first-party', () => { const webex = makeWebex('http://example.com', undefined, undefined, { credentials: { client_id: testClientId, - } + }, }); webex.request.onFirstCall().resolves({statusCode: 200, body: {access_token: 'token'}}); @@ -569,7 +603,7 @@ describe('plugin-authorization-browser-first-party', () => { webex.authorization._startQRCodePolling(options); clock.tick(2000); - await clock.runAllAsync() + await clock.runAllAsync(); assert.calledOnce(webex.request); @@ -577,7 +611,10 @@ describe('plugin-authorization-browser-first-party', () => { assert.equal(request.args[0].form.client_id, testClientId); assert.equal(request.args[0].form.device_code, testDeviceCode); - assert.equal(request.args[0].form.grant_type, 'urn:ietf:params:oauth:grant-type:device_code'); + assert.equal( + request.args[0].form.grant_type, + 'urn:ietf:params:oauth:grant-type:device_code' + ); assert.calledOnce(webex.authorization.cancelQRCodePolling); assert.calledTwice(emitSpy); @@ -593,23 +630,26 @@ describe('plugin-authorization-browser-first-party', () => { const options = { device_code: 'test-device-code', interval: 2, - expires_in: 300 + expires_in: 300, }; - - webex.request.onFirstCall().rejects({statusCode: 428, body: {message: 'authorization_pending'}}); + + webex.request + .onFirstCall() + .rejects({statusCode: 428, body: {message: 'authorization_pending'}}); webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}}); sinon.spy(webex.authorization, 'cancelQRCodePolling'); const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); - + webex.authorization._startQRCodePolling(options); - clock.tick(4000); - await clock.runAllAsync() - + await clock.tickAsync(4000); + //await clock.runAllAsync() + assert.calledTwice(webex.request); assert.calledOnce(webex.authorization.cancelQRCodePolling); - assert.calledTwice(emitSpy); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess'); - assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled'); + assert.calledThrice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationSuccess'); + assert.equal(emitSpy.getCall(2).args[1].eventType, 'pollingCanceled'); clock.restore(); }); @@ -619,23 +659,21 @@ describe('plugin-authorization-browser-first-party', () => { const options = { device_code: 'test-device-code', interval: 5, - expires_in: 10 + expires_in: 9, }; - + webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}}); sinon.spy(webex.authorization, 'cancelQRCodePolling'); const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); webex.authorization._startQRCodePolling(options); - clock.tick(10000); - await clock.runAllAsync() - - assert.calledTwice(webex.request); + await clock.tickAsync(10_000); + + assert.calledOnce(webex.request); assert.calledOnce(webex.authorization.cancelQRCodePolling); - assert.calledThrice(emitSpy); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending'); + assert.calledTwice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending'); assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationFailure'); - assert.equal(emitSpy.getCall(2).args[1].eventType, 'pollingCanceled'); clock.restore(); }); @@ -644,54 +682,129 @@ describe('plugin-authorization-browser-first-party', () => { const options = { device_code: 'test-device-code', interval: 2, - expires_in: 300 + expires_in: 300, }; - - webex.authorization.pollingRequest = 1; + + webex.authorization.pollingTimer = 1; const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); webex.authorization._startQRCodePolling(options); assert.calledOnce(emitSpy); - assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure'); - webex.authorization.pollingRequest = null; + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure'); + webex.authorization.pollingTimer = null; + }); + + it('should skip a interval when server ask for slow_down', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test-device-code', + interval: 2, + expires_in: 300, + }; + + webex.request.onFirstCall().rejects({statusCode: 400, body: {message: 'slow_down'}}); + webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}}); + sinon.spy(webex.authorization, 'cancelQRCodePolling'); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization._startQRCodePolling(options); + await clock.tickAsync(4000); + + // Request only once because of slow_down + assert.calledOnce(webex.request); + + // Wait for next interval + await clock.tickAsync(2000); + + assert.calledTwice(webex.request); + assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledTwice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled'); + clock.restore(); + }); + + it('should ignore the response from the previous polling', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test-device-code', + interval: 2, + expires_in: 300, + }; + + webex.request.onFirstCall().callsFake(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({statusCode: 200, body: {access_token: 'token'}}); + }, 1000); + }); + }); + + webex.request + .onSecondCall() + .rejects({statusCode: 428, body: {message: 'authorizationPending'}}); + sinon.spy(webex.authorization, 'cancelQRCodePolling'); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization._startQRCodePolling(options); + await clock.tickAsync(2500); + + webex.authorization.cancelQRCodePolling(); + + // Start new polling + + webex.authorization._startQRCodePolling(options); + + // Wait for next interval + await clock.tickAsync(3000); + + assert.calledTwice(webex.request); + assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledTwice(emitSpy); + // authorizationSuccess event should not be emitted + assert.equal(emitSpy.getCall(0).args[1].eventType, 'pollingCanceled'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationPending'); + clock.restore(); }); }); describe('#cancelQRCodePolling()', () => { it('should stop polling after cancellation', async () => { - const clock = sinon.useFakeTimers(); - const webex = makeWebex('http://example.com'); - const options = { - device_code: 'test-device-code', - interval: 2, - expires_in: 300 - }; - - webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}}); - const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); - - webex.authorization._startQRCodePolling(options); - // First poll - clock.tick(2000); - assert.calledOnce(webex.request); - - webex.authorization.cancelQRCodePolling(); - // Wait for next interval - clock.tick(2000); - - const eventArgs = emitSpy.getCall(0).args; - - // Verify no additional requests were made - assert.calledOnce(webex.request); - assert.calledOnce(emitSpy); - assert.equal(eventArgs[1].eventType, 'pollingCanceled'); - clock.restore(); - }); + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test-device-code', + interval: 2, + expires_in: 300, + }; + + webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}}); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization._startQRCodePolling(options); + // First poll + clock.tick(2000); + assert.calledOnce(webex.request); + + webex.authorization.cancelQRCodePolling(); + // Wait for next interval + clock.tick(2000); + + const eventArgs = emitSpy.getCall(0).args; + + // Verify no additional requests were made + assert.calledOnce(webex.request); + assert.calledOnce(emitSpy); + assert.equal(eventArgs[1].eventType, 'pollingCanceled'); + clock.restore(); + }); it('should clear interval and reset polling request', () => { const clock = sinon.useFakeTimers(); const webex = makeWebex('http://example.com'); - + const options = { device_code: 'test_device_code', interval: 2, @@ -699,22 +812,21 @@ describe('plugin-authorization-browser-first-party', () => { }; webex.authorization._startQRCodePolling(options); - assert.isDefined(webex.authorization.pollingRequest); - + assert.isDefined(webex.authorization.pollingTimer); + webex.authorization.cancelQRCodePolling(); - assert.isNull(webex.authorization.pollingRequest); - + assert.isNull(webex.authorization.pollingTimer); + clock.restore(); }); - + it('should handle cancellation when no polling is in progress', () => { const webex = makeWebex('http://example.com'); - assert.isNull(webex.authorization.pollingRequest); - + assert.isNull(webex.authorization.pollingTimer); + webex.authorization.cancelQRCodePolling(); - assert.isNull(webex.authorization.pollingRequest); + assert.isNull(webex.authorization.pollingTimer); }); - }); describe('#_generateCodeChallenge', () => { @@ -836,7 +948,7 @@ describe('plugin-authorization-browser-first-party', () => { const orgId = webex.authorization._extractOrgIdFromCode(code); assert.isUndefined(orgId); - }) + }); }); }); }); diff --git a/packages/@webex/plugin-logger/src/logger.js b/packages/@webex/plugin-logger/src/logger.js index 7ef5a925fa9..183602dbe50 100644 --- a/packages/@webex/plugin-logger/src/logger.js +++ b/packages/@webex/plugin-logger/src/logger.js @@ -252,6 +252,20 @@ const Logger = WebexPlugin.extend({ return this.getCurrentLevel(); }, + /** + * Clears the log buffers + * + * @instance + * @memberof Logger + * @public + * @returns {undefined} + */ + clearBuffers() { + this.clientBuffer = []; + this.sdkBuffer = []; + this.buffer = []; + }, + /** * Format logs (for upload) * diff --git a/packages/@webex/plugin-meetings/package.json b/packages/@webex/plugin-meetings/package.json index 9772e87d059..09bcb56df67 100644 --- a/packages/@webex/plugin-meetings/package.json +++ b/packages/@webex/plugin-meetings/package.json @@ -62,7 +62,7 @@ }, "dependencies": { "@webex/common": "workspace:*", - "@webex/internal-media-core": "2.11.3", + "@webex/internal-media-core": "2.12.2", "@webex/internal-plugin-conversation": "workspace:*", "@webex/internal-plugin-device": "workspace:*", "@webex/internal-plugin-llm": "workspace:*", diff --git a/packages/@webex/plugin-meetings/src/config.ts b/packages/@webex/plugin-meetings/src/config.ts index c3d82c71146..28c451ab152 100644 --- a/packages/@webex/plugin-meetings/src/config.ts +++ b/packages/@webex/plugin-meetings/src/config.ts @@ -95,5 +95,7 @@ export default { // This only applies to non-multistream meetings iceCandidatesGatheringTimeout: undefined, backendIpv6NativeSupport: false, + reachabilityGetClusterTimeout: 5000, + logUploadIntervalMultiplicationFactor: 0, // if set to 0 or undefined, logs won't be uploaded periodically }, }; diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 6fa1a188e31..9ab2f03b185 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -5,6 +5,7 @@ import jwtDecode from 'jwt-decode'; import {StatelessWebexPlugin} from '@webex/webex-core'; // @ts-ignore - Types not available for @webex/common import {Defer} from '@webex/common'; +import {safeSetTimeout, safeSetInterval} from '@webex/common-timers'; import { ClientEvent, ClientEventLeaveReason, @@ -702,6 +703,8 @@ export default class Meeting extends StatelessWebexPlugin { private iceCandidateErrors: Map; private iceCandidatesCount: number; private rtcMetrics?: RtcMetrics; + private uploadLogsTimer?: ReturnType; + private logUploadIntervalIndex: number; /** * @param {Object} attrs @@ -770,6 +773,8 @@ export default class Meeting extends StatelessWebexPlugin { ); this.callStateForMetrics.correlationId = this.id; } + this.logUploadIntervalIndex = 0; + /** * @instance * @type {String} @@ -3249,6 +3254,9 @@ export default class Meeting extends StatelessWebexPlugin { options: {meetingId: this.id}, }); } + Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.GUEST_ENTERED_LOBBY, { + correlation_id: this.correlationId, + }); this.updateLLMConnection(); }); this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, async (payload) => { @@ -3272,6 +3280,9 @@ export default class Meeting extends StatelessWebexPlugin { name: 'client.lobby.exited', options: {meetingId: this.id}, }); + Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.GUEST_EXITED_LOBBY, { + correlation_id: this.correlationId, + }); } this.rtcMetrics?.sendNextMetrics(); this.updateLLMConnection(); @@ -4060,6 +4071,65 @@ export default class Meeting extends StatelessWebexPlugin { Trigger.trigger(this, options, EVENTS.REQUEST_UPLOAD_LOGS, this); } + /** + * sets the timer for periodic log upload + * @returns {void} + */ + private setLogUploadTimer() { + // start with short timeouts and increase them later on so in case users have very long multi-hour meetings we don't get too fragmented logs + const LOG_UPLOAD_INTERVALS = [0.1, 1, 15, 15, 30, 30, 30, 60]; + + const delay = + 1000 * + // @ts-ignore - config coming from registerPlugin + this.config.logUploadIntervalMultiplicationFactor * + LOG_UPLOAD_INTERVALS[this.logUploadIntervalIndex]; + + if (this.logUploadIntervalIndex < LOG_UPLOAD_INTERVALS.length - 1) { + this.logUploadIntervalIndex += 1; + } + + this.uploadLogsTimer = safeSetTimeout(() => { + this.uploadLogsTimer = undefined; + + this.uploadLogs(); + + // just as an extra precaution, to avoid uploading logs forever in case something goes wrong + // and the page remains opened, we stop it if there is no media connection + if (!this.mediaProperties.webrtcMediaConnection) { + return; + } + + this.setLogUploadTimer(); + }, delay); + } + + /** + * Starts a periodic upload of logs + * + * @returns {undefined} + */ + public startPeriodicLogUpload() { + // @ts-ignore - config coming from registerPlugin + if (this.config.logUploadIntervalMultiplicationFactor && !this.uploadLogsTimer) { + this.logUploadIntervalIndex = 0; + + this.setLogUploadTimer(); + } + } + + /** + * Stops the periodic upload of logs + * + * @returns {undefined} + */ + public stopPeriodicLogUpload() { + if (this.uploadLogsTimer) { + clearTimeout(this.uploadLogsTimer); + this.uploadLogsTimer = undefined; + } + } + /** * Removes remote audio, video and share streams from class instance's mediaProperties * @returns {undefined} @@ -4771,8 +4841,6 @@ export default class Meeting extends StatelessWebexPlugin { if (!joinResponse) { // This is the 1st attempt or a retry after join request failed -> we need to do a join with TURN discovery - // @ts-ignore - joinOptions.reachability = await this.webex.meetings.reachability.getReachabilityResults(); const turnDiscoveryRequest = await this.roap.generateTurnDiscoveryRequestMessage( this, true @@ -4904,6 +4972,8 @@ export default class Meeting extends StatelessWebexPlugin { ); } + this.cleanUpBeforeReconnection(); + return this.reconnectionManager .reconnect(options, async () => { await this.waitForRemoteSDPAnswer(); @@ -6321,7 +6391,7 @@ export default class Meeting extends StatelessWebexPlugin { this.mediaProperties.webrtcMediaConnection.on( MediaConnectionEventNames.ICE_CANDIDATE, (event) => { - if (event.candidate) { + if (event.candidate && event.candidate.candidate && event.candidate.candidate.length > 0) { this.iceCandidatesCount += 1; } } @@ -7032,6 +7102,23 @@ export default class Meeting extends StatelessWebexPlugin { } } + private async cleanUpBeforeReconnection(): Promise { + try { + // when media fails, we want to upload a webrtc dump to see whats going on + // this function is async, but returns once the stats have been gathered + await this.forceSendStatsReport({callFrom: 'cleanUpBeforeReconnection'}); + + if (this.statsAnalyzer) { + await this.statsAnalyzer.stopAnalyzer(); + } + } catch (error) { + LoggerProxy.logger.error( + 'Meeting:index#cleanUpBeforeReconnection --> Error during cleanup: ', + error + ); + } + } + /** * Creates an instance of LocusMediaRequest for this meeting - it is needed for doing any calls * to Locus /media API (these are used for sending Roap messages and updating audio/video mute status). @@ -7228,6 +7315,7 @@ export default class Meeting extends StatelessWebexPlugin { // We can log ReceiveSlot SSRCs only after the SDP exchange, so doing it here: this.remoteMediaManager?.logAllReceiveSlots(); + this.startPeriodicLogUpload(); } catch (error) { LoggerProxy.logger.error(`${LOG_HEADER} failed to establish media connection: `, error); diff --git a/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts b/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts index 488d5836dd6..0d56f77a796 100644 --- a/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts +++ b/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts @@ -2,8 +2,9 @@ import {defer} from 'lodash'; import {Defer} from '@webex/common'; import {WebexPlugin} from '@webex/webex-core'; -import {MEDIA, HTTP_VERBS, ROAP, IP_VERSION} from '../constants'; +import {MEDIA, HTTP_VERBS, ROAP} from '../constants'; import LoggerProxy from '../common/logs/logger-proxy'; +import {ClientMediaPreferences} from '../reachability/reachability.types'; export type MediaRequestType = 'RoapMessage' | 'LocalMute'; export type RequestResult = any; @@ -14,9 +15,8 @@ export type RoapRequest = { mediaId: string; roapMessage: any; reachability: any; + clientMediaPreferences: ClientMediaPreferences; sequence?: any; - joinCookie: any; // any, because this is opaque to the client, we pass whatever object we got from one backend component (Orpheus) to the other (Locus) - ipVersion?: IP_VERSION; }; export type LocalMuteRequest = { @@ -202,10 +202,6 @@ export class LocusMediaRequest extends WebexPlugin { const body: any = { device: this.config.device, correlationId: this.config.correlationId, - clientMediaPreferences: { - preferTranscoding: this.config.preferTranscoding, - ipver: request.type === 'RoapMessage' ? request.ipVersion : undefined, - }, }; const localMedias: any = { @@ -223,7 +219,7 @@ export class LocusMediaRequest extends WebexPlugin { case 'RoapMessage': localMedias.roapMessage = request.roapMessage; localMedias.reachability = request.reachability; - body.clientMediaPreferences.joinCookie = request.joinCookie; + body.clientMediaPreferences = request.clientMediaPreferences; // @ts-ignore this.webex.internal.newMetrics.submitClientEvent({ diff --git a/packages/@webex/plugin-meetings/src/meeting/request.ts b/packages/@webex/plugin-meetings/src/meeting/request.ts index 89714139bef..c8b34564d65 100644 --- a/packages/@webex/plugin-meetings/src/meeting/request.ts +++ b/packages/@webex/plugin-meetings/src/meeting/request.ts @@ -26,11 +26,11 @@ import { SEND_DTMF_ENDPOINT, _SLIDES_, ANNOTATION, - IP_VERSION, } from '../constants'; import {SendReactionOptions, ToggleReactionsOptions} from './request.type'; import MeetingUtil from './util'; import {AnnotationInfo} from '../annotation/annotation.types'; +import {ClientMediaPreferences} from '../reachability/reachability.types'; /** * @class MeetingRequest @@ -128,8 +128,8 @@ export default class MeetingRequest extends StatelessWebexPlugin { locale?: string; deviceCapabilities?: Array; liveAnnotationSupported: boolean; - ipVersion?: IP_VERSION; alias?: string; + clientMediaPreferences: ClientMediaPreferences; }) { const { asResourceOccupant, @@ -147,12 +147,11 @@ export default class MeetingRequest extends StatelessWebexPlugin { moveToResource, roapMessage, reachability, - preferTranscoding, breakoutsSupported, locale, deviceCapabilities = [], liveAnnotationSupported, - ipVersion, + clientMediaPreferences, alias, } = options; @@ -160,8 +159,6 @@ export default class MeetingRequest extends StatelessWebexPlugin { let url = ''; - const joinCookie = await this.getJoinCookie(); - const body: any = { asResourceOccupant, device: { @@ -176,11 +173,7 @@ export default class MeetingRequest extends StatelessWebexPlugin { allowMultiDevice: true, ensureConversation: ensureConversation || false, supportsNativeLobby: 1, - clientMediaPreferences: { - preferTranscoding: preferTranscoding ?? true, - joinCookie, - ipver: ipVersion, - }, + clientMediaPreferences, }; if (alias) { diff --git a/packages/@webex/plugin-meetings/src/meeting/util.ts b/packages/@webex/plugin-meetings/src/meeting/util.ts index 8fa62d26d8c..0b727d67874 100644 --- a/packages/@webex/plugin-meetings/src/meeting/util.ts +++ b/packages/@webex/plugin-meetings/src/meeting/util.ts @@ -115,7 +115,7 @@ const MeetingUtil = { return IP_VERSION.unknown; }, - joinMeeting: (meeting, options) => { + joinMeeting: async (meeting, options) => { if (!meeting) { return Promise.reject(new ParameterError('You need a meeting object.')); } @@ -127,6 +127,27 @@ const MeetingUtil = { options: {meetingId: meeting.id}, }); + let reachability; + let clientMediaPreferences = { + // bare minimum fallback value that should allow us to join + ipver: IP_VERSION.unknown, + joinCookie: undefined, + preferTranscoding: !meeting.isMultistream, + }; + + try { + clientMediaPreferences = await webex.meetings.reachability.getClientMediaPreferences( + meeting.isMultistream, + MeetingUtil.getIpVersion(webex) + ); + reachability = await webex.meetings.reachability.getReachabilityReportToAttachToRoap(); + } catch (e) { + LoggerProxy.logger.error( + 'Meeting:util#joinMeeting --> Error getting reachability or clientMediaPreferences:', + e + ); + } + // eslint-disable-next-line no-warning-comments // TODO: check if the meeting is in JOINING state // if Joining state termintate the request as user might click multiple times @@ -138,20 +159,19 @@ const MeetingUtil = { locusUrl: meeting.locusUrl, locusClusterUrl: meeting.meetingInfo?.locusClusterUrl, correlationId: meeting.correlationId, - reachability: options.reachability, + reachability, roapMessage: options.roapMessage, permissionToken: meeting.permissionToken, resourceId: options.resourceId || null, moderator: options.moderator, pin: options.pin, moveToResource: options.moveToResource, - preferTranscoding: !meeting.isMultistream, asResourceOccupant: options.asResourceOccupant, breakoutsSupported: options.breakoutsSupported, locale: options.locale, deviceCapabilities: options.deviceCapabilities, liveAnnotationSupported: options.liveAnnotationSupported, - ipVersion: MeetingUtil.getIpVersion(meeting.getWebexObject()), + clientMediaPreferences, }) .then((res) => { const parsed = MeetingUtil.parseLocusJoin(res); @@ -177,6 +197,7 @@ const MeetingUtil = { cleanUp: (meeting) => { meeting.getWebexObject().internal.device.meetingEnded(); + meeting.stopPeriodicLogUpload(); meeting.breakouts.cleanUp(); meeting.simultaneousInterpretation.cleanUp(); diff --git a/packages/@webex/plugin-meetings/src/meetings/index.ts b/packages/@webex/plugin-meetings/src/meetings/index.ts index 6229462791b..dd0ade33a9f 100644 --- a/packages/@webex/plugin-meetings/src/meetings/index.ts +++ b/packages/@webex/plugin-meetings/src/meetings/index.ts @@ -1044,48 +1044,55 @@ export default class Meetings extends WebexPlugin { */ fetchUserPreferredWebexSite() { // @ts-ignore - return this.webex.people._getMe().then((me) => { - const isGuestUser = me.type === 'appuser'; - if (!isGuestUser) { - return this.request.getMeetingPreferences().then((res) => { - if (res) { - const preferredWebexSite = MeetingsUtil.parseDefaultSiteFromMeetingPreferences(res); - this.preferredWebexSite = preferredWebexSite; - // @ts-ignore - this.webex.internal.services._getCatalog().addAllowedDomains([preferredWebexSite]); - } + return this.webex.people + ._getMe() + .then((me) => { + const isGuestUser = me.type === 'appuser'; + if (!isGuestUser) { + return this.request.getMeetingPreferences().then((res) => { + if (res) { + const preferredWebexSite = MeetingsUtil.parseDefaultSiteFromMeetingPreferences(res); + this.preferredWebexSite = preferredWebexSite; + // @ts-ignore + this.webex.internal.services._getCatalog().addAllowedDomains([preferredWebexSite]); + } - // fall back to getting the preferred site from the user information - if (!this.preferredWebexSite) { - // @ts-ignore - return this.webex.internal.user - .get() - .then((user) => { - const preferredWebexSite = - user?.userPreferences?.userPreferencesItems?.preferredWebExSite; - if (preferredWebexSite) { - this.preferredWebexSite = preferredWebexSite; - // @ts-ignore - this.webex.internal.services - ._getCatalog() - .addAllowedDomains([preferredWebexSite]); - } else { - throw new Error('site not found'); - } - }) - .catch(() => { - LoggerProxy.logger.error( - 'Failed to fetch preferred site from user - no site will be set' - ); - }); - } + // fall back to getting the preferred site from the user information + if (!this.preferredWebexSite) { + // @ts-ignore + return this.webex.internal.user + .get() + .then((user) => { + const preferredWebexSite = + user?.userPreferences?.userPreferencesItems?.preferredWebExSite; + if (preferredWebexSite) { + this.preferredWebexSite = preferredWebexSite; + // @ts-ignore + this.webex.internal.services + ._getCatalog() + .addAllowedDomains([preferredWebexSite]); + } else { + throw new Error('site not found'); + } + }) + .catch(() => { + LoggerProxy.logger.error( + 'Failed to fetch preferred site from user - no site will be set' + ); + }); + } - return Promise.resolve(); - }); - } + return Promise.resolve(); + }); + } - return Promise.resolve(); - }); + return Promise.resolve(); + }) + .catch(() => { + LoggerProxy.logger.error( + 'Failed to retrieve user information. No preferredWebexSite will be set' + ); + }); } /** diff --git a/packages/@webex/plugin-meetings/src/metrics/constants.ts b/packages/@webex/plugin-meetings/src/metrics/constants.ts index 3cc6cb108fd..65b82975ba4 100644 --- a/packages/@webex/plugin-meetings/src/metrics/constants.ts +++ b/packages/@webex/plugin-meetings/src/metrics/constants.ts @@ -71,6 +71,8 @@ const BEHAVIORAL_METRICS = { TURN_DISCOVERY_REQUIRES_OK: 'js_sdk_turn_discovery_requires_ok', REACHABILITY_COMPLETED: 'js_sdk_reachability_completed', WEBINAR_REGISTRATION_ERROR: 'js_sdk_webinar_registration_error', + GUEST_ENTERED_LOBBY: 'js_sdk_guest_entered_lobby', + GUEST_EXITED_LOBBY: 'js_sdk_guest_exited_lobby', }; export {BEHAVIORAL_METRICS as default}; diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index 3803f95e1df..cc3674ac169 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -6,20 +6,7 @@ import {convertStunUrlToTurn, convertStunUrlToTurnTls} from './util'; import EventsScope from '../common/events/events-scope'; import {CONNECTION_STATE, Enum, ICE_GATHERING_STATE} from '../constants'; - -// result for a specific transport protocol (like udp or tcp) -export type TransportResult = { - result: 'reachable' | 'unreachable' | 'untested'; - latencyInMilliseconds?: number; // amount of time it took to get the first ICE candidate - clientMediaIPs?: string[]; -}; - -// reachability result for a specific media cluster -export type ClusterReachabilityResult = { - udp: TransportResult; - tcp: TransportResult; - xtls: TransportResult; -}; +import {ClusterReachabilityResult} from './reachability.types'; // data for the Events.resultReady event export type ResultEventData = { diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts index ff6d47a14b7..a0903d9decc 100644 --- a/packages/@webex/plugin-meetings/src/reachability/index.ts +++ b/packages/@webex/plugin-meetings/src/reachability/index.ts @@ -9,64 +9,31 @@ import {Defer} from '@webex/common'; import LoggerProxy from '../common/logs/logger-proxy'; import MeetingUtil from '../meeting/util'; -import {REACHABILITY} from '../constants'; +import {IP_VERSION, REACHABILITY} from '../constants'; import ReachabilityRequest, {ClusterList} from './request'; +import { + ClusterReachabilityResult, + TransportResult, + ClientMediaPreferences, + ReachabilityMetrics, + ReachabilityReportV0, + ReachabilityReportV1, + ReachabilityResults, + ReachabilityResultsForBackend, + TransportResultForBackend, + GetClustersTrigger, +} from './reachability.types'; import { ClientMediaIpsUpdatedEventData, ClusterReachability, - ClusterReachabilityResult, Events, ResultEventData, - TransportResult, } from './clusterReachability'; import EventsScope from '../common/events/events-scope'; import BEHAVIORAL_METRICS from '../metrics/constants'; import Metrics from '../metrics'; -export type ReachabilityMetrics = { - reachability_public_udp_success: number; - reachability_public_udp_failed: number; - reachability_public_tcp_success: number; - reachability_public_tcp_failed: number; - reachability_public_xtls_success: number; - reachability_public_xtls_failed: number; - reachability_vmn_udp_success: number; - reachability_vmn_udp_failed: number; - reachability_vmn_tcp_success: number; - reachability_vmn_tcp_failed: number; - reachability_vmn_xtls_success: number; - reachability_vmn_xtls_failed: number; -}; - -/** - * This is the type that matches what backend expects us to send to them. It is a bit weird, because - * it uses strings instead of booleans and numbers, but that's what they require. - */ -export type TransportResultForBackend = { - reachable?: 'true' | 'false'; - latencyInMilliseconds?: string; - clientMediaIPs?: string[]; - untested?: 'true'; -}; - -export type ReachabilityResultForBackend = { - udp: TransportResultForBackend; - tcp: TransportResultForBackend; - xtls: TransportResultForBackend; -}; - -// this is the type that is required by the backend when we send them reachability results -export type ReachabilityResultsForBackend = Record; - -// this is the type used by Reachability class internally and stored in local storage -export type ReachabilityResults = Record< - string, - ClusterReachabilityResult & { - isVideoMesh?: boolean; - } ->; - // timeouts in seconds const DEFAULT_TIMEOUT = 3; const VIDEO_MESH_TIMEOUT = 1; @@ -84,6 +51,9 @@ export default class Reachability extends EventsScope { [key: string]: ClusterReachability; }; + minRequiredClusters?: number; + orpheusApiVersion?: number; + reachabilityDefer?: Defer; vmnTimer?: ReturnType; @@ -92,6 +62,8 @@ export default class Reachability extends EventsScope { expectedResultsCount = {videoMesh: {udp: 0}, public: {udp: 0, tcp: 0, xtls: 0}}; resultsCount = {videoMesh: {udp: 0}, public: {udp: 0, tcp: 0, xtls: 0}}; + startTime = undefined; + totalDuration = undefined; protected lastTrigger?: string; @@ -118,14 +90,35 @@ export default class Reachability extends EventsScope { /** * Fetches the list of media clusters from the backend + * @param {string} trigger - explains the reason for starting reachability, used by Orpheus + * @param {Object} previousReport - last reachability report * @param {boolean} isRetry * @private * @returns {Promise<{clusters: ClusterList, joinCookie: any}>} */ - async getClusters(isRetry = false): Promise<{clusters: ClusterList; joinCookie: any}> { + async getClusters( + trigger: GetClustersTrigger, + previousReport?: any, + isRetry = false + ): Promise<{ + clusters: ClusterList; + joinCookie: any; + }> { try { - const {clusters, joinCookie} = await this.reachabilityRequest.getClusters( - MeetingUtil.getIpVersion(this.webex) + const {clusters, joinCookie, discoveryOptions} = await this.reachabilityRequest.getClusters( + trigger, + MeetingUtil.getIpVersion(this.webex), + previousReport + ); + + this.minRequiredClusters = discoveryOptions?.['early-call-min-clusters']; + this.orpheusApiVersion = discoveryOptions?.['report-version']; + + // @ts-ignore + await this.webex.boundedStorage.put( + this.namespace, + REACHABILITY.localStorageJoinCookie, + JSON.stringify(joinCookie) ); return {clusters, joinCookie}; @@ -138,7 +131,7 @@ export default class Reachability extends EventsScope { `Reachability:index#getClusters --> Failed with error: ${error}, retrying...` ); - return this.getClusters(true); + return this.getClusters(trigger, previousReport, true); } } @@ -159,14 +152,7 @@ export default class Reachability extends EventsScope { // @ts-ignore this.webex.internal.device.ipNetworkDetector.detect(true); - const {clusters, joinCookie} = await this.getClusters(); - - // @ts-ignore - await this.webex.boundedStorage.put( - this.namespace, - REACHABILITY.localStorageJoinCookie, - JSON.stringify(joinCookie) - ); + const {clusters} = await this.getClusters('startup'); this.reachabilityDefer = new Defer(); @@ -181,6 +167,98 @@ export default class Reachability extends EventsScope { } } + /** + * Gets the last join cookie we got from Orpheus + * + * @returns {Promise} join cookie + */ + async getJoinCookie() { + // @ts-ignore + const joinCookieRaw = await this.webex.boundedStorage + .get(REACHABILITY.namespace, REACHABILITY.localStorageJoinCookie) + .catch(() => {}); + + let joinCookie; + + if (joinCookieRaw) { + try { + joinCookie = JSON.parse(joinCookieRaw); + } catch (e) { + LoggerProxy.logger.error( + `MeetingRequest#constructor --> Error in parsing join cookie data: ${e}` + ); + } + } + + return joinCookie; + } + + /** + * Returns the reachability report that needs to be attached to the ROAP messages + * that we send to the backend. + * + * @returns {Promise} + */ + async getReachabilityReport(): Promise< + | { + joinCookie: any; + reachability?: ReachabilityReportV1; + } + | { + reachability: ReachabilityReportV0; + } + > { + const reachabilityResult = await this.getReachabilityResults(); + const joinCookie = await this.getJoinCookie(); + + // Orpheus API version 0 + if (!this.orpheusApiVersion) { + return { + reachability: reachabilityResult, + }; + } + + // Orpheus API version 1 + return { + reachability: { + version: 1, + result: { + usedDiscoveryOptions: { + 'early-call-min-clusters': this.minRequiredClusters, + }, + metrics: { + 'total-duration-ms': this.totalDuration, + }, + tests: reachabilityResult, + }, + }, + joinCookie, + }; + } + + /** + * This method is called when we don't succeed in reaching the minimum number of clusters + * required by Orpheus. It sends the results to Orpheus and gets a new list that it tries to reach again. + * @returns {Promise} reachability results + * @public + * @memberof Reachability + */ + public async gatherReachabilityFallback(): Promise { + try { + const reachabilityReport = await this.getReachabilityReport(); + + const {clusters} = await this.getClusters('early-call/no-min-reached', reachabilityReport); + + // stop all previous reachability checks that might still be going on in the background + this.abortCurrentChecks(); + + // Perform Reachability Check + await this.performReachabilityChecks(clusters); + } catch (error) { + LoggerProxy.logger.error(`Reachability:index#gatherReachabilityFallback --> Error:`, error); + } + } + /** * Returns statistics about last reachability results. The returned value is an object * with a flat list of properties so that it can be easily sent with metrics @@ -304,7 +382,7 @@ export default class Reachability extends EventsScope { } catch (e) { // empty storage, that's ok LoggerProxy.logger.warn( - 'Roap:request#attachReachabilityData --> Error parsing reachability data: ', + 'Reachability:index#getReachabilityResults --> Error parsing reachability data: ', e ); } @@ -336,7 +414,7 @@ export default class Reachability extends EventsScope { ); } catch (e) { LoggerProxy.logger.error( - `Roap:request#attachReachabilityData --> Error in parsing reachability data: ${e}` + `Reachability:index#isAnyPublicClusterReachable --> Error in parsing reachability data: ${e}` ); } } @@ -393,7 +471,7 @@ export default class Reachability extends EventsScope { ); } catch (e) { LoggerProxy.logger.error( - `Roap:request#attachReachabilityData --> Error in parsing reachability data: ${e}` + `Reachability:index#isWebexMediaBackendUnreachable --> Error in parsing reachability data: ${e}` ); } } @@ -427,6 +505,30 @@ export default class Reachability extends EventsScope { return unreachableList; } + /** + * Gets the number of reachable clusters from last run reachability check + * @returns {number} reachable clusters count + * @private + * @memberof Reachability + */ + private getNumberOfReachableClusters(): number { + let count = 0; + + Object.entries(this.clusterReachability).forEach(([key, clusterReachability]) => { + const result = clusterReachability.getResult(); + + if ( + result.udp.result === 'reachable' || + result.tcp.result === 'reachable' || + result.xtls.result === 'reachable' + ) { + count += 1; + } + }); + + return count; + } + /** * Make a log of unreachable clusters. * @returns {undefined} @@ -465,18 +567,27 @@ export default class Reachability extends EventsScope { /** * Resolves the promise returned by gatherReachability() method + * @param {boolean} checkMinRequiredClusters - if true, it will check if we have reached the minimum required clusters and do a fallback if needed * @returns {void} */ - private resolveReachabilityPromise() { - if (this.vmnTimer) { - clearTimeout(this.vmnTimer); - } - if (this.publicCloudTimer) { - clearTimeout(this.publicCloudTimer); - } + private resolveReachabilityPromise(checkMinRequiredClusters = true) { + this.totalDuration = performance.now() - this.startTime; + + this.clearTimer('vmnTimer'); + this.clearTimer('publicCloudTimer'); this.logUnreachableClusters(); this.reachabilityDefer?.resolve(); + + if (checkMinRequiredClusters) { + const numReachableClusters = this.getNumberOfReachableClusters(); + if (this.minRequiredClusters && numReachableClusters < this.minRequiredClusters) { + LoggerProxy.logger.log( + `Reachability:index#resolveReachabilityPromise --> minRequiredClusters not reached (${numReachableClusters} < ${this.minRequiredClusters}), doing reachability fallback` + ); + this.gatherReachabilityFallback(); + } + } } /** @@ -591,6 +702,8 @@ export default class Reachability extends EventsScope { `Reachability:index#startTimers --> Reachability checks timed out (${DEFAULT_TIMEOUT}s)` ); + // check against minimum required clusters, do a new call if we don't have enough + // resolve the promise, so that the client won't be blocked waiting on meetings.register() for too long this.resolveReachabilityPromise(); }, DEFAULT_TIMEOUT * 1000); @@ -646,6 +759,32 @@ export default class Reachability extends EventsScope { this.resultsCount.public.xtls = 0; } + /** + * Clears the timer + * + * @param {string} timer name of the timer to clear + * @returns {void} + */ + private clearTimer(timer: string) { + if (this[timer]) { + clearTimeout(this[timer]); + this[timer] = undefined; + } + } + + /** + * Aborts current checks that are in progress + * + * @returns {void} + */ + private abortCurrentChecks() { + this.clearTimer('vmnTimer'); + this.clearTimer('publicCloudTimer'); + this.clearTimer('overallTimer'); + + this.abortClusterReachability(); + } + /** * Performs reachability checks for all clusters * @param {ClusterList} clusterList @@ -656,9 +795,7 @@ export default class Reachability extends EventsScope { this.clusterReachability = {}; - if (!clusterList || !Object.keys(clusterList).length) { - return; - } + this.startTime = performance.now(); LoggerProxy.logger.log( `Reachability:index#performReachabilityChecks --> doing UDP${ @@ -671,7 +808,6 @@ export default class Reachability extends EventsScope { ); this.resetResultCounters(); - this.startTimers(); // sanitize the urls in the clusterList Object.keys(clusterList).forEach((key) => { @@ -721,6 +857,24 @@ export default class Reachability extends EventsScope { // save the initialized results (in case we don't get any "resultReady" events at all) await this.storeResults(results); + if (!clusterList || !Object.keys(clusterList).length) { + // nothing to do, finish immediately + this.resolveReachabilityPromise(false); + + this.emit( + { + file: 'reachability', + function: 'performReachabilityChecks', + }, + 'reachability:done', + {} + ); + + return; + } + + this.startTimers(); + // now start the reachability on all the clusters Object.keys(clusterList).forEach((key) => { const cluster = clusterList[key]; @@ -753,8 +907,7 @@ export default class Reachability extends EventsScope { await this.storeResults(results); if (areAllResultsReady) { - clearTimeout(this.overallTimer); - this.overallTimer = undefined; + this.clearTimer('overallTimer'); this.emit( { file: 'reachability', @@ -785,4 +938,59 @@ export default class Reachability extends EventsScope { this.clusterReachability[key].start(); // not awaiting on purpose }); } + + /** + * Returns the clientMediaPreferences object that needs to be sent to the backend + * when joining a meeting + * + * @param {boolean} isMultistream + * @param {IP_VERSION} ipver + * @returns {Object} + */ + async getClientMediaPreferences( + isMultistream: boolean, + ipver?: IP_VERSION + ): Promise { + // if 0 or undefined, we assume version 0 and don't send any reachability in clientMediaPreferences + if (!this.orpheusApiVersion) { + return { + ipver, + joinCookie: await this.getJoinCookie(), + preferTranscoding: !isMultistream, + }; + } + + // must be version 1 + + // for version 1, the reachability report goes into clientMediaPreferences (and it contains joinCookie) + const reachabilityReport = (await this.getReachabilityReport()) as { + joinCookie: any; + reachability?: ReachabilityReportV1; + }; + + return { + ipver, + preferTranscoding: !isMultistream, + ...reachabilityReport, + }; + } + + /** + * Returns the reachability report that needs to be attached to the ROAP messages + * that we send to the backend. + * It may return undefined, if reachability is not needed to be attached to ROAP messages (that's the case for v1 or Orpheus API) + * + * @returns {Promise} object that needs to be attached to Roap messages + */ + async getReachabilityReportToAttachToRoap(): Promise { + // version 0 + if (!this.orpheusApiVersion) { + return this.getReachabilityResults(); + } + + // version 1 + + // for version 1 we don't attach anything to Roap messages, reachability report is sent inside clientMediaPreferences + return undefined; + } } diff --git a/packages/@webex/plugin-meetings/src/reachability/reachability.types.ts b/packages/@webex/plugin-meetings/src/reachability/reachability.types.ts new file mode 100644 index 00000000000..62b94311637 --- /dev/null +++ b/packages/@webex/plugin-meetings/src/reachability/reachability.types.ts @@ -0,0 +1,85 @@ +import {IP_VERSION} from '../constants'; + +// result for a specific transport protocol (like udp or tcp) +export type TransportResult = { + result: 'reachable' | 'unreachable' | 'untested'; + latencyInMilliseconds?: number; // amount of time it took to get the first ICE candidate + clientMediaIPs?: string[]; +}; + +// reachability result for a specific media cluster +export type ClusterReachabilityResult = { + udp: TransportResult; + tcp: TransportResult; + xtls: TransportResult; +}; + +export type ReachabilityMetrics = { + reachability_public_udp_success: number; + reachability_public_udp_failed: number; + reachability_public_tcp_success: number; + reachability_public_tcp_failed: number; + reachability_public_xtls_success: number; + reachability_public_xtls_failed: number; + reachability_vmn_udp_success: number; + reachability_vmn_udp_failed: number; + reachability_vmn_tcp_success: number; + reachability_vmn_tcp_failed: number; + reachability_vmn_xtls_success: number; + reachability_vmn_xtls_failed: number; +}; + +/** + * This is the type that matches what backend expects us to send to them. It is a bit weird, because + * it uses strings instead of booleans and numbers, but that's what they require. + */ +export type TransportResultForBackend = { + reachable?: 'true' | 'false'; + latencyInMilliseconds?: string; + clientMediaIPs?: string[]; + untested?: 'true'; +}; + +export type ReachabilityResultForBackend = { + udp: TransportResultForBackend; + tcp: TransportResultForBackend; + xtls: TransportResultForBackend; +}; + +// this is the type that is required by the backend when we send them reachability results +export type ReachabilityResultsForBackend = Record; + +// this is the type used by Reachability class internally and stored in local storage +export type ReachabilityResults = Record< + string, + ClusterReachabilityResult & { + isVideoMesh?: boolean; + } +>; + +export type ReachabilityReportV0 = ReachabilityResultsForBackend; + +export type ReachabilityReportV1 = { + version: 1; + result: { + usedDiscoveryOptions: { + 'early-call-min-clusters': number; + // there are more options, but we don't support them yet + }; + metrics: { + 'total-duration-ms': number; + // there are more metrics, but we don't support them yet + }; + tests: Record; + }; +}; + +export interface ClientMediaPreferences { + ipver: IP_VERSION; + joinCookie: any; + preferTranscoding: boolean; + reachability?: ReachabilityReportV1; // only present when using Orpheus API version 1 +} + +/* Orpheus API supports more triggers, but we don't use them yet */ +export type GetClustersTrigger = 'startup' | 'early-call/no-min-reached'; diff --git a/packages/@webex/plugin-meetings/src/reachability/request.ts b/packages/@webex/plugin-meetings/src/reachability/request.ts index 19dbf959d0e..0a2e3897376 100644 --- a/packages/@webex/plugin-meetings/src/reachability/request.ts +++ b/packages/@webex/plugin-meetings/src/reachability/request.ts @@ -1,5 +1,6 @@ import LoggerProxy from '../common/logs/logger-proxy'; import {HTTP_VERBS, RESOURCE, API, IP_VERSION} from '../constants'; +import {GetClustersTrigger} from './reachability.types'; export interface ClusterNode { isVideoMesh: boolean; @@ -30,43 +31,67 @@ class ReachabilityRequest { /** * Gets the cluster information * + * @param {string} trigger that's passed to Orpheus * @param {IP_VERSION} ipVersion information about current ip network we're on + * @param {Object} previousReport last reachability result * @returns {Promise} */ - getClusters = (ipVersion?: IP_VERSION): Promise<{clusters: ClusterList; joinCookie: any}> => - this.webex.internal.newMetrics.callDiagnosticLatencies - .measureLatency( - () => - this.webex.request({ - method: HTTP_VERBS.GET, - shouldRefreshAccessToken: false, - api: API.CALLIOPEDISCOVERY, - resource: RESOURCE.CLUSTERS, - qs: { - JCSupport: 1, - ipver: ipVersion, + getClusters = ( + trigger: GetClustersTrigger, + ipVersion?: IP_VERSION, + previousReport?: any + ): Promise<{ + clusters: ClusterList; + joinCookie: any; + discoveryOptions?: Record; + }> => { + // we only measure latency for the initial startup call, not for other triggers + const callWrapper = + trigger === 'startup' + ? this.webex.internal.newMetrics.callDiagnosticLatencies.measureLatency.bind( + this.webex.internal.newMetrics.callDiagnosticLatencies + ) + : (func) => func(); + + return callWrapper( + () => + this.webex.request({ + method: HTTP_VERBS.POST, + shouldRefreshAccessToken: false, + api: API.CALLIOPEDISCOVERY, + resource: RESOURCE.CLUSTERS, + body: { + ipver: ipVersion, + 'supported-options': { + 'report-version': 1, + 'early-call-min-clusters': true, }, - }), - 'internal.get.cluster.time' - ) - .then((res) => { - const {clusters, joinCookie} = res.body; + 'previous-report': previousReport, + trigger, + }, + timeout: this.webex.config.meetings.reachabilityGetClusterTimeout, + }), + 'internal.get.cluster.time' + ).then((res) => { + const {clusters, joinCookie, discoveryOptions} = res.body; - Object.keys(clusters).forEach((key) => { - clusters[key].isVideoMesh = !!res.body.clusterClasses?.hybridMedia?.includes(key); - }); + Object.keys(clusters).forEach((key) => { + clusters[key].isVideoMesh = !!res.body.clusterClasses?.hybridMedia?.includes(key); + }); - LoggerProxy.logger.log( - `Reachability:request#getClusters --> get clusters (ipver=${ipVersion}) successful:${JSON.stringify( - clusters - )}` - ); + LoggerProxy.logger.log( + `Reachability:request#getClusters --> get clusters (ipver=${ipVersion}) successful:${JSON.stringify( + clusters + )}` + ); - return { - clusters, - joinCookie, - }; - }); + return { + clusters, + joinCookie, + discoveryOptions, + }; + }); + }; /** * gets remote SDP For Clusters diff --git a/packages/@webex/plugin-meetings/src/roap/index.ts b/packages/@webex/plugin-meetings/src/roap/index.ts index 5761db15d5f..e17ad2ca217 100644 --- a/packages/@webex/plugin-meetings/src/roap/index.ts +++ b/packages/@webex/plugin-meetings/src/roap/index.ts @@ -107,7 +107,7 @@ export default class Roap extends StatelessWebexPlugin { roapMessage, locusSelfUrl: meeting.selfUrl, mediaId: options.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, }) .then(() => { @@ -141,7 +141,7 @@ export default class Roap extends StatelessWebexPlugin { roapMessage, locusSelfUrl: meeting.selfUrl, mediaId: options.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, }); } @@ -170,7 +170,7 @@ export default class Roap extends StatelessWebexPlugin { roapMessage, locusSelfUrl: meeting.selfUrl, mediaId: options.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, }) .then(() => { @@ -207,10 +207,9 @@ export default class Roap extends StatelessWebexPlugin { roapMessage, locusSelfUrl: meeting.selfUrl, mediaId: sendEmptyMediaId ? '' : meeting.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, preferTranscoding: !meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, - ipVersion: MeetingUtil.getIpVersion(meeting.webex), }) .then(({locus, mediaConnections}) => { if (mediaConnections) { diff --git a/packages/@webex/plugin-meetings/src/roap/request.ts b/packages/@webex/plugin-meetings/src/roap/request.ts index 30b8522ef27..fc1dc5589f5 100644 --- a/packages/@webex/plugin-meetings/src/roap/request.ts +++ b/packages/@webex/plugin-meetings/src/roap/request.ts @@ -4,44 +4,13 @@ import {StatelessWebexPlugin} from '@webex/webex-core'; import LoggerProxy from '../common/logs/logger-proxy'; import {IP_VERSION, REACHABILITY} from '../constants'; import {LocusMediaRequest} from '../meeting/locusMediaRequest'; +import MeetingUtil from '../meeting/util'; +import {ClientMediaPreferences} from '../reachability/reachability.types'; /** * @class RoapRequest */ export default class RoapRequest extends StatelessWebexPlugin { - /** - * Returns reachability data. - * @param {Object} localSdp - * @returns {Object} - */ - async attachReachabilityData(localSdp) { - let joinCookie; - - // @ts-ignore - const reachabilityResult = await this.webex.meetings.reachability.getReachabilityResults(); - - if (reachabilityResult && Object.keys(reachabilityResult).length) { - localSdp.reachability = reachabilityResult; - } - - // @ts-ignore - const joinCookieRaw = await this.webex.boundedStorage - .get(REACHABILITY.namespace, REACHABILITY.localStorageJoinCookie) - .catch(() => {}); - - if (joinCookieRaw) { - try { - joinCookie = JSON.parse(joinCookieRaw); - } catch (e) { - LoggerProxy.logger.error( - `MeetingRequest#constructor --> Error in parsing join cookie data: ${e}` - ); - } - } - - return {localSdp, joinCookie}; - } - /** * Sends a ROAP message * @param {Object} options @@ -50,18 +19,16 @@ export default class RoapRequest extends StatelessWebexPlugin { * @param {String} options.mediaId * @param {String} options.correlationId * @param {String} options.meetingId - * @param {IP_VERSION} options.ipVersion only required for offers * @returns {Promise} returns the response/failure of the request */ async sendRoap(options: { roapMessage: any; locusSelfUrl: string; mediaId: string; - meetingId: string; - ipVersion?: IP_VERSION; + isMultistream: boolean; locusMediaRequest?: LocusMediaRequest; }) { - const {roapMessage, locusSelfUrl, mediaId, locusMediaRequest, ipVersion} = options; + const {roapMessage, locusSelfUrl, isMultistream, mediaId, locusMediaRequest} = options; if (!mediaId) { LoggerProxy.logger.info('Roap:request#sendRoap --> sending empty mediaID'); @@ -74,13 +41,33 @@ export default class RoapRequest extends StatelessWebexPlugin { return Promise.reject(new Error('sendRoap called when locusMediaRequest is undefined')); } - const {localSdp: localSdpWithReachabilityData, joinCookie} = await this.attachReachabilityData({ - roapMessage, - }); + + let reachability; + let clientMediaPreferences: ClientMediaPreferences = { + // bare minimum fallback value that should allow us to join; + joinCookie: undefined, + ipver: IP_VERSION.unknown, + preferTranscoding: !isMultistream, + }; + + try { + clientMediaPreferences = + // @ts-ignore + await this.webex.meetings.reachability.getClientMediaPreferences( + isMultistream, + // @ts-ignore + MeetingUtil.getIpVersion(this.webex) + ); + reachability = + // @ts-ignore + await this.webex.meetings.reachability.getReachabilityReportToAttachToRoap(); + } catch (error) { + LoggerProxy.logger.error('Roap:request#sendRoap --> reachability error:', error); + } LoggerProxy.logger.info( `Roap:request#sendRoap --> ${roapMessage.messageType} seq:${roapMessage.seq} ${ - ipVersion ? `ipver=${ipVersion} ` : '' + clientMediaPreferences?.ipver ? `ipver=${clientMediaPreferences?.ipver} ` : '' } ${locusSelfUrl}` ); @@ -88,11 +75,10 @@ export default class RoapRequest extends StatelessWebexPlugin { .send({ type: 'RoapMessage', selfUrl: locusSelfUrl, - joinCookie, mediaId, roapMessage, - reachability: localSdpWithReachabilityData.reachability, - ipVersion, + reachability, + clientMediaPreferences, }) .then((res) => { // always it will be the first mediaConnection Object diff --git a/packages/@webex/plugin-meetings/src/roap/turnDiscovery.ts b/packages/@webex/plugin-meetings/src/roap/turnDiscovery.ts index 1c732e93052..a1034df7cd0 100644 --- a/packages/@webex/plugin-meetings/src/roap/turnDiscovery.ts +++ b/packages/@webex/plugin-meetings/src/roap/turnDiscovery.ts @@ -408,10 +408,8 @@ export default class TurnDiscovery { locusSelfUrl: meeting.selfUrl, // @ts-ignore - Fix missing type mediaId: isReconnecting ? '' : meeting.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, - // @ts-ignore - because of meeting.webex - ipVersion: MeetingUtil.getIpVersion(meeting.webex), }) .then(async (response) => { const {mediaConnections} = response; @@ -451,7 +449,7 @@ export default class TurnDiscovery { locusSelfUrl: meeting.selfUrl, // @ts-ignore - fix type mediaId: meeting.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, }); } diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 75d4627b912..791f04e0c39 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -90,8 +90,8 @@ import WebExMeetingsErrors from '../../../../src/common/errors/webex-meetings-er import ParameterError from '../../../../src/common/errors/parameter'; import PasswordError from '../../../../src/common/errors/password-error'; import CaptchaError from '../../../../src/common/errors/captcha-error'; -import PermissionError from '../../../../src/common/errors/permission'; -import WebinarRegistrationError from '../../../../src/common/errors/webinar-registration-error'; +import PermissionError from '../../../../src/common/errors/permission'; +import WebinarRegistrationError from '../../../../src/common/errors/webinar-registration-error'; import IntentToJoinError from '../../../../src/common/errors/intent-to-join'; import testUtils from '../../../utils/testUtils'; import { @@ -377,7 +377,10 @@ describe('plugin-meetings', () => { } ); assert.equal(newMeeting.correlationId, newMeeting.id); - assert.deepEqual(newMeeting.callStateForMetrics, {correlationId: newMeeting.id, sessionCorrelationId: ''}); + assert.deepEqual(newMeeting.callStateForMetrics, { + correlationId: newMeeting.id, + sessionCorrelationId: '', + }); }); it('correlationId can be provided in callStateForMetrics', () => { @@ -646,7 +649,6 @@ describe('plugin-meetings', () => { }); const fakeRoapMessage = {id: 'fake TURN discovery message'}; - const fakeReachabilityResults = {id: 'fake reachability'}; const fakeTurnServerInfo = {id: 'fake turn info'}; const fakeJoinResult = {id: 'join result'}; @@ -664,8 +666,6 @@ describe('plugin-meetings', () => { .stub(meeting, 'addMediaInternal') .returns(Promise.resolve(test4)); - webex.meetings.reachability.getReachabilityResults.resolves(fakeReachabilityResults); - generateTurnDiscoveryRequestMessageStub = sinon .stub(meeting.roap, 'generateTurnDiscoveryRequestMessage') .resolves({roapMessage: fakeRoapMessage}); @@ -685,7 +685,6 @@ describe('plugin-meetings', () => { assert.calledOnceWithExactly(meeting.join, { ...joinOptions, roapMessage: fakeRoapMessage, - reachability: fakeReachabilityResults, }); assert.calledOnceWithExactly(generateTurnDiscoveryRequestMessageStub, meeting, true); assert.calledOnceWithExactly( @@ -722,7 +721,6 @@ describe('plugin-meetings', () => { assert.calledOnceWithExactly(meeting.join, { ...joinOptions, roapMessage: undefined, - reachability: fakeReachabilityResults, }); assert.calledOnceWithExactly(generateTurnDiscoveryRequestMessageStub, meeting, true); assert.notCalled(handleTurnDiscoveryHttpResponseStub); @@ -754,7 +752,6 @@ describe('plugin-meetings', () => { assert.calledOnceWithExactly(meeting.join, { ...joinOptions, roapMessage: fakeRoapMessage, - reachability: fakeReachabilityResults, }); assert.calledOnceWithExactly(generateTurnDiscoveryRequestMessageStub, meeting, true); assert.calledOnceWithExactly( @@ -2468,6 +2465,63 @@ describe('plugin-meetings', () => { checkWorking(); }); + it('should upload logs periodically', async () => { + const clock = sinon.useFakeTimers(); + + meeting.roap.doTurnDiscovery = sinon + .stub() + .resolves({turnServerInfo: undefined, turnDiscoverySkippedReason: undefined}); + + let logUploadCounter = 0; + + TriggerProxy.trigger.callsFake((meetingObject, options, event) => { + if ( + meetingObject === meeting && + options.file === 'meeting/index' && + options.function === 'uploadLogs' && + event === 'REQUEST_UPLOAD_LOGS' + ) { + logUploadCounter += 1; + } + }); + + meeting.config.logUploadIntervalMultiplicationFactor = 1; + meeting.meetingState = 'ACTIVE'; + + await meeting.addMedia({ + mediaSettings: {}, + }); + + const checkLogCounter = (delay, expectedCounter) => { + // first check that the counter is not increased just before the delay + clock.tick(delay - 50); + assert.equal(logUploadCounter, expectedCounter - 1); + + // and now check that it has reached expected value after the delay + clock.tick(50); + assert.equal(logUploadCounter, expectedCounter); + }; + + checkLogCounter(100, 1); + checkLogCounter(1000, 2); + checkLogCounter(15000, 3); + checkLogCounter(15000, 4); + checkLogCounter(30000, 5); + checkLogCounter(30000, 6); + checkLogCounter(30000, 7); + checkLogCounter(60000, 8); + checkLogCounter(60000, 9); + checkLogCounter(60000, 10); + + // simulate media connection being removed -> no more log uploads should happen + meeting.mediaProperties.webrtcMediaConnection = undefined; + + clock.tick(60000); + assert.equal(logUploadCounter, 11); + + clock.restore(); + }); + it('should attach the media and return promise when in the lobby if allowMediaInLobby is set', async () => { meeting.roap.doTurnDiscovery = sinon .stub() @@ -3689,6 +3743,8 @@ describe('plugin-meetings', () => { meeting.setMercuryListener = sinon.stub(); meeting.locusInfo.onFullLocus = sinon.stub(); meeting.webex.meetings.geoHintInfo = {regionCode: 'EU', countryCode: 'UK'}; + meeting.webex.meetings.reachability.getReachabilityReportToAttachToRoap = sinon.stub().resolves({id: 'fake reachability'}); + meeting.webex.meetings.reachability.getClientMediaPreferences = sinon.stub().resolves({id: 'fake clientMediaPreferences'}); meeting.roap.doTurnDiscovery = sinon.stub().resolves({ turnServerInfo: { url: 'turns:turn-server-url:443?transport=tcp', @@ -3808,12 +3864,12 @@ describe('plugin-meetings', () => { id: 'fake locus from mocked join request', locusUrl: 'fake locus url', mediaId: 'fake media id', - }) + }); sinon.stub(meeting.meetingRequest, 'joinMeeting').resolves({ headers: { trackingid: 'fake tracking id', - } - }) + }, + }); await meeting.join({enableMultistream: isMultistream}); }); @@ -3874,6 +3930,9 @@ describe('plugin-meetings', () => { const checkSdpOfferSent = ({audioMuted, videoMuted}) => { const {sdp, seq, tieBreaker} = roapOfferMessage; + assert.calledWith(meeting.webex.meetings.reachability.getClientMediaPreferences, meeting.isMultistream, 0); + assert.calledWith(meeting.webex.meetings.reachability.getReachabilityReportToAttachToRoap); + assert.calledWith(locusMediaRequestStub, { method: 'PUT', uri: `${meeting.selfUrl}/media`, @@ -3887,14 +3946,12 @@ describe('plugin-meetings', () => { correlationId: meeting.correlationId, localMedias: [ { - localSdp: `{"audioMuted":${audioMuted},"videoMuted":${videoMuted},"roapMessage":{"messageType":"OFFER","sdps":["${sdp}"],"version":"2","seq":"${seq}","tieBreaker":"${tieBreaker}","headers":["includeAnswerInHttpResponse","noOkInTransaction"]}}`, + localSdp: `{"audioMuted":${audioMuted},"videoMuted":${videoMuted},"roapMessage":{"messageType":"OFFER","sdps":["${sdp}"],"version":"2","seq":"${seq}","tieBreaker":"${tieBreaker}","headers":["includeAnswerInHttpResponse","noOkInTransaction"]},"reachability":{"id":"fake reachability"}}`, mediaId: 'fake media id', }, ], clientMediaPreferences: { - preferTranscoding: !meeting.isMultistream, - joinCookie: undefined, - ipver: 0, + id: 'fake clientMediaPreferences', }, }, }); @@ -3915,13 +3972,11 @@ describe('plugin-meetings', () => { }, correlationId: meeting.correlationId, clientMediaPreferences: { - preferTranscoding: !meeting.isMultistream, - ipver: undefined, - joinCookie: undefined, + id: 'fake clientMediaPreferences', }, localMedias: [ { - localSdp: `{"audioMuted":${audioMuted},"videoMuted":${videoMuted},"roapMessage":{"messageType":"OK","version":"2","seq":"${seq}"}}`, + localSdp: `{"audioMuted":${audioMuted},"videoMuted":${videoMuted},"roapMessage":{"messageType":"OK","version":"2","seq":"${seq}"},"reachability":{"id":"fake reachability"}}`, mediaId: 'fake media id', }, ], @@ -3947,10 +4002,6 @@ describe('plugin-meetings', () => { mediaId: 'fake media id', }, ], - clientMediaPreferences: { - preferTranscoding: !meeting.isMultistream, - ipver: undefined, - }, respOnlySdp: true, usingResource: null, }, @@ -4006,7 +4057,10 @@ describe('plugin-meetings', () => { assert.notCalled( meeting.sendSlotManager.getSlot(MediaType.AudioMain).publishStream ); - assert.throws(meeting.publishStreams(localStreams), `Attempted to publish microphone stream with ended readyState, correlationId=${meeting.correlationId}`); + assert.throws( + meeting.publishStreams(localStreams), + `Attempted to publish microphone stream with ended readyState, correlationId=${meeting.correlationId}` + ); } else { assert.calledOnceWithExactly( meeting.sendSlotManager.getSlot(MediaType.AudioMain).publishStream, @@ -4019,7 +4073,10 @@ describe('plugin-meetings', () => { assert.notCalled( meeting.sendSlotManager.getSlot(MediaType.VideoMain).publishStream ); - assert.throws(meeting.publishStreams(localStreams), `Attempted to publish camera stream with ended readyState, correlationId=${meeting.correlationId}`); + assert.throws( + meeting.publishStreams(localStreams), + `Attempted to publish camera stream with ended readyState, correlationId=${meeting.correlationId}` + ); } else { assert.calledOnceWithExactly( meeting.sendSlotManager.getSlot(MediaType.VideoMain).publishStream, @@ -4032,7 +4089,10 @@ describe('plugin-meetings', () => { assert.notCalled( meeting.sendSlotManager.getSlot(MediaType.AudioSlides).publishStream ); - assert.throws(meeting.publishStreams(localStreams), `Attempted to publish screenShare audio stream with ended readyState, correlationId=${meeting.correlationId}`); + assert.throws( + meeting.publishStreams(localStreams), + `Attempted to publish screenShare audio stream with ended readyState, correlationId=${meeting.correlationId}` + ); } else { assert.calledOnceWithExactly( meeting.sendSlotManager.getSlot(MediaType.AudioSlides).publishStream, @@ -4045,7 +4105,10 @@ describe('plugin-meetings', () => { assert.notCalled( meeting.sendSlotManager.getSlot(MediaType.VideoSlides).publishStream ); - assert.throws(meeting.publishStreams(localStreams), `Attempted to publish screenShare video stream with ended readyState, correlationId=${meeting.correlationId}`); + assert.throws( + meeting.publishStreams(localStreams), + `Attempted to publish screenShare video stream with ended readyState, correlationId=${meeting.correlationId}` + ); } else { assert.calledOnceWithExactly( meeting.sendSlotManager.getSlot(MediaType.VideoSlides).publishStream, @@ -4340,14 +4403,14 @@ describe('plugin-meetings', () => { const handleDeviceLoggingSpy = sinon.spy(Meeting, 'handleDeviceLogging'); await meeting.addMedia({audioEnabled: false}); //calling handleDeviceLogging with audioEnaled as true adn videoEnabled as false - assert.calledWith(handleDeviceLoggingSpy,false,true); + assert.calledWith(handleDeviceLoggingSpy, false, true); }); it('addMedia() works correctly when video is disabled with no streams to publish', async () => { const handleDeviceLoggingSpy = sinon.spy(Meeting, 'handleDeviceLogging'); await meeting.addMedia({videoEnabled: false}); //calling handleDeviceLogging audioEnabled as true videoEnabled as false - assert.calledWith(handleDeviceLoggingSpy,true,false); + assert.calledWith(handleDeviceLoggingSpy, true, false); }); it('addMedia() works correctly when video is disabled with no streams to publish', async () => { @@ -4416,12 +4479,11 @@ describe('plugin-meetings', () => { assert.calledTwice(locusMediaRequestStub); }); - it('addMedia() works correctly when both shareAudio and shareVideo is disabled with no streams publish', async () => { const handleDeviceLoggingSpy = sinon.spy(Meeting, 'handleDeviceLogging'); await meeting.addMedia({shareAudioEnabled: false, shareVideoEnabled: false}); //calling handleDeviceLogging with audioEnabled true and videoEnabled as true - assert.calledWith(handleDeviceLoggingSpy,true,true); + assert.calledWith(handleDeviceLoggingSpy, true, true); }); describe('publishStreams()/unpublishStreams() calls', () => { @@ -6276,14 +6338,22 @@ describe('plugin-meetings', () => { meeting.attrs.meetingInfoProvider = { fetchMeetingInfo: sinon .stub() - .throws(new MeetingInfoV2WebinarRegistrationError(403021, FAKE_MEETING_INFO, 'a message')), + .throws( + new MeetingInfoV2WebinarRegistrationError(403021, FAKE_MEETING_INFO, 'a message') + ), }; - await assert.isRejected(meeting.fetchMeetingInfo({sendCAevents: true}), WebinarRegistrationError); + await assert.isRejected( + meeting.fetchMeetingInfo({sendCAevents: true}), + WebinarRegistrationError + ); assert.deepEqual(meeting.meetingInfo, FAKE_MEETING_INFO); assert.equal(meeting.meetingInfoFailureCode, 403021); - assert.equal(meeting.meetingInfoFailureReason, MEETING_INFO_FAILURE_REASON.WEBINAR_REGISTRATION); + assert.equal( + meeting.meetingInfoFailureReason, + MEETING_INFO_FAILURE_REASON.WEBINAR_REGISTRATION + ); }); }); @@ -6998,7 +7068,10 @@ describe('plugin-meetings', () => { assert.deepEqual(meeting.callStateForMetrics, {correlationId, sessionCorrelationId: ''}); meeting.setCorrelationId(uuid1); assert.equal(meeting.correlationId, uuid1); - assert.deepEqual(meeting.callStateForMetrics, {correlationId: uuid1, sessionCorrelationId: ''}); + assert.deepEqual(meeting.callStateForMetrics, { + correlationId: uuid1, + sessionCorrelationId: '', + }); }); }); @@ -7670,11 +7743,11 @@ describe('plugin-meetings', () => { id: 'stream', getTracks: () => [{id: 'track', addEventListener: sinon.stub()}], }; - const simulateConnectionStateChange = (newState) => { + const simulateConnectionStateChange = async (newState) => { meeting.mediaProperties.webrtcMediaConnection.getConnectionState = sinon .stub() .returns(newState); - eventListeners[MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED](); + await eventListeners[MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED](); }; beforeEach(() => { @@ -7744,11 +7817,17 @@ describe('plugin-meetings', () => { }); it('should collect ice candidates', () => { - eventListeners[MediaConnectionEventNames.ICE_CANDIDATE]({candidate: 'candidate'}); + eventListeners[MediaConnectionEventNames.ICE_CANDIDATE]({candidate: {candidate: 'candidate'}}); assert.equal(meeting.iceCandidatesCount, 1); }); + it('should not collect empty ice candidates', () => { + eventListeners[MediaConnectionEventNames.ICE_CANDIDATE]({candidate: {candidate: ''}}); + + assert.equal(meeting.iceCandidatesCount, 0); + }); + it('should not collect null ice candidates', () => { eventListeners[MediaConnectionEventNames.ICE_CANDIDATE]({candidate: null}); @@ -7930,7 +8009,7 @@ describe('plugin-meetings', () => { meeting.reconnectionManager = new ReconnectionManager(meeting); meeting.reconnectionManager.iceReconnected = sinon.stub().returns(undefined); meeting.setNetworkStatus = sinon.stub().returns(undefined); - meeting.statsAnalyzer = {startAnalyzer: sinon.stub()}; + meeting.statsAnalyzer = {startAnalyzer: sinon.stub(), stopAnalyzer: sinon.stub()}; meeting.mediaProperties.webrtcMediaConnection = { // mock the on() method and store all the listeners on: sinon.stub().callsFake((event, listener) => { @@ -8005,10 +8084,10 @@ describe('plugin-meetings', () => { }); describe('CONNECTION_STATE_CHANGED event when state = "Failed"', () => { - const mockFailedEvent = () => { + const mockFailedEvent = async () => { meeting.setupMediaConnectionListeners(); - simulateConnectionStateChange(ConnectionState.Failed); + await simulateConnectionStateChange(ConnectionState.Failed); }; const checkBehavioralMetricSent = (hasMediaConnectionConnectedAtLeastOnce = false) => { @@ -8038,6 +8117,22 @@ describe('plugin-meetings', () => { assert.notCalled(webex.internal.newMetrics.submitClientEvent); checkBehavioralMetricSent(true); }); + + it('stop stats analyzer during reconnection ', async () => { + meeting.hasMediaConnectionConnectedAtLeastOnce = true; + meeting.statsAnalyzer.stopAnalyzer = sinon.stub().resolves(); + meeting.reconnectionManager = { + reconnect: sinon.stub().resolves(), + resetReconnectionTimer: () => {} + }; + meeting.currentMediaStatus = { + video: true + }; + + await mockFailedEvent(); + + assert.calledOnce(meeting.statsAnalyzer.stopAnalyzer); + }); }); describe('should send correct metrics for ROAP_FAILURE event', () => { @@ -8582,6 +8677,13 @@ describe('plugin-meetings', () => { {payload: test1} ); assert.calledOnce(meeting.updateLLMConnection); + assert.calledOnceWithExactly( + Metrics.sendBehavioralMetric, + BEHAVIORAL_METRICS.GUEST_ENTERED_LOBBY, + { + correlation_id: meeting.correlationId, + } + ); done(); }); it('listens to the self admitted guest event', (done) => { @@ -8603,6 +8705,13 @@ describe('plugin-meetings', () => { assert.calledOnce(meeting.updateLLMConnection); assert.calledOnceWithExactly(meeting.rtcMetrics.sendNextMetrics); + assert.calledOnceWithExactly( + Metrics.sendBehavioralMetric, + BEHAVIORAL_METRICS.GUEST_EXITED_LOBBY, + { + correlation_id: meeting.correlationId, + } + ); done(); }); @@ -9127,7 +9236,6 @@ describe('plugin-meetings', () => { webcastInstance: { url: 'url', }, - }, }; @@ -9141,10 +9249,7 @@ describe('plugin-meetings', () => { newLocusResources ); - assert.calledWith( - meeting.webinar.updateWebcastUrl, - newLocusResources - ); + assert.calledWith(meeting.webinar.updateWebcastUrl, newLocusResources); done(); }); @@ -12302,14 +12407,10 @@ describe('plugin-meetings', () => { const testEmit = async (unmuteAllowed) => { meeting.audio = { handleServerLocalUnmuteRequired: sinon.stub(), - } - await meeting.locusInfo.emitScoped( - {}, - LOCUSINFO.EVENTS.LOCAL_UNMUTE_REQUIRED, - { - unmuteAllowed, - } - ); + }; + await meeting.locusInfo.emitScoped({}, LOCUSINFO.EVENTS.LOCAL_UNMUTE_REQUIRED, { + unmuteAllowed, + }); assert.calledWith( TriggerProxy.trigger, @@ -12325,7 +12426,11 @@ describe('plugin-meetings', () => { }, } ); - assert.calledOnceWithExactly(meeting.audio.handleServerLocalUnmuteRequired, meeting, unmuteAllowed) + assert.calledOnceWithExactly( + meeting.audio.handleServerLocalUnmuteRequired, + meeting, + unmuteAllowed + ); }; [true, false].forEach((unmuteAllowed) => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/locusMediaRequest.ts b/packages/@webex/plugin-meetings/test/unit/spec/meeting/locusMediaRequest.ts index e0e12e728e5..3ffdd5f53d2 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/locusMediaRequest.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/locusMediaRequest.ts @@ -34,12 +34,19 @@ describe('LocusMediaRequest.send()', () => { 'wjfkm.wjfkm.*': {udp:{reachable: true}, tcp:{reachable:false}}, '1eb65fdf-9643-417f-9974-ad72cae0e10f.59268c12-7a04-4b23-a1a1-4c74be03019a.*': {udp:{reachable: false}, tcp:{reachable:true}}, }, - joinCookie: { - anycastEntryPoint: 'aws-eu-west-1', - clientIpAddress: 'some ip', - timeShot: '2023-05-23T08:03:49Z', - }, - ipVersion: IP_VERSION.only_ipv4, + clientMediaPreferences: { + preferTranscoding: false, + joinCookie: { + anycastEntryPoint: 'aws-eu-west-1', + clientIpAddress: 'some ip', + timeShot: '2023-05-23T08:03:49Z', + }, + ipver: IP_VERSION.only_ipv4, + reachability: { + version: '1', + result: 'some fake reachability result', + } + } }; const createExpectedRoapBody = (expectedMessageType, expectedMute:{audioMuted: boolean, videoMuted: boolean}) => { @@ -53,12 +60,16 @@ describe('LocusMediaRequest.send()', () => { } ], clientMediaPreferences: { - preferTranscoding: true, + preferTranscoding: false, ipver: 4, joinCookie: { anycastEntryPoint: 'aws-eu-west-1', clientIpAddress: 'some ip', timeShot: '2023-05-23T08:03:49Z' + }, + reachability: { + version: '1', + result: 'some fake reachability result', } } }; @@ -87,10 +98,6 @@ describe('LocusMediaRequest.send()', () => { localSdp: `{"audioMuted":${expectedMute.audioMuted},"videoMuted":${expectedMute.videoMuted}}`, }, ], - clientMediaPreferences: { - preferTranscoding: true, - ipver: undefined, - }, }; if (sequence) { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/request.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/request.js index 5e53406d31c..07578638839 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/request.js @@ -196,6 +196,7 @@ describe('plugin-meetings', () => { const permissionToken = 'permission-token'; const installationId = 'installationId'; const reachability = 'reachability'; + const clientMediaPreferences = 'clientMediaPreferences'; await meetingsRequest.joinMeeting({ locusUrl, @@ -204,6 +205,7 @@ describe('plugin-meetings', () => { roapMessage, reachability, permissionToken, + clientMediaPreferences }); const requestParams = meetingsRequest.request.getCall(0).args[0]; @@ -214,6 +216,7 @@ describe('plugin-meetings', () => { assert.equal(requestParams.body.device.countryCode, 'US'); assert.equal(requestParams.body.permissionToken, 'permission-token'); assert.equal(requestParams.body.device.regionCode, 'WEST-COAST'); + assert.equal(requestParams.body.clientMediaPreferences, 'clientMediaPreferences'); assert.include(requestParams.body.device.localIp, '127.0.0'); assert.deepEqual(requestParams.body.localMedias, [ {localSdp: '{"roapMessage":"roap-message","reachability":"reachability"}'}, @@ -386,32 +389,6 @@ describe('plugin-meetings', () => { assert.deepEqual(requestParams.body.alias, undefined); }); - - it('includes joinCookie and ipver correctly', async () => { - const locusUrl = 'locusURL'; - const deviceUrl = 'deviceUrl'; - const correlationId = 'random-uuid'; - const roapMessage = 'roap-message'; - const permissionToken = 'permission-token'; - - await meetingsRequest.joinMeeting({ - locusUrl, - deviceUrl, - correlationId, - roapMessage, - permissionToken, - ipVersion: IP_VERSION.ipv4_and_ipv6, - }); - const requestParams = meetingsRequest.request.getCall(0).args[0]; - - assert.equal(requestParams.method, 'POST'); - assert.equal(requestParams.uri, `${locusUrl}/participant?alternateRedirect=true`); - assert.deepEqual(requestParams.body.clientMediaPreferences, { - joinCookie: {anycastEntryPoint: 'aws-eu-west-1'}, - preferTranscoding: true, - ipver: 1, - }); - }); }); describe('#pstn', () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js index 0c9be4c03f5..6389862bfd2 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js @@ -22,6 +22,12 @@ describe('plugin-meetings', () => { meetings: Meetings, }, }); + + webex.meetings.reachability = { + getReachabilityReportToAttachToRoap: sinon.stub().resolves({}), + getClientMediaPreferences: sinon.stub().resolves({}), + }; + const logger = { info: sandbox.stub(), log: sandbox.stub(), @@ -39,6 +45,7 @@ describe('plugin-meetings', () => { meeting.cleanupLocalStreams = sinon.stub().returns(Promise.resolve()); meeting.closeRemoteStreams = sinon.stub().returns(Promise.resolve()); meeting.closePeerConnections = sinon.stub().returns(Promise.resolve()); + meeting.stopPeriodicLogUpload = sinon.stub(); meeting.unsetRemoteStreams = sinon.stub(); meeting.unsetPeerConnections = sinon.stub(); @@ -64,6 +71,7 @@ describe('plugin-meetings', () => { assert.calledOnce(meeting.cleanupLocalStreams); assert.calledOnce(meeting.closeRemoteStreams); assert.calledOnce(meeting.closePeerConnections); + assert.calledOnce(meeting.stopPeriodicLogUpload); assert.calledOnce(meeting.unsetRemoteStreams); assert.calledOnce(meeting.unsetPeerConnections); @@ -408,17 +416,39 @@ describe('plugin-meetings', () => { }); it('#Should call `meetingRequest.joinMeeting', async () => { + meeting.isMultistream = true; + + const FAKE_REACHABILITY_REPORT = { + id: 'fake reachability report', + }; + const FAKE_CLIENT_MEDIA_PREFERENCES = { + id: 'fake client media preferences', + }; + + webex.meetings.reachability.getReachabilityReportToAttachToRoap.resolves(FAKE_REACHABILITY_REPORT); + webex.meetings.reachability.getClientMediaPreferences.resolves(FAKE_CLIENT_MEDIA_PREFERENCES); + + sinon + .stub(webex.internal.device.ipNetworkDetector, 'supportsIpV4') + .get(() => true); + sinon + .stub(webex.internal.device.ipNetworkDetector, 'supportsIpV6') + .get(() => true); + await MeetingUtil.joinMeeting(meeting, { reachability: 'reachability', roapMessage: 'roapMessage', }); + assert.calledOnceWithExactly(webex.meetings.reachability.getReachabilityReportToAttachToRoap); + assert.calledOnceWithExactly(webex.meetings.reachability.getClientMediaPreferences, meeting.isMultistream, IP_VERSION.ipv4_and_ipv6); + assert.calledOnce(meeting.meetingRequest.joinMeeting); const parameter = meeting.meetingRequest.joinMeeting.getCall(0).args[0]; assert.equal(parameter.inviteeAddress, 'meetingJoinUrl'); - assert.equal(parameter.preferTranscoding, true); - assert.equal(parameter.reachability, 'reachability'); + assert.equal(parameter.reachability, FAKE_REACHABILITY_REPORT); + assert.equal(parameter.clientMediaPreferences, FAKE_CLIENT_MEDIA_PREFERENCES); assert.equal(parameter.roapMessage, 'roapMessage'); assert.calledOnce(meeting.setLocus) @@ -445,6 +475,29 @@ describe('plugin-meetings', () => { }); }); + it('should handle failed reachability report retrieval', async () => { + webex.meetings.reachability.getReachabilityReportToAttachToRoap.rejects( + new Error('fake error') + ); + await MeetingUtil.joinMeeting(meeting, {}); + // Verify meeting join still proceeds + assert.calledOnce(meeting.meetingRequest.joinMeeting); + }); + + it('should handle failed clientMediaPreferences retrieval', async () => { + webex.meetings.reachability.getClientMediaPreferences.rejects(new Error('fake error')); + meeting.isMultistream = true; + await MeetingUtil.joinMeeting(meeting, {}); + // Verify meeting join still proceeds + assert.calledOnce(meeting.meetingRequest.joinMeeting); + const parameter = meeting.meetingRequest.joinMeeting.getCall(0).args[0]; + assert.deepEqual(parameter.clientMediaPreferences, { + preferTranscoding: false, + ipver: 0, + joinCookie: undefined, + }); + }); + it('#Should call meetingRequest.joinMeeting with breakoutsSupported=true when passed in as true', async () => { await MeetingUtil.joinMeeting(meeting, { breakoutsSupported: true, @@ -480,17 +533,6 @@ describe('plugin-meetings', () => { assert.deepEqual(parameter.deviceCapabilities, ['TEST']); }); - it('#Should call meetingRequest.joinMeeting with preferTranscoding=false when multistream is enabled', async () => { - meeting.isMultistream = true; - await MeetingUtil.joinMeeting(meeting, {}); - - assert.calledOnce(meeting.meetingRequest.joinMeeting); - const parameter = meeting.meetingRequest.joinMeeting.getCall(0).args[0]; - - assert.equal(parameter.inviteeAddress, 'meetingJoinUrl'); - assert.equal(parameter.preferTranscoding, false); - }); - it('#Should fallback sipUrl if meetingJoinUrl does not exists', async () => { meeting.meetingJoinUrl = undefined; meeting.sipUri = 'sipUri'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js index 89a64498e36..545a05fbd97 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js @@ -2077,6 +2077,21 @@ describe('plugin-meetings', () => { ]); }); + it('should handle failure to get user information if scopes are insufficient', async () => { + loggerProxySpy = sinon.spy(LoggerProxy.logger, 'error'); + Object.assign(webex.people, { + _getMe: sinon.stub().returns(Promise.reject()), + }); + + await webex.meetings.fetchUserPreferredWebexSite(); + + assert.equal(webex.meetings.preferredWebexSite, ''); + assert.calledOnceWithExactly( + loggerProxySpy, + 'Failed to retrieve user information. No preferredWebexSite will be set' + ); + }); + const setup = ({me = { type: 'validuser'}, user} = {}) => { loggerProxySpy = sinon.spy(LoggerProxy.logger, 'error'); assert.deepEqual(webex.internal.services._getCatalog().getAllowedDomains(), []); @@ -2093,7 +2108,7 @@ describe('plugin-meetings', () => { Object.assign(webex.people, { _getMe: sinon.stub().returns(Promise.resolve(me)), - }); + }); }; it('should not call request.getMeetingPreferences if user is a guest', async () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts index 029668742ee..f532ad885e4 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts @@ -1664,6 +1664,270 @@ describe('gatherReachability', () => { assert.neverCalledWith(clusterReachabilityCtorStub); }); + + describe('fallback mechanism and multiple calls to getClusters', () => { + let receivedEvents; + + const mockGetClustersEmptyResult = { + discoveryOptions: { + ['early-call-min-clusters']: 0, + ['report-version']: 1, + }, + clusters: {}, // empty cluster list + joinCookie: {id: 'cookie'}, + }; + + beforeEach(() => { + webex.config.meetings.experimental = { + enableTcpReachability: true, + enableTlsReachability: true, + }; + + receivedEvents = { + done: 0, + }; + }); + + it('keeps retrying if minimum required clusters are not reached', async () => { + const reachability = new Reachability(webex); + + reachability.on('reachability:done', () => { + receivedEvents.done += 1; + }); + + const mockGetClustersResult1 = { + discoveryOptions: { + ['early-call-min-clusters']: 2, + ['report-version']: 1, + }, + clusters: { + clusterA0: { + udp: ['udp-urlA'], + tcp: ['tcp-urlA'], + xtls: ['xtls-urlA'], + isVideoMesh: false, + }, + clusterB0: { + udp: ['udp-urlB'], + tcp: ['tcp-urlB'], + xtls: ['xtls-urlB'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'cookie1'}, + }; + const mockGetClustersResult2 = { + discoveryOptions: { + ['early-call-min-clusters']: 2, + ['report-version']: 1, + }, + clusters: { + clusterA1: { + udp: ['udp-urlA'], + tcp: ['tcp-urlA'], + xtls: ['xtls-urlA'], + isVideoMesh: false, + }, + clusterB1: { + udp: ['udp-urlB'], + tcp: ['tcp-urlB'], + xtls: ['xtls-urlB'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'cookie2'}, + }; + const mockGetClustersResult3 = { + discoveryOptions: { + ['early-call-min-clusters']: 1, + ['report-version']: 1, + }, + clusters: { + clusterA2: { + udp: ['udp-urlA'], + tcp: ['tcp-urlA'], + xtls: ['xtls-urlA'], + isVideoMesh: false, + }, + clusterB2: { + udp: ['udp-urlB'], + tcp: ['tcp-urlB'], + xtls: ['xtls-urlB'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'cookie3'}, + }; + + reachability.reachabilityRequest.getClusters = sinon.stub(); + reachability.reachabilityRequest.getClusters.onCall(0).returns(mockGetClustersResult1); + reachability.reachabilityRequest.getClusters.onCall(1).returns(mockGetClustersResult2); + + reachability.reachabilityRequest.getClusters.onCall(2).returns(mockGetClustersResult3); + + const resultPromise = reachability.gatherReachability('test'); + + await testUtils.flushPromises(); + + // trigger some mock result events from ClusterReachability instances, + // but only from 1 cluster, so not enough to reach the minimum required + mockClusterReachabilityInstances['clusterA0'].emitFakeResult('udp', { + result: 'reachable', + clientMediaIPs: ['1.2.3.4'], + latencyInMilliseconds: 11, + }); + + clock.tick(3000); + await resultPromise; + await testUtils.flushPromises(); + + // because the minimum was not reached, another call to getClusters should be made + assert.calledTwice(reachability.reachabilityRequest.getClusters); + + // simulate no results this time + + // check that while the 2nd attempt is in progress, the join cookie is already available from the 2nd call to getClusters + const clientMediaPreferences = await reachability.getClientMediaPreferences( + true, + IP_VERSION.unknown + ); + + assert.deepEqual(clientMediaPreferences.joinCookie, mockGetClustersResult2.joinCookie); + + clock.tick(3000); + await testUtils.flushPromises(); + + assert.calledThrice(reachability.reachabilityRequest.getClusters); + + await testUtils.flushPromises(); + + // this time 1 result will be enough to reach the minimum + mockClusterReachabilityInstances['clusterA2'].emitFakeResult('udp', { + result: 'reachable', + clientMediaIPs: ['1.2.3.4'], + latencyInMilliseconds: 11, + }); + clock.tick(3000); + + // the reachability results should include only results from the last attempt + await checkResults( + { + clusterA2: { + udp: {result: 'reachable', clientMediaIPs: ['1.2.3.4'], latencyInMilliseconds: 11}, + tcp: {result: 'unreachable'}, + xtls: {result: 'unreachable'}, + isVideoMesh: false, + }, + clusterB2: { + udp: {result: 'unreachable'}, + tcp: {result: 'unreachable'}, + xtls: {result: 'unreachable'}, + isVideoMesh: false, + }, + }, + mockGetClustersResult3.joinCookie + ); + + // wait some more time to make sure that there are no timers that fire from one of the previous checks + clock.tick(20000); + + // as the first 2 attempts failed and didn't reach the overall timeout, there should be only 1 done event emitted + assert.equal(receivedEvents.done, 1); + }); + + it('handles getClusters() returning empty list on 1st call', async () => { + const reachability = new Reachability(webex); + + reachability.on('reachability:done', () => { + receivedEvents.done += 1; + }); + + reachability.reachabilityRequest.getClusters = sinon + .stub() + .resolves(mockGetClustersEmptyResult); + + const resultPromise = reachability.gatherReachability('test'); + + await testUtils.flushPromises(); + + clock.tick(3000); + await resultPromise; + await testUtils.flushPromises(); + + assert.calledOnce(reachability.reachabilityRequest.getClusters); + reachability.reachabilityRequest.getClusters.resetHistory(); + + assert.equal(receivedEvents.done, 1); + await checkResults({}, mockGetClustersEmptyResult.joinCookie); + + // because we didn't actually test anything (we got empty cluster list from getClusters()), we should + // not say that webex backend is unreachable + assert.equal(await reachability.isWebexMediaBackendUnreachable(), false); + + // wait to check that there are no other things happening + clock.tick(20000); + await testUtils.flushPromises(); + + assert.notCalled(reachability.reachabilityRequest.getClusters); + assert.equal(receivedEvents.done, 1); + }); + + it('handles getClusters() returning empty list on 2nd call', async () => { + const reachability = new Reachability(webex); + + reachability.on('reachability:done', () => { + receivedEvents.done += 1; + }); + + const mockGetClustersResult1 = { + discoveryOptions: { + ['early-call-min-clusters']: 2, + ['report-version']: 1, + }, + clusters: { + clusterA0: { + udp: ['udp-urlA'], + tcp: ['tcp-urlA'], + xtls: ['xtls-urlA'], + isVideoMesh: false, + }, + clusterB0: { + udp: ['udp-urlB'], + tcp: ['tcp-urlB'], + xtls: ['xtls-urlB'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'cookie1'}, + }; + + reachability.reachabilityRequest.getClusters = sinon.stub(); + reachability.reachabilityRequest.getClusters.onCall(0).returns(mockGetClustersResult1); + reachability.reachabilityRequest.getClusters.onCall(1).returns(mockGetClustersEmptyResult); + + const resultPromise = reachability.gatherReachability('test'); + + await testUtils.flushPromises(); + + clock.tick(3000); + await resultPromise; + await testUtils.flushPromises(); + + // because the minimum was not reached, another call to getClusters should be made + assert.calledTwice(reachability.reachabilityRequest.getClusters); + + // the reachability results should include only results from the last attempt + await checkResults({}, mockGetClustersEmptyResult.joinCookie); + + // as the first 2 attempts failed and didn't reach the overall timeout, there should be only 1 done event emitted + assert.equal(receivedEvents.done, 1); + // because we didn't actually test anything (we got empty cluster list from getClusters()), we should + // not say that webex backend is unreachable + assert.equal(await reachability.isWebexMediaBackendUnreachable(), false); + }); + }); + + }); describe('getReachabilityResults', () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js b/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js index 372c398b4b4..c012b862b9a 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/request.js @@ -35,16 +35,11 @@ describe('plugin-meetings/reachability', () => { }); describe('#getClusters', () => { + let previousReport; beforeEach(() => { sinon.spy(webex.internal.newMetrics.callDiagnosticLatencies, 'measureLatency'); - }); - afterEach(() => { - sinon.restore(); - }); - - it('sends a GET request with the correct params', async () => { webex.request = sinon.mock().returns(Promise.resolve({ body: { clusterClasses: { @@ -57,21 +52,67 @@ describe('plugin-meetings/reachability', () => { } })); - const res = await reachabilityRequest.getClusters(IP_VERSION.only_ipv4); - const requestParams = webex.request.getCall(0).args[0]; + webex.config.meetings.reachabilityGetClusterTimeout = 3000; - assert.equal(requestParams.method, 'GET'); - assert.equal(requestParams.resource, `clusters`); - assert.equal(requestParams.api, 'calliopeDiscovery'); - assert.equal(requestParams.shouldRefreshAccessToken, false); + previousReport = { + id: 'fake previous report', + } + }); + + afterEach(() => { + sinon.restore(); + }); + + it('sends a POST request with the correct params when trigger is "startup"', async () => { + const res = await reachabilityRequest.getClusters('startup', IP_VERSION.only_ipv4, previousReport); + const requestParams = webex.request.getCall(0).args[0]; - assert.deepEqual(requestParams.qs, { - JCSupport: 1, - ipver: 4, + assert.deepEqual(requestParams, { + method: 'POST', + resource: `clusters`, + api: 'calliopeDiscovery', + shouldRefreshAccessToken: false, + timeout: 3000, + body: { + ipver: IP_VERSION.only_ipv4, + 'supported-options': { + 'report-version': 1, + 'early-call-min-clusters': true, + }, + 'previous-report': previousReport, + trigger: 'startup', + }, }); + assert.deepEqual(res.clusters.clusterId, {udp: "testUDP", isVideoMesh: true}) assert.deepEqual(res.joinCookie, {anycastEntryPoint: "aws-eu-west-1"}) assert.calledOnceWithExactly(webex.internal.newMetrics.callDiagnosticLatencies.measureLatency, sinon.match.func, 'internal.get.cluster.time'); }); + + it('sends a POST request with the correct params when trigger is other than "startup"', async () => { + const res = await reachabilityRequest.getClusters('early-call/no-min-reached', IP_VERSION.only_ipv4, previousReport); + const requestParams = webex.request.getCall(0).args[0]; + + assert.deepEqual(requestParams, { + method: 'POST', + resource: `clusters`, + api: 'calliopeDiscovery', + shouldRefreshAccessToken: false, + timeout: 3000, + body: { + ipver: IP_VERSION.only_ipv4, + 'supported-options': { + 'report-version': 1, + 'early-call-min-clusters': true, + }, + 'previous-report': previousReport, + trigger: 'early-call/no-min-reached', + }, + }); + + assert.deepEqual(res.clusters.clusterId, {udp: "testUDP", isVideoMesh: true}) + assert.deepEqual(res.joinCookie, {anycastEntryPoint: "aws-eu-west-1"}) + assert.notCalled(webex.internal.newMetrics.callDiagnosticLatencies.measureLatency); + }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/roap/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/roap/index.ts index 4b8bcc621d5..baefe4ee274 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/roap/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/roap/index.ts @@ -161,7 +161,7 @@ describe('Roap', () => { roapMessage: expectedRoapMessage, locusSelfUrl: meeting.selfUrl, mediaId: meeting.mediaId, - meetingId: meeting.id, + isMultistream: meeting.isMultistream, locusMediaRequest: meeting.locusMediaRequest, }) ); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/roap/request.ts b/packages/@webex/plugin-meetings/test/unit/spec/roap/request.ts index 70cb8df3bbd..ace38ba8a17 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/roap/request.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/roap/request.ts @@ -4,6 +4,7 @@ import {assert} from '@webex/test-helper-chai'; import MockWebex from '@webex/test-helper-mock-webex'; import Meetings from '@webex/plugin-meetings'; import RoapRequest from '@webex/plugin-meetings/src/roap/request'; +import MeetingUtil from '@webex/plugin-meetings/src/meeting/util'; import {IP_VERSION, REACHABILITY} from '@webex/plugin-meetings/src/constants'; describe('plugin-meetings/roap', () => { @@ -23,6 +24,11 @@ describe('plugin-meetings/roap', () => { regionCode: 'WEST-COAST', }; + webex.meetings.reachability = { + getReachabilityReportToAttachToRoap: sinon.stub().resolves({}), + getClientMediaPreferences: sinon.stub().resolves({}), + }; + webex.internal = { services: { get: sinon.mock().returns(locusUrl), @@ -36,6 +42,8 @@ describe('plugin-meetings/roap', () => { }, }; + sinon.stub(MeetingUtil, 'getIpVersion').returns(IP_VERSION.ipv4_and_ipv6); + // @ts-ignore roapRequest = new RoapRequest({webex}); @@ -74,146 +82,80 @@ describe('plugin-meetings/roap', () => { ); }); - describe('#attachReachabilityData', () => { - it('returns the correct reachability data', async () => { - const res = await roapRequest.attachReachabilityData({}); - - assert.deepEqual(res.localSdp, { - reachability: { - clusterId: { - udp: { - reachable: 'true', - latencyInMilliseconds: '10', - }, - tcp: { - reachable: 'false', - }, - xtls: { - untested: 'true', - } - }, - }, - }); - assert.deepEqual(res.joinCookie, { - anycastEntryPoint: 'aws-eu-west-1', - }); - }); + afterEach(() => { + sinon.restore(); + }) - it('handles the case when reachability data does not exist', async () => { - await webex.boundedStorage.del(REACHABILITY.namespace, REACHABILITY.localStorageJoinCookie); + describe('sendRoap', () => { + it('includes clientMediaPreferences and reachability in the request correctly', async () => { + const locusMediaRequest = {send: sinon.stub().resolves({body: {locus: {}}})}; - await webex.boundedStorage.del(REACHABILITY.namespace, REACHABILITY.localStorageResult); - const sdp = { - some: 'attribute', + const FAKE_REACHABILITY_REPORT = { + id: 'fake reachability report', + }; + const FAKE_CLIENT_MEDIA_PREFERENCES = { + id: 'fake client media preferences', }; - const result = await roapRequest.attachReachabilityData(sdp); - - assert.deepEqual(result, { - joinCookie: undefined, - localSdp: { - some: 'attribute', - }, - }); - }); - }); - - describe('sendRoap', () => { - it('includes joinCookie in the request correctly', async () => { - const locusMediaRequest = {send: sinon.stub().resolves({body: {locus: {}}})}; - const ipVersion = IP_VERSION.unknown; + webex.meetings.reachability.getReachabilityReportToAttachToRoap.resolves(FAKE_REACHABILITY_REPORT); + webex.meetings.reachability.getClientMediaPreferences.resolves(FAKE_CLIENT_MEDIA_PREFERENCES); await roapRequest.sendRoap({ locusSelfUrl: locusUrl, - ipVersion, mediaId: 'mediaId', roapMessage: { seq: 'seq', }, meetingId: 'meeting-id', + isMultistream: true, locusMediaRequest, }); + assert.calledOnceWithExactly(webex.meetings.reachability.getReachabilityReportToAttachToRoap); + assert.calledOnceWithExactly(webex.meetings.reachability.getClientMediaPreferences, true, IP_VERSION.ipv4_and_ipv6); + const requestParams = locusMediaRequest.send.getCall(0).args[0]; assert.deepEqual(requestParams, { type: 'RoapMessage', selfUrl: locusUrl, - ipVersion, - joinCookie: { - anycastEntryPoint: 'aws-eu-west-1', - }, + clientMediaPreferences: FAKE_CLIENT_MEDIA_PREFERENCES, mediaId: 'mediaId', roapMessage: { seq: 'seq', }, - reachability: { - clusterId: { - tcp: { - reachable: 'false', - }, - udp: { - latencyInMilliseconds: '10', - reachable: 'true', - }, - xtls: { - untested: 'true', - }, - }, - }, + reachability: FAKE_REACHABILITY_REPORT, }); }); - }); - it('calls attachReachabilityData when sendRoap', async () => { - const locusMediaRequest = { send: sinon.stub().resolves({body: {locus: {}}})}; + it('includes default clientMediaPreferences if calls to reachability fail', async () => { + const locusMediaRequest = {send: sinon.stub().resolves({body: {locus: {}}})}; - const newSdp = { - new: 'sdp', - reachability: { someResult: 'whatever' } - }; - const ipVersion = IP_VERSION.only_ipv6; + webex.meetings.reachability.getClientMediaPreferences.rejects(new Error('fake error')); - roapRequest.attachReachabilityData = sinon.stub().returns( - Promise.resolve({ - localSdp: newSdp, - joinCookie: { - anycastEntryPoint: 'aws-eu-west-1', + await roapRequest.sendRoap({ + locusSelfUrl: locusUrl, + mediaId: 'mediaId', + roapMessage: { + seq: 'seq', }, - }) - ); - - await roapRequest.sendRoap({ - roapMessage: { - seq: 1, - }, - locusSelfUrl: 'locusSelfUrl', - ipVersion, - mediaId: 'mediaId', - meetingId: 'meetingId', - preferTranscoding: true, - locusMediaRequest - }); - - const requestParams = locusMediaRequest.send.getCall(0).args[0]; + meetingId: 'meeting-id', + isMultistream: true, + locusMediaRequest, + }); - assert.deepEqual(requestParams, { - type: 'RoapMessage', - selfUrl: 'locusSelfUrl', - ipVersion, - joinCookie: { - anycastEntryPoint: 'aws-eu-west-1', - }, - mediaId: 'mediaId', - roapMessage: { - seq: 1, - }, - reachability: { someResult: 'whatever' }, - }); + assert.calledOnce(webex.meetings.reachability.getClientMediaPreferences); - assert.calledOnceWithExactly(roapRequest.attachReachabilityData, { - roapMessage: { - seq: 1, - }, + const requestParams = locusMediaRequest.send.getCall(0).args[0]; + assert.deepEqual(requestParams, { + type: 'RoapMessage', + selfUrl: locusUrl, + clientMediaPreferences: {ipver: 0, joinCookie: undefined, preferTranscoding: false}, + mediaId: 'mediaId', + roapMessage: { + seq: 'seq', + }, + reachability: undefined, + }); }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/roap/turnDiscovery.ts b/packages/@webex/plugin-meetings/test/unit/spec/roap/turnDiscovery.ts index c8ce7ce9699..cef80410c05 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/roap/turnDiscovery.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/roap/turnDiscovery.ts @@ -38,21 +38,25 @@ describe('TurnDiscovery', () => { mediaId: 'fake media id', locusUrl: `https://locus-a.wbx2.com/locus/api/v1/loci/${FAKE_LOCUS_ID}`, roapSeq: -1, - audio:{ + audio: { isLocallyMuted: () => true, }, - video:{ + video: { isLocallyMuted: () => false, }, setRoapSeq: sinon.fake((newSeq) => { testMeeting.roapSeq = newSeq; }), updateMediaConnections: sinon.stub(), - webex: {meetings: {reachability: { - isAnyPublicClusterReachable: () => Promise.resolve(false), - }}}, + webex: { + meetings: { + reachability: { + isAnyPublicClusterReachable: () => Promise.resolve(false), + }, + }, + }, isMultistream: false, - locusMediaRequest: { fake: true }, + locusMediaRequest: {fake: true}, }; }); @@ -78,13 +82,15 @@ describe('TurnDiscovery', () => { }, locusSelfUrl: testMeeting.selfUrl, mediaId: expectedMediaId, - meetingId: testMeeting.id, + isMultistream: testMeeting.isMultistream, locusMediaRequest: testMeeting.locusMediaRequest, }; if (messageType === 'TURN_DISCOVERY_REQUEST') { - expectedSendRoapArgs.ipVersion = 0; - expectedSendRoapArgs.roapMessage.headers = ['includeAnswerInHttpResponse', 'noOkInTransaction']; + expectedSendRoapArgs.roapMessage.headers = [ + 'includeAnswerInHttpResponse', + 'noOkInTransaction', + ]; } assert.calledWith(mockRoapRequest.sendRoap, expectedSendRoapArgs); @@ -121,7 +127,12 @@ describe('TurnDiscovery', () => { }); // checks that OK roap message was sent or not sent and that the result is as expected - const checkResult = async (resultPromise, expectedRoapMessageSent, expectedResult, expectedSkipReason?: string) => { + const checkResult = async ( + resultPromise, + expectedRoapMessageSent, + expectedResult, + expectedSkipReason?: string + ) => { let turnServerInfo, turnDiscoverySkippedReason; if (expectedRoapMessageSent === 'OK') { @@ -261,9 +272,11 @@ describe('TurnDiscovery', () => { }, ], }; - mockRoapRequest.sendRoap = sinon.fake.returns(new Promise((resolve) => { - sendRoapPromiseResolve = resolve; - })); + mockRoapRequest.sendRoap = sinon.fake.returns( + new Promise((resolve) => { + sendRoapPromiseResolve = resolve; + }) + ); const td = new TurnDiscovery(mockRoapRequest); const result = td.doTurnDiscovery(testMeeting, false); @@ -302,7 +315,12 @@ describe('TurnDiscovery', () => { // @ts-ignore mockRoapRequest.sendRoap.resetHistory(); - await checkResult(result, undefined, undefined, 'failure: Unexpected token o in JSON at position 1'); + await checkResult( + result, + undefined, + undefined, + 'failure: Unexpected token o in JSON at position 1' + ); checkFailureMetricsSent(); }); @@ -366,7 +384,12 @@ describe('TurnDiscovery', () => { // @ts-ignore mockRoapRequest.sendRoap.resetHistory(); - await checkResult(result, undefined, undefined, 'failure: TURN_DISCOVERY_RESPONSE in http response has unexpected messageType: {"seq":"0","messageType":"ERROR"}'); + await checkResult( + result, + undefined, + undefined, + 'failure: TURN_DISCOVERY_RESPONSE in http response has unexpected messageType: {"seq":"0","messageType":"ERROR"}' + ); }); }); }); @@ -534,7 +557,10 @@ describe('TurnDiscovery', () => { const {turnServerInfo, turnDiscoverySkippedReason} = await promise; assert.isUndefined(turnServerInfo); - assert.equal(turnDiscoverySkippedReason, 'failure: Timed out waiting for TURN_DISCOVERY_RESPONSE'); + assert.equal( + turnDiscoverySkippedReason, + 'failure: Timed out waiting for TURN_DISCOVERY_RESPONSE' + ); checkFailureMetricsSent(); }); @@ -559,7 +585,10 @@ describe('TurnDiscovery', () => { const {turnServerInfo, turnDiscoverySkippedReason} = await turnDiscoveryPromise; assert.isUndefined(turnServerInfo); - assert.equal(turnDiscoverySkippedReason, `failure: TURN_DISCOVERY_RESPONSE from test missing some headers: ["x-cisco-turn-url=${FAKE_TURN_URL}","x-cisco-turn-username=${FAKE_TURN_USERNAME}"]`); + assert.equal( + turnDiscoverySkippedReason, + `failure: TURN_DISCOVERY_RESPONSE from test missing some headers: ["x-cisco-turn-url=${FAKE_TURN_URL}","x-cisco-turn-username=${FAKE_TURN_USERNAME}"]` + ); checkFailureMetricsSent(); }); @@ -576,7 +605,10 @@ describe('TurnDiscovery', () => { const {turnServerInfo, turnDiscoverySkippedReason} = await turnDiscoveryPromise; assert.isUndefined(turnServerInfo); - assert.equal(turnDiscoverySkippedReason, 'failure: TURN_DISCOVERY_RESPONSE from test missing some headers: undefined'); + assert.equal( + turnDiscoverySkippedReason, + 'failure: TURN_DISCOVERY_RESPONSE from test missing some headers: undefined' + ); checkFailureMetricsSent(); }); @@ -596,7 +628,10 @@ describe('TurnDiscovery', () => { const {turnServerInfo, turnDiscoverySkippedReason} = await turnDiscoveryPromise; assert.isUndefined(turnServerInfo); - assert.equal(turnDiscoverySkippedReason, 'failure: TURN_DISCOVERY_RESPONSE from test missing some headers: []'); + assert.equal( + turnDiscoverySkippedReason, + 'failure: TURN_DISCOVERY_RESPONSE from test missing some headers: []' + ); checkFailureMetricsSent(); }); @@ -646,17 +681,21 @@ describe('TurnDiscovery', () => { {isAnyPublicClusterReachable: true, expectedIsSkipped: true}, {isAnyPublicClusterReachable: false, expectedIsSkipped: false}, ].forEach(({isAnyPublicClusterReachable, expectedIsSkipped}) => { - it(`returns ${expectedIsSkipped} when isAnyPublicClusterReachable() returns ${isAnyPublicClusterReachable ? 'true' : 'false'}`, async () => { - sinon.stub(testMeeting.webex.meetings.reachability, 'isAnyPublicClusterReachable').resolves(isAnyPublicClusterReachable); + it(`returns ${expectedIsSkipped} when isAnyPublicClusterReachable() returns ${ + isAnyPublicClusterReachable ? 'true' : 'false' + }`, async () => { + sinon + .stub(testMeeting.webex.meetings.reachability, 'isAnyPublicClusterReachable') + .resolves(isAnyPublicClusterReachable); const td = new TurnDiscovery(mockRoapRequest); const isSkipped = await td.isSkipped(testMeeting); assert.equal(isSkipped, expectedIsSkipped); - }) - }) - }) + }); + }); + }); describe('handleTurnDiscoveryResponse', () => { it("doesn't do anything if turn discovery was not started", () => { @@ -664,14 +703,17 @@ describe('TurnDiscovery', () => { // there is not much we can check, but we mainly want to make // sure that it doesn't crash - td.handleTurnDiscoveryResponse({ - messageType: 'TURN_DISCOVERY_RESPONSE', - headers: [ - `x-cisco-turn-url=${FAKE_TURN_URL}`, - `x-cisco-turn-username=${FAKE_TURN_USERNAME}`, - `x-cisco-turn-password=${FAKE_TURN_PASSWORD}`, - ], - }, 'from test'); + td.handleTurnDiscoveryResponse( + { + messageType: 'TURN_DISCOVERY_RESPONSE', + headers: [ + `x-cisco-turn-url=${FAKE_TURN_URL}`, + `x-cisco-turn-username=${FAKE_TURN_USERNAME}`, + `x-cisco-turn-password=${FAKE_TURN_PASSWORD}`, + ], + }, + 'from test' + ); assert.notCalled(mockRoapRequest.sendRoap); }); @@ -743,9 +785,11 @@ describe('TurnDiscovery', () => { let promiseResolve; // set it up so that doTurnDiscovery doesn't complete - mockRoapRequest.sendRoap = sinon.fake.returns(new Promise((resolve) => { - promiseResolve = resolve; - })); + mockRoapRequest.sendRoap = sinon.fake.returns( + new Promise((resolve) => { + promiseResolve = resolve; + }) + ); td.doTurnDiscovery(testMeeting, false, true); // now call generateTurnDiscoveryRequestMessage @@ -775,19 +819,19 @@ describe('TurnDiscovery', () => { `x-cisco-turn-url=${FAKE_TURN_URL}`, `x-cisco-turn-username=${FAKE_TURN_USERNAME}`, `x-cisco-turn-password=${FAKE_TURN_PASSWORD}`, - 'noOkInTransaction' + 'noOkInTransaction', ], - } + }; td = new TurnDiscovery(mockRoapRequest); }); // checks if another TURN discovery can be started without any problem const checkNextTurnDiscovery = async () => { - // after each test check that another TURN discovery can be started without any problems - const secondMessage = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + // after each test check that another TURN discovery can be started without any problems + const secondMessage = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - assert.isDefined(secondMessage.roapMessage); + assert.isDefined(secondMessage.roapMessage); }; it('works as expected when called with undefined httpResponse', async () => { @@ -804,8 +848,14 @@ describe('TurnDiscovery', () => { [ {testCase: 'is missing mediaConnections', httpResponse: {}}, {testCase: 'is missing mediaConnections[0]', httpResponse: {mediaConnections: []}}, - {testCase: 'is missing mediaConnections[0].remoteSdp', httpResponse: {mediaConnections: [{}]}}, - {testCase: 'is missing roapMesssage in mediaConnections[0].remoteSdp', httpResponse: {mediaConnections: [{remoteSdp: JSON.stringify({something: "whatever"})}]}}, + { + testCase: 'is missing mediaConnections[0].remoteSdp', + httpResponse: {mediaConnections: [{}]}, + }, + { + testCase: 'is missing roapMesssage in mediaConnections[0].remoteSdp', + httpResponse: {mediaConnections: [{remoteSdp: JSON.stringify({something: 'whatever'})}]}, + }, ].forEach(({testCase, httpResponse}) => { it(`handles httpResponse that ${testCase}`, async () => { await td.generateTurnDiscoveryRequestMessage(testMeeting, true); @@ -817,145 +867,150 @@ describe('TurnDiscovery', () => { turnDiscoverySkippedReason: 'missing http response', }); }); - }); - - it('handles httpResponse with invalid JSON in mediaConnections[0].remoteSdp', async () => { - await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + }); - const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, {mediaConnections: [{remoteSdp: 'not a json'}]}); + it('handles httpResponse with invalid JSON in mediaConnections[0].remoteSdp', async () => { + await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - assert.deepEqual(result, { - turnServerInfo: undefined, - turnDiscoverySkippedReason: 'failure: Unexpected token o in JSON at position 1', - }); + const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, { + mediaConnections: [{remoteSdp: 'not a json'}], }); - it('fails when called before generateTurnDiscoveryRequestMessage() was called', async () => { - const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; - await assert.isRejected(td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse), - 'handleTurnDiscoveryHttpResponse() called before generateTurnDiscoveryRequestMessage()'); + assert.deepEqual(result, { + turnServerInfo: undefined, + turnDiscoverySkippedReason: 'failure: Unexpected token o in JSON at position 1', }); + }); - it('works as expected when called with valid httpResponse', async () => { - const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; + it('fails when called before generateTurnDiscoveryRequestMessage() was called', async () => { + const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; + await assert.isRejected( + td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse), + 'handleTurnDiscoveryHttpResponse() called before generateTurnDiscoveryRequestMessage()' + ); + }); - // we spy on handleTurnDiscoveryResponse and check that it's called so that we don't have to repeat - // all the edge case tests here, they're already covered in other tests that call handleTurnDiscoveryResponse - const handleTurnDiscoveryResponseSpy = sinon.spy(td, 'handleTurnDiscoveryResponse'); + it('works as expected when called with valid httpResponse', async () => { + const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; - await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); + // we spy on handleTurnDiscoveryResponse and check that it's called so that we don't have to repeat + // all the edge case tests here, they're already covered in other tests that call handleTurnDiscoveryResponse + const handleTurnDiscoveryResponseSpy = sinon.spy(td, 'handleTurnDiscoveryResponse'); - assert.deepEqual(result, { - turnServerInfo: { - url: FAKE_TURN_URL, - username: FAKE_TURN_USERNAME, - password: FAKE_TURN_PASSWORD, - }, - turnDiscoverySkippedReason: undefined, - }); + await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); - assert.calledOnceWithExactly(handleTurnDiscoveryResponseSpy, roapMessage, 'in http response'); + assert.deepEqual(result, { + turnServerInfo: { + url: FAKE_TURN_URL, + username: FAKE_TURN_USERNAME, + password: FAKE_TURN_PASSWORD, + }, + turnDiscoverySkippedReason: undefined, }); - it('works as expected when httpResponse is missing some headers', async () => { - roapMessage.headers = [ - `x-cisco-turn-url=${FAKE_TURN_URL}`, // missing headers for username and password - ]; + assert.calledOnceWithExactly(handleTurnDiscoveryResponseSpy, roapMessage, 'in http response'); + }); - const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; + it('works as expected when httpResponse is missing some headers', async () => { + roapMessage.headers = [ + `x-cisco-turn-url=${FAKE_TURN_URL}`, // missing headers for username and password + ]; - // we spy on handleTurnDiscoveryResponse and check that it's called so that we don't have to repeat - // all the edge case tests here, they're already covered in other tests that call handleTurnDiscoveryResponse - // we test just this 1 edge case here to confirm that when handleTurnDiscoveryResponse rejects, we get the correct result - const handleTurnDiscoveryResponseSpy = sinon.spy(td, 'handleTurnDiscoveryResponse'); + const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; - await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); + // we spy on handleTurnDiscoveryResponse and check that it's called so that we don't have to repeat + // all the edge case tests here, they're already covered in other tests that call handleTurnDiscoveryResponse + // we test just this 1 edge case here to confirm that when handleTurnDiscoveryResponse rejects, we get the correct result + const handleTurnDiscoveryResponseSpy = sinon.spy(td, 'handleTurnDiscoveryResponse'); - assert.deepEqual(result, { - turnServerInfo: undefined, - turnDiscoverySkippedReason: 'failure: TURN_DISCOVERY_RESPONSE in http response missing some headers: ["x-cisco-turn-url=turns:fakeTurnServer.com:443?transport=tcp"]', - }); - assert.calledOnceWithExactly(handleTurnDiscoveryResponseSpy, roapMessage, 'in http response'); + await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); - checkNextTurnDiscovery(); + assert.deepEqual(result, { + turnServerInfo: undefined, + turnDiscoverySkippedReason: + 'failure: TURN_DISCOVERY_RESPONSE in http response missing some headers: ["x-cisco-turn-url=turns:fakeTurnServer.com:443?transport=tcp"]', }); + assert.calledOnceWithExactly(handleTurnDiscoveryResponseSpy, roapMessage, 'in http response'); - it('sends OK when required', async () => { - roapMessage.headers = [ - `x-cisco-turn-url=${FAKE_TURN_URL}`, - `x-cisco-turn-username=${FAKE_TURN_USERNAME}`, - `x-cisco-turn-password=${FAKE_TURN_PASSWORD}`, - // noOkInTransaction is missing - ]; - const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; + checkNextTurnDiscovery(); + }); - await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); + it('sends OK when required', async () => { + roapMessage.headers = [ + `x-cisco-turn-url=${FAKE_TURN_URL}`, + `x-cisco-turn-username=${FAKE_TURN_USERNAME}`, + `x-cisco-turn-password=${FAKE_TURN_PASSWORD}`, + // noOkInTransaction is missing + ]; + const httpResponse = {mediaConnections: [{remoteSdp: JSON.stringify({roapMessage})}]}; - assert.deepEqual(result, { - turnServerInfo: { - url: FAKE_TURN_URL, - username: FAKE_TURN_USERNAME, - password: FAKE_TURN_PASSWORD, - }, - turnDiscoverySkippedReason: undefined, - }); + await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + const result = await td.handleTurnDiscoveryHttpResponse(testMeeting, httpResponse); - // check that OK was sent along with the metric for it - await checkRoapMessageSent('OK', 0); + assert.deepEqual(result, { + turnServerInfo: { + url: FAKE_TURN_URL, + username: FAKE_TURN_USERNAME, + password: FAKE_TURN_PASSWORD, + }, + turnDiscoverySkippedReason: undefined, + }); - assert.calledWith( - Metrics.sendBehavioralMetric, - BEHAVIORAL_METRICS.TURN_DISCOVERY_REQUIRES_OK, - sinon.match({ - correlation_id: testMeeting.correlationId, - locus_id: FAKE_LOCUS_ID, - }) - ); + // check that OK was sent along with the metric for it + await checkRoapMessageSent('OK', 0); - checkNextTurnDiscovery(); - }); + assert.calledWith( + Metrics.sendBehavioralMetric, + BEHAVIORAL_METRICS.TURN_DISCOVERY_REQUIRES_OK, + sinon.match({ + correlation_id: testMeeting.correlationId, + locus_id: FAKE_LOCUS_ID, + }) + ); - describe('abort', () => { - it('allows starting a new TURN discovery', async () => { - let result; + checkNextTurnDiscovery(); + }); - // this mock is required for doTurnDiscovery() to work - mockRoapRequest.sendRoap = sinon.fake.resolves({ - mediaConnections: [ - { - mediaId: '464ff97f-4bda-466a-ad06-3a22184a2274', - remoteSdp: `{"roapMessage": {"messageType":"TURN_DISCOVERY_RESPONSE","seq":"0","headers": ["x-cisco-turn-url=${FAKE_TURN_URL}","x-cisco-turn-username=${FAKE_TURN_USERNAME}","x-cisco-turn-password=${FAKE_TURN_PASSWORD}", "noOkInTransaction"]}}`, - }, - ], - }); + describe('abort', () => { + it('allows starting a new TURN discovery', async () => { + let result; - result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - assert.isDefined(result.roapMessage); + // this mock is required for doTurnDiscovery() to work + mockRoapRequest.sendRoap = sinon.fake.resolves({ + mediaConnections: [ + { + mediaId: '464ff97f-4bda-466a-ad06-3a22184a2274', + remoteSdp: `{"roapMessage": {"messageType":"TURN_DISCOVERY_RESPONSE","seq":"0","headers": ["x-cisco-turn-url=${FAKE_TURN_URL}","x-cisco-turn-username=${FAKE_TURN_USERNAME}","x-cisco-turn-password=${FAKE_TURN_PASSWORD}", "noOkInTransaction"]}}`, + }, + ], + }); - td.abort(); + result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + assert.isDefined(result.roapMessage); - result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - assert.isDefined(result.roapMessage); + td.abort(); - td.abort(); + result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + assert.isDefined(result.roapMessage); - // check also that doTurnDiscovery() works after abort() - result = await td.doTurnDiscovery(testMeeting, false); - }); + td.abort(); + + // check also that doTurnDiscovery() works after abort() + result = await td.doTurnDiscovery(testMeeting, false); + }); - it('does nothing when called outside of a TURN discovery', async () => { - let result; + it('does nothing when called outside of a TURN discovery', async () => { + let result; - // call abort() without any other calls before it - it should do nothing - // there is not much we can check, so afterwards we just check that we can start a new TURN discovery - td.abort(); + // call abort() without any other calls before it - it should do nothing + // there is not much we can check, so afterwards we just check that we can start a new TURN discovery + td.abort(); - result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); - assert.isDefined(result.roapMessage); - }); + result = await td.generateTurnDiscoveryRequestMessage(testMeeting, true); + assert.isDefined(result.roapMessage); }); + }); }); }); diff --git a/packages/calling/package.json b/packages/calling/package.json index b5684f56853..007d081094a 100644 --- a/packages/calling/package.json +++ b/packages/calling/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@types/platform": "1.3.4", - "@webex/internal-media-core": "2.11.3", + "@webex/internal-media-core": "2.12.2", "@webex/media-helpers": "workspace:*", "async-mutex": "0.4.0", "buffer": "6.0.3", diff --git a/packages/webex/src/meetings.js b/packages/webex/src/meetings.js index 6483fdb533c..26fcc7d7825 100644 --- a/packages/webex/src/meetings.js +++ b/packages/webex/src/meetings.js @@ -24,6 +24,7 @@ require('@webex/internal-plugin-support'); require('@webex/internal-plugin-user'); require('@webex/internal-plugin-voicea'); require('@webex/plugin-people'); +require('@webex/internal-plugin-llm'); const merge = require('lodash/merge'); const WebexCore = require('@webex/webex-core').default; diff --git a/yarn.lock b/yarn.lock index 5073f4fb5de..c0bdfa3cc65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7419,7 +7419,7 @@ __metadata: "@typescript-eslint/eslint-plugin": 5.38.1 "@typescript-eslint/parser": 5.38.1 "@web/dev-server": 0.4.5 - "@webex/internal-media-core": 2.11.3 + "@webex/internal-media-core": 2.12.2 "@webex/media-helpers": "workspace:*" async-mutex: 0.4.0 buffer: 6.0.3 @@ -7710,22 +7710,22 @@ __metadata: languageName: unknown linkType: soft -"@webex/internal-media-core@npm:2.11.3": - version: 2.11.3 - resolution: "@webex/internal-media-core@npm:2.11.3" +"@webex/internal-media-core@npm:2.12.2": + version: 2.12.2 + resolution: "@webex/internal-media-core@npm:2.12.2" dependencies: "@babel/runtime": ^7.18.9 "@babel/runtime-corejs2": ^7.25.0 "@webex/rtcstats": ^1.5.0 "@webex/ts-sdp": 1.7.0 "@webex/web-capabilities": ^1.4.1 - "@webex/web-client-media-engine": 3.24.2 + "@webex/web-client-media-engine": 3.26.2 events: ^3.3.0 typed-emitter: ^2.1.0 uuid: ^8.3.2 webrtc-adapter: ^8.1.2 xstate: ^4.30.6 - checksum: b95c917890c98ded1346d093656a8c54cb4ae7c0a8a93ccccaf39e72913f3c2c8a53829d257eb5ac74a087ee2c4583c37ed3752acfbab179e6533761eb9a5ed0 + checksum: 78adf83e60b4bdf2f9f18eb3fada692220f163eb6fcec66521f136093370ae5019dcf7b6fee424ca3396ba713313af3dc022d53573e339a5420d49119583c058 languageName: node linkType: hard @@ -8392,10 +8392,10 @@ __metadata: languageName: unknown linkType: soft -"@webex/json-multistream@npm:2.1.6": - version: 2.1.6 - resolution: "@webex/json-multistream@npm:2.1.6" - checksum: 18cd8e24151c88fc563c6224cc358c9e2e3cda78d80baddba8dd58aa3e79bf4d78ff12613b27cad5a0242856e84e3c6001e12916e404a68398c68e5439e5154b +"@webex/json-multistream@npm:2.2.0": + version: 2.2.0 + resolution: "@webex/json-multistream@npm:2.2.0" + checksum: 2f3f8de556e083cd7e9aa0fa0ddec13c5d0a63f5d809e5ce7075110cfa34178f12b9dde2e093fa49ed6f78fcbbe72de9059d2cbfd23e371e4c595baf9ad0449b languageName: node linkType: hard @@ -8484,7 +8484,7 @@ __metadata: "@babel/preset-typescript": 7.22.11 "@webex/babel-config-legacy": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.11.3 + "@webex/internal-media-core": 2.12.2 "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" "@webex/test-helper-chai": "workspace:*" @@ -8745,7 +8745,7 @@ __metadata: "@webex/babel-config-legacy": "workspace:*" "@webex/common": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.11.3 + "@webex/internal-media-core": 2.12.2 "@webex/internal-plugin-conversation": "workspace:*" "@webex/internal-plugin-device": "workspace:*" "@webex/internal-plugin-llm": "workspace:*" @@ -9448,11 +9448,11 @@ __metadata: languageName: node linkType: hard -"@webex/web-client-media-engine@npm:3.24.2": - version: 3.24.2 - resolution: "@webex/web-client-media-engine@npm:3.24.2" +"@webex/web-client-media-engine@npm:3.26.2": + version: 3.26.2 + resolution: "@webex/web-client-media-engine@npm:3.26.2" dependencies: - "@webex/json-multistream": 2.1.6 + "@webex/json-multistream": 2.2.0 "@webex/rtcstats": ^1.5.0 "@webex/ts-events": ^1.0.1 "@webex/ts-sdp": 1.7.0 @@ -9463,7 +9463,7 @@ __metadata: js-logger: ^1.6.1 typed-emitter: ^2.1.0 uuid: ^8.3.2 - checksum: 26ac75ffcb519a11b3a9f2b601b13b85c4c4e4b685503da0e752d03137af65d22472329f45fbd08de138d93ebd92295645f98c9b879617c838afee61c0589df7 + checksum: 85e990dd48320fc9dd478541c1ffdea591f048286fb5186cfb23a163e3b9f0c530aa7d37460cd1f0dc577c7d359fc289c31a4321bc915ffaca41be252f19cab1 languageName: node linkType: hard