diff --git a/docs/index.html b/docs/index.html
index e02f3127a57..744882a23cf 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -82,8 +82,6 @@
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/browser-plugin-presence/app.js b/docs/samples/browser-plugin-presence/app.js
deleted file mode 100644
index c0b056d7200..00000000000
--- a/docs/samples/browser-plugin-presence/app.js
+++ /dev/null
@@ -1,205 +0,0 @@
-/* eslint-env browser */
-
-/* global Webex */
-
-/* eslint-disable no-console */
-/* eslint-disable require-jsdoc */
-
-// Declare some globals that we'll need throughout.
-let webex;
-let enableProd = true;
-let subscribedUserIds = [];
-
-const credentialsFormElm = document.querySelector('#credentials');
-const tokenElm = document.querySelector('#access-token');
-const saveElm = document.querySelector('#access-token-save');
-const authStatusElm = document.querySelector('#access-token-status');
-const selfPresenceElm = document.querySelector('#self-presence-status');
-const selfPresenceBtn = document.querySelector('#sd-get-self-presence');
-const setPresenceStatusElm = document.querySelector('#set-presence');
-const setPresenceTtl = document.querySelector('#presence-ttl');
-const setPresenceBtn = document.querySelector('#sd-set-self-presence');
-const getPresenceBtn = document.querySelector('#sd-get-user-presence');
-const getUserPresenceElm = document.querySelector('#get-user-presence');
-const userPresenceStatusElm = document.querySelector('#user-presence-status');
-const presenceNotifications = document.querySelector('#subscribe-presence-notifications');
-const usersToSubOrUnsub = document.querySelector('#subscribe-id');
-const subscribePresenceBtn = document.querySelector('#subscribe-presence');
-const unsubscribePresenceBtn = document.querySelector('#unsubscribe-presence');
-const subscribeNotificationBox = document.querySelector('#subscribe-presence-notifications');
-
-// Store and Grab `access-token` from localstorage
-if (localStorage.getItem('date') > new Date().getTime()) {
- tokenElm.value = localStorage.getItem('access-token');
-} else {
- localStorage.removeItem('access-token');
-}
-
-tokenElm.addEventListener('change', (event) => {
- localStorage.setItem('access-token', event.target.value);
- localStorage.setItem('date', new Date().getTime() + 12 * 60 * 60 * 1000);
-});
-
-function changeEnv() {
- enableProd = !enableProd;
- enableProduction.innerHTML = enableProd ? 'In Production' : 'In Integration';
-}
-
-function updateStatus(enabled) {
- selfPresenceBtn.disabled = !enabled;
- setPresenceBtn.disabled = !enabled;
- getPresenceBtn.disabled = !enabled;
- subscribePresenceBtn.disabled = !enabled;
- unsubscribePresenceBtn.disabled = !enabled;
-}
-
-
-async function initWebex(e) {
- e.preventDefault();
- console.log('Authentication#initWebex()');
-
- tokenElm.disabled = true;
- saveElm.disabled = true;
- selfPresenceBtn.disabled = true;
- setPresenceBtn.disabled = true;
- getPresenceBtn.disabled = true;
- subscribePresenceBtn.disabled = true;
- unsubscribePresenceBtn.disabled = true;
-
- authStatusElm.innerText = 'initializing...';
-
- const webexConfig = {
- config: {
- logger: {
- level: 'debug', // set the desired log level
- },
- meetings: {
- reconnection: {
- enabled: true,
- },
- enableRtx: true,
- },
- encryption: {
- kmsInitialTimeout: 8000,
- kmsMaxTimeout: 40000,
- batcherMaxCalls: 30,
- caroots: null,
- },
- dss: {},
- },
- credentials: {
- access_token: tokenElm.value
- }
- };
-
- if (!enableProd) {
- webexConfig.config.services = {
- discovery: {
- u2c: 'https://u2c-intb.ciscospark.com/u2c/api/v1',
- hydra: 'https://apialpha.ciscospark.com/v1/',
- },
- };
- }
-
- webex = window.webex = Webex.init(webexConfig);
-
- webex.once('ready', () => {
- console.log('Authentication#initWebex() :: Webex Ready');
- authStatusElm.innerText = 'Webex is ready. Saved access token!';
- });
-
- webex.messages.listen()
- .then(() => {
- updateStatus(true);
- })
- .catch((err) => {
- console.error(`error listening to messages: ${err}`);
- });
-}
-
-credentialsFormElm.addEventListener('submit', initWebex);
-
-
-function getSelfPresence() {
- console.log('Presence enabled: ', webex.presence.isEnabled());
- webex.presence.get(webex.internal.device.userId)
- .then((res) => {
- selfPresenceElm.innerText = JSON.stringify(res, null, 2);
- })
- .catch((error) => {
- console.log('Error while fetching self presence status', error);
- selfPresenceElm.innerText = 'Error while fetching self presence status';
- });
-}
-
-function setSelfPresence() {
- const status = setPresenceStatusElm.value;
- const ttl = setPresenceTtl.value;
- webex.presence.setStatus(status, ttl)
- .then(() => {
- console.log('Set status for the user successfully');
- })
- .catch((error) => {
- console.log('Error occurred while setting user\'s status', error);
- })
-}
-
-function getUserPresence() {
- const userId = getUserPresenceElm.value.trim();
- webex.presence.get(userId)
- .then((response) => {
- userPresenceStatusElm.innerText = JSON.stringify(response, null, 2);
- })
- .catch((error) => {
- console.log('Error occurred while trying to get user\'s presence', error);
- })
-}
-
-function handlePresenceUpdate(payload) {
- let value = subscribeNotificationBox.innerText;
- value += '\n\n';
- value += JSON.stringify(payload.data, null, 2);
- subscribeNotificationBox.innerText = value;
-}
-
-function setupPresenceListener() {
- webex.internal.mercury.on('event:apheleia.subscription_update', handlePresenceUpdate);
-}
-
-function removePresenceListener() {
- webex.internal.mercury.off('event:apheleia.subscription_update', handlePresenceUpdate);
-}
-
-function subscribePresence() {
- const ids = usersToSubOrUnsub.value.trim().split(',');
- if (subscribedUserIds.length == 0) {
- setupPresenceListener();
- }
- webex.presence.subscribe(ids)
- .then(() => {
- console.log('successfully subscribed');
- ids.map((id) => subscribedUserIds.push(id));
- })
- .catch((error) => {
- console.log('encountered error while subscribing', error);
- })
-}
-
-function removeFromArray(A, B) {
- return A.filter(element => !B.includes(element));
-}
-
-function unsubscribePresence() {
- const ids = usersToSubOrUnsub.value.trim().split(',');
- if (subscribedUserIds.length == 0) {
- removePresenceListener();
- }
- webex.presence.unsubscribe(ids)
- .then(() => {
- console.log('successfully unsubscribed');
- subscribedUserIds = removeFromArray(subscribedUserIds, ids);
- })
- .catch((error) => {
- console.log('encountered error while unsubscribing', error);
- })
-}
diff --git a/docs/samples/browser-plugin-presence/index.html b/docs/samples/browser-plugin-presence/index.html
deleted file mode 100644
index 0a2a6f465b9..00000000000
--- a/docs/samples/browser-plugin-presence/index.html
+++ /dev/null
@@ -1,96 +0,0 @@
-
-
-
-
-
-
Presence Kitchen Sink
-
-
-
-
-
-
-
-
-
-
- Webex - Presence
- Use this kitchen sink to interact with the Webex Presence service
-
-
-
-
-
-
-
-
- Presence Actions
- Note: To get userIds for the below operations, use the People API in the developer portal to search names and get user's respective userId values.
-
-
- Get own user's presence status
-
- Get Status
-
-
-
- Set Presence Status
-
-
-
- Set Status
-
-
- Get a different user's presence status
-
-
- Get Status
-
-
-
-
-
- Subscribe
-
-
Input User IDs to subscribe for their respective presence statuses. Separate multiple IDs with a comma
-
-
Subscribe
-
Unsubscribe
-
Presence Notifications
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/samples/browser-plugin-presence/style.css b/docs/samples/browser-plugin-presence/style.css
deleted file mode 100644
index e8bca7f3eff..00000000000
--- a/docs/samples/browser-plugin-presence/style.css
+++ /dev/null
@@ -1,358 +0,0 @@
-body {
- margin: 0;
- padding: 0;
- background-color: #eee;
- font-family: sans-serif;
- font-size: 16px;
-}
-
-/* Nice defaults */
-h2 {
- font-weight: 500;
- font-size: 2.2rem;
- margin: 0 0 1rem 0;
-}
-
-section {
- background-color: #fff;
- padding: 1rem;
- margin-bottom: 1.5rem;
- border-radius: 8px;
-}
-
-select {
- min-width: 300px;
- margin-right: 1rem;
-}
-
-label {
- font-size: 1rem;
- margin-left: 0.2rem;
- white-space: nowrap;
-}
-label:first-child {
- margin-left: 0;
-}
-
-pre {
- padding: 0.5rem;
- background-color: #eee;
- white-space: pre-wrap;
-}
-
-code {
- display: inline-block;
- padding: 0.0625rem 0.25rem;
- font-family: Consolas, Liberation Mono, Courier, monospace;
- line-height: 1rem;
- color: crimson;
- font-size: 0.8rem;
- background-color: #ededed;
- border: 0.0625rem solid rgba(0, 0, 0, 0.12);
- border-radius: 0.25rem;
- box-sizing: border-box;
-}
-
-fieldset {
- margin: 0 0.5rem;
- margin-bottom: 2rem;
- border: 1px solid rgba(0, 0, 0, 0.1);
-}
-fieldset p {
- padding: 1rem 0;
- margin: 0;
-}
-fieldset legend {
- font-weight: bold;
- font-size: 1.1rem;
-}
-
-button, input[type="button"] {
- position: relative;
- display: inline-block;
- min-width: 4.5rem;
- font-family: CiscoSansTT Regular, Helvetica Neue, Helvetica, sans-serif;
- font-weight: 400;
- text-align: center;
- text-decoration: none;
- cursor: pointer;
- border: none;
- font-size: 0.9rem;
- line-height: 1.5rem;
- border-radius: 1.125rem;
- height: 2.25rem;
- padding: 0.375rem 1.125rem;
- color: var(--md-button-secondary-text-color, #000);
- background-color: var(--md-button-secondary-bg-color, #dedede);
- border-color: transparent;
- transition: background-color 0.15s ease;
-}
-
-button:hover, input[type="button"]:hover {
- color: var(--md-button-secondary-text-color, #000);
- background-color: var(--md-button-secondary-hover-bg-color, #ccc);
-}
-
-button:active, input[type="button"]:active {
- background-color: var(--md-button-bg-color, rgba(0, 0, 0, 0.3));
-}
-
-button:disabled, input[type="button"]:disabled {
- color: var(--md-button-disabled-text-color, rgba(0, 0, 0, 0.2));
- fill: var(--md-button-disabled-text-color, rgba(0, 0, 0, 0.2));
- background-color: var(--md-button-disabled-bg-color, rgba(0, 0, 0, 0.04));
- pointer-events: none;
- cursor: default;
-}
-
-button.btn--green, input[type="button"].btn--green {
- color: #fff;
- background-color: #00ab50;
- border-color: transparent;
-}
-
-button.btn--green:disabled, input[type="button"].btn--green:disabled{
- opacity: .5;
-}
-
-button.btn--red, input[type="button"].btn--red {
- color: #fff;
- color: var(--md-button-cancel-text-color, #fff);
- background-color: #f7644a;
- background-color: var(--md-button-cancel-bg-color, #f7644a);
- border-color: transparent;
-}
-
-.btn--red:disabled,
-.btn--green:disabled {
- opacity: .5;
-}
-
-button.btn-code, input[type="button"].btn-code {
- font-family: Consolas, Liberation Mono, Courier, monospace;
- margin: 0.2rem 0;
-}
-
-input[type=text],
-input[type=password],
-input[type=email] {
- width: 100%;
- margin-bottom: 1rem;
- box-sizing: border-box;
- border: 0.0625rem solid;
- border-color: var(--md-input-outline-color, #ccc);
- background-color: var(--md-input-background-color, #fff);
- border-radius: 4px;
- font-family: CiscoSansTT Regular, Helvetica Neue, Helvetica, sans-serif;
- font-size: 1rem;
- height: 2.25rem;
- width: 100%;
- padding: 0 1rem;
- transition: box-shadow 0.15s ease-out;
-}
-
-input[type=text]:focus,
-input[type=password]:focus,
-input[type=email]:focus {
- border-color: transparent;
- outline: none;
- box-shadow: 0 0 4px 2px rgba(0, 160, 209, 0.75);
- transition: box-shadow 0.15s ease-in;
-}
-
-.btn-group {
- display: flex;
- margin: 0.5rem 0;
- margin-bottom: 0;
-}
-.btn-group button {
- flex: 1;
- margin-right: 1rem;
-}
-.btn-group button:last-of-type {
- margin-right: 0;
-}
-
-/* Utilities */
-.flex {
- display: flex;
-}
-
-.flex--wrap {
- flex-wrap: wrap;
-}
-
-.flex--center {
- justify-content: center;
-}
-
-/* Margin utils */
-.u-m {
- margin: 1rem !important;
-}
-
-.u-mv {
- margin-top: 1rem !important;
- margin-bottom: 1rem !important;
-}
-
-.u-mb {
- margin-bottom: 1rem !important;
-}
-
-.u-mt {
- margin-top: 1rem !important;
-}
-
-/* Padding Utils */
-.u-p {
- padding: 1rem !important;
-}
-
-.u-pv {
- padding-top: 1rem !important;
- padding-bottom: 1rem !important;
-}
-
-.u-pb {
- padding-bottom: 1rem !important;
-}
-
-.u-pt {
- padding-top: 1rem !important;
-}
-
-/* Misc Styles */
-.note {
- margin: 0.8rem 0;
- background: rgba(232, 232, 232, 0.4);
- padding: 1rem;
- border-radius: 6px;
- color: #555;
-}
-
-.transcription {
- display: flex;
- flex-direction: column;
-}
-
-.transcription textarea {
- width: 100%;
- min-height: 10rem;
-}
-
-.device-type-label {
- width: 125px;
- display: inline-block;
-}
-
-.context-info {
- width: 200px;
- display: inline-block;
-}
-
-.meeting-list {
- list-style-type: none;
-}
-
-/* Container - Docs / Streams Fixed */
-.container {
- overflow: auto;
- height: 100vh;
-}
-
-.docs {
- max-width: 87.5rem;
- margin-top: 1.5rem;
-}
-
-.hidden {
- display: none;
-}
-
-th , td {
- border:1px solid black;
- padding: 5px;
- font-size: small;
-}
-
-table {
- border-collapse: collapse;
-}
-
-.styled-table {
- border-collapse: collapse;
- margin: 1.5625 0;
- font-size: 0.9em;
- font-family: sans-serif;
- min-width: 25rem;
- box-shadow: 0 0 1.25rem rgba(0, 0, 0, 0.15);
-}
-
-.styled-table thead tr {
- background-color: #009879;
- color: #ffffff;
- text-align: left;
-}
-
-.styled-table th,
-.styled-table td {
- padding: 1.25rem 1.25rem;
-}
-
-.styled-table tbody tr {
- border-bottom: 0.063rem solid #dddddd;
-}
-
-.styled-table tbody tr:nth-of-type(even) {
- background-color: #f3f3f3;
-}
-
-.styled-table tbody tr:last-of-type {
- border-bottom: 0.125rem solid #009879;
-}
-
-h2 {
- color: #0052bf;
- font-size: 1.5rem;
- width: 100%;
-}
-
-legend {
- font-size: 1.15rem;
- color: #1a73e8;
-}
-
-.collapsible {
- cursor: pointer;
-}
-
-.section-content {
- height: auto;
- visibility: visible;
-}
-
-.collapsed {
- height: 0;
- visibility: hidden;
-}
-
-.text-color {
- color: #555;
-}
-
-input[type="string"],
-select {
- padding: 0 0.5rem;
- height: 1.75rem;
- border-radius: 4px;
- box-sizing: border-box;
- border: 0.0625rem solid;
- border-color: var(--md-input-outline-color, #ccc);
- background-color: var(--md-input-background-color, #fff);
- font-family: CiscoSansTT Regular, Helvetica Neue, Helvetica, sans-serif;
- font-size: 1rem;
- transition: box-shadow 0.15s ease-out;
- color: #555;
- width: 10rem;
-}
\ No newline at end of file
diff --git a/docs/samples/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/docs/samples/index.html b/docs/samples/index.html
index 1491e74e6c5..54eabee35e6 100644
--- a/docs/samples/index.html
+++ b/docs/samples/index.html
@@ -7,7 +7,6 @@
Hosted Samples
diff --git a/packages/@webex/internal-plugin-conversation/src/conversation.js b/packages/@webex/internal-plugin-conversation/src/conversation.js
index 414c26050b1..ebdc02019d9 100644
--- a/packages/@webex/internal-plugin-conversation/src/conversation.js
+++ b/packages/@webex/internal-plugin-conversation/src/conversation.js
@@ -68,8 +68,9 @@ const getConvoLimit = (options = {}) => {
let limit;
if (options.conversationsLimit) {
+ const value = Math.max(options.conversationsLimit, 0);
limit = {
- value: options.conversationsLimit,
+ value,
name: 'conversationsLimit',
};
}
@@ -339,33 +340,36 @@ const Conversation = WebexPlugin.extend({
* @param {String} recipientId,
* @returns {Promise
}
*/
- addReaction(conversation, displayName, activity, recipientId) {
- return this.createReactionHmac(displayName, activity).then((hmac) => {
- const addReactionPayload = {
- actor: {objectType: 'person', id: this.webex.internal.device.userId},
- target: {
- id: conversation.id,
- objectType: 'conversation',
- },
- verb: 'add',
- objectType: 'activity',
- parent: {
- type: 'reaction',
- id: activity.id,
- },
- object: {
- objectType: 'reaction2',
- displayName,
- hmac,
- },
- };
+ async addReaction(conversation, displayName, activity, recipientId) {
+ let hmac;
+ if (this.config.includeEncryptionTransforms) {
+ hmac = await this.createReactionHmac(displayName, activity);
+ }
- if (recipientId) {
- addReactionPayload.recipients = {items: [{id: recipientId, objectType: 'person'}]};
- }
+ const addReactionPayload = {
+ actor: {objectType: 'person', id: this.webex.internal.device.userId},
+ target: {
+ id: conversation.id,
+ objectType: 'conversation',
+ },
+ verb: 'add',
+ objectType: 'activity',
+ parent: {
+ type: 'reaction',
+ id: activity.id,
+ },
+ object: {
+ objectType: 'reaction2',
+ displayName,
+ hmac,
+ },
+ };
- return this.sendReaction(conversation, addReactionPayload);
- });
+ if (recipientId) {
+ addReactionPayload.recipients = {items: [{id: recipientId, objectType: 'person'}]};
+ }
+
+ return this.sendReaction(conversation, addReactionPayload);
},
/**
diff --git a/packages/@webex/internal-plugin-conversation/test/integration/spec/get.js b/packages/@webex/internal-plugin-conversation/test/integration/spec/get.js
index 81f2a45b535..c54ba480b5d 100644
--- a/packages/@webex/internal-plugin-conversation/test/integration/spec/get.js
+++ b/packages/@webex/internal-plugin-conversation/test/integration/spec/get.js
@@ -363,6 +363,15 @@ describe('plugin-conversation', function () {
assert.include(map(conversations, 'url'), conversation2.url);
}));
+ it('retrieves no conversations', () =>
+ webex.internal.conversation
+ .list({
+ conversationsLimit: -100,
+ })
+ .then((conversations) => {
+ assert.lengthOf(conversations, 0);
+ }));
+
it('retrieves a paginated set of conversations', () =>
webex.internal.conversation
.paginate({
diff --git a/packages/@webex/internal-plugin-conversation/test/unit/spec/conversation.js b/packages/@webex/internal-plugin-conversation/test/unit/spec/conversation.js
index ee967bc2453..05a6f117352 100644
--- a/packages/@webex/internal-plugin-conversation/test/unit/spec/conversation.js
+++ b/packages/@webex/internal-plugin-conversation/test/unit/spec/conversation.js
@@ -41,49 +41,27 @@ describe('plugin-conversation', () => {
const {conversation} = webex.internal;
const recipientId = 'example-recipient-id';
const expected = {items: [{id: recipientId, objectType: 'person'}]};
+ conversation.config.includeEncryptionTransforms = true;
conversation.sendReaction = sinon.stub().returns(Promise.resolve());
conversation.createReactionHmac = sinon.stub().returns(Promise.resolve('hmac'));
return conversation.addReaction({}, 'example-display-name', {}, recipientId).then(() => {
+ assert.called(conversation.createReactionHmac);
assert.deepEqual(conversation.sendReaction.args[0][1].recipients, expected);
});
});
- });
-
- describe('deleteReaction()', () => {
- it('should add recipients to the payload if provided', () => {
- const {conversation} = webex.internal;
- const recipientId = 'example-recipient-id';
- const expected = {items: [{id: recipientId, objectType: 'person'}]};
- conversation.sendReaction = sinon.stub().returns(Promise.resolve());
-
- return conversation.deleteReaction({}, 'example-reaction-id', recipientId).then(() => {
- assert.deepEqual(conversation.sendReaction.args[0][1].recipients, expected);
- });
- });
- });
- describe('prepare()', () => {
- it('should ammend activity recipients to the returned object', () => {
- const {conversation} = webex.internal;
- const activity = {recipients: 'example-recipients'};
-
- return conversation.prepare(activity).then((results) => {
- assert.deepEqual(results.recipients, activity.recipients);
- });
- });
- });
-
- describe('addReaction()', () => {
- it('should add recipients to the payload if provided', () => {
+ it('will not call createReactionHmac if config prohibits', () => {
const {conversation} = webex.internal;
const recipientId = 'example-recipient-id';
const expected = {items: [{id: recipientId, objectType: 'person'}]};
+ conversation.config.includeEncryptionTransforms = false;
conversation.sendReaction = sinon.stub().returns(Promise.resolve());
- conversation.createReactionHmac = sinon.stub().returns(Promise.resolve('hmac'));
+ conversation.createReactionHmac = sinon.stub();
return conversation.addReaction({}, 'example-display-name', {}, recipientId).then(() => {
assert.deepEqual(conversation.sendReaction.args[0][1].recipients, expected);
+ assert.notCalled(conversation.createReactionHmac);
});
});
});
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/src/ipNetworkDetector.ts b/packages/@webex/internal-plugin-device/src/ipNetworkDetector.ts
index cf7b828d002..44c3c9c9649 100644
--- a/packages/@webex/internal-plugin-device/src/ipNetworkDetector.ts
+++ b/packages/@webex/internal-plugin-device/src/ipNetworkDetector.ts
@@ -4,6 +4,12 @@
import {WebexPlugin} from '@webex/webex-core';
+const STATE = {
+ INITIAL: 'initial',
+ IN_PROGRESS: 'in-progress',
+ IDLE: 'idle',
+};
+
/**
* @class
*/
@@ -17,6 +23,8 @@ const IpNetworkDetector = WebexPlugin.extend({
firstIpV6: ['number', true, -1], // time [ms] it took to receive first IPv6 candidate
firstMdns: ['number', true, -1], // time [ms] it took to receive first mDNS candidate
totalTime: ['number', true, -1], // total time [ms] it took to do the last IP network detection
+ state: ['string', true, STATE.INITIAL],
+ pendingDetection: ['object', false, undefined],
},
derived: {
@@ -155,21 +163,41 @@ const IpNetworkDetector = WebexPlugin.extend({
* Detects if we are on IPv4 and/or IPv6 network. Once it resolves, read the
* supportsIpV4 and supportsIpV6 props to find out the result.
*
- * @returns {Promise}
+ * @param {boolean} force - if false, the detection will only be done if we haven't managed to get any meaningful results yet
+ * @returns {Promise}
*/
- async detect() {
+ async detect(force = false) {
let results;
let pc;
+ if (this.state === STATE.IN_PROGRESS) {
+ this.pendingDetection = {force};
+
+ return;
+ }
+
+ if (!force && this.state !== STATE.INITIAL && !this.receivedOnlyMDnsCandidates()) {
+ // we already have the results, no need to do the detection again
+ return;
+ }
+
try {
+ this.state = STATE.IN_PROGRESS;
+
pc = new RTCPeerConnection();
results = await this.gatherLocalCandidates(pc);
} finally {
pc.close();
+ this.state = STATE.IDLE;
}
- return results;
+ if (this.pendingDetection) {
+ const {force: forceParam} = this.pendingDetection;
+
+ this.pendingDetection = undefined;
+ this.detect(forceParam);
+ }
},
});
diff --git a/packages/@webex/internal-plugin-device/test/unit/spec/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-device/test/unit/spec/ipNetworkDetector.js b/packages/@webex/internal-plugin-device/test/unit/spec/ipNetworkDetector.js
index 5e44e318fe5..b5d57f6ce30 100644
--- a/packages/@webex/internal-plugin-device/test/unit/spec/ipNetworkDetector.js
+++ b/packages/@webex/internal-plugin-device/test/unit/spec/ipNetworkDetector.js
@@ -3,6 +3,8 @@ import sinon from 'sinon';
import IpNetworkDetector from '@webex/internal-plugin-device/src/ipNetworkDetector';
import MockWebex from '@webex/test-helper-mock-webex';
+const flushPromises = () => new Promise(setImmediate);
+
describe('plugin-device', () => {
describe('IpNetworkDetector', () => {
let webex;
@@ -326,7 +328,7 @@ describe('plugin-device', () => {
});
// now call detect() again
- const promise2 = ipNetworkDetector.detect();
+ const promise2 = ipNetworkDetector.detect(true);
// everything should have been reset
assert.equal(ipNetworkDetector.supportsIpV4, undefined);
@@ -340,6 +342,87 @@ describe('plugin-device', () => {
await promise2;
});
+ it('queues another detect() call if one is already in progress', async () => {
+ const promise = ipNetworkDetector.detect();
+
+ simulateCandidate(50, '192.168.0.1');
+
+ await flushPromises();
+
+ assert.calledOnce(fakePeerConnection.createDataChannel);
+ assert.calledOnce(fakePeerConnection.createOffer);
+ assert.calledOnce(fakePeerConnection.setLocalDescription);
+
+ // now call detect() again
+ ipNetworkDetector.detect(true);
+
+ // simulate the end of the detection -> another one should be started
+ simulateEndOfCandidateGathering(10);
+
+ await promise;
+
+ assert.calledTwice(fakePeerConnection.createDataChannel);
+ assert.calledTwice(fakePeerConnection.createOffer);
+ assert.calledTwice(fakePeerConnection.setLocalDescription);
+
+ simulateCandidate(50, '2a02:c7c:a0d0:8a00:db9b:d4de:d1f7:4c49');
+ simulateEndOfCandidateGathering(10);
+
+ // results should reflect the last run detection
+ checkResults({
+ supportsIpV4: false,
+ supportsIpV6: true,
+ timings: {
+ totalTime: 60,
+ ipv4: -1,
+ ipv6: 50,
+ mdns: -1,
+ },
+ });
+
+ await flushPromises();
+
+ // no more detections should be started
+ assert.calledTwice(fakePeerConnection.createDataChannel);
+ });
+
+ it.each`
+ force | state | receivedOnlyMDnsCandidates | expectedToRunDetection
+ ${true} | ${'initial'} | ${false} | ${true}
+ ${true} | ${'idle'} | ${false} | ${true}
+ ${true} | ${'initial'} | ${true} | ${true}
+ ${true} | ${'idle'} | ${true} | ${true}
+ ${false} | ${'initial'} | ${false} | ${true}
+ ${false} | ${'initial'} | ${true} | ${true}
+ ${false} | ${'idle'} | ${true} | ${true}
+ ${false} | ${'idle'} | ${false} | ${false}
+ `(
+ 'force=$force, state=$state, receivedOnlyMDnsCandidates=$receivedOnlyMDnsCandidates => expectedToRunDetection=$expectedToRunDetection',
+ async ({force, state, receivedOnlyMDnsCandidates, expectedToRunDetection}) => {
+ ipNetworkDetector.state = state;
+ sinon
+ .stub(ipNetworkDetector, 'receivedOnlyMDnsCandidates')
+ .returns(receivedOnlyMDnsCandidates);
+
+ const result = ipNetworkDetector.detect(force);
+
+ if (expectedToRunDetection) {
+ simulateEndOfCandidateGathering(10);
+ }
+ await result;
+
+ if (expectedToRunDetection) {
+ assert.calledOnce(fakePeerConnection.createDataChannel);
+ assert.calledOnce(fakePeerConnection.createOffer);
+ assert.calledOnce(fakePeerConnection.setLocalDescription);
+ } else {
+ assert.notCalled(fakePeerConnection.createDataChannel);
+ assert.notCalled(fakePeerConnection.createOffer);
+ assert.notCalled(fakePeerConnection.setLocalDescription);
+ }
+ }
+ );
+
it('rejects if one of RTCPeerConnection operations fails', async () => {
const fakeError = new Error('fake error');
diff --git a/packages/@webex/internal-plugin-mercury/src/mercury.js b/packages/@webex/internal-plugin-mercury/src/mercury.js
index 25d0a7bbc69..5c94ebc4265 100644
--- a/packages/@webex/internal-plugin-mercury/src/mercury.js
+++ b/packages/@webex/internal-plugin-mercury/src/mercury.js
@@ -185,6 +185,8 @@ const Mercury = WebexPlugin.extend({
webSocketUrl.query.multipleConnections = true;
}
+ webSocketUrl.query.clientTimestamp = Date.now();
+
return url.format(webSocketUrl);
});
},
diff --git a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js
index 275cc9c8494..cd65a870e0c 100644
--- a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js
+++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js
@@ -447,7 +447,7 @@ describe('plugin-mercury', () => {
assert.isFalse(mercury.connecting, 'Mercury is not connecting');
assert.calledWith(
Socket.prototype.open,
- sinon.match(/ws:\/\/providedurl.com/),
+ sinon.match(/ws:\/\/providedurl.com.*clientTimestamp[=]\d+/),
sinon.match.any
);
});
@@ -783,16 +783,16 @@ describe('plugin-mercury', () => {
it('uses provided webSocketUrl', () =>
webex.internal.mercury
._prepareUrl('ws://provided.com')
- .then((wsUrl) => assert.match(wsUrl, /provided.com/)));
+ .then((wsUrl) => assert.match(wsUrl, /.*provided.com.*/)));
it('requests text-mode WebSockets', () =>
webex.internal.mercury
._prepareUrl()
- .then((wsUrl) => assert.match(wsUrl, /outboundWireFormat=text/)));
+ .then((wsUrl) => assert.match(wsUrl, /.*outboundWireFormat=text.*/)));
it('requests the buffer state message', () =>
webex.internal.mercury
._prepareUrl()
- .then((wsUrl) => assert.match(wsUrl, /bufferStates=true/)));
+ .then((wsUrl) => assert.match(wsUrl, /.*bufferStates=true.*/)));
it('does not add conditional properties', () =>
webex.internal.mercury._prepareUrl().then((wsUrl) => {
diff --git a/packages/@webex/internal-plugin-metrics/package.json b/packages/@webex/internal-plugin-metrics/package.json
index 002feb20183..bffd332e58d 100644
--- a/packages/@webex/internal-plugin-metrics/package.json
+++ b/packages/@webex/internal-plugin-metrics/package.json
@@ -37,7 +37,7 @@
"dependencies": {
"@webex/common": "workspace:*",
"@webex/common-timers": "workspace:*",
- "@webex/event-dictionary-ts": "^1.0.1546",
+ "@webex/event-dictionary-ts": "^1.0.1594",
"@webex/internal-plugin-metrics": "workspace:*",
"@webex/test-helper-chai": "workspace:*",
"@webex/test-helper-mock-webex": "workspace:*",
diff --git a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts
index 2a64fc5fe92..a8865bfe80a 100644
--- a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts
+++ b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts
@@ -718,6 +718,11 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin {
webexSubServiceType: this.getSubServiceType(meeting),
};
+ const joinFlowVersion = options.joinFlowVersion ?? meeting.callStateForMetrics?.joinFlowVersion;
+ if (joinFlowVersion) {
+ clientEventObject.joinFlowVersion = joinFlowVersion;
+ }
+
return clientEventObject;
}
@@ -761,6 +766,10 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin {
loginType: this.getCurLoginType(),
};
+ if (options.joinFlowVersion) {
+ clientEventObject.joinFlowVersion = options.joinFlowVersion;
+ }
+
return clientEventObject;
}
diff --git a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts
index a72093d4978..1946d4fcd4b 100644
--- a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts
+++ b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts
@@ -110,6 +110,8 @@ export type MetricEventVerb =
| 'warn'
| 'exit';
+export type MetricEventJoinFlowVersion = 'Other' | 'NewFTE';
+
export type SubmitClientEventOptions = {
meetingId?: string;
mediaConnections?: any[];
@@ -123,6 +125,7 @@ export type SubmitClientEventOptions = {
browserLaunchMethod?: BrowserLaunchMethodType;
webexConferenceIdStr?: string;
globalMeetingId?: string;
+ joinFlowVersion?: MetricEventJoinFlowVersion;
};
export type SubmitMQEOptions = {
diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts
index bbbe37ecbf9..d269f8e7a87 100644
--- a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts
+++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts
@@ -48,7 +48,7 @@ describe('internal-plugin-metrics', () => {
...fakeMeeting,
id: '2',
correlationId: 'correlationId2',
- callStateForMetrics: {loginType: 'fakeLoginType'},
+ callStateForMetrics: {loginType: 'fakeLoginType', joinFlowVersion: 'Other'},
};
const fakeMeeting3 = {
@@ -56,7 +56,7 @@ describe('internal-plugin-metrics', () => {
id: '3',
correlationId: 'correlationId3',
sessionCorrelationId: 'sessionCorrelationId3',
- }
+ };
const fakeMeetings = {
1: fakeMeeting,
@@ -103,8 +103,8 @@ describe('internal-plugin-metrics', () => {
return '192.168.1.90';
}
},
- }
- }
+ },
+ };
},
},
geoHintInfo: {
@@ -771,7 +771,6 @@ describe('internal-plugin-metrics', () => {
webexConferenceIdStr: undefined,
sessionCorrelationId: undefined,
globalMeetingId: undefined,
- sessionCorrelationId: undefined,
});
assert.notCalled(generateClientEventErrorPayloadSpy);
assert.calledWith(
@@ -1075,7 +1074,7 @@ describe('internal-plugin-metrics', () => {
correlationId: 'correlationId',
webexConferenceIdStr: 'webexConferenceIdStr1',
globalMeetingId: 'globalMeetingId1',
- sessionCorrelationId: 'sessionCorrelationId1'
+ sessionCorrelationId: 'sessionCorrelationId1',
};
cd.submitClientEvent({
@@ -1169,7 +1168,7 @@ describe('internal-plugin-metrics', () => {
webexConferenceIdStr: 'webexConferenceIdStr1',
globalMeetingId: 'globalMeetingId1',
preLoginId: 'myPreLoginId',
- sessionCorrelationId: 'sessionCorrelationId1'
+ sessionCorrelationId: 'sessionCorrelationId1',
};
cd.submitClientEvent({
@@ -1182,7 +1181,7 @@ describe('internal-plugin-metrics', () => {
webexConferenceIdStr: 'webexConferenceIdStr1',
globalMeetingId: 'globalMeetingId1',
preLoginId: 'myPreLoginId',
- sessionCorrelationId: 'sessionCorrelationId1'
+ sessionCorrelationId: 'sessionCorrelationId1',
});
assert.notCalled(generateClientEventErrorPayloadSpy);
@@ -1271,6 +1270,7 @@ describe('internal-plugin-metrics', () => {
loginType: 'fakeLoginType',
name: 'client.alert.displayed',
userType: 'host',
+ joinFlowVersion: 'Other',
isConvergedArchitectureEnabled: undefined,
webexSubServiceType: undefined,
},
@@ -1293,7 +1293,7 @@ describe('internal-plugin-metrics', () => {
const options = {
meetingId: fakeMeeting2.id,
mediaConnections: [{mediaAgentAlias: 'alias', mediaAgentGroupId: '1'}],
- sessionCorrelationId: 'sessionCorrelationId1'
+ sessionCorrelationId: 'sessionCorrelationId1',
};
cd.submitClientEvent({
@@ -1322,6 +1322,7 @@ describe('internal-plugin-metrics', () => {
loginType: 'fakeLoginType',
name: 'client.alert.displayed',
userType: 'host',
+ joinFlowVersion: 'Other',
isConvergedArchitectureEnabled: undefined,
webexSubServiceType: undefined,
},
@@ -1462,7 +1463,7 @@ describe('internal-plugin-metrics', () => {
category: 'other',
errorCode: 9999,
errorData: {
- errorName: 'Error'
+ errorName: 'Error',
},
serviceErrorCode: 9999,
errorDescription: 'UnknownError',
@@ -1536,7 +1537,7 @@ describe('internal-plugin-metrics', () => {
category: 'other',
errorCode: 9999,
errorData: {
- errorName: 'Error'
+ errorName: 'Error',
},
serviceErrorCode: 9999,
errorDescription: 'UnknownError',
@@ -1785,7 +1786,7 @@ describe('internal-plugin-metrics', () => {
meetingId: fakeMeeting.id,
webexConferenceIdStr: 'webexConferenceIdStr1',
globalMeetingId: 'globalMeetingId1',
- sessionCorrelationId: 'sessionCorrelationId1'
+ sessionCorrelationId: 'sessionCorrelationId1',
};
cd.submitMQE({
@@ -2251,7 +2252,7 @@ describe('internal-plugin-metrics', () => {
serviceErrorCode: 9999,
errorCode: 9999,
errorData: {
- errorName: 'Error'
+ errorName: 'Error',
},
rawErrorMessage: 'bad times',
});
@@ -2757,6 +2758,83 @@ describe('internal-plugin-metrics', () => {
]);
});
});
+
+ it('includes expected joinFlowVersion from options when in-meeting', async () => {
+ // meetingId means in-meeting
+ const options = {
+ meetingId: fakeMeeting.id,
+ joinFlowVersion: 'NewFTE',
+ };
+
+ const triggered = new Date();
+ const fetchOptions = await cd.buildClientEventFetchRequestOptions({
+ name: 'client.exit.app',
+ payload: {trigger: 'user-interaction', canProceed: false},
+ options,
+ });
+
+ assert.equal(
+ fetchOptions.body.metrics[0].eventPayload.event.joinFlowVersion,
+ options.joinFlowVersion
+ );
+ });
+
+ it('includes expected joinFlowVersion from meeting callStateForMetrics when in-meeting', async () => {
+ // meetingId means in-meeting
+ const options = {
+ meetingId: fakeMeeting2.id,
+ };
+
+ const triggered = new Date();
+ const fetchOptions = await cd.buildClientEventFetchRequestOptions({
+ name: 'client.exit.app',
+ payload: {trigger: 'user-interaction', canProceed: false},
+ options,
+ });
+
+ assert.equal(fetchOptions.body.metrics[0].eventPayload.event.joinFlowVersion, 'Other');
+ });
+
+ it('prioritizes joinFlowVersion from options over meeting callStateForMetrics', async () => {
+ // meetingId means in-meeting
+ const options = {
+ meetingId: fakeMeeting2.id,
+ joinFlowVersion: 'NewFTE',
+ };
+
+ const triggered = new Date();
+ const fetchOptions = await cd.buildClientEventFetchRequestOptions({
+ name: 'client.exit.app',
+ payload: {trigger: 'user-interaction', canProceed: false},
+ options,
+ });
+
+ assert.equal(
+ fetchOptions.body.metrics[0].eventPayload.event.joinFlowVersion,
+ options.joinFlowVersion
+ );
+ });
+
+ it('includes expected joinFlowVersion from options during prejoin', async () => {
+ // correlationId and no meeting id means prejoin
+ const options = {
+ correlationId: 'myCorrelationId',
+ preLoginId: 'myPreLoginId',
+ joinFlowVersion: 'NewFTE',
+ };
+
+ const triggered = new Date();
+ const fetchOptions = await cd.buildClientEventFetchRequestOptions({
+ name: 'client.exit.app',
+ payload: {trigger: 'user-interaction', canProceed: false},
+ options,
+ });
+
+ assert.equal(
+ fetchOptions.body.metrics[0].eventPayload.event.joinFlowVersion,
+ options.joinFlowVersion
+ );
+ });
});
describe('#submitToCallDiagnosticsPreLogin', () => {
diff --git a/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..21d1db01cda 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;
}
@@ -131,6 +135,7 @@ const Support = WebexPlugin.extend({
'surveySessionId',
'productAreaTag',
'issueTypeTag',
+ 'issueDescTag',
'locussessionid',
'autoupload',
]
diff --git a/packages/@webex/internal-plugin-support/test/unit/spec/support.js b/packages/@webex/internal-plugin-support/test/unit/spec/support.js
index dca8c01936f..5d0fc5a62bd 100644
--- a/packages/@webex/internal-plugin-support/test/unit/spec/support.js
+++ b/packages/@webex/internal-plugin-support/test/unit/spec/support.js
@@ -151,6 +151,14 @@ describe('plugin-support', () => {
assert.equal(found?.value, autoupload);
});
+
+ it('sends issuedesctag if specified in metadata', () => {
+ const issueDescTag = 'issueDescTag';
+ const result = webex.internal.support._constructFileMetadata({issueDescTag});
+ const found = result.find((attr) => attr.key === 'issueDescTag');
+
+ assert.equal(found?.value, issueDescTag);
+ });
});
describe('#submitLogs()', () => {
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 477b64e751d..4c79d317fb8 100644
--- a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js
+++ b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js
@@ -6,6 +6,7 @@
import querystring from 'querystring';
import url from 'url';
+import {EventEmitter} from 'events';
import {base64, oneFlight, whileInFlight} from '@webex/common';
import {grantErrors, WebexPlugin} from '@webex/webex-core';
@@ -21,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
@@ -66,6 +77,50 @@ const Authorization = WebexPlugin.extend({
namespace: 'Credentials',
+ /**
+ * EventEmitter for authorization events
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @type {EventEmitter}
+ * @public
+ */
+ eventEmitter: new EventEmitter(),
+
+ /**
+ * Stores the timer ID for QR code polling
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @type {?number}
+ * @private
+ */
+ pollingTimer: null,
+ /**
+ * Stores the expiration timer ID for QR code polling
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @type {?number}
+ * @private
+ */
+ pollingExpirationTimer: null,
+
+ /**
+ * 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
* @instance
@@ -240,6 +295,206 @@ 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
+ * @memberof AuthorizationBrowserFirstParty
+ * @emits #qRCodeLogin
+ */
+ initQRCodeLogin() {
+ if (this.pollingTimer) {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
+ eventType: 'getUserCodeFailure',
+ data: {message: 'There is already a polling request'},
+ });
+ return;
+ }
+
+ this.webex
+ .request({
+ method: 'POST',
+ service: 'oauth-helper',
+ resource: '/actions/device/authorize',
+ form: {
+ client_id: this.config.client_id,
+ scope: this.config.scope,
+ },
+ auth: {
+ user: this.config.client_id,
+ pass: this.config.client_secret,
+ sendImmediately: true,
+ },
+ })
+ .then((res) => {
+ const {user_code, verification_uri, verification_uri_complete} = res.body;
+ const verificationUriComplete = this._generateQRCodeVerificationUrl(verification_uri_complete);
+ this.eventEmitter.emit(Events.qRCodeLogin, {
+ eventType: 'getUserCodeSuccess',
+ userData: {
+ userCode: user_code,
+ verificationUri: verification_uri,
+ 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(Events.qRCodeLogin, {
+ eventType: 'getUserCodeFailure',
+ data: res.body,
+ });
+ });
+ },
+
+ /**
+ * Polling the server to check whether the user has completed authorization
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @param {Object} options
+ * @emits #qRCodeLogin
+ */
+ _startQRCodePolling(options = {}) {
+ if (!options.device_code) {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
+ eventType: 'authorizationFailure',
+ data: {message: 'A deviceCode is required'},
+ });
+ return;
+ }
+
+ if (this.pollingTimer) {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
+ eventType: 'authorizationFailure',
+ data: {message: 'There is already a polling request'},
+ });
+ return;
+ }
+
+ const {device_code: deviceCode, expires_in: expiresIn = 300} = options;
+ let interval = options.interval ?? 2;
+
+ this.pollingExpirationTimer = setTimeout(() => {
+ this.cancelQRCodePolling(false);
+ this.eventEmitter.emit(Events.qRCodeLogin, {
+ eventType: 'authorizationFailure',
+ data: {message: 'Authorization timed out'},
+ });
+ }, expiresIn * 1000);
+
+ const polling = () => {
+ this.pollingId += 1;
+ this.currentPollingId = this.pollingId;
+
+ this.webex
+ .request({
+ method: 'POST',
+ service: 'oauth-helper',
+ resource: '/actions/device/token',
+ form: {
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
+ device_code: deviceCode,
+ client_id: this.config.client_id,
+ },
+ auth: {
+ user: this.config.client_id,
+ pass: this.config.client_secret,
+ sendImmediately: true,
+ },
+ })
+ .then((res) => {
+ // if the pollingId has changed, it means that the polling request has been canceled
+ if (this.currentPollingId !== this.pollingId) return;
+
+ this.eventEmitter.emit(Events.qRCodeLogin, {
+ eventType: 'authorizationSuccess',
+ data: res.body,
+ });
+ this.webex.credentials.set({supertoken: res.body});
+ this.cancelQRCodePolling();
+ })
+ .catch((res) => {
+ // if the pollingId has changed, it means that the polling request has been canceled
+ if (this.currentPollingId !== this.pollingId) return;
+
+ // 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(Events.qRCodeLogin, {
+ eventType: 'authorizationPending',
+ data: res.body,
+ });
+ schedulePolling(interval);
+ return;
+ }
+
+ this.cancelQRCodePolling();
+
+ this.eventEmitter.emit(Events.qRCodeLogin, {
+ eventType: 'authorizationFailure',
+ data: res.body,
+ });
+ });
+ };
+
+ const schedulePolling = (interval) =>
+ (this.pollingTimer = setTimeout(polling, interval * 1000));
+
+ schedulePolling(interval);
+ },
+
+ /**
+ * cancel polling request
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @returns {void}
+ */
+ cancelQRCodePolling(withCancelEvent = true) {
+ if (this.pollingTimer && withCancelEvent) {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
+ eventType: 'pollingCanceled',
+ });
+ }
+
+ this.currentPollingId = null;
+
+ clearTimeout(this.pollingExpirationTimer);
+ this.pollingExpirationTimer = null;
+ clearTimeout(this.pollingTimer);
+ this.pollingTimer = null;
+ },
+
/**
* Extracts the orgId from the returned code from idbroker
* Description of how to parse the code can be found here:
diff --git a/packages/@webex/plugin-authorization-browser-first-party/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 6069b45ac15..a5a95285de2 100644
--- a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js
+++ b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js
@@ -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,6 +443,396 @@ 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.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.pollingTimer = null;
+ });
+
+ it('should send correct request parameters to the API', async () => {
+ const clock = sinon.useFakeTimers();
+ const testClientId = 'test-client-id';
+ const testScope = 'test-scope';
+ const sampleData = {
+ device_code: 'test123',
+ expires_in: 300,
+ user_code: '421175',
+ verification_uri: 'http://example.com',
+ verification_uri_complete: 'http://example.com',
+ interval: 2,
+ };
+
+ const webex = makeWebex('http://example.com', undefined, undefined, {
+ credentials: {
+ client_id: testClientId,
+ scope: testScope,
+ },
+ });
+ webex.request.onFirstCall().resolves({statusCode: 200, body: sampleData});
+ sinon.spy(webex.authorization, '_startQRCodePolling');
+ sinon.spy(webex.authorization, '_generateQRCodeVerificationUrl');
+ const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
+
+ webex.authorization.initQRCodeLogin();
+ clock.tick(2000);
+ await clock.runAllAsync();
+
+ assert.calledTwice(webex.request);
+ assert.calledOnce(webex.authorization._startQRCodePolling);
+ assert.calledOnce(webex.authorization._generateQRCodeVerificationUrl);
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeSuccess');
+
+ const request = webex.request.getCall(0);
+
+ assert.equal(request.args[0].form.client_id, testClientId);
+ assert.equal(request.args[0].form.scope, testScope);
+ clock.restore();
+ });
+
+ it('should use POST method and correct endpoint', async () => {
+ const clock = sinon.useFakeTimers();
+ const webex = makeWebex('http://example.com');
+ const sampleData = {
+ device_code: 'test123',
+ expires_in: 300,
+ user_code: '421175',
+ verification_uri: 'http://example.com',
+ verification_uri_complete: 'http://example.com',
+ interval: 2,
+ };
+ webex.request.resolves().resolves({statusCode: 200, body: sampleData});
+
+ webex.authorization.initQRCodeLogin();
+ clock.tick(2000);
+ await clock.runAllAsync();
+
+ const request = webex.request.getCall(0);
+ assert.equal(request.args[0].method, 'POST');
+ assert.equal(request.args[0].service, 'oauth-helper');
+ assert.equal(request.args[0].resource, '/actions/device/authorize');
+ clock.restore();
+ });
+
+ it('should emit getUserCodeFailure event', async () => {
+ const clock = sinon.useFakeTimers();
+ const webex = makeWebex('http://example.com');
+ webex.request.rejects(new Error('API Error'));
+ const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
+
+ webex.authorization.initQRCodeLogin();
+
+ await clock.runAllAsync();
+
+ assert.calledOnce(emitSpy);
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure');
+ clock.restore();
+ });
+ });
+
+ describe('#_startQRCodePolling()', () => {
+ it('requires a deviceCode', () => {
+ const webex = makeWebex('http://example.com');
+
+ const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
+
+ webex.authorization._startQRCodePolling({});
+
+ assert.calledOnce(emitSpy);
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure');
+ });
+
+ it('should send correct request parameters to the API', async () => {
+ const clock = sinon.useFakeTimers();
+ const testClientId = 'test-client-id';
+ const testDeviceCode = 'test-device-code';
+
+ const options = {
+ device_code: testDeviceCode,
+ interval: 2,
+ expires_in: 300,
+ };
+
+ const webex = makeWebex('http://example.com', undefined, undefined, {
+ credentials: {
+ client_id: testClientId,
+ },
+ });
+
+ webex.request.onFirstCall().resolves({statusCode: 200, body: {access_token: 'token'}});
+ const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
+ const credentialsSetSpy = sinon.spy(webex.credentials, 'set');
+ sinon.spy(webex.authorization, 'cancelQRCodePolling');
+
+ webex.authorization._startQRCodePolling(options);
+ clock.tick(2000);
+ await clock.runAllAsync();
+
+ assert.calledOnce(webex.request);
+
+ const request = webex.request.getCall(0);
+
+ assert.equal(request.args[0].form.client_id, testClientId);
+ assert.equal(request.args[0].form.device_code, testDeviceCode);
+ assert.equal(
+ request.args[0].form.grant_type,
+ 'urn:ietf:params:oauth:grant-type:device_code'
+ );
+
+ assert.calledOnce(webex.authorization.cancelQRCodePolling);
+ assert.calledOnce(credentialsSetSpy);
+ assert.calledTwice(emitSpy);
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess');
+ assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled');
+
+ clock.restore();
+ });
+
+ it('should respect polling interval', async () => {
+ const clock = sinon.useFakeTimers();
+ const webex = makeWebex('http://example.com');
+ const options = {
+ device_code: 'test-device-code',
+ interval: 2,
+ expires_in: 300,
+ };
+
+ webex.request
+ .onFirstCall()
+ .rejects({statusCode: 428, body: {message: 'authorization_pending'}});
+ webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}});
+ sinon.spy(webex.authorization, 'cancelQRCodePolling');
+ const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
+
+ webex.authorization._startQRCodePolling(options);
+ await clock.tickAsync(4000);
+ //await clock.runAllAsync()
+
+ assert.calledTwice(webex.request);
+ assert.calledOnce(webex.authorization.cancelQRCodePolling);
+ assert.calledThrice(emitSpy);
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending');
+ assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationSuccess');
+ assert.equal(emitSpy.getCall(2).args[1].eventType, 'pollingCanceled');
+ clock.restore();
+ });
+
+ it('should timeout after expires_in seconds', async () => {
+ const clock = sinon.useFakeTimers();
+ const webex = makeWebex('http://example.com');
+ const options = {
+ device_code: 'test-device-code',
+ interval: 5,
+ expires_in: 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);
+ await clock.tickAsync(10_000);
+
+ assert.calledOnce(webex.request);
+ assert.calledOnce(webex.authorization.cancelQRCodePolling);
+ assert.calledTwice(emitSpy);
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending');
+ assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationFailure');
+ clock.restore();
+ });
+
+ it('should prevent concurrent polling attempts if this is already a polling request', async () => {
+ const webex = makeWebex('http://example.com');
+ const options = {
+ device_code: 'test-device-code',
+ interval: 2,
+ expires_in: 300,
+ };
+
+ webex.authorization.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.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');
+ const credentialsSetSpy = sinon.spy(webex.credentials, 'set');
+
+ 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.calledOnce(credentialsSetSpy);
+ 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();
+ });
+ it('should clear interval and reset polling request', () => {
+ const clock = sinon.useFakeTimers();
+ const webex = makeWebex('http://example.com');
+
+ const options = {
+ device_code: 'test_device_code',
+ interval: 2,
+ expires_in: 300,
+ };
+
+ webex.authorization._startQRCodePolling(options);
+ assert.isDefined(webex.authorization.pollingTimer);
+
+ webex.authorization.cancelQRCodePolling();
+ 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.pollingTimer);
+
+ webex.authorization.cancelQRCodePolling();
+ assert.isNull(webex.authorization.pollingTimer);
+ });
+ });
+
describe('#_generateCodeChallenge', () => {
const expectedCodeChallenge = 'code challenge';
// eslint-disable-next-line no-underscore-dangle
@@ -562,7 +952,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/README.md b/packages/@webex/plugin-meetings/README.md
index 52d211515c2..4a3568f9747 100644
--- a/packages/@webex/plugin-meetings/README.md
+++ b/packages/@webex/plugin-meetings/README.md
@@ -857,7 +857,8 @@ meeting.members.raiseOrLowerHand(memberId);
// You can lower all hands in a meeting
// use a memberId string to indicate who is requesting lowering all hands
-meeting.members.lowerAllHands(requestingMemberId);
+// (optional) use a roles array to indicate who should have their hands lowered, default to all roles
+meeting.members.lowerAllHands(requestingMemberId, roles);
// You can transfer the host role to another member in the meeting, this is proxied by meeting.transfer
// use a memberId string and a moderator boolean to transfer or not, default to true
diff --git a/packages/@webex/plugin-meetings/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/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts
index 5e357a2263c..5d5b493835e 100644
--- a/packages/@webex/plugin-meetings/src/constants.ts
+++ b/packages/@webex/plugin-meetings/src/constants.ts
@@ -356,6 +356,11 @@ export const EVENT_TRIGGERS = {
'meeting:controls:view-the-participants-list:updated',
MEETING_CONTROLS_RAISE_HAND_UPDATED: 'meeting:controls:raise-hand:updated',
MEETING_CONTROLS_VIDEO_UPDATED: 'meeting:controls:video:updated',
+ MEETING_CONTROLS_STAGE_VIEW_UPDATED: 'meeting:controls:stage-view:updated',
+ MEETING_CONTROLS_WEBCAST_UPDATED: 'meeting:controls:webcast:updated',
+ MEETING_CONTROLS_MEETING_FULL_UPDATED: 'meeting:controls:meeting-full:updated',
+ MEETING_CONTROLS_PRACTICE_SESSION_STATUS_UPDATED:
+ 'meeting:controls:practice-session-status:updated',
// Locus URL changed
MEETING_LOCUS_URL_UPDATE: 'meeting:locus:locusUrl:update',
MEETING_STREAM_PUBLISH_STATE_CHANGED: 'meeting:streamPublishStateChanged',
@@ -676,7 +681,11 @@ export const LOCUSINFO = {
CONTROLS_REACTIONS_CHANGED: 'CONTROLS_REACTIONS_CHANGED',
CONTROLS_VIEW_THE_PARTICIPANTS_LIST_CHANGED: 'CONTROLS_VIEW_THE_PARTICIPANTS_LIST_CHANGED',
CONTROLS_RAISE_HAND_CHANGED: 'CONTROLS_RAISE_HAND_CHANGED',
+ CONTROLS_WEBCAST_CHANGED: 'CONTROLS_WEBCAST_CHANGED',
+ CONTROLS_MEETING_FULL_CHANGED: 'CONTROLS_MEETING_FULL_CHANGED',
+ CONTROLS_PRACTICE_SESSION_STATUS_UPDATED: 'CONTROLS_PRACTICE_SESSION_STATUS_UPDATED',
CONTROLS_VIDEO_CHANGED: 'CONTROLS_VIDEO_CHANGED',
+ CONTROLS_STAGE_VIEW_UPDATED: 'CONTROLS_STAGE_VIEW_UPDATED',
SELF_UNADMITTED_GUEST: 'SELF_UNADMITTED_GUEST',
SELF_ADMITTED_GUEST: 'SELF_ADMITTED_GUEST',
SELF_REMOTE_VIDEO_MUTE_STATUS_UPDATED: 'SELF_REMOTE_VIDEO_MUTE_STATUS_UPDATED',
@@ -702,6 +711,7 @@ export const LOCUSINFO = {
SELF_MEETING_INTERPRETATION_CHANGED: 'SELF_MEETING_INTERPRETATION_CHANGED',
MEDIA_INACTIVITY: 'MEDIA_INACTIVITY',
LINKS_SERVICES: 'LINKS_SERVICES',
+ LINKS_RESOURCES: 'LINKS_RESOURCES',
},
};
@@ -894,6 +904,10 @@ export const DISPLAY_HINTS = {
RECORDING_CONTROL_PAUSE: 'RECORDING_CONTROL_PAUSE',
RECORDING_CONTROL_STOP: 'RECORDING_CONTROL_STOP',
RECORDING_CONTROL_RESUME: 'RECORDING_CONTROL_RESUME',
+ PREMISE_RECORDING_CONTROL_START: 'PREMISE_RECORDING_CONTROL_START',
+ PREMISE_RECORDING_CONTROL_PAUSE: 'PREMISE_RECORDING_CONTROL_PAUSE',
+ PREMISE_RECORDING_CONTROL_STOP: 'PREMISE_RECORDING_CONTROL_STOP',
+ PREMISE_RECORDING_CONTROL_RESUME: 'PREMISE_RECORDING_CONTROL_RESUME',
LOCK_CONTROL_UNLOCK: 'LOCK_CONTROL_UNLOCK',
LOCK_CONTROL_LOCK: 'LOCK_CONTROL_LOCK',
LOCK_STATUS_LOCKED: 'LOCK_STATUS_LOCKED',
@@ -944,6 +958,11 @@ export const DISPLAY_HINTS = {
// participants list
DISABLE_VIEW_THE_PARTICIPANT_LIST: 'DISABLE_VIEW_THE_PARTICIPANT_LIST',
ENABLE_VIEW_THE_PARTICIPANT_LIST: 'ENABLE_VIEW_THE_PARTICIPANT_LIST',
+ // for webinar participants list
+ DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST: 'DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST',
+ ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST: 'ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST',
+ DISABLE_SHOW_ATTENDEE_COUNT: 'DISABLE_SHOW_ATTENDEE_COUNT',
+ ENABLE_SHOW_ATTENDEE_COUNT: 'ENABLE_SHOW_ATTENDEE_COUNT',
// raise hand
DISABLE_RAISE_HAND: 'DISABLE_RAISE_HAND',
@@ -963,6 +982,22 @@ export const DISPLAY_HINTS = {
// Voip (audio/video)
VOIP_IS_ENABLED: 'VOIP_IS_ENABLED',
+
+ // Webcast
+ WEBCAST_CONTROL_START: 'WEBCAST_CONTROL_START',
+ WEBCAST_CONTROL_STOP: 'WEBCAST_CONTROL_STOP',
+
+ // Stage View
+ STAGE_VIEW_ACTIVE: 'STAGE_VIEW_ACTIVE',
+ STAGE_VIEW_INACTIVE: 'STAGE_VIEW_INACTIVE',
+ ENABLE_STAGE_VIEW: 'ENABLE_STAGE_VIEW',
+ DISABLE_STAGE_VIEW: 'DISABLE_STAGE_VIEW',
+
+ // Practice Session
+ PRACTICE_SESSION_ON: 'PRACTICE_SESSION_ON',
+ PRACTICE_SESSION_OFF: 'PRACTICE_SESSION_OFF',
+ SHOW_PRACTICE_SESSION_START: 'SHOW_PRACTICE_SESSION_START',
+ SHOW_PRACTICE_SESSION_STOP: 'SHOW_PRACTICE_SESSION_STOP',
};
export const INTERSTITIAL_DISPLAY_HINTS = [DISPLAY_HINTS.VOIP_IS_ENABLED];
diff --git a/packages/@webex/plugin-meetings/src/controls-options-manager/enums.ts b/packages/@webex/plugin-meetings/src/controls-options-manager/enums.ts
index 2e3ac29d86e..323a7ffd1a0 100644
--- a/packages/@webex/plugin-meetings/src/controls-options-manager/enums.ts
+++ b/packages/@webex/plugin-meetings/src/controls-options-manager/enums.ts
@@ -2,6 +2,7 @@ enum Setting {
disallowUnmute = 'DisallowUnmute',
muteOnEntry = 'MuteOnEntry',
muted = 'Muted',
+ roles = 'Roles',
}
enum Control {
diff --git a/packages/@webex/plugin-meetings/src/controls-options-manager/index.ts b/packages/@webex/plugin-meetings/src/controls-options-manager/index.ts
index d09a2f7446e..92ad7db2604 100644
--- a/packages/@webex/plugin-meetings/src/controls-options-manager/index.ts
+++ b/packages/@webex/plugin-meetings/src/controls-options-manager/index.ts
@@ -177,7 +177,12 @@ export default class ControlsOptionsManager {
* @memberof ControlsOptionsManager
* @returns {Promise}
*/
- private setControls(setting: {[key in Setting]?: boolean}): Promise {
+ private setControls(setting: {
+ [Setting.muted]?: boolean;
+ [Setting.disallowUnmute]?: boolean;
+ [Setting.muteOnEntry]?: boolean;
+ [Setting.roles]?: Array;
+ }): Promise {
LoggerProxy.logger.log(
`ControlsOptionsManager:index#setControls --> ${JSON.stringify(setting)}`
);
@@ -190,6 +195,7 @@ export default class ControlsOptionsManager {
Object.entries(setting).forEach(([key, value]) => {
if (
!shouldSkipCheckToMergeBody &&
+ value !== undefined &&
!Util?.[`${value ? CAN_SET : CAN_UNSET}${key}`](this.displayHints)
) {
error = new PermissionError(`${key} [${value}] not allowed, due to moderator property.`);
@@ -219,6 +225,14 @@ export default class ControlsOptionsManager {
}
break;
+ case Setting.roles:
+ if (Array.isArray(value)) {
+ body.audio = body.audio
+ ? {...body.audio, [camelCase(key)]: value}
+ : {[camelCase(key)]: value};
+ }
+ break;
+
default:
error = new PermissionError(`${key} [${value}] not allowed, due to moderator property.`);
}
@@ -261,18 +275,21 @@ export default class ControlsOptionsManager {
* @param {boolean} mutedEnabled
* @param {boolean} disallowUnmuteEnabled
* @param {boolean} muteOnEntryEnabled
+ * @param {array} roles which should be muted
* @memberof ControlsOptionsManager
* @returns {Promise}
*/
public setMuteAll(
mutedEnabled: boolean,
disallowUnmuteEnabled: boolean,
- muteOnEntryEnabled: boolean
+ muteOnEntryEnabled: boolean,
+ roles: Array
): Promise {
return this.setControls({
[Setting.muted]: mutedEnabled,
[Setting.disallowUnmute]: disallowUnmuteEnabled,
[Setting.muteOnEntry]: muteOnEntryEnabled,
+ [Setting.roles]: roles,
});
}
}
diff --git a/packages/@webex/plugin-meetings/src/controls-options-manager/types.ts b/packages/@webex/plugin-meetings/src/controls-options-manager/types.ts
index cefa0a76ddd..5d155b464ed 100644
--- a/packages/@webex/plugin-meetings/src/controls-options-manager/types.ts
+++ b/packages/@webex/plugin-meetings/src/controls-options-manager/types.ts
@@ -36,6 +36,8 @@ export interface VideoProperties {
export interface ViewTheParticipantListProperties {
enabled?: boolean;
+ panelistEnabled?: boolean;
+ attendeeCount?: boolean;
}
export type Properties =
diff --git a/packages/@webex/plugin-meetings/src/controls-options-manager/util.ts b/packages/@webex/plugin-meetings/src/controls-options-manager/util.ts
index a297e51b405..0dad61a9938 100644
--- a/packages/@webex/plugin-meetings/src/controls-options-manager/util.ts
+++ b/packages/@webex/plugin-meetings/src/controls-options-manager/util.ts
@@ -217,6 +217,18 @@ class Utils {
if (control.properties.enabled === false) {
requiredHints.push(DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST);
}
+ if (control.properties.panelistEnabled === true) {
+ requiredHints.push(DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST);
+ }
+ if (control.properties.panelistEnabled === false) {
+ requiredHints.push(DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST);
+ }
+ if (control.properties.attendeeCount === true) {
+ requiredHints.push(DISPLAY_HINTS.ENABLE_SHOW_ATTENDEE_COUNT);
+ }
+ if (control.properties.attendeeCount === false) {
+ requiredHints.push(DISPLAY_HINTS.DISABLE_SHOW_ATTENDEE_COUNT);
+ }
return Utils.hasHints({requiredHints, displayHints});
}
diff --git a/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts b/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts
index f2b4594a74a..6fbc52b930b 100644
--- a/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts
+++ b/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts
@@ -79,7 +79,11 @@ ControlsUtils.parse = (controls: any) => {
}
if (controls?.viewTheParticipantList) {
- parsedControls.viewTheParticipantList = {enabled: controls.viewTheParticipantList.enabled};
+ parsedControls.viewTheParticipantList = {
+ enabled: controls.viewTheParticipantList?.enabled ?? false,
+ panelistEnabled: controls.viewTheParticipantList?.panelistEnabled ?? false,
+ attendeeCount: controls.viewTheParticipantList?.attendeeCount ?? 0,
+ };
}
if (controls?.raiseHand) {
@@ -90,6 +94,23 @@ ControlsUtils.parse = (controls: any) => {
parsedControls.video = {enabled: controls.video.enabled};
}
+ if (controls?.webcastControl) {
+ parsedControls.webcastControl = {streaming: controls.webcastControl.streaming};
+ }
+
+ if (controls?.meetingFull) {
+ parsedControls.meetingFull = {
+ meetingFull: controls.meetingFull?.meetingFull ?? false,
+ meetingPanelistFull: controls.meetingFull?.meetingPanelistFull ?? false,
+ };
+ }
+
+ if (controls?.practiceSession) {
+ parsedControls.practiceSession = {
+ enabled: controls.practiceSession.enabled,
+ };
+ }
+
return parsedControls;
};
@@ -121,7 +142,11 @@ ControlsUtils.getControls = (oldControls: any, newControls: any) => {
previous?.reactions?.showDisplayNameWithReactions,
hasViewTheParticipantListChanged:
- current?.viewTheParticipantList?.enabled !== previous?.viewTheParticipantList?.enabled,
+ current?.viewTheParticipantList?.enabled !== previous?.viewTheParticipantList?.enabled ||
+ current?.viewTheParticipantList?.panelistEnabled !==
+ previous?.viewTheParticipantList?.panelistEnabled ||
+ current?.viewTheParticipantList?.attendeeCount !==
+ previous?.viewTheParticipantList?.attendeeCount,
hasRaiseHandChanged: current?.raiseHand?.enabled !== previous?.raiseHand?.enabled,
@@ -167,6 +192,25 @@ ControlsUtils.getControls = (oldControls: any, newControls: any) => {
hasVideoEnabledChanged:
newControls.video?.enabled !== undefined &&
!isEqual(previous?.videoEnabled, current?.videoEnabled),
+
+ hasWebcastChanged: !isEqual(
+ previous?.webcastControl?.streaming,
+ current?.webcastControl?.streaming
+ ),
+
+ hasMeetingFullChanged:
+ !isEqual(previous?.meetingFull?.meetingFull, current?.meetingFull?.meetingFull) ||
+ !isEqual(
+ previous?.meetingFull?.meetingPanelistFull,
+ current?.meetingFull?.meetingPanelistFull
+ ),
+
+ hasPracticeSessionEnabledChanged: !isEqual(
+ previous?.practiceSession?.enabled,
+ current?.practiceSession?.enabled
+ ),
+
+ hasStageViewChanged: !isEqual(previous?.videoLayout, current?.videoLayout),
},
};
};
diff --git a/packages/@webex/plugin-meetings/src/locus-info/fullState.ts b/packages/@webex/plugin-meetings/src/locus-info/fullState.ts
index b3feb424da4..f207823a675 100644
--- a/packages/@webex/plugin-meetings/src/locus-info/fullState.ts
+++ b/packages/@webex/plugin-meetings/src/locus-info/fullState.ts
@@ -6,6 +6,7 @@ FullState.parse = (fullState) => ({
type: fullState.type || FULL_STATE.UNKNOWN,
meetingState: fullState.state,
locked: fullState.locked,
+ attendeeCount: typeof fullState.attendeeCount === 'number' ? fullState.attendeeCount : 0,
});
FullState.getFullState = (oldFullState, newFullState) => {
diff --git a/packages/@webex/plugin-meetings/src/locus-info/index.ts b/packages/@webex/plugin-meetings/src/locus-info/index.ts
index 489d12fda57..93df547fb20 100644
--- a/packages/@webex/plugin-meetings/src/locus-info/index.ts
+++ b/packages/@webex/plugin-meetings/src/locus-info/index.ts
@@ -64,6 +64,7 @@ export default class LocusInfo extends EventsScope {
replace: any;
url: any;
services: any;
+ resources: any;
mainSessionLocusCache: any;
/**
* Constructor
@@ -263,6 +264,7 @@ export default class LocusInfo extends EventsScope {
this.updateHostInfo(locus.host);
this.updateMediaShares(locus.mediaShares);
this.updateServices(locus.links?.services);
+ this.updateResources(locus.links?.resources);
}
/**
@@ -452,6 +454,7 @@ export default class LocusInfo extends EventsScope {
this.updateIdentifiers(locus.identities);
this.updateEmbeddedApps(locus.embeddedApps);
this.updateServices(locus.links?.services);
+ this.updateResources(locus.links?.resources);
this.compareAndUpdate();
// update which required to compare different objects from locus
}
@@ -805,6 +808,10 @@ export default class LocusInfo extends EventsScope {
hasRaiseHandChanged,
hasVideoChanged,
hasInterpretationChanged,
+ hasWebcastChanged,
+ hasMeetingFullChanged,
+ hasPracticeSessionEnabledChanged,
+ hasStageViewChanged,
},
current,
} = ControlsUtils.getControls(this.controls, controls);
@@ -1008,6 +1015,38 @@ export default class LocusInfo extends EventsScope {
);
}
+ if (hasWebcastChanged) {
+ this.emitScoped(
+ {file: 'locus-info', function: 'updateControls'},
+ LOCUSINFO.EVENTS.CONTROLS_WEBCAST_CHANGED,
+ {state: current.webcastControl}
+ );
+ }
+
+ if (hasMeetingFullChanged) {
+ this.emitScoped(
+ {file: 'locus-info', function: 'updateControls'},
+ LOCUSINFO.EVENTS.CONTROLS_MEETING_FULL_CHANGED,
+ {state: current.meetingFull}
+ );
+ }
+
+ if (hasPracticeSessionEnabledChanged) {
+ this.emitScoped(
+ {file: 'locus-info', function: 'updateControls'},
+ LOCUSINFO.EVENTS.CONTROLS_PRACTICE_SESSION_STATUS_UPDATED,
+ {state: current.practiceSession}
+ );
+ }
+
+ if (hasStageViewChanged) {
+ this.emitScoped(
+ {file: 'locus-info', function: 'updateControls'},
+ LOCUSINFO.EVENTS.CONTROLS_STAGE_VIEW_UPDATED,
+ {state: current.videoLayout}
+ );
+ }
+
this.controls = controls;
}
}
@@ -1064,6 +1103,27 @@ export default class LocusInfo extends EventsScope {
}
}
+ /**
+ * @param {Object} resources
+ * @returns {undefined}
+ * @memberof LocusInfo
+ */
+ updateResources(resources: Record<'webcastInstance', {url: string}>) {
+ if (resources && !isEqual(this.resources, resources)) {
+ this.resources = resources;
+ this.emitScoped(
+ {
+ file: 'locus-info',
+ function: 'updateResources',
+ },
+ LOCUSINFO.EVENTS.LINKS_RESOURCES,
+ {
+ resources,
+ }
+ );
+ }
+ }
+
/**
* @param {Object} fullState
* @returns {undefined}
diff --git a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts
index 161e90a9fbe..6b6c80894f5 100644
--- a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts
+++ b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts
@@ -3,6 +3,7 @@
*/
import {MEETINGS} from '../constants';
+import ControlsOptionsUtil from '../controls-options-manager/util';
/**
* IInMeetingActions
@@ -25,6 +26,7 @@ interface IInMeetingActions {
canStartRecording?: boolean;
canPauseRecording?: boolean;
canResumeRecording?: boolean;
+ isPremiseRecordingEnabled?: boolean;
canStopRecording?: boolean;
canRaiseHand?: boolean;
canLowerAllHands?: boolean;
@@ -64,6 +66,10 @@ interface IInMeetingActions {
canUpdateShareControl?: boolean;
canEnableViewTheParticipantsList?: boolean;
canDisableViewTheParticipantsList?: boolean;
+ canEnableViewTheParticipantsListPanelist?: boolean;
+ canDisableViewTheParticipantsListPanelist?: boolean;
+ canEnableShowAttendeeCount?: boolean;
+ canDisableShowAttendeeCount?: boolean;
canEnableRaiseHand?: boolean;
canDisableRaiseHand?: boolean;
canEnableVideo?: boolean;
@@ -83,6 +89,15 @@ interface IInMeetingActions {
canShareWhiteBoard?: boolean;
enforceVirtualBackground?: boolean;
canPollingAndQA?: boolean;
+ canStartWebcast?: boolean;
+ canStopWebcast?: boolean;
+ canShowStageView?: boolean;
+ canEnableStageView?: boolean;
+ canDisableStageView?: boolean;
+ isPracticeSessionOn?: boolean;
+ isPracticeSessionOff?: boolean;
+ canStartPracticeSession?: boolean;
+ canStopPracticeSession?: boolean;
}
/**
@@ -107,6 +122,8 @@ export default class InMeetingActions implements IInMeetingActions {
canResumeRecording = null;
+ isPremiseRecordingEnabled = null;
+
canStopRecording = null;
canSetMuteOnEntry = null;
@@ -201,6 +218,14 @@ export default class InMeetingActions implements IInMeetingActions {
canDisableViewTheParticipantsList = null;
+ canEnableViewTheParticipantsListPanelist = null;
+
+ canDisableViewTheParticipantsListPanelist = null;
+
+ canEnableShowAttendeeCount = null;
+
+ canDisableShowAttendeeCount = null;
+
canEnableRaiseHand = null;
canDisableRaiseHand = null;
@@ -238,6 +263,25 @@ export default class InMeetingActions implements IInMeetingActions {
canShareWhiteBoard = null;
canPollingAndQA = null;
+
+ canStartWebcast = null;
+
+ canStopWebcast = null;
+
+ canShowStageView = null;
+
+ canEnableStageView = null;
+
+ canDisableStageView = null;
+
+ isPracticeSessionOn = null;
+
+ isPracticeSessionOff = null;
+
+ canStartPracticeSession = null;
+
+ canStopPracticeSession = null;
+
/**
* Returns all meeting action options
* @returns {Object}
@@ -260,6 +304,7 @@ export default class InMeetingActions implements IInMeetingActions {
canPauseRecording: this.canPauseRecording,
canResumeRecording: this.canResumeRecording,
canStopRecording: this.canStopRecording,
+ isPremiseRecordingEnabled: this.isPremiseRecordingEnabled,
canRaiseHand: this.canRaiseHand,
canLowerAllHands: this.canLowerAllHands,
canLowerSomeoneElsesHand: this.canLowerSomeoneElsesHand,
@@ -298,6 +343,10 @@ export default class InMeetingActions implements IInMeetingActions {
canUpdateShareControl: this.canUpdateShareControl,
canEnableViewTheParticipantsList: this.canEnableViewTheParticipantsList,
canDisableViewTheParticipantsList: this.canDisableViewTheParticipantsList,
+ canEnableViewTheParticipantsListPanelist: this.canEnableViewTheParticipantsListPanelist,
+ canDisableViewTheParticipantsListPanelist: this.canDisableViewTheParticipantsListPanelist,
+ canEnableShowAttendeeCount: this.canEnableShowAttendeeCount,
+ canDisableShowAttendeeCount: this.canDisableShowAttendeeCount,
canEnableRaiseHand: this.canEnableRaiseHand,
canDisableRaiseHand: this.canDisableRaiseHand,
canEnableVideo: this.canEnableVideo,
@@ -317,6 +366,15 @@ export default class InMeetingActions implements IInMeetingActions {
supportHDV: this.supportHDV,
canShareWhiteBoard: this.canShareWhiteBoard,
canPollingAndQA: this.canPollingAndQA,
+ canStartWebcast: this.canStartWebcast,
+ canStopWebcast: this.canStopWebcast,
+ canShowStageView: this.canShowStageView,
+ canEnableStageView: this.canEnableStageView,
+ canDisableStageView: this.canDisableStageView,
+ isPracticeSessionOn: this.isPracticeSessionOn,
+ isPracticeSessionOff: this.isPracticeSessionOff,
+ canStartPracticeSession: this.canStartPracticeSession,
+ canStopPracticeSession: this.canStopPracticeSession,
});
/**
diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts
index b11fd127e64..f5311e002e3 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}
@@ -2014,6 +2019,7 @@ export default class Meeting extends StatelessWebexPlugin {
this.setUpLocusInfoSelfListener();
this.setUpLocusInfoMeetingListener();
this.setUpLocusServicesListener();
+ this.setUpLocusResourcesListener();
// members update listeners
this.setUpLocusFullStateListener();
this.setUpLocusUrlListener();
@@ -2635,6 +2641,43 @@ export default class Meeting extends StatelessWebexPlugin {
);
});
+ this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_WEBCAST_CHANGED, ({state}) => {
+ Trigger.trigger(
+ this,
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
+ EVENT_TRIGGERS.MEETING_CONTROLS_WEBCAST_UPDATED,
+ {state}
+ );
+ });
+
+ this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_MEETING_FULL_CHANGED, ({state}) => {
+ Trigger.trigger(
+ this,
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
+ EVENT_TRIGGERS.MEETING_CONTROLS_MEETING_FULL_UPDATED,
+ {state}
+ );
+ });
+
+ this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_PRACTICE_SESSION_STATUS_UPDATED, ({state}) => {
+ this.webinar.updatePracticeSessionStatus(state);
+ Trigger.trigger(
+ this,
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
+ EVENT_TRIGGERS.MEETING_CONTROLS_PRACTICE_SESSION_STATUS_UPDATED,
+ {state}
+ );
+ });
+
+ this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_STAGE_VIEW_UPDATED, ({state}) => {
+ Trigger.trigger(
+ this,
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
+ EVENT_TRIGGERS.MEETING_CONTROLS_STAGE_VIEW_UPDATED,
+ {state}
+ );
+ });
+
this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_VIDEO_CHANGED, ({state}) => {
Trigger.trigger(
this,
@@ -2996,10 +3039,20 @@ export default class Meeting extends StatelessWebexPlugin {
this.breakouts.breakoutServiceUrlUpdate(payload?.services?.breakout?.url);
this.annotation.approvalUrlUpdate(payload?.services?.approval?.url);
this.simultaneousInterpretation.approvalUrlUpdate(payload?.services?.approval?.url);
- this.webinar.webcastUrlUpdate(payload?.services?.webcast?.url);
- this.webinar.webinarAttendeesSearchingUrlUpdate(
- payload?.services?.webinarAttendeesSearching?.url
- );
+ });
+ }
+
+ /**
+ * Set up the locus info resources link listener
+ * update the locusInfo for webcast instance url
+ * @param {Object} payload - The event payload
+ * @returns {undefined}
+ * @private
+ * @memberof Meeting
+ */
+ private setUpLocusResourcesListener() {
+ this.locusInfo.on(LOCUSINFO.EVENTS.LINKS_RESOURCES, (payload) => {
+ this.webinar.updateWebcastUrl(payload);
});
}
@@ -3104,7 +3157,7 @@ export default class Meeting extends StatelessWebexPlugin {
private setUpLocusInfoSelfListener() {
this.locusInfo.on(LOCUSINFO.EVENTS.LOCAL_UNMUTE_REQUIRED, (payload) => {
if (this.audio) {
- this.audio.handleServerLocalUnmuteRequired(this);
+ this.audio.handleServerLocalUnmuteRequired(this, payload.unmuteAllowed);
Trigger.trigger(
this,
{
@@ -3202,6 +3255,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) => {
@@ -3225,6 +3281,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();
@@ -3311,7 +3370,7 @@ export default class Meeting extends StatelessWebexPlugin {
this.simultaneousInterpretation.updateCanManageInterpreters(
payload.newRoles?.includes(SELF_ROLES.MODERATOR)
);
- this.webinar.updateCanManageWebcast(payload.newRoles?.includes(SELF_ROLES.MODERATOR));
+ this.webinar.updateRoleChanged(payload);
Trigger.trigger(
this,
{
@@ -3458,6 +3517,7 @@ export default class Meeting extends StatelessWebexPlugin {
emailAddress: string;
email: string;
phoneNumber: string;
+ roles: Array;
},
alertIfActive = true
) {
@@ -3714,6 +3774,10 @@ export default class Meeting extends StatelessWebexPlugin {
this.userDisplayHints,
this.selfUserPolicies
),
+ isPremiseRecordingEnabled: RecordingUtil.isPremiseRecordingEnabled(
+ this.userDisplayHints,
+ this.selfUserPolicies
+ ),
canRaiseHand: MeetingUtil.canUserRaiseHand(this.userDisplayHints),
canLowerAllHands: MeetingUtil.canUserLowerAllHands(this.userDisplayHints),
canLowerSomeoneElsesHand: MeetingUtil.canUserLowerSomeoneElsesHand(this.userDisplayHints),
@@ -3805,6 +3869,22 @@ export default class Meeting extends StatelessWebexPlugin {
requiredHints: [DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST],
displayHints: this.userDisplayHints,
}),
+ canEnableViewTheParticipantsListPanelist: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST],
+ displayHints: this.userDisplayHints,
+ }),
+ canDisableViewTheParticipantsListPanelist: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST],
+ displayHints: this.userDisplayHints,
+ }),
+ canEnableShowAttendeeCount: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.ENABLE_SHOW_ATTENDEE_COUNT],
+ displayHints: this.userDisplayHints,
+ }),
+ canDisableShowAttendeeCount: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.DISABLE_SHOW_ATTENDEE_COUNT],
+ displayHints: this.userDisplayHints,
+ }),
canEnableRaiseHand: ControlsOptionsUtil.hasHints({
requiredHints: [DISPLAY_HINTS.ENABLE_RAISE_HAND],
displayHints: this.userDisplayHints,
@@ -3821,6 +3901,42 @@ export default class Meeting extends StatelessWebexPlugin {
requiredHints: [DISPLAY_HINTS.DISABLE_VIDEO],
displayHints: this.userDisplayHints,
}),
+ canStartWebcast: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.WEBCAST_CONTROL_START],
+ displayHints: this.userDisplayHints,
+ }),
+ canStopWebcast: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.WEBCAST_CONTROL_STOP],
+ displayHints: this.userDisplayHints,
+ }),
+ canShowStageView: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.STAGE_VIEW_ACTIVE],
+ displayHints: this.userDisplayHints,
+ }),
+ canEnableStageView: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.ENABLE_STAGE_VIEW],
+ displayHints: this.userDisplayHints,
+ }),
+ canDisableStageView: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.DISABLE_STAGE_VIEW],
+ displayHints: this.userDisplayHints,
+ }),
+ isPracticeSessionOn: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.PRACTICE_SESSION_ON],
+ displayHints: this.userDisplayHints,
+ }),
+ isPracticeSessionOff: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.PRACTICE_SESSION_OFF],
+ displayHints: this.userDisplayHints,
+ }),
+ canStartPracticeSession: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.SHOW_PRACTICE_SESSION_START],
+ displayHints: this.userDisplayHints,
+ }),
+ canStopPracticeSession: ControlsOptionsUtil.hasHints({
+ requiredHints: [DISPLAY_HINTS.SHOW_PRACTICE_SESSION_STOP],
+ displayHints: this.userDisplayHints,
+ }),
canShareFile:
(ControlsOptionsUtil.hasHints({
requiredHints: [DISPLAY_HINTS.SHARE_FILE],
@@ -3977,6 +4093,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}
@@ -4688,8 +4863,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
@@ -4821,6 +4994,8 @@ export default class Meeting extends StatelessWebexPlugin {
);
}
+ this.cleanUpBeforeReconnection();
+
return this.reconnectionManager
.reconnect(options, async () => {
await this.waitForRemoteSDPAnswer();
@@ -6238,7 +6413,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;
}
}
@@ -6949,6 +7124,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).
@@ -7040,7 +7232,7 @@ export default class Meeting extends StatelessWebexPlugin {
shareAudioEnabled = true,
shareVideoEnabled = true,
remoteMediaManagerConfig,
- bundlePolicy,
+ bundlePolicy = 'max-bundle',
} = options;
this.allowMediaInLobby = options?.allowMediaInLobby;
@@ -7145,6 +7337,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);
@@ -7927,18 +8120,21 @@ export default class Meeting extends StatelessWebexPlugin {
* @param {boolean} mutedEnabled
* @param {boolean} disallowUnmuteEnabled
* @param {boolean} muteOnEntryEnabled
+ * @param {array} roles
* @public
* @memberof Meeting
*/
public setMuteAll(
mutedEnabled: boolean,
disallowUnmuteEnabled: boolean,
- muteOnEntryEnabled: boolean
+ muteOnEntryEnabled: boolean,
+ roles: Array
) {
return this.controlsOptionsManager.setMuteAll(
mutedEnabled,
disallowUnmuteEnabled,
- muteOnEntryEnabled
+ muteOnEntryEnabled,
+ roles
);
}
diff --git a/packages/@webex/plugin-meetings/src/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/muteState.ts b/packages/@webex/plugin-meetings/src/meeting/muteState.ts
index e834514e732..2adbd562887 100644
--- a/packages/@webex/plugin-meetings/src/meeting/muteState.ts
+++ b/packages/@webex/plugin-meetings/src/meeting/muteState.ts
@@ -394,21 +394,25 @@ export class MuteState {
* @public
* @memberof MuteState
* @param {Object} [meeting] the meeting object
+ * @param {Boolean} [unmuteAllowed] whether the user is allowed to unmute self
* @returns {undefined}
*/
- public handleServerLocalUnmuteRequired(meeting?: any) {
+ public handleServerLocalUnmuteRequired(meeting: any, unmuteAllowed: boolean) {
if (!this.state.client.enabled) {
LoggerProxy.logger.warn(
`Meeting:muteState#handleServerLocalUnmuteRequired --> ${this.type}: localAudioUnmuteRequired received while ${this.type} is disabled -> local unmute will not result in ${this.type} being sent`
);
} else {
LoggerProxy.logger.info(
- `Meeting:muteState#handleServerLocalUnmuteRequired --> ${this.type}: localAudioUnmuteRequired received -> doing local unmute`
+ `Meeting:muteState#handleServerLocalUnmuteRequired --> ${this.type}: localAudioUnmuteRequired received -> doing local unmute (unmuteAllowed=${unmuteAllowed})`
);
}
// todo: I'm seeing "you can now unmute yourself " popup when this happens - but same thing happens on web.w.c so we can ignore for now
this.state.server.remoteMute = false;
+ this.state.server.unmuteAllowed = unmuteAllowed;
+
+ this.applyUnmuteAllowedToStream(meeting);
// change user mute state to false, but keep localMute true if overall mute state is still true
this.muteLocalStream(meeting, false, 'localUnmuteRequired');
diff --git a/packages/@webex/plugin-meetings/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..e93b79bff93 100644
--- a/packages/@webex/plugin-meetings/src/meetings/index.ts
+++ b/packages/@webex/plugin-meetings/src/meetings/index.ts
@@ -155,6 +155,9 @@ export type BasicMeetingInformation = {
};
meetingInfo: any;
sessionCorrelationId: string;
+ roles: string[];
+ getCurUserType: () => string | null;
+ callStateForMetrics: CallStateForMetrics;
};
/**
@@ -1044,48 +1047,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'
+ );
+ });
}
/**
@@ -1136,6 +1146,9 @@ export default class Meetings extends WebexPlugin {
sessionId: meeting.locusInfo?.fullState?.sessionId,
},
},
+ roles: meeting.roles,
+ callStateForMetrics: meeting.callStateForMetrics,
+ getCurUserType: meeting.getCurUserType,
});
this.meetingCollection.delete(meeting.id);
Trigger.trigger(
diff --git a/packages/@webex/plugin-meetings/src/members/index.ts b/packages/@webex/plugin-meetings/src/members/index.ts
index e29cd2d50cb..f709100b3a4 100644
--- a/packages/@webex/plugin-meetings/src/members/index.ts
+++ b/packages/@webex/plugin-meetings/src/members/index.ts
@@ -915,11 +915,12 @@ export default class Members extends StatelessWebexPlugin {
/**
* Lower all hands of members in a meeting
* @param {String} requestingMemberId - id of the participant which requested the lower all hands
+ * @param {array} roles which should be lowered
* @returns {Promise}
* @public
* @memberof Members
*/
- public lowerAllHands(requestingMemberId: string) {
+ public lowerAllHands(requestingMemberId: string, roles: Array) {
if (!this.locusUrl) {
return Promise.reject(
new ParameterError(
@@ -936,7 +937,8 @@ export default class Members extends StatelessWebexPlugin {
}
const options = MembersUtil.generateLowerAllHandsMemberOptions(
requestingMemberId,
- this.locusUrl
+ this.locusUrl,
+ roles
);
return this.membersRequest.lowerAllHandsMember(options);
diff --git a/packages/@webex/plugin-meetings/src/members/util.ts b/packages/@webex/plugin-meetings/src/members/util.ts
index bd491145c0d..930d187b2d7 100644
--- a/packages/@webex/plugin-meetings/src/members/util.ts
+++ b/packages/@webex/plugin-meetings/src/members/util.ts
@@ -46,6 +46,7 @@ const MembersUtil = {
{
address:
options.invitee.emailAddress || options.invitee.email || options.invitee.phoneNumber,
+ ...(options.invitee.roles ? {roles: options.invitee.roles} : {}),
},
],
alertIfActive: options.alertIfActive,
@@ -166,9 +167,10 @@ const MembersUtil = {
locusUrl,
}),
- generateLowerAllHandsMemberOptions: (requestingParticipantId, locusUrl) => ({
+ generateLowerAllHandsMemberOptions: (requestingParticipantId, locusUrl, roles) => ({
requestingParticipantId,
locusUrl,
+ ...(roles !== undefined && {roles}),
}),
/**
@@ -253,6 +255,7 @@ const MembersUtil = {
const body = {
hand: {
raised: false,
+ ...(options.roles !== undefined && {roles: options.roles}),
},
requestingParticipantId: options.requestingParticipantId,
};
diff --git a/packages/@webex/plugin-meetings/src/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..a9472e3a8f1 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 = {
@@ -370,11 +357,14 @@ export class ClusterReachability extends EventsScope {
this.startTimestamp = performance.now();
+ // Set up the state change listeners before triggering the ICE gathering
+ const gatherIceCandidatePromise = this.gatherIceCandidates();
+
// not awaiting the next call on purpose, because we're not sending the offer anywhere and there won't be any answer
// we just need to make this call to trigger the ICE gathering process
this.pc.setLocalDescription(offer);
- await this.gatherIceCandidates();
+ await gatherIceCandidatePromise;
} catch (error) {
LoggerProxy.logger.warn(`Reachability:ClusterReachability#start --> Error: `, error);
}
diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts
index 7b6868f04f0..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);
}
}
@@ -154,19 +147,12 @@ export default class Reachability extends EventsScope {
try {
this.lastTrigger = trigger;
- // kick off ip version detection. For now we don't await it, as we're doing it
- // to gather the timings and send them with our reachability metrics
+ // kick off ip version detection. We don't await it, as we don't want to waste time
+ // and if it fails, that's ok we can still carry on
// @ts-ignore
- this.webex.internal.device.ipNetworkDetector.detect();
+ this.webex.internal.device.ipNetworkDetector.detect(true);
- const {clusters, joinCookie} = await this.getClusters();
-
- // @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/recording-controller/enums.ts b/packages/@webex/plugin-meetings/src/recording-controller/enums.ts
index a6089b59bde..92ebff556c4 100644
--- a/packages/@webex/plugin-meetings/src/recording-controller/enums.ts
+++ b/packages/@webex/plugin-meetings/src/recording-controller/enums.ts
@@ -1,8 +1,11 @@
-enum RecordingAction {
+export enum RecordingAction {
Start = 'Start',
Stop = 'Stop',
Pause = 'Pause',
Resume = 'Resume',
}
-export default RecordingAction;
+export enum RecordingType {
+ Premise = 'premise',
+ Cloud = 'cloud',
+}
diff --git a/packages/@webex/plugin-meetings/src/recording-controller/index.ts b/packages/@webex/plugin-meetings/src/recording-controller/index.ts
index 5ffabd358be..e71f56db767 100644
--- a/packages/@webex/plugin-meetings/src/recording-controller/index.ts
+++ b/packages/@webex/plugin-meetings/src/recording-controller/index.ts
@@ -1,9 +1,9 @@
import PermissionError from '../common/errors/permission';
+import LoggerProxy from '../common/logs/logger-proxy';
import {CONTROLS, HTTP_VERBS, SELF_POLICY} from '../constants';
import MeetingRequest from '../meeting/request';
-import RecordingAction from './enums';
+import {RecordingAction, RecordingType} from './enums';
import Util from './util';
-import LoggerProxy from '../common/logs/logger-proxy';
/**
* @description Recording manages the recording functionality of the meeting object, there should only be one instantation of recording per meeting
@@ -228,11 +228,12 @@ export default class RecordingController {
/**
* @param {RecordingAction} action
+ * @param {RecordingType} recordingType
* @private
* @memberof RecordingController
* @returns {Promise}
*/
- private recordingService(action: RecordingAction): Promise {
+ private recordingService(action: RecordingAction, recordingType: RecordingType): Promise {
// @ts-ignore
return this.request.request({
body: {
@@ -242,6 +243,7 @@ export default class RecordingController {
recording: {
action: action.toLowerCase(),
},
+ recordingType,
},
uri: `${this.serviceUrl}/loci/${this.locusId}/recording`,
method: HTTP_VERBS.PUT,
@@ -276,14 +278,25 @@ export default class RecordingController {
* @returns {Promise}
*/
private recordingFacade(action: RecordingAction): Promise {
+ const isPremiseRecordingEnabled = Util.isPremiseRecordingEnabled(
+ this.displayHints,
+ this.selfUserPolicies
+ );
LoggerProxy.logger.log(
`RecordingController:index#recordingFacade --> recording action [${action}]`
);
+ let recordingType: RecordingType;
+ if (isPremiseRecordingEnabled) {
+ recordingType = RecordingType.Premise;
+ } else {
+ recordingType = RecordingType.Cloud;
+ }
+
// assumes action is proper cased (i.e., Example)
if (Util?.[`canUser${action}`](this.displayHints, this.selfUserPolicies)) {
if (this.serviceUrl) {
- return this.recordingService(action);
+ return this.recordingService(action, recordingType);
}
return this.recordingControls(action);
diff --git a/packages/@webex/plugin-meetings/src/recording-controller/util.ts b/packages/@webex/plugin-meetings/src/recording-controller/util.ts
index 807decd1aa6..88a242bde8d 100644
--- a/packages/@webex/plugin-meetings/src/recording-controller/util.ts
+++ b/packages/@webex/plugin-meetings/src/recording-controller/util.ts
@@ -1,33 +1,47 @@
import {DISPLAY_HINTS, SELF_POLICY} from '../constants';
-import RecordingAction from './enums';
+import {RecordingAction} from './enums';
import MeetingUtil from '../meeting/util';
const canUserStart = (
displayHints: Array,
userPolicies: Record
): boolean =>
- displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_START) &&
+ (displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_START) ||
+ displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_START)) &&
MeetingUtil.selfSupportsFeature(SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD, userPolicies);
const canUserPause = (
displayHints: Array,
userPolicies: Record
): boolean =>
- displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_PAUSE) &&
+ (displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_PAUSE) ||
+ displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_PAUSE)) &&
MeetingUtil.selfSupportsFeature(SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD, userPolicies);
const canUserResume = (
displayHints: Array,
userPolicies: Record
): boolean =>
- displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_RESUME) &&
+ (displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_RESUME) ||
+ displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_RESUME)) &&
MeetingUtil.selfSupportsFeature(SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD, userPolicies);
const canUserStop = (
displayHints: Array,
userPolicies: Record
): boolean =>
- displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_STOP) &&
+ (displayHints.includes(DISPLAY_HINTS.RECORDING_CONTROL_STOP) ||
+ displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_STOP)) &&
+ MeetingUtil.selfSupportsFeature(SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD, userPolicies);
+
+const isPremiseRecordingEnabled = (
+ displayHints: Array,
+ userPolicies: Record
+): boolean =>
+ (displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_START) ||
+ displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_PAUSE) ||
+ displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_STOP) ||
+ displayHints.includes(DISPLAY_HINTS.PREMISE_RECORDING_CONTROL_RESUME)) &&
MeetingUtil.selfSupportsFeature(SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD, userPolicies);
const extractLocusId = (url: string) => {
@@ -70,6 +84,7 @@ export default {
canUserPause,
canUserResume,
canUserStop,
+ isPremiseRecordingEnabled,
deriveRecordingStates,
extractLocusId,
};
diff --git a/packages/@webex/plugin-meetings/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/src/webinar/index.ts b/packages/@webex/plugin-meetings/src/webinar/index.ts
index aa2016541f6..f185161d8b6 100644
--- a/packages/@webex/plugin-meetings/src/webinar/index.ts
+++ b/packages/@webex/plugin-meetings/src/webinar/index.ts
@@ -2,9 +2,11 @@
* Copyright (c) 2015-2023 Cisco Systems, Inc. See LICENSE file.
*/
import {WebexPlugin} from '@webex/webex-core';
-import {MEETINGS} from '../constants';
+import {get} from 'lodash';
+import {HTTP_VERBS, MEETINGS, SELF_ROLES} from '../constants';
import WebinarCollection from './collection';
+import LoggerProxy from '../common/logs/logger-proxy';
/**
* @class Webinar
@@ -17,14 +19,16 @@ const Webinar = WebexPlugin.extend({
props: {
locusUrl: 'string', // appears current webinar's locus url
- webcastUrl: 'string', // current webinar's webcast url
- webinarAttendeesSearchingUrl: 'string', // current webinarAttendeesSearching url
+ webcastInstanceUrl: 'string', // current webinar's webcast instance url
canManageWebcast: 'boolean', // appears the ability to manage webcast
+ selfIsPanelist: 'boolean', // self is panelist
+ selfIsAttendee: 'boolean', // self is attendee
+ practiceSessionEnabled: 'boolean', // practice session enabled
},
/**
* Update the current locus url of the webinar
- * @param {string} locusUrl // locus url
+ * @param {string} locusUrl
* @returns {void}
*/
locusUrlUpdate(locusUrl) {
@@ -32,30 +36,72 @@ const Webinar = WebexPlugin.extend({
},
/**
- * Update the current webcast url of the meeting
- * @param {string} webcastUrl // webcast url
+ * Update the current webcast instance url of the meeting
+ * @param {object} payload
* @returns {void}
*/
- webcastUrlUpdate(webcastUrl) {
- this.set('webcastUrl', webcastUrl);
+ updateWebcastUrl(payload) {
+ this.set('webcastInstanceUrl', get(payload, 'resources.webcastInstance.url'));
},
/**
- * Update the current webinarAttendeesSearching url of the meeting
- * @param {string} webinarAttendeesSearchingUrl // webinarAttendeesSearching url
+ * Update whether self has capability to manage start/stop webcast (only host can manage it)
+ * @param {boolean} canManageWebcast
* @returns {void}
*/
- webinarAttendeesSearchingUrlUpdate(webinarAttendeesSearchingUrl) {
- this.set('webinarAttendeesSearchingUrl', webinarAttendeesSearchingUrl);
+ updateCanManageWebcast(canManageWebcast) {
+ this.set('canManageWebcast', canManageWebcast);
},
/**
- * Update whether self has capability to manage start/stop webcast (only host can manage it)
- * @param {boolean} canManageWebcast
+ * Updates user roles and manages associated state transitions
+ * @param {object} payload
+ * @param {string[]} payload.oldRoles - Previous roles of the user
+ * @param {string[]} payload.newRoles - New roles of the user
+ * @returns {{isPromoted: boolean, isDemoted: boolean}} Role transition states
+ */
+ updateRoleChanged(payload) {
+ const oldRoles = get(payload, 'oldRoles', []);
+ const newRoles = get(payload, 'newRoles', []);
+
+ const isPromoted =
+ oldRoles.includes(SELF_ROLES.ATTENDEE) && newRoles.includes(SELF_ROLES.PANELIST);
+ const isDemoted =
+ oldRoles.includes(SELF_ROLES.PANELIST) && newRoles.includes(SELF_ROLES.ATTENDEE);
+ this.set('selfIsPanelist', newRoles.includes(SELF_ROLES.PANELIST));
+ this.set('selfIsAttendee', newRoles.includes(SELF_ROLES.ATTENDEE));
+ this.updateCanManageWebcast(newRoles.includes(SELF_ROLES.MODERATOR));
+
+ return {isPromoted, isDemoted};
+ },
+
+ /**
+ * start or stop practice session for webinar
+ * @param {boolean} enabled
+ * @returns {Promise}
+ */
+ setPracticeSessionState(enabled) {
+ return this.request({
+ method: HTTP_VERBS.PATCH,
+ uri: `${this.locusUrl}/controls`,
+ body: {
+ practiceSession: {
+ enabled,
+ },
+ },
+ }).catch((error) => {
+ LoggerProxy.logger.error('Meeting:webinar#setPracticeSessionState failed', error);
+ throw error;
+ });
+ },
+
+ /**
+ * update practice session status
+ * @param {object} payload
* @returns {void}
*/
- updateCanManageWebcast(canManageWebcast) {
- this.set('canManageWebcast', canManageWebcast);
+ updatePracticeSessionStatus(payload) {
+ this.set('practiceSessionEnabled', payload.enabled);
},
});
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/index.js b/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/index.js
index 4b0e84e0be6..f78893badee 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/index.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/index.js
@@ -22,7 +22,7 @@ describe('plugin-meetings', () => {
describe('Mute On Entry', () => {
let manager;
-
+
beforeEach(() => {
request = {
request: sinon.stub().returns(Promise.resolve()),
@@ -37,85 +37,85 @@ describe('plugin-meetings', () => {
});
describe('setMuteOnEntry', () => {
- it('rejects when correct display hint is not present enabled=false', () => {
+ it('rejects when correct display hint is not present enabled=false', () => {
const result = manager.setMuteOnEntry(false);
-
+
assert.notCalled(request.request);
-
+
assert.isRejected(result);
});
- it('rejects when correct display hint is not present enabled=true', () => {
+ it('rejects when correct display hint is not present enabled=true', () => {
const result = manager.setMuteOnEntry(true);
-
+
assert.notCalled(request.request);
-
+
assert.isRejected(result);
});
-
+
it('can set mute on entry when the display hint is available enabled=true', () => {
manager.setDisplayHints(['ENABLE_MUTE_ON_ENTRY']);
-
+
const result = manager.setMuteOnEntry(true);
-
+
assert.calledWith(request.request, { uri: 'test/id/controls',
body: { muteOnEntry: { enabled: true } },
method: HTTP_VERBS.PATCH});
-
+
assert.deepEqual(result, request.request.firstCall.returnValue);
});
it('can set mute on entry when the display hint is available enabled=false', () => {
manager.setDisplayHints(['DISABLE_MUTE_ON_ENTRY']);
-
+
const result = manager.setMuteOnEntry(false);
-
+
assert.calledWith(request.request, { uri: 'test/id/controls',
body: { muteOnEntry: { enabled: false } },
method: HTTP_VERBS.PATCH});
-
+
assert.deepEqual(result, request.request.firstCall.returnValue);
});
});
describe('setDisallowUnmute', () => {
- it('rejects when correct display hint is not present enabled=false', () => {
+ it('rejects when correct display hint is not present enabled=false', () => {
const result = manager.setDisallowUnmute(false);
-
+
assert.notCalled(request.request);
-
+
assert.isRejected(result);
});
- it('rejects when correct display hint is not present enabled=true', () => {
+ it('rejects when correct display hint is not present enabled=true', () => {
const result = manager.setDisallowUnmute(true);
-
+
assert.notCalled(request.request);
-
+
assert.isRejected(result);
});
-
- it('can set mute on entry when the display hint is available enabled=true', () => {
+
+ it('can set disallow unmute when ENABLE_HARD_MUTE display hint is available', () => {
manager.setDisplayHints(['ENABLE_HARD_MUTE']);
-
+
const result = manager.setDisallowUnmute(true);
-
+
assert.calledWith(request.request, { uri: 'test/id/controls',
body: { disallowUnmute: { enabled: true } },
method: HTTP_VERBS.PATCH});
-
+
assert.deepEqual(result, request.request.firstCall.returnValue);
});
- it('can set mute on entry when the display hint is available enabled=false', () => {
+ it('can set allow unmute when DISABLE_HARD_MUTE display hint is available', () => {
manager.setDisplayHints(['DISABLE_HARD_MUTE']);
-
+
const result = manager.setDisallowUnmute(false);
-
+
assert.calledWith(request.request, { uri: 'test/id/controls',
body: { disallowUnmute: { enabled: false } },
method: HTTP_VERBS.PATCH});
-
+
assert.deepEqual(result, request.request.firstCall.returnValue);
});
});
@@ -218,7 +218,7 @@ describe('plugin-meetings', () => {
})
});
- it('rejects when correct display hint is not present mutedEnabled=false', () => {
+ it('rejects when correct display hint is not present mutedEnabled=false', () => {
const result = manager.setMuteAll(false, false, false);
assert.notCalled(request.request);
@@ -226,7 +226,7 @@ describe('plugin-meetings', () => {
assert.isRejected(result);
});
- it('rejects when correct display hint is not present mutedEnabled=true', () => {
+ it('rejects when correct display hint is not present mutedEnabled=true', () => {
const result = manager.setMuteAll(true, false, false);
assert.notCalled(request.request);
@@ -281,7 +281,31 @@ describe('plugin-meetings', () => {
assert.deepEqual(result, request.request.firstCall.returnValue);
});
+
+ it('can set mute all panelists when the display hint is available mutedEnabled=true', () => {
+ manager.setDisplayHints(['MUTE_ALL', 'DISABLE_HARD_MUTE', 'DISABLE_MUTE_ON_ENTRY']);
+
+ const result = manager.setMuteAll(true, true, true, ['panelist']);
+
+ assert.calledWith(request.request, { uri: 'test/id/controls',
+ body: { audio: { muted: true, disallowUnmute: true, muteOnEntry: true, roles: ['panelist'] } },
+ method: HTTP_VERBS.PATCH});
+
+ assert.deepEqual(result, request.request.firstCall.returnValue);
+ });
+
+ it('can set mute all attendees when the display hint is available mutedEnabled=true', () => {
+ manager.setDisplayHints(['MUTE_ALL', 'DISABLE_HARD_MUTE', 'DISABLE_MUTE_ON_ENTRY']);
+
+ const result = manager.setMuteAll(true, true, true, ['attendee']);
+
+ assert.calledWith(request.request, { uri: 'test/id/controls',
+ body: { audio: { muted: true, disallowUnmute: true, muteOnEntry: true, roles: ['attendee'] } },
+ method: HTTP_VERBS.PATCH});
+
+ assert.deepEqual(result, request.request.firstCall.returnValue);
+ });
});
});
});
-});
\ No newline at end of file
+});
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/util.js b/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/util.js
index 375cf02487b..083c4399e4e 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/util.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/controls-options-manager/util.js
@@ -348,6 +348,50 @@ describe('plugin-meetings', () => {
});
});
+ it('should call hasHints() with proper hints when `panelistEnabled` is true, attendeeCount is false', () => {
+ ControlsOptionsUtil.canUpdateViewTheParticipantsList({properties: {enabled: true, panelistEnabled: true, attendeeCount: false}}, []);
+
+ assert.calledWith(ControlsOptionsUtil.hasHints, {
+ requiredHints: [DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST,
+ DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST,
+ DISPLAY_HINTS.DISABLE_SHOW_ATTENDEE_COUNT],
+ displayHints: [],
+ });
+ });
+
+ it('should call hasHints() with proper hints when `panelistEnabled` is true, attendeeCount is true', () => {
+ ControlsOptionsUtil.canUpdateViewTheParticipantsList({properties: {enabled: true, panelistEnabled: true, attendeeCount: true}}, []);
+
+ assert.calledWith(ControlsOptionsUtil.hasHints, {
+ requiredHints: [DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST,
+ DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST,
+ DISPLAY_HINTS.ENABLE_SHOW_ATTENDEE_COUNT],
+ displayHints: [],
+ });
+ });
+
+ it('should call hasHints() with proper hints when `panelistEnabled` is false, attendeeCount is false', () => {
+ ControlsOptionsUtil.canUpdateViewTheParticipantsList({properties: {enabled: true, panelistEnabled: false, attendeeCount: false}}, []);
+
+ assert.calledWith(ControlsOptionsUtil.hasHints, {
+ requiredHints: [DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST,
+ DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST,
+ DISPLAY_HINTS.DISABLE_SHOW_ATTENDEE_COUNT],
+ displayHints: [],
+ });
+ });
+
+ it('should call hasHints() with proper hints when `panelistEnabled` is false, attendeeCount is true', () => {
+ ControlsOptionsUtil.canUpdateViewTheParticipantsList({properties: {enabled: true, panelistEnabled: false, attendeeCount: true}}, []);
+
+ assert.calledWith(ControlsOptionsUtil.hasHints, {
+ requiredHints: [DISPLAY_HINTS.ENABLE_VIEW_THE_PARTICIPANT_LIST,
+ DISPLAY_HINTS.DISABLE_VIEW_THE_PARTICIPANT_LIST_PANELIST,
+ DISPLAY_HINTS.ENABLE_SHOW_ATTENDEE_COUNT],
+ displayHints: [],
+ });
+ });
+
it('should return the resolution of hasHints()', () => {
const expected = 'example-return-value';
ControlsOptionsUtil.hasHints.returns(expected);
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js
index 92249d2a4bb..38717f39ad1 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js
@@ -82,11 +82,13 @@ describe('plugin-meetings', () => {
});
it('should parse the viewTheParticipantList control', () => {
- const newControls = {viewTheParticipantList: {enabled: true}};
+ const newControls = {viewTheParticipantList: {enabled: true, panelistEnabled: true, attendeeCount: false}};
const parsedControls = ControlsUtils.parse(newControls);
assert.equal(parsedControls.viewTheParticipantList.enabled, newControls.viewTheParticipantList.enabled);
+ assert.equal(parsedControls.viewTheParticipantList.panelistEnabled, newControls.viewTheParticipantList.panelistEnabled);
+ assert.equal(parsedControls.viewTheParticipantList.attendeeCount, newControls.viewTheParticipantList.attendeeCount);
});
it('should parse the raiseHand control', () => {
@@ -105,6 +107,42 @@ describe('plugin-meetings', () => {
assert.equal(parsedControls.video.enabled, newControls.video.enabled);
});
+ it('should parse the webcast control', () => {
+ const newControls = {webcastControl: {streaming: true}};
+
+ const parsedControls = ControlsUtils.parse(newControls);
+
+ assert.equal(parsedControls.webcastControl.streaming, newControls.webcastControl.streaming);
+ });
+
+ it('should parse the meeting full control', () => {
+ const newControls = {meetingFull: {meetingFull: true, meetingPanelistFull: false}};
+
+ const parsedControls = ControlsUtils.parse(newControls);
+
+ assert.equal(parsedControls.meetingFull.meetingFull, newControls.meetingFull.meetingFull);
+ assert.equal(parsedControls.meetingFull.meetingPanelistFull, newControls.meetingFull.meetingPanelistFull);
+ });
+
+ it('should parse the practiceSession control', () => {
+ const newControls = {practiceSession: {enabled: true}};
+
+ const parsedControls = ControlsUtils.parse(newControls);
+
+ assert.equal(parsedControls.practiceSession.enabled, newControls.practiceSession.enabled);
+ });
+
+ it('should parse the videoLayout control', () => {
+ const newControls = {videoLayout: {overrideDefault: true, lockAttendeeViewOnStageOnly:false, stageParameters: {}}};
+
+ const parsedControls = ControlsUtils.parse(newControls);
+
+ assert.equal(parsedControls.videoLayout.overrideDefault, newControls.videoLayout.overrideDefault);
+ assert.equal(parsedControls.videoLayout.lockAttendeeViewOnStageOnly, newControls.videoLayout.lockAttendeeViewOnStageOnly);
+ assert.equal(parsedControls.videoLayout.stageParameters, newControls.videoLayout.stageParameters);
+
+ });
+
describe('videoEnabled', () => {
it('returns expected', () => {
const result = ControlsUtils.parse({video: {enabled: true}});
@@ -170,11 +208,21 @@ describe('plugin-meetings', () => {
});
it('returns hasViewTheParticipantListChanged = true when changed', () => {
- const newControls = {viewTheParticipantList: {enabled: true}};
+ const oldControls = {viewTheParticipantList: {enabled: true, panelistEnabled: true, attendeeCount: false}};
- const {updates} = ControlsUtils.getControls(defaultControls, newControls);
+ let result = ControlsUtils.getControls(oldControls, {viewTheParticipantList: {enabled: false, panelistEnabled: true, attendeeCount: false}});
+
+ assert.equal(result.updates.hasViewTheParticipantListChanged, true);
+
+ result = ControlsUtils.getControls(oldControls, {viewTheParticipantList: {enabled: true, panelistEnabled: false, attendeeCount: false}});
- assert.equal(updates.hasViewTheParticipantListChanged, true);
+ assert.equal(result.updates.hasViewTheParticipantListChanged, true);
+ result = ControlsUtils.getControls(oldControls, {viewTheParticipantList: {enabled: true, panelistEnabled: true, attendeeCount: true}});
+
+ assert.equal(result.updates.hasViewTheParticipantListChanged, true);
+ result = ControlsUtils.getControls(oldControls, {viewTheParticipantList: {enabled: true, panelistEnabled: true, attendeeCount: false}});
+
+ assert.equal(result.updates.hasViewTheParticipantListChanged, false);
});
it('returns hasRaiseHandChanged = true when changed', () => {
@@ -193,6 +241,34 @@ describe('plugin-meetings', () => {
assert.equal(updates.hasVideoChanged, true);
});
+ it('returns hasWebcastChanged = true when changed', () => {
+ const newControls = {webcastControl: {streaming: true}};
+
+ const {updates} = ControlsUtils.getControls(defaultControls, newControls);
+
+ assert.equal(updates.hasWebcastChanged, true);
+ });
+
+ it('returns hasMeetingFullChanged = true when changed', () => {
+ const newControls = {meetingFull: {meetingFull: true, meetingPanelistFull: false}};
+
+ let result = ControlsUtils.getControls(defaultControls, newControls);
+
+ assert.equal(result.updates.hasMeetingFullChanged, true);
+
+ result = ControlsUtils.getControls(newControls, {meetingFull: {meetingFull: true, meetingPanelistFull: true}});
+
+ assert.equal(result.updates.hasMeetingFullChanged, true);
+ });
+
+ it('returns hasPracticeSessionEnabledChanged = true when changed', () => {
+ const newControls = {practiceSession: {enabled: true}};
+
+ const {updates} = ControlsUtils.getControls(defaultControls, newControls);
+
+ assert.equal(updates.hasPracticeSessionEnabledChanged, true);
+ });
+
it('returns hasEntryExitToneChanged = true when mode changed', () => {
const newControls = {
entryExitTone: {
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js
index 599f5a6f389..e9c012ba2b4 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js
@@ -81,7 +81,7 @@ describe('plugin-meetings', () => {
newControls = {
disallowUnmute: {enabled: true},
lock: {},
- meetingFull: {},
+ meetingFull: {meetingFull: false, meetingPanelistFull: true},
muteOnEntry: {enabled: true},
raiseHand: {enabled: true},
reactions: {enabled: true, showDisplayNameWithReactions: true},
@@ -95,12 +95,15 @@ describe('plugin-meetings', () => {
},
shareControl: {control: 'example-value'},
transcribe: {},
- viewTheParticipantList: {enabled: true},
+ viewTheParticipantList: {enabled: true, panelistEnabled: true, attendeeCount: false},
meetingContainer: {
meetingContainerUrl: 'http://new-url.com',
},
entryExitTone: {enabled: true, mode: 'foo'},
video: {enabled: true},
+ videoLayout: {overrideDefault: true, lockAttendeeViewOnStageOnly:false, stageParameters: {}},
+ webcastControl: {streaming: false},
+ practiceSession: {enabled: true},
};
});
@@ -205,6 +208,58 @@ describe('plugin-meetings', () => {
);
});
+ it('should trigger the CONTROLS_STAGE_VIEW_UPDATED event when necessary', () => {
+ locusInfo.controls = {};
+ locusInfo.emitScoped = sinon.stub();
+ locusInfo.updateControls(newControls);
+
+ assert.calledWith(
+ locusInfo.emitScoped,
+ {file: 'locus-info', function: 'updateControls'},
+ LOCUSINFO.EVENTS.CONTROLS_STAGE_VIEW_UPDATED,
+ {state: newControls.videoLayout}
+ );
+ });
+
+ it('should trigger the CONTROLS_WEBCAST_CHANGED event when necessary', () => {
+ locusInfo.controls = {};
+ locusInfo.emitScoped = sinon.stub();
+ locusInfo.updateControls(newControls);
+
+ assert.calledWith(
+ locusInfo.emitScoped,
+ {file: 'locus-info', function: 'updateControls'},
+ LOCUSINFO.EVENTS.CONTROLS_WEBCAST_CHANGED,
+ {state: newControls.webcastControl}
+ );
+ });
+
+ it('should trigger the CONTROLS_MEETING_FULL_CHANGED event when necessary', () => {
+ locusInfo.controls = {};
+ locusInfo.emitScoped = sinon.stub();
+ locusInfo.updateControls(newControls);
+
+ assert.calledWith(
+ locusInfo.emitScoped,
+ {file: 'locus-info', function: 'updateControls'},
+ LOCUSINFO.EVENTS.CONTROLS_MEETING_FULL_CHANGED,
+ {state: newControls.meetingFull}
+ );
+ });
+
+ it('should trigger the CONTROLS_PRACTICE_SESSION_STATUS_UPDATED event when necessary', () => {
+ locusInfo.controls = {};
+ locusInfo.emitScoped = sinon.stub();
+ locusInfo.updateControls(newControls);
+
+ assert.calledWith(
+ locusInfo.emitScoped,
+ {file: 'locus-info', function: 'updateControls'},
+ LOCUSINFO.EVENTS.CONTROLS_PRACTICE_SESSION_STATUS_UPDATED,
+ {state: newControls.practiceSession}
+ );
+ });
+
it('should not trigger the CONTROLS_RECORDING_UPDATED event', () => {
locusInfo.controls = {};
locusInfo.emitScoped = sinon.stub();
@@ -1729,6 +1784,7 @@ describe('plugin-meetings', () => {
locusInfo.updateMemberShip = sinon.stub();
locusInfo.updateIdentifiers = sinon.stub();
locusInfo.updateEmbeddedApps = sinon.stub();
+ locusInfo.updateResources = sinon.stub();
locusInfo.compareAndUpdate = sinon.stub();
locusInfo.updateLocusInfo(newLocus);
@@ -1750,6 +1806,7 @@ describe('plugin-meetings', () => {
assert.notCalled(locusInfo.updateMemberShip);
assert.notCalled(locusInfo.updateIdentifiers);
assert.notCalled(locusInfo.updateEmbeddedApps);
+ assert.notCalled(locusInfo.updateResources);
assert.notCalled(locusInfo.compareAndUpdate);
});
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts b/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts
index 9fe27480635..bd673f7acb6 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts
+++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts
@@ -33,6 +33,7 @@ describe('plugin-meetings', () => {
canStartManualCaption: null,
canStopManualCaption: null,
isManualCaptionActive: null,
+ isPremiseRecordingEnabled: null,
isSaveTranscriptsEnabled: null,
isWebexAssistantActive: null,
canViewCaptionPanel: null,
@@ -60,6 +61,10 @@ describe('plugin-meetings', () => {
canUpdateShareControl: null,
canEnableViewTheParticipantsList: null,
canDisableViewTheParticipantsList: null,
+ canEnableViewTheParticipantsListPanelist: null,
+ canDisableViewTheParticipantsListPanelist: null,
+ canEnableShowAttendeeCount: null,
+ canDisableShowAttendeeCount: null,
canEnableRaiseHand: null,
canDisableRaiseHand: null,
canEnableVideo: null,
@@ -79,6 +84,16 @@ describe('plugin-meetings', () => {
canShareWhiteBoard: null,
enforceVirtualBackground: null,
canPollingAndQA: null,
+ canStartWebcast: null,
+ canStopWebcast: null,
+ canShowStageView: null,
+ canEnableStageView: null,
+ canDisableStageView: null,
+ isPracticeSessionOn : null,
+ isPracticeSessionOff : null,
+ canStartPracticeSession: null,
+ canStopPracticeSession: null,
+
...expected,
};
@@ -117,6 +132,7 @@ describe('plugin-meetings', () => {
'canStartManualCaption',
'canStopManualCaption',
'isManualCaptionActive',
+ 'isPremiseRecordingEnabled',
'isSaveTranscriptsEnabled',
'isWebexAssistantActive',
'canViewCaptionPanel',
@@ -144,6 +160,10 @@ describe('plugin-meetings', () => {
'canUpdateShareControl',
'canEnableViewTheParticipantsList',
'canDisableViewTheParticipantsList',
+ 'canEnableViewTheParticipantsListPanelist',
+ 'canDisableViewTheParticipantsListPanelist',
+ 'canEnableShowAttendeeCount',
+ 'canDisableShowAttendeeCount',
'canEnableRaiseHand',
'canDisableRaiseHand',
'canEnableVideo',
@@ -163,7 +183,17 @@ describe('plugin-meetings', () => {
'canShareWhiteBoard',
'enforceVirtualBackground',
'canPollingAndQA',
- ].forEach((key) => {
+ 'canStartWebcast',
+ 'canStopWebcast',
+ 'canShowStageView',
+ 'canEnableStageView',
+ 'canDisableStageView',
+ 'isPracticeSessionOn',
+ 'isPracticeSessionOff',
+ 'canStartPracticeSession',
+ 'canStopPracticeSession',
+
+ ].forEach((key) => {
it(`get and set for ${key} work as expected`, () => {
const inMeetingActions = new InMeetingActions();
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js
index 87dee5f9ceb..71a930a9253 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()
@@ -3442,47 +3496,60 @@ describe('plugin-meetings', () => {
});
});
- it('should pass bundlePolicy to createMediaConnection', async () => {
+ describe('bundlePolicy', () => {
const FAKE_TURN_URL = 'turns:webex.com:3478';
const FAKE_TURN_USER = 'some-turn-username';
const FAKE_TURN_PASSWORD = 'some-password';
- meeting.meetingState = 'ACTIVE';
- Media.createMediaConnection.resetHistory();
-
- meeting.roap.doTurnDiscovery = sinon.stub().resolves({
- turnServerInfo: {
- url: FAKE_TURN_URL,
- username: FAKE_TURN_USER,
- password: FAKE_TURN_PASSWORD,
- },
- turnDiscoverySkippedReason: undefined,
- });
- const media = meeting.addMedia({
- mediaSettings: {},
- bundlePolicy: 'bundlePolicy-value',
- });
+ beforeEach(() => {
+ meeting.meetingState = 'ACTIVE';
+ Media.createMediaConnection.resetHistory();
- assert.exists(media);
- await media;
- assert.calledOnce(meeting.roap.doTurnDiscovery);
- assert.calledWith(meeting.roap.doTurnDiscovery, meeting, false);
- assert.calledOnce(Media.createMediaConnection);
- assert.calledWith(
- Media.createMediaConnection,
- false,
- meeting.getMediaConnectionDebugId(),
- meeting.id,
- sinon.match({
+ meeting.roap.doTurnDiscovery = sinon.stub().resolves({
turnServerInfo: {
url: FAKE_TURN_URL,
username: FAKE_TURN_USER,
password: FAKE_TURN_PASSWORD,
},
- bundlePolicy: 'bundlePolicy-value',
- })
- );
- assert.calledOnce(fakeMediaConnection.initiateOffer);
+ turnDiscoverySkippedReason: undefined,
+ });
+ });
+
+ const runCheck = async (bundlePolicy, expectedValue) => {
+ const media = meeting.addMedia({
+ mediaSettings: {},
+ bundlePolicy,
+ });
+
+ assert.exists(media);
+ await media;
+ assert.calledOnce(meeting.roap.doTurnDiscovery);
+ assert.calledWith(meeting.roap.doTurnDiscovery, meeting, false);
+ assert.calledOnce(Media.createMediaConnection);
+ assert.calledWith(
+ Media.createMediaConnection,
+ false,
+ meeting.getMediaConnectionDebugId(),
+ meeting.id,
+ sinon.match({
+ turnServerInfo: {
+ url: FAKE_TURN_URL,
+ username: FAKE_TURN_USER,
+ password: FAKE_TURN_PASSWORD,
+ },
+ bundlePolicy: expectedValue,
+ })
+ );
+ assert.calledOnce(fakeMediaConnection.initiateOffer);
+ };
+
+ it('should pass bundlePolicy to createMediaConnection', async () => {
+ await runCheck('max-compat', 'max-compat');
+ });
+
+ it('should pass max-bundle to createMediaConnection if bundlePolicy is not provided', async () => {
+ await runCheck(undefined, 'max-bundle');
+ });
});
it('succeeds even if getDevices() throws', async () => {
@@ -3676,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',
@@ -3795,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});
});
@@ -3861,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`,
@@ -3874,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',
},
},
});
@@ -3902,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',
},
],
@@ -3934,10 +4002,6 @@ describe('plugin-meetings', () => {
mediaId: 'fake media id',
},
],
- clientMediaPreferences: {
- preferTranscoding: !meeting.isMultistream,
- ipver: undefined,
- },
respOnlySdp: true,
usingResource: null,
},
@@ -3993,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,
@@ -4006,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,
@@ -4019,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,
@@ -4032,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,
@@ -4327,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 () => {
@@ -4403,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', () => {
@@ -6263,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
+ );
});
});
@@ -6985,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: '',
+ });
});
});
@@ -7657,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(() => {
@@ -7731,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});
@@ -7917,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) => {
@@ -7992,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) => {
@@ -8025,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', () => {
@@ -8569,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) => {
@@ -8590,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();
});
@@ -8885,6 +9007,81 @@ describe('plugin-meetings', () => {
);
});
+ it('listens to MEETING_CONTROLS_WEBCAST_UPDATED', async () => {
+ const state = {example: 'value'};
+
+ await meeting.locusInfo.emitScoped(
+ {function: 'test', file: 'test'},
+ LOCUSINFO.EVENTS.CONTROLS_WEBCAST_CHANGED,
+ {state}
+ );
+
+ assert.calledWith(
+ TriggerProxy.trigger,
+ meeting,
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
+ EVENT_TRIGGERS.MEETING_CONTROLS_WEBCAST_UPDATED,
+ {state}
+ );
+ });
+
+ it('listens to MEETING_CONTROLS_MEETING_FULL_UPDATED', async () => {
+ const state = {example: 'value'};
+
+ await meeting.locusInfo.emitScoped(
+ {function: 'test', file: 'test'},
+ LOCUSINFO.EVENTS.CONTROLS_MEETING_FULL_CHANGED,
+ {state}
+ );
+
+ assert.calledWith(
+ TriggerProxy.trigger,
+ meeting,
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
+ EVENT_TRIGGERS.MEETING_CONTROLS_MEETING_FULL_UPDATED,
+ {state}
+ );
+ });
+
+ it('listens to MEETING_CONTROLS_PRACTICE_SESSION_STATUS_UPDATED', async () => {
+ meeting.webinar.updatePracticeSessionStatus = sinon.stub();
+
+ const state = {example: 'value'};
+
+ await meeting.locusInfo.emitScoped(
+ {function: 'test', file: 'test'},
+ LOCUSINFO.EVENTS.CONTROLS_PRACTICE_SESSION_STATUS_UPDATED,
+ {state}
+ );
+
+ assert.calledOnceWithExactly( meeting.webinar.updatePracticeSessionStatus, state);
+ assert.calledWith(
+ TriggerProxy.trigger,
+ meeting,
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
+ EVENT_TRIGGERS.MEETING_CONTROLS_PRACTICE_SESSION_STATUS_UPDATED,
+ {state}
+ );
+ });
+
+ it('listens to MEETING_CONTROLS_STAGE_VIEW_UPDATED', async () => {
+ const state = {example: 'value'};
+
+ await meeting.locusInfo.emitScoped(
+ {function: 'test', file: 'test'},
+ LOCUSINFO.EVENTS.CONTROLS_STAGE_VIEW_UPDATED,
+ {state}
+ );
+
+ assert.calledWith(
+ TriggerProxy.trigger,
+ meeting,
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
+ EVENT_TRIGGERS.MEETING_CONTROLS_STAGE_VIEW_UPDATED,
+ {state}
+ );
+ });
+
it('listens to MEETING_CONTROLS_VIDEO_UPDATED', async () => {
const state = {example: 'value'};
@@ -8998,12 +9195,6 @@ describe('plugin-meetings', () => {
approval: {
url: 'url',
},
- webcast: {
- url: 'url',
- },
- webinarAttendeesSearching: {
- url: 'url',
- },
},
};
@@ -9017,10 +9208,6 @@ describe('plugin-meetings', () => {
meeting.simultaneousInterpretation = {
approvalUrlUpdate: sinon.stub().returns(undefined),
};
- meeting.webinar = {
- webcastUrlUpdate: sinon.stub().returns(undefined),
- webinarAttendeesSearchingUrlUpdate: sinon.stub().returns(undefined),
- };
meeting.locusInfo.emit(
{function: 'test', file: 'test'},
@@ -9040,19 +9227,37 @@ describe('plugin-meetings', () => {
meeting.simultaneousInterpretation.approvalUrlUpdate,
newLocusServices.services.approval.url
);
- assert.calledWith(
- meeting.webinar.webcastUrlUpdate,
- newLocusServices.services.webcast.url
- );
- assert.calledWith(
- meeting.webinar.webinarAttendeesSearchingUrlUpdate,
- newLocusServices.services.webinarAttendeesSearching.url
- );
assert.calledOnce(meeting.recordingController.setSessionId);
done();
});
});
+ describe('#setUpLocusResourcesListener', () => {
+ it('listens to the locus resources update event', (done) => {
+ const newLocusResources = {
+ resources: {
+ webcastInstance: {
+ url: 'url',
+ },
+ },
+ };
+
+ meeting.webinar = {
+ updateWebcastUrl: sinon.stub().returns(undefined),
+ };
+
+ meeting.locusInfo.emit(
+ {function: 'test', file: 'test'},
+ 'LINKS_RESOURCES',
+ newLocusResources
+ );
+
+ assert.calledWith(meeting.webinar.updateWebcastUrl, newLocusResources);
+
+ done();
+ });
+ });
+
describe('#setUpLocusInfoMediaInactiveListener', () => {
it('listens to disconnect due to un activity ', (done) => {
TriggerProxy.trigger.reset();
@@ -12200,6 +12405,43 @@ describe('plugin-meetings', () => {
await testEmit(false);
});
});
+
+ describe('LOCAL_UNMUTE_REQUIRED locus event', () => {
+ const testEmit = async (unmuteAllowed) => {
+ meeting.audio = {
+ handleServerLocalUnmuteRequired: sinon.stub(),
+ };
+ await meeting.locusInfo.emitScoped({}, LOCUSINFO.EVENTS.LOCAL_UNMUTE_REQUIRED, {
+ unmuteAllowed,
+ });
+
+ assert.calledWith(
+ TriggerProxy.trigger,
+ sinon.match.instanceOf(Meeting),
+ {
+ file: 'meeting/index',
+ function: 'setUpLocusInfoSelfListener',
+ },
+ EVENT_TRIGGERS.MEETING_SELF_UNMUTED_BY_OTHERS,
+ {
+ payload: {
+ unmuteAllowed,
+ },
+ }
+ );
+ assert.calledOnceWithExactly(
+ meeting.audio.handleServerLocalUnmuteRequired,
+ meeting,
+ unmuteAllowed
+ );
+ };
+
+ [true, false].forEach((unmuteAllowed) => {
+ it(`emits the expected event and calls handleServerLocalUnmuteRequired() when unmuteAllowed=${unmuteAllowed}`, async () => {
+ await testEmit(unmuteAllowed);
+ });
+ });
+ });
});
});
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/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/muteState.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js
index 7af0df9e88b..c89e591ff06 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js
@@ -151,7 +151,7 @@ describe('plugin-meetings', () => {
meeting.mediaProperties.audioStream.setServerMuted = sinon.stub().callsFake((muted) => {
meeting.mediaProperties.audioStream.userMuted = muted;
});
- audio.handleServerLocalUnmuteRequired(meeting);
+ audio.handleServerLocalUnmuteRequired(meeting, true);
await testUtils.flushPromises();
@@ -161,6 +161,8 @@ describe('plugin-meetings', () => {
false,
'localUnmuteRequired'
);
+ // and unmuteAllowed was updated
+ assert.calledWith(meeting.mediaProperties.audioStream.setUnmuteAllowed, true);
// and local unmute was sent to server
assert.calledOnce(MeetingUtil.remoteUpdateAudioVideo);
@@ -184,7 +186,7 @@ describe('plugin-meetings', () => {
meeting.mediaProperties.audioStream.setServerMuted = sinon.stub().callsFake((muted) => {
meeting.mediaProperties.audioStream.userMuted = muted;
});
- audio.handleServerLocalUnmuteRequired(meeting);
+ audio.handleServerLocalUnmuteRequired(meeting, true);
await testUtils.flushPromises();
@@ -215,7 +217,7 @@ describe('plugin-meetings', () => {
meeting.mediaProperties.videoStream.setServerMuted = sinon.stub().callsFake((muted) => {
meeting.mediaProperties.videoStream.userMuted = muted;
});
- video.handleServerLocalUnmuteRequired(meeting);
+ video.handleServerLocalUnmuteRequired(meeting, true);
await testUtils.flushPromises();
@@ -225,6 +227,8 @@ describe('plugin-meetings', () => {
false,
'localUnmuteRequired'
);
+ // and unmuteAllowed was updated
+ assert.calledWith(meeting.mediaProperties.videoStream.setUnmuteAllowed, true);
// and local unmute was sent to server
assert.calledOnce(MeetingUtil.remoteUpdateAudioVideo);
@@ -248,7 +252,7 @@ describe('plugin-meetings', () => {
meeting.mediaProperties.videoStream.setServerMuted = sinon.stub().callsFake((muted) => {
meeting.mediaProperties.videoStream.userMuted = muted;
});
- video.handleServerLocalUnmuteRequired(meeting);
+ video.handleServerLocalUnmuteRequired(meeting, true);
await testUtils.flushPromises();
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..770605819be 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js
@@ -131,9 +131,9 @@ describe('plugin-meetings', () => {
logger,
people: {
_getMe: sinon.stub().resolves({
- type: 'validuser',
+ type: 'validuser',
}),
- }
+ },
});
startReachabilityStub = sinon.stub(webex.meetings, 'startReachability').resolves();
@@ -1985,6 +1985,8 @@ describe('plugin-meetings', () => {
const meetingIds = {
meetingId: meeting.id,
correlationId: meeting.correlationId,
+ roles: meeting.roles,
+ callStateForMetrics: meeting.callStateForMetrics,
};
webex.meetings.destroy(meeting, test1);
@@ -2021,6 +2023,8 @@ describe('plugin-meetings', () => {
assert.equal(deletedMeetingInfo.id, meetingIds.meetingId);
assert.equal(deletedMeetingInfo.correlationId, meetingIds.correlationId);
+ assert.equal(deletedMeetingInfo.roles, meetingIds.roles);
+ assert.equal(deletedMeetingInfo.callStateForMetrics, meetingIds.callStateForMetrics);
});
});
@@ -2077,7 +2081,22 @@ describe('plugin-meetings', () => {
]);
});
- const setup = ({me = { type: 'validuser'}, user} = {}) => {
+ 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,14 +2112,14 @@ 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 () => {
setup({me: {type: 'appuser'}});
-
+
await webex.meetings.fetchUserPreferredWebexSite();
-
+
assert.equal(webex.meetings.preferredWebexSite, '');
assert.deepEqual(webex.internal.services._getCatalog().getAllowedDomains(), []);
assert.notCalled(webex.internal.services.getMeetingPreferences);
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/index.js b/packages/@webex/plugin-meetings/test/unit/spec/members/index.js
index 64421f2df8c..23627ab151b 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/members/index.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/members/index.js
@@ -660,17 +660,20 @@ describe('plugin-meetings', () => {
resultPromise,
spies,
expectedRequestingMemberId,
- expectedLocusUrl
+ expectedLocusUrl,
+ expectedRoles,
) => {
await assert.isFulfilled(resultPromise);
assert.calledOnceWithExactly(
spies.generateLowerAllHandsMemberOptions,
expectedRequestingMemberId,
- expectedLocusUrl
+ expectedLocusUrl,
+ expectedRoles,
);
assert.calledOnceWithExactly(spies.lowerAllHandsMember, {
requestingParticipantId: expectedRequestingMemberId,
locusUrl: expectedLocusUrl,
+ ...(expectedRoles !== undefined && { roles: expectedRoles })
});
assert.strictEqual(resultPromise, spies.lowerAllHandsMember.getCall(0).returnValue);
};
@@ -707,6 +710,26 @@ describe('plugin-meetings', () => {
await checkValid(resultPromise, spies, requestingMemberId, url1);
});
+
+ it('should make the correct request when called with valid requestingMemberId and roles', async () => {
+ const requestingMemberId = 'test-member-id';
+ const roles = ['panelist', 'attendee'];
+ const { members, spies } = setup('test-locus-url');
+
+ const resultPromise = members.lowerAllHands(requestingMemberId, roles);
+
+ await checkValid(resultPromise, spies, requestingMemberId, 'test-locus-url', roles);
+ });
+
+ it('should handle an empty roles array correctly', async () => {
+ const requestingMemberId = 'test-member-id';
+ const roles = [];
+ const { members, spies } = setup('test-locus-url');
+
+ const resultPromise = members.lowerAllHands(requestingMemberId, roles);
+
+ await checkValid(resultPromise, spies, requestingMemberId, 'test-locus-url', roles);
+ });
});
describe('#editDisplayName', () => {
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/request.js b/packages/@webex/plugin-meetings/test/unit/spec/members/request.js
index e1fe2b3606c..c743c2fc6a9 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/members/request.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/members/request.js
@@ -225,7 +225,7 @@ describe('plugin-meetings', () => {
});
describe('#assignRolesMember', () => {
- it('sends a PATCH to the locus endpoint', async () => {
+ it('sends a assignRolesMember PATCH to the locus endpoint', async () => {
const locusUrl = url1;
const memberId = 'test1';
const roles = [
@@ -255,7 +255,7 @@ describe('plugin-meetings', () => {
});
describe('#raiseHand', () => {
- it('sends a PATCH to the locus endpoint', async () => {
+ it('sends a raiseOrLowerHandMember PATCH to the locus endpoint', async () => {
const locusUrl = url1;
const memberId = 'test1';
@@ -319,7 +319,7 @@ describe('plugin-meetings', () => {
assert.strictEqual(result, requestResponse);
});
- it('sends a PATCH to the locus endpoint', async () => {
+ it('sends a lowerAllHandsMember PATCH to the locus endpoint', async () => {
const locusUrl = url1;
const memberId = 'test1';
@@ -348,6 +348,40 @@ describe('plugin-meetings', () => {
},
});
});
+
+ it('sends a lowerAllHandsMember PATCH to the locus endpoint with roles', async () => {
+ const locusUrl = url1;
+ const memberId = 'test1';
+ const roles = ['attendee'];
+
+ const options = {
+ requestingParticipantId: memberId,
+ locusUrl,
+ roles,
+ };
+
+ const getRequestParamsSpy = sandbox.spy(membersUtil, 'getLowerAllHandsMemberRequestParams');
+
+ await membersRequest.lowerAllHandsMember(options);
+
+ assert.calledOnceWithExactly(getRequestParamsSpy, {
+ requestingParticipantId: memberId,
+ locusUrl: url1,
+ roles: ['attendee'],
+ });
+
+ checkRequest({
+ method: 'PATCH',
+ uri: `${locusUrl}/controls`,
+ body: {
+ hand: {
+ raised: false,
+ roles: ['attendee'],
+ },
+ requestingParticipantId: memberId,
+ },
+ });
+ });
});
describe('#editDisplayName', () => {
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js b/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js
index 1ce608fe8a0..e79aca4e490 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js
@@ -101,7 +101,7 @@ describe('plugin-meetings', () => {
});
});
describe('#generateLowerAllHandsMemberOptions', () => {
- it('returns the correct options', () => {
+ it('returns the correct options without roles', () => {
const requestingParticipantId = 'test';
const locusUrl = 'urlTest1';
@@ -113,6 +113,20 @@ describe('plugin-meetings', () => {
}
);
});
+ it('returns the correct options with roles', () => {
+ const requestingParticipantId = 'test';
+ const locusUrl = 'urlTest1';
+ const roles = ['panelist'];
+
+ assert.deepEqual(
+ MembersUtil.generateLowerAllHandsMemberOptions(requestingParticipantId, locusUrl, roles),
+ {
+ requestingParticipantId,
+ locusUrl,
+ roles,
+ }
+ );
+ });
});
describe('#generateEditDisplayNameMemberOptions', () => {
it('returns the correct options', () => {
@@ -248,5 +262,100 @@ describe('plugin-meetings', () => {
testParams(false);
});
});
+
+ describe('#getAddMemberBody', () => {
+ it('returns the correct body with email address and roles', () => {
+ const options = {
+ invitee: {
+ emailAddress: 'test@example.com',
+ roles: ['role1', 'role2'],
+ },
+ alertIfActive: true,
+ };
+
+ assert.deepEqual(MembersUtil.getAddMemberBody(options), {
+ invitees: [
+ {
+ address: 'test@example.com',
+ roles: ['role1', 'role2'],
+ },
+ ],
+ alertIfActive: true,
+ });
+ });
+
+ it('returns the correct body with phone number and no roles', () => {
+ const options = {
+ invitee: {
+ phoneNumber: '1234567890',
+ },
+ alertIfActive: false,
+ };
+
+ assert.deepEqual(MembersUtil.getAddMemberBody(options), {
+ invitees: [
+ {
+ address: '1234567890',
+ },
+ ],
+ alertIfActive: false,
+ });
+ });
+
+ it('returns the correct body with fallback to email', () => {
+ const options = {
+ invitee: {
+ email: 'fallback@example.com',
+ },
+ alertIfActive: true,
+ };
+
+ assert.deepEqual(MembersUtil.getAddMemberBody(options), {
+ invitees: [
+ {
+ address: 'fallback@example.com',
+ },
+ ],
+ alertIfActive: true,
+ });
+ });
+
+ it('handles missing `alertIfActive` gracefully', () => {
+ const options = {
+ invitee: {
+ emailAddress: 'test@example.com',
+ roles: ['role1'],
+ },
+ };
+
+ assert.deepEqual(MembersUtil.getAddMemberBody(options), {
+ invitees: [
+ {
+ address: 'test@example.com',
+ roles: ['role1'],
+ },
+ ],
+ alertIfActive: undefined,
+ });
+ });
+
+ it('ignores roles if not provided', () => {
+ const options = {
+ invitee: {
+ emailAddress: 'test@example.com',
+ },
+ alertIfActive: false,
+ };
+
+ assert.deepEqual(MembersUtil.getAddMemberBody(options), {
+ invitees: [
+ {
+ address: 'test@example.com',
+ },
+ ],
+ alertIfActive: false,
+ });
+ });
+ });
});
});
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
index b2a23609004..8b76fe0eef3 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
+++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
@@ -15,6 +15,7 @@ describe('ClusterReachability', () => {
let previousRTCPeerConnection;
let clusterReachability;
let fakePeerConnection;
+ let gatherIceCandidatesSpy;
const emittedEvents: Record = {
[Events.resultReady]: [],
@@ -44,6 +45,8 @@ describe('ClusterReachability', () => {
xtls: ['stun:xtls1.webex.com', 'stun:xtls2.webex.com:443'],
});
+ gatherIceCandidatesSpy = sinon.spy(clusterReachability, 'gatherIceCandidates');
+
resetEmittedEvents();
clusterReachability.on(Events.resultReady, (data: ResultEventData) => {
@@ -151,6 +154,10 @@ describe('ClusterReachability', () => {
assert.calledOnceWithExactly(fakePeerConnection.createOffer, {offerToReceiveAudio: true});
assert.calledOnce(fakePeerConnection.setLocalDescription);
+ // Make sure that gatherIceCandidates is called before setLocalDescription
+ // as setLocalDescription triggers the ICE gathering process
+ assert.isTrue(gatherIceCandidatesSpy.calledBefore(fakePeerConnection.setLocalDescription));
+
clusterReachability.abort();
await promise;
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts
index 29b9ae371ec..f532ad885e4 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts
+++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts
@@ -1234,7 +1234,7 @@ describe('gatherReachability', () => {
assert.equal(receivedEvents['done'], 1);
// and that ip network detection was started
- assert.calledOnceWithExactly(webex.internal.device.ipNetworkDetector.detect);
+ assert.calledOnceWithExactly(webex.internal.device.ipNetworkDetector.detect, true);
// finally, check the metrics - they should contain values from ipNetworkDetector
assert.calledWith(Metrics.sendBehavioralMetric, 'js_sdk_reachability_completed', {
@@ -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/recording-controller/index.js b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js
index 5e45c719162..6fe2abda085 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js
@@ -221,7 +221,21 @@ describe('plugin-meetings', () => {
assert.calledWith(request.request, {
uri: `test/loci/id/recording`,
- body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'start'}},
+ body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'start'}, recordingType: 'cloud'},
+ method: HTTP_VERBS.PUT,
+ });
+
+ assert.deepEqual(result, request.request.firstCall.returnValue);
+ });
+
+ it('can start premise recording when the correct display hint is present', () => {
+ controller.setDisplayHints(['PREMISE_RECORDING_CONTROL_START']);
+
+ const result = controller.startRecording();
+
+ assert.calledWith(request.request, {
+ uri: `test/loci/id/recording`,
+ body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'start'}, recordingType: 'premise'},
method: HTTP_VERBS.PUT,
});
@@ -238,14 +252,28 @@ describe('plugin-meetings', () => {
assert.isRejected(result);
});
- it('can start recording when the correct display hint is present', () => {
+ it('can stop recording when the correct display hint is present', () => {
controller.setDisplayHints(['RECORDING_CONTROL_STOP']);
const result = controller.stopRecording();
assert.calledWith(request.request, {
uri: `test/loci/id/recording`,
- body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'stop'}},
+ body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'stop'}, recordingType: 'cloud'},
+ method: HTTP_VERBS.PUT,
+ });
+
+ assert.deepEqual(result, request.request.firstCall.returnValue);
+ });
+
+ it('can stop premise recording when the correct display hint is present', () => {
+ controller.setDisplayHints(['PREMISE_RECORDING_CONTROL_STOP']);
+
+ const result = controller.stopRecording();
+
+ assert.calledWith(request.request, {
+ uri: `test/loci/id/recording`,
+ body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'stop'}, recordingType: 'premise'},
method: HTTP_VERBS.PUT,
});
@@ -269,7 +297,21 @@ describe('plugin-meetings', () => {
assert.calledWith(request.request, {
uri: `test/loci/id/recording`,
- body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'pause'}},
+ body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'pause'}, recordingType: 'cloud'},
+ method: HTTP_VERBS.PUT,
+ });
+
+ assert.deepEqual(result, request.request.firstCall.returnValue);
+ });
+
+ it('can pause premise recording when the correct display hint is present', () => {
+ controller.setDisplayHints(['PREMISE_RECORDING_CONTROL_PAUSE']);
+
+ const result = controller.pauseRecording();
+
+ assert.calledWith(request.request, {
+ uri: `test/loci/id/recording`,
+ body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'pause'}, recordingType: 'premise'},
method: HTTP_VERBS.PUT,
});
@@ -293,7 +335,21 @@ describe('plugin-meetings', () => {
assert.calledWith(request.request, {
uri: `test/loci/id/recording`,
- body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'resume'}},
+ body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'resume'}, recordingType: 'cloud'},
+ method: HTTP_VERBS.PUT,
+ });
+
+ assert.deepEqual(result, request.request.firstCall.returnValue);
+ });
+
+ it('can resume premise recording when the correct display hint is present', () => {
+ controller.setDisplayHints(['PREMISE_RECORDING_CONTROL_RESUME']);
+
+ const result = controller.resumeRecording();
+
+ assert.calledWith(request.request, {
+ uri: `test/loci/id/recording`,
+ body: {meetingInfo: {locusSessionId: 'testId'}, recording: {action: 'resume'}, recordingType: 'premise'},
method: HTTP_VERBS.PUT,
});
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/util.js b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/util.js
index 2dcc54040a8..0358a7bf148 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/util.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/util.js
@@ -1,5 +1,5 @@
import RecordingUtil from '@webex/plugin-meetings/src/recording-controller/util';
-import RecordingAction from '@webex/plugin-meetings/src/recording-controller/enums';
+import { RecordingAction } from '@webex/plugin-meetings/src/recording-controller/enums';
import {SELF_POLICY} from '@webex/plugin-meetings/src/constants';
import {assert} from 'chai';
@@ -29,6 +29,15 @@ describe('plugin-meetings', () => {
);
});
+ it('can start premise recording when the correct display hint is present', () => {
+ locusInfo.parsedLocus.info.userDisplayHints.push('PREMISE_RECORDING_CONTROL_START');
+
+ assert.equal(
+ RecordingUtil.canUserStart(locusInfo.parsedLocus.info.userDisplayHints),
+ true
+ );
+ });
+
it('can start recording when the correct display hint is present and the policy is true', () => {
locusInfo.parsedLocus.info.userDisplayHints.push('RECORDING_CONTROL_START');
@@ -69,6 +78,15 @@ describe('plugin-meetings', () => {
);
});
+ it('can pause premise recording when the correct display hint is present', () => {
+ locusInfo.parsedLocus.info.userDisplayHints.push('PREMISE_RECORDING_CONTROL_PAUSE');
+
+ assert.equal(
+ RecordingUtil.canUserPause(locusInfo.parsedLocus.info.userDisplayHints),
+ true
+ );
+ });
+
it('can pause recording when the correct display hint is present and the policy is true', () => {
locusInfo.parsedLocus.info.userDisplayHints.push('RECORDING_CONTROL_PAUSE');
@@ -109,6 +127,15 @@ describe('plugin-meetings', () => {
);
});
+ it('can stop premise recording when the correct display hint is present', () => {
+ locusInfo.parsedLocus.info.userDisplayHints.push('PREMISE_RECORDING_CONTROL_STOP');
+
+ assert.equal(
+ RecordingUtil.canUserStop(locusInfo.parsedLocus.info.userDisplayHints),
+ true
+ );
+ });
+
it('can stop recording when the correct display hint is present and the policy is true', () => {
locusInfo.parsedLocus.info.userDisplayHints.push('RECORDING_CONTROL_STOP', {
[SELF_POLICY.SUPPORT_NETWORK_BASED_RECORD]: true,
@@ -142,7 +169,7 @@ describe('plugin-meetings', () => {
});
describe('canUserResume', () => {
- it('can start recording when the correct display hint is present', () => {
+ it('can resume recording when the correct display hint is present', () => {
locusInfo.parsedLocus.info.userDisplayHints.push('RECORDING_CONTROL_RESUME');
assert.equal(
@@ -151,7 +178,16 @@ describe('plugin-meetings', () => {
);
});
- it('can start recording when the correct display hint is present and the policy is true', () => {
+ it('can resume premise recording when the correct display hint is present', () => {
+ locusInfo.parsedLocus.info.userDisplayHints.push('PREMISE_RECORDING_CONTROL_RESUME');
+
+ assert.equal(
+ RecordingUtil.canUserResume(locusInfo.parsedLocus.info.userDisplayHints),
+ true
+ );
+ });
+
+ it('can resume recording when the correct display hint is present and the policy is true', () => {
locusInfo.parsedLocus.info.userDisplayHints.push('RECORDING_CONTROL_RESUME');
assert.equal(
diff --git a/packages/@webex/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/@webex/plugin-meetings/test/unit/spec/webinar/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts
index d4988d44bee..d466995c637 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts
+++ b/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts
@@ -29,32 +29,145 @@ describe('plugin-meetings', () => {
});
});
- describe('#webcastUrlUpdate', () => {
- it('sets the webcast url', () => {
- webinar.webcastUrlUpdate('newUrl');
+ describe('#updateWebcastUrl', () => {
+ it('sets the webcast instance url', () => {
+ webinar.updateWebcastUrl({resources: {webcastInstance: {url:'newUrl'}}});
- assert.equal(webinar.webcastUrl, 'newUrl');
+ assert.equal(webinar.webcastInstanceUrl, 'newUrl');
});
});
- describe('#webinarAttendeesSearchingUrlUpdate', () => {
- it('sets the webinarAttendeesSearching url', () => {
- webinar.webinarAttendeesSearchingUrlUpdate('newUrl');
- assert.equal(webinar.webinarAttendeesSearchingUrl, 'newUrl');
- });
+ describe('#updateCanManageWebcast', () => {
+ it('sets the webcast instance url when valid', () => {
+ webinar.updateWebcastUrl({resources: {webcastInstance: {url:'newUrl'}}});
+ assert.equal(webinar.webcastInstanceUrl, 'newUrl', 'webcast instance URL should be updated');
+ });
+
+ it('handles missing resources gracefully', () => {
+ webinar.updateWebcastUrl({});
+ assert.isUndefined(webinar.webcastInstanceUrl, 'webcast instance URL should be undefined');
+ });
+
+ it('handles missing webcastInstance gracefully', () => {
+ webinar.updateWebcastUrl({resources: {}});
+ assert.isUndefined(webinar.webcastInstanceUrl, 'webcast instance URL should be undefined');
+ });
+
+ it('handles missing URL gracefully', () => {
+ webinar.updateWebcastUrl({resources: {webcastInstance: {}}});
+ assert.isUndefined(webinar.webcastInstanceUrl, 'webcast instance URL should be undefined');
+ });
});
- describe('#updateCanManageWebcast', () => {
- it('update canManageWebcast', () => {
- webinar.updateCanManageWebcast(true);
+ describe('#updateRoleChanged', () => {
+ it('updates roles when promoted from attendee to panelist', () => {
+ const payload = {
+ oldRoles: ['ATTENDEE'],
+ newRoles: ['PANELIST']
+ };
+
+ const result = webinar.updateRoleChanged(payload);
- assert.equal(webinar.canManageWebcast, true);
+ assert.equal(webinar.selfIsPanelist, true, 'self should be a panelist');
+ assert.equal(webinar.selfIsAttendee, false, 'self should not be an attendee');
+ assert.equal(webinar.canManageWebcast, false, 'self should not have manage webcast capability');
+ assert.equal(result.isPromoted, true, 'should indicate promotion');
+ assert.equal(result.isDemoted, false, 'should not indicate demotion');
+ });
- webinar.updateCanManageWebcast(false);
+ it('updates roles when demoted from panelist to attendee', () => {
+ const payload = {
+ oldRoles: ['PANELIST'],
+ newRoles: ['ATTENDEE']
+ };
- assert.equal(webinar.canManageWebcast, false);
+ const result = webinar.updateRoleChanged(payload);
+
+ assert.equal(webinar.selfIsPanelist, false, 'self should not be a panelist');
+ assert.equal(webinar.selfIsAttendee, true, 'self should be an attendee');
+ assert.equal(webinar.canManageWebcast, false, 'self should not have manage webcast capability');
+ assert.equal(result.isPromoted, false, 'should not indicate promotion');
+ assert.equal(result.isDemoted, true, 'should indicate demotion');
+ });
+
+ it('updates roles when promoted to moderator', () => {
+ const payload = {
+ oldRoles: ['PANELIST'],
+ newRoles: ['MODERATOR']
+ };
+
+ const result = webinar.updateRoleChanged(payload);
+
+ assert.equal(webinar.selfIsPanelist, false, 'self should not be a panelist');
+ assert.equal(webinar.selfIsAttendee, false, 'self should not be an attendee');
+ assert.equal(webinar.canManageWebcast, true, 'self should have manage webcast capability');
+ assert.equal(result.isPromoted, false, 'should not indicate promotion');
+ assert.equal(result.isDemoted, false, 'should not indicate demotion');
+ });
+
+ it('updates roles when unchanged (remains as panelist)', () => {
+ const payload = {
+ oldRoles: ['PANELIST'],
+ newRoles: ['PANELIST']
+ };
+
+ const result = webinar.updateRoleChanged(payload);
+
+ assert.equal(webinar.selfIsPanelist, true, 'self should remain a panelist');
+ assert.equal(webinar.selfIsAttendee, false, 'self should not be an attendee');
+ assert.equal(webinar.canManageWebcast, false, 'self should not have manage webcast capability');
+ assert.equal(result.isPromoted, false, 'should not indicate promotion');
+ assert.equal(result.isDemoted, false, 'should not indicate demotion');
+ });
+ });
+
+ describe("#setPracticeSessionState", () => {
+ [true, false].forEach((enabled) => {
+ it(`sends a PATCH request to ${enabled ? "enable" : "disable"} the practice session`, async () => {
+ const result = await webinar.setPracticeSessionState(enabled);
+ assert.calledOnce(webex.request);
+ assert.calledWith(webex.request, {
+ method: "PATCH",
+ uri: `${webinar.locusUrl}/controls`,
+ body: {
+ practiceSession: { enabled }
+ }
+ });
+ assert.equal(result, "REQUEST_RETURN_VALUE", "should return the resolved value from the request");
});
});
+
+ it('handles API call failures gracefully', async () => {
+ webex.request.rejects(new Error('API_ERROR'));
+ const errorLogger = sinon.stub(LoggerProxy.logger, 'error');
+
+ try {
+ await webinar.setPracticeSessionState(true);
+ assert.fail('setPracticeSessionState should throw an error');
+ } catch (error) {
+ assert.equal(error.message, 'API_ERROR', 'should throw the correct error');
+ assert.calledOnce(errorLogger);
+ assert.calledWith(errorLogger, 'Meeting:webinar#setPracticeSessionState failed', sinon.match.instanceOf(Error));
+ }
+
+ errorLogger.restore();
+ });
+ });
+
+ describe('#updatePracticeSessionStatus', () => {
+ it('sets PS state true', () => {
+ webinar.updatePracticeSessionStatus({enabled: true});
+
+ assert.equal(webinar.practiceSessionEnabled, true);
+ });
+ it('sets PS state true', () => {
+ webinar.updatePracticeSessionStatus({enabled: false});
+
+ assert.equal(webinar.practiceSessionEnabled, false);
+ });
+ });
+
+
})
})
diff --git a/packages/@webex/webex-core/src/lib/webex-http-error.js b/packages/@webex/webex-core/src/lib/webex-http-error.js
index 766fffe3da8..dbf4db970c5 100644
--- a/packages/@webex/webex-core/src/lib/webex-http-error.js
+++ b/packages/@webex/webex-core/src/lib/webex-http-error.js
@@ -22,6 +22,15 @@ export default class WebexHttpError extends HttpError {
value: res.options,
});
+ Reflect.defineProperty(this, 'body', {
+ enumerable: false,
+ value: res.body,
+ });
+
+ if (this.body && this.body.errorCode) {
+ message += `\nerrorCode : ${this.body.errorCode}`;
+ }
+
if (this.options.url) {
message += `\n${this.options.method} ${this.options.url}`;
} else if (this.options.uri) {
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 a0fe1d26df7..3c8f7a98dbf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7419,7 +7419,7 @@ __metadata:
"@typescript-eslint/eslint-plugin": 5.38.1
"@typescript-eslint/parser": 5.38.1
"@web/dev-server": 0.4.5
- "@webex/internal-media-core": 2.11.3
+ "@webex/internal-media-core": 2.12.2
"@webex/media-helpers": "workspace:*"
async-mutex: 0.4.0
buffer: 6.0.3
@@ -7618,9 +7618,9 @@ __metadata:
languageName: unknown
linkType: soft
-"@webex/event-dictionary-ts@npm:^1.0.1546":
- version: 1.0.1546
- resolution: "@webex/event-dictionary-ts@npm:1.0.1546"
+"@webex/event-dictionary-ts@npm:^1.0.1594":
+ version: 1.0.1594
+ resolution: "@webex/event-dictionary-ts@npm:1.0.1594"
dependencies:
amf-client-js: ^5.2.6
json-schema-to-typescript: ^12.0.0
@@ -7628,7 +7628,7 @@ __metadata:
ramldt2jsonschema: ^1.2.3
shelljs: ^0.8.5
webapi-parser: ^0.5.0
- checksum: d938300584c5dcdeb5924a072c20aac85e9826c9631961e604c0e1dd577172f5d11a34c0b52dbb49f86386a80ecb842446a368172e5e64c273d06903f5843fa4
+ checksum: 814a1029031bc47b7579ff52aa6ca3bc83323246e81927df5c97d531d487709befb84d2f1d7a85f9deb6ce5148eff57ee2a1d219f4568371c2c0a8b12aa167ee
languageName: node
linkType: hard
@@ -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
@@ -8126,7 +8126,7 @@ __metadata:
"@webex/common": "workspace:*"
"@webex/common-timers": "workspace:*"
"@webex/eslint-config-legacy": "workspace:*"
- "@webex/event-dictionary-ts": ^1.0.1546
+ "@webex/event-dictionary-ts": ^1.0.1594
"@webex/internal-plugin-metrics": "workspace:*"
"@webex/jest-config-legacy": "workspace:*"
"@webex/legacy-tools": "workspace:*"
@@ -8392,10 +8392,10 @@ __metadata:
languageName: unknown
linkType: soft
-"@webex/json-multistream@npm:2.1.6":
- version: 2.1.6
- resolution: "@webex/json-multistream@npm:2.1.6"
- checksum: 18cd8e24151c88fc563c6224cc358c9e2e3cda78d80baddba8dd58aa3e79bf4d78ff12613b27cad5a0242856e84e3c6001e12916e404a68398c68e5439e5154b
+"@webex/json-multistream@npm:2.2.0":
+ version: 2.2.0
+ resolution: "@webex/json-multistream@npm:2.2.0"
+ checksum: 2f3f8de556e083cd7e9aa0fa0ddec13c5d0a63f5d809e5ce7075110cfa34178f12b9dde2e093fa49ed6f78fcbbe72de9059d2cbfd23e371e4c595baf9ad0449b
languageName: node
linkType: hard
@@ -8484,7 +8484,7 @@ __metadata:
"@babel/preset-typescript": 7.22.11
"@webex/babel-config-legacy": "workspace:*"
"@webex/eslint-config-legacy": "workspace:*"
- "@webex/internal-media-core": 2.11.3
+ "@webex/internal-media-core": 2.12.2
"@webex/jest-config-legacy": "workspace:*"
"@webex/legacy-tools": "workspace:*"
"@webex/test-helper-chai": "workspace:*"
@@ -8720,7 +8720,7 @@ __metadata:
"@webex/babel-config-legacy": "workspace:*"
"@webex/common": "workspace:*"
"@webex/eslint-config-legacy": "workspace:*"
- "@webex/internal-media-core": 2.11.3
+ "@webex/internal-media-core": 2.12.2
"@webex/internal-plugin-conversation": "workspace:*"
"@webex/internal-plugin-device": "workspace:*"
"@webex/internal-plugin-llm": "workspace:*"
@@ -9423,11 +9423,11 @@ __metadata:
languageName: node
linkType: hard
-"@webex/web-client-media-engine@npm:3.24.2":
- version: 3.24.2
- resolution: "@webex/web-client-media-engine@npm:3.24.2"
+"@webex/web-client-media-engine@npm:3.26.2":
+ version: 3.26.2
+ resolution: "@webex/web-client-media-engine@npm:3.26.2"
dependencies:
- "@webex/json-multistream": 2.1.6
+ "@webex/json-multistream": 2.2.0
"@webex/rtcstats": ^1.5.0
"@webex/ts-events": ^1.0.1
"@webex/ts-sdp": 1.7.0
@@ -9438,7 +9438,7 @@ __metadata:
js-logger: ^1.6.1
typed-emitter: ^2.1.0
uuid: ^8.3.2
- checksum: 26ac75ffcb519a11b3a9f2b601b13b85c4c4e4b685503da0e752d03137af65d22472329f45fbd08de138d93ebd92295645f98c9b879617c838afee61c0589df7
+ checksum: 85e990dd48320fc9dd478541c1ffdea591f048286fb5186cfb23a163e3b9f0c530aa7d37460cd1f0dc577c7d359fc289c31a4321bc915ffaca41be252f19cab1
languageName: node
linkType: hard