@webex/plugin-meetings 3.0.0-beta.111 → 3.0.0-beta.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +45 -1
  2. package/dist/breakouts/breakout.js +1 -1
  3. package/dist/breakouts/index.js +1 -1
  4. package/dist/media/index.js +0 -21
  5. package/dist/media/index.js.map +1 -1
  6. package/dist/meeting/index.js +19 -0
  7. package/dist/meeting/index.js.map +1 -1
  8. package/dist/meeting/locusMediaRequest.js +288 -0
  9. package/dist/meeting/locusMediaRequest.js.map +1 -0
  10. package/dist/meeting/muteState.js +49 -34
  11. package/dist/meeting/muteState.js.map +1 -1
  12. package/dist/meeting/request.js +12 -47
  13. package/dist/meeting/request.js.map +1 -1
  14. package/dist/meeting/util.js +20 -19
  15. package/dist/meeting/util.js.map +1 -1
  16. package/dist/roap/index.js +4 -19
  17. package/dist/roap/index.js.map +1 -1
  18. package/dist/roap/request.js +23 -39
  19. package/dist/roap/request.js.map +1 -1
  20. package/dist/roap/turnDiscovery.js +2 -10
  21. package/dist/roap/turnDiscovery.js.map +1 -1
  22. package/dist/types/meeting/index.d.ts +3 -0
  23. package/dist/types/meeting/locusMediaRequest.d.ts +68 -0
  24. package/dist/types/meeting/muteState.d.ts +3 -2
  25. package/dist/types/meeting/request.d.ts +4 -18
  26. package/dist/types/roap/request.d.ts +6 -8
  27. package/dist/types/roap/turnDiscovery.d.ts +4 -1
  28. package/package.json +19 -19
  29. package/src/media/index.ts +0 -23
  30. package/src/meeting/index.ts +22 -0
  31. package/src/meeting/locusMediaRequest.ts +303 -0
  32. package/src/meeting/muteState.ts +24 -9
  33. package/src/meeting/request.ts +15 -51
  34. package/src/meeting/util.ts +17 -13
  35. package/src/roap/index.ts +4 -16
  36. package/src/roap/request.ts +22 -42
  37. package/src/roap/turnDiscovery.ts +2 -8
  38. package/test/unit/spec/meeting/locusMediaRequest.ts +414 -0
  39. package/test/unit/spec/meeting/muteState.js +97 -71
  40. package/test/unit/spec/meeting/request.js +19 -0
  41. package/test/unit/spec/meeting/utils.js +31 -37
  42. package/test/unit/spec/roap/index.ts +2 -37
  43. package/test/unit/spec/roap/request.ts +27 -57
  44. package/test/unit/spec/roap/turnDiscovery.ts +3 -5
@@ -3,9 +3,10 @@
3
3
  import {StatelessWebexPlugin} from '@webex/webex-core';
4
4
 
5
5
  import LoggerProxy from '../common/logs/logger-proxy';
6
- import {MEDIA, HTTP_VERBS, REACHABILITY} from '../constants';
6
+ import {REACHABILITY} from '../constants';
7
7
  import Metrics from '../metrics';
8
8
  import {eventType} from '../metrics/config';
9
+ import {LocusMediaRequest} from '../meeting/locusMediaRequest';
9
10
 
10
11
  /**
11
12
  * @class RoapRequest
@@ -64,69 +65,48 @@ export default class RoapRequest extends StatelessWebexPlugin {
64
65
  * @param {String} options.locusSelfUrl
65
66
  * @param {String} options.mediaId
66
67
  * @param {String} options.correlationId
67
- * @param {Boolean} options.audioMuted
68
- * @param {Boolean} options.videoMuted
69
68
  * @param {String} options.meetingId
70
- * @param {Boolean} options.preferTranscoding
71
69
  * @returns {Promise} returns the response/failure of the request
72
70
  */
73
71
  async sendRoap(options: {
74
72
  roapMessage: any;
75
73
  locusSelfUrl: string;
76
74
  mediaId: string;
77
- correlationId: string;
78
- audioMuted: boolean;
79
- videoMuted: boolean;
80
75
  meetingId: string;
81
- preferTranscoding?: boolean;
76
+ locusMediaRequest?: LocusMediaRequest;
82
77
  }) {
83
- const {roapMessage, locusSelfUrl, mediaId, correlationId, meetingId} = options;
78
+ const {roapMessage, locusSelfUrl, mediaId, meetingId, locusMediaRequest} = options;
84
79
 
85
80
  if (!mediaId) {
86
- LoggerProxy.logger.info('Roap:request#sendRoap --> Race Condition /call mediaID not present');
81
+ LoggerProxy.logger.info('Roap:request#sendRoap --> sending empty mediaID');
87
82
  }
88
83
 
84
+ if (!locusMediaRequest) {
85
+ LoggerProxy.logger.warn(
86
+ 'Roap:request#sendRoap --> locusMediaRequest unavailable, not sending roap'
87
+ );
88
+
89
+ return Promise.reject(new Error('sendRoap called when locusMediaRequest is undefined'));
90
+ }
89
91
  const {localSdp: localSdpWithReachabilityData, joinCookie} = await this.attachReachabilityData({
90
92
  roapMessage,
91
- // eslint-disable-next-line no-warning-comments
92
- // TODO: check whats the need for video and audiomute
93
- audioMuted: !!options.audioMuted,
94
- videoMuted: !!options.videoMuted,
95
93
  });
96
94
 
97
- const mediaUrl = `${locusSelfUrl}/${MEDIA}`;
98
- // @ts-ignore
99
- const deviceUrl = this.webex.internal.device.url;
100
-
101
95
  LoggerProxy.logger.info(
102
- `Roap:request#sendRoap --> ${mediaUrl} \n ${roapMessage.messageType} \n seq:${roapMessage.seq}`
96
+ `Roap:request#sendRoap --> ${locusSelfUrl} \n ${roapMessage.messageType} \n seq:${roapMessage.seq}`
103
97
  );
104
98
 
105
99
  Metrics.postEvent({event: eventType.MEDIA_REQUEST, meetingId});
106
100
 
107
- // @ts-ignore
108
- return this.request({
109
- uri: mediaUrl,
110
- method: HTTP_VERBS.PUT,
111
- body: {
112
- device: {
113
- url: deviceUrl,
114
- // @ts-ignore
115
- deviceType: this.config.meetings.deviceType,
116
- },
117
- correlationId,
118
- localMedias: [
119
- {
120
- localSdp: JSON.stringify(localSdpWithReachabilityData),
121
- mediaId: options.mediaId,
122
- },
123
- ],
124
- clientMediaPreferences: {
125
- preferTranscoding: options.preferTranscoding ?? true,
126
- joinCookie,
127
- },
128
- },
129
- })
101
+ return locusMediaRequest
102
+ .send({
103
+ type: 'RoapMessage',
104
+ selfUrl: locusSelfUrl,
105
+ joinCookie,
106
+ mediaId,
107
+ roapMessage,
108
+ reachability: localSdpWithReachabilityData.reachability,
109
+ })
130
110
  .then((res) => {
131
111
  Metrics.postEvent({event: eventType.MEDIA_RESPONSE, meetingId});
132
112
 
@@ -176,15 +176,12 @@ export default class TurnDiscovery {
176
176
  return this.roapRequest
177
177
  .sendRoap({
178
178
  roapMessage,
179
- correlationId: meeting.correlationId,
180
179
  // @ts-ignore - Fix missing type
181
180
  locusSelfUrl: meeting.selfUrl,
182
181
  // @ts-ignore - Fix missing type
183
182
  mediaId: isReconnecting ? '' : meeting.mediaId,
184
- audioMuted: meeting.audio?.isLocallyMuted(),
185
- videoMuted: meeting.video?.isLocallyMuted(),
186
183
  meetingId: meeting.id,
187
- preferTranscoding: !meeting.isMultistream,
184
+ locusMediaRequest: meeting.locusMediaRequest,
188
185
  })
189
186
  .then(({mediaConnections}) => {
190
187
  if (mediaConnections) {
@@ -213,11 +210,8 @@ export default class TurnDiscovery {
213
210
  locusSelfUrl: meeting.selfUrl,
214
211
  // @ts-ignore - fix type
215
212
  mediaId: meeting.mediaId,
216
- correlationId: meeting.correlationId,
217
- audioMuted: meeting.audio?.isLocallyMuted(),
218
- videoMuted: meeting.video?.isLocallyMuted(),
219
213
  meetingId: meeting.id,
220
- preferTranscoding: !meeting.isMultistream,
214
+ locusMediaRequest: meeting.locusMediaRequest,
221
215
  });
222
216
  }
223
217
 
@@ -0,0 +1,414 @@
1
+ import sinon from 'sinon';
2
+ import {assert} from '@webex/test-helper-chai';
3
+ import { cloneDeep, defer } from 'lodash';
4
+
5
+ import MockWebex from '@webex/test-helper-mock-webex';
6
+ import Meetings from '@webex/plugin-meetings';
7
+ import { LocalMuteRequest, LocusMediaRequest, RoapRequest } from "@webex/plugin-meetings/src/meeting/locusMediaRequest";
8
+ import testUtils from '../../../utils/testUtils';
9
+ import { Defer } from '@webex/common';
10
+
11
+ describe('LocusMediaRequest.send()', () => {
12
+ let locusMediaRequest: LocusMediaRequest;
13
+ let webexRequestStub;
14
+ let mockWebex;
15
+
16
+ const fakeLocusResponse = {
17
+ locus: { something: 'whatever'}
18
+ };
19
+
20
+ const exampleRoapRequestBody:RoapRequest = {
21
+ type: 'RoapMessage',
22
+ mediaId: 'mediaId',
23
+ selfUrl: 'fakeMeetingSelfUrl',
24
+ roapMessage: {
25
+ messageType: 'OFFER',
26
+ sdps: ['sdp'],
27
+ version: '2',
28
+ seq: 1,
29
+ tieBreaker: 0xfffffffe,
30
+ },
31
+ reachability: {
32
+ 'wjfkm.wjfkm.*': {udp:{reachable: true}, tcp:{reachable:false}},
33
+ '1eb65fdf-9643-417f-9974-ad72cae0e10f.59268c12-7a04-4b23-a1a1-4c74be03019a.*': {udp:{reachable: false}, tcp:{reachable:true}},
34
+ },
35
+ joinCookie: {
36
+ anycastEntryPoint: 'aws-eu-west-1',
37
+ clientIpAddress: 'some ip',
38
+ timeShot: '2023-05-23T08:03:49Z',
39
+ },
40
+ };
41
+
42
+ const createExpectedRoapBody = (expectedMessageType, expectedMute:{audioMuted: boolean, videoMuted: boolean}) => {
43
+ return {
44
+ device: { url: 'deviceUrl', deviceType: 'deviceType' },
45
+ correlationId: 'correlationId',
46
+ localMedias: [
47
+ {
48
+ localSdp: `{"audioMuted":${expectedMute.audioMuted},"videoMuted":${expectedMute.videoMuted},"roapMessage":{"messageType":"${expectedMessageType}","sdps":["sdp"],"version":"2","seq":1,"tieBreaker":4294967294},"reachability":{"wjfkm.wjfkm.*":{"udp":{"reachable":true},"tcp":{"reachable":false}},"1eb65fdf-9643-417f-9974-ad72cae0e10f.59268c12-7a04-4b23-a1a1-4c74be03019a.*":{"udp":{"reachable":false},"tcp":{"reachable":true}}}}`,
49
+ mediaId: 'mediaId'
50
+ }
51
+ ],
52
+ clientMediaPreferences: {
53
+ preferTranscoding: true,
54
+ joinCookie: {
55
+ anycastEntryPoint: 'aws-eu-west-1',
56
+ clientIpAddress: 'some ip',
57
+ timeShot: '2023-05-23T08:03:49Z'
58
+ }
59
+ }
60
+ };
61
+ };
62
+
63
+ const exampleLocalMuteRequestBody:LocalMuteRequest = {
64
+ type: 'LocalMute',
65
+ mediaId: 'mediaId',
66
+ selfUrl: 'fakeMeetingSelfUrl',
67
+ muteOptions: {},
68
+ };
69
+
70
+ const createExpectedLocalMuteBody = (expectedMute:{audioMuted: boolean, videoMuted: boolean}) => {
71
+ return {
72
+ device: {
73
+ url: 'deviceUrl',
74
+ deviceType: 'deviceType',
75
+ },
76
+ correlationId: 'correlationId',
77
+ usingResource: null,
78
+ respOnlySdp: true,
79
+ localMedias: [
80
+ {
81
+ mediaId: 'mediaId',
82
+ localSdp: `{"audioMuted":${expectedMute.audioMuted},"videoMuted":${expectedMute.videoMuted}}`,
83
+ },
84
+ ],
85
+ clientMediaPreferences: {
86
+ preferTranscoding: true,
87
+ },
88
+ }
89
+ };
90
+
91
+ beforeEach(() => {
92
+ mockWebex = new MockWebex({
93
+ children: {
94
+ meetings: Meetings,
95
+ },
96
+ });
97
+
98
+ locusMediaRequest = new LocusMediaRequest({
99
+ device: {
100
+ url: 'deviceUrl',
101
+ deviceType: 'deviceType',
102
+ },
103
+ correlationId: 'correlationId',
104
+ preferTranscoding: true,
105
+ }, {
106
+ parent: mockWebex,
107
+ });
108
+ webexRequestStub = sinon.stub(locusMediaRequest, 'request').resolves(fakeLocusResponse);
109
+ })
110
+
111
+ const sendLocalMute = (muteOptions) => locusMediaRequest.send({...exampleLocalMuteRequestBody, muteOptions});
112
+
113
+ const sendRoapMessage = (messageType) => {
114
+ const request = cloneDeep(exampleRoapRequestBody);
115
+
116
+ request.roapMessage.messageType = messageType;
117
+ return locusMediaRequest.send(request);
118
+ }
119
+
120
+ /** Helper function that makes sure the LocusMediaRequest.confluenceState is 'created' */
121
+ const ensureConfluenceCreated = async () => {
122
+ await sendRoapMessage('OFFER');
123
+
124
+ webexRequestStub.resetHistory();
125
+ }
126
+
127
+ it('sends a roap message', async () => {
128
+ const result = await sendRoapMessage('OFFER');
129
+
130
+ assert.equal(result, fakeLocusResponse);
131
+
132
+ assert.calledOnceWithExactly(webexRequestStub, {
133
+ method: 'PUT',
134
+ uri: 'fakeMeetingSelfUrl/media',
135
+ body: createExpectedRoapBody('OFFER', {audioMuted: true, videoMuted: true}),
136
+ });
137
+ });
138
+
139
+ it('sends a local mute request', async () => {
140
+ await ensureConfluenceCreated();
141
+
142
+ const result = await sendLocalMute({audioMuted: false, videoMuted: false});
143
+
144
+ assert.equal(result, fakeLocusResponse);
145
+
146
+ assert.calledOnceWithExactly(webexRequestStub, {
147
+ method: 'PUT',
148
+ uri: 'fakeMeetingSelfUrl/media',
149
+ body: createExpectedLocalMuteBody({audioMuted: false, videoMuted: false}),
150
+ });
151
+ });
152
+
153
+ it('sends a local mute request with the last audio/video mute values when called multiple times in same processing cycle', async () => {
154
+ await ensureConfluenceCreated();
155
+
156
+ let result1;
157
+ let result2;
158
+
159
+ const promise1 = sendLocalMute({audioMuted: true, videoMuted: false})
160
+ .then((result) => {
161
+ result1 = result;
162
+ });
163
+
164
+ const promise2 = sendLocalMute({audioMuted: false, videoMuted: true})
165
+ .then((result) => {
166
+ result2 = result;
167
+ });
168
+
169
+ await testUtils.flushPromises();
170
+
171
+ await promise1;
172
+ await promise2;
173
+ assert.equal(result1, fakeLocusResponse);
174
+ assert.equal(result2, fakeLocusResponse);
175
+
176
+ assert.calledOnceWithExactly(webexRequestStub, {
177
+ method: 'PUT',
178
+ uri: 'fakeMeetingSelfUrl/media',
179
+ body: createExpectedLocalMuteBody({audioMuted: false, videoMuted: true}),
180
+ });
181
+
182
+ });
183
+
184
+ it('sends a local mute request with the last audio/video mute values', async () => {
185
+ await ensureConfluenceCreated();
186
+
187
+ await Promise.all([
188
+ sendLocalMute({audioMuted: undefined, videoMuted: false}),
189
+ sendLocalMute({audioMuted: true, videoMuted: undefined}),
190
+ sendLocalMute({audioMuted: false, videoMuted: true}),
191
+ sendLocalMute({audioMuted: true, videoMuted: false}),
192
+ ]);
193
+
194
+ assert.calledOnceWithExactly(webexRequestStub, {
195
+ method: 'PUT',
196
+ uri: 'fakeMeetingSelfUrl/media',
197
+ body: createExpectedLocalMuteBody({audioMuted: true, videoMuted: false}),
198
+ });
199
+
200
+ });
201
+
202
+ it('sends only roap when roap and local mute are requested', async () => {
203
+ await Promise.all([
204
+ sendLocalMute({audioMuted: false, videoMuted: undefined}),
205
+ sendRoapMessage('OFFER'),
206
+ sendLocalMute({audioMuted: true, videoMuted: false}),
207
+ ]);
208
+
209
+ /* check that only the roap message was sent and it had the last
210
+ values for audio and video mute
211
+ */
212
+ assert.calledOnceWithExactly(webexRequestStub, {
213
+ method: 'PUT',
214
+ uri: 'fakeMeetingSelfUrl/media',
215
+ body: createExpectedRoapBody('OFFER', {audioMuted: true, videoMuted: false}),
216
+ });
217
+ });
218
+
219
+ describe('queueing', () => {
220
+ let clock;
221
+ let requestsToLocus;
222
+ let results;
223
+
224
+ beforeEach(() => {
225
+ clock = sinon.useFakeTimers();
226
+ requestsToLocus = [];
227
+ results = [];
228
+
229
+ // setup the mock so that each new request that we send to Locus,
230
+ // returns a promise that we control from this test
231
+ webexRequestStub.callsFake(() => {
232
+ const defer = new Defer();
233
+ requestsToLocus.push(defer);
234
+ return defer.promise;
235
+ });
236
+ });
237
+
238
+ afterEach(() => {
239
+ clock.restore();
240
+ });
241
+
242
+ /** LocusMediaRequest.send() uses Lodash.defer(), so it only starts sending any requests
243
+ * after the processing cycle from which it was called is finished.
244
+ * This helper function waits for this to happen - it's needed, because we're using
245
+ * fake timers in these tests
246
+ */
247
+ const ensureQueueProcessingIsStarted = () => {
248
+ clock.tick(1);
249
+ }
250
+ it('queues requests if there is one already in progress', async () => {
251
+ results.push(sendRoapMessage('OFFER'));
252
+
253
+ ensureQueueProcessingIsStarted();
254
+
255
+ // check that OFFER has been sent out
256
+ assert.calledWith(webexRequestStub, {
257
+ method: 'PUT',
258
+ uri: 'fakeMeetingSelfUrl/media',
259
+ body: createExpectedRoapBody('OFFER', {audioMuted: true, videoMuted: true}),
260
+ });
261
+
262
+ webexRequestStub.resetHistory();
263
+
264
+ // at this point the request should be sent out and "in progress",
265
+ // so any further calls should be queued
266
+ results.push(sendRoapMessage('OK'));
267
+
268
+ // OK should not be sent out yet, only queued
269
+ assert.notCalled(webexRequestStub);
270
+
271
+ // now simulate the first locus request (offer) to resolve,
272
+ // so that the next request from the queue (ok) can be sent out
273
+ requestsToLocus[0].resolve();
274
+ await testUtils.flushPromises();
275
+ ensureQueueProcessingIsStarted();
276
+
277
+ // verify OK was sent out
278
+ assert.calledWith(webexRequestStub, {
279
+ method: 'PUT',
280
+ uri: 'fakeMeetingSelfUrl/media',
281
+ body: createExpectedRoapBody('OK', {audioMuted: true, videoMuted: true}),
282
+ });
283
+
284
+ // promise returned by the first call to send OFFER should be resolved by now
285
+ await results[0];
286
+
287
+ // simulate Locus sending http response to OK
288
+ requestsToLocus[1].resolve();
289
+ await results[1];
290
+ });
291
+
292
+ it('combines local mute requests into a single /media request to Locus when queueing', async () => {
293
+ results.push(sendRoapMessage('OFFER'));
294
+ results.push(sendLocalMute({audioMuted: false, videoMuted: false}));
295
+
296
+ ensureQueueProcessingIsStarted();
297
+
298
+ // check that OFFER and local mute have been combined into
299
+ // a single OFFER request with the right mute values
300
+ assert.calledOnceWithExactly(webexRequestStub, {
301
+ method: 'PUT',
302
+ uri: 'fakeMeetingSelfUrl/media',
303
+ body: createExpectedRoapBody('OFFER', {audioMuted: false, videoMuted: false}),
304
+ });
305
+
306
+ webexRequestStub.resetHistory();
307
+
308
+ // at this point the request should be sent out and "in progress",
309
+ // so any further calls should be queued
310
+ results.push(sendLocalMute({audioMuted: true, videoMuted: false}));
311
+ results.push(sendRoapMessage('OK'));
312
+ results.push(sendLocalMute({audioMuted: false, videoMuted: true}));
313
+
314
+ // nothing should be sent out yet, only queued
315
+ assert.notCalled(webexRequestStub);
316
+
317
+ // now simulate the first locus request (offer) to resolve,
318
+ // so that the next request from the queue (ok) can be sent out
319
+ requestsToLocus[0].resolve();
320
+ await testUtils.flushPromises();
321
+ ensureQueueProcessingIsStarted();
322
+
323
+ // verify OK was sent out
324
+ assert.calledOnceWithExactly(webexRequestStub, {
325
+ method: 'PUT',
326
+ uri: 'fakeMeetingSelfUrl/media',
327
+ body: createExpectedRoapBody('OK', {audioMuted: false, videoMuted: true}),
328
+ });
329
+
330
+ // promise returned by the first call to send OFFER should be resolved by now
331
+ await results[0];
332
+
333
+ // simulate Locus sending http response to OK
334
+ requestsToLocus[1].resolve();
335
+ await results[1];
336
+ });
337
+
338
+ describe('confluence creation', () => {
339
+ it('resolves without sending the request if LocalMute is requested before Roap Offer is sent (confluence state is "not created")', async () => {
340
+ const result = await sendLocalMute({audioMuted: false, videoMuted: true});
341
+
342
+ assert.notCalled(webexRequestStub);
343
+ assert.deepEqual(result, {});
344
+ });
345
+
346
+ it('queues LocalMute if requested after first Roap Offer was sent but before it got http response (confluence state is "creation in progress")', async () => {
347
+ let result;
348
+
349
+ // send roap offer so that confluence state is "creation in progress"
350
+ sendRoapMessage('OFFER');
351
+
352
+ ensureQueueProcessingIsStarted();
353
+
354
+ sendLocalMute({audioMuted: false, videoMuted: true})
355
+ .then((response) => {
356
+ result = response;
357
+ });
358
+
359
+ // only roap offer should have been sent so far
360
+ assert.calledOnceWithExactly(webexRequestStub, {
361
+ method: 'PUT',
362
+ uri: 'fakeMeetingSelfUrl/media',
363
+ body: createExpectedRoapBody('OFFER', {audioMuted: true, videoMuted: true}),
364
+ });
365
+ assert.equal(result, undefined); // sendLocalMute shouldn't resolve yet, as the request should be queued
366
+
367
+ // now let the Offer be completed - so confluence state will be "complete"
368
+ webexRequestStub.resetHistory();
369
+ requestsToLocus[0].resolve({});
370
+ await testUtils.flushPromises();
371
+
372
+ // now the queued up local mute request should have been sent out
373
+ assert.calledOnceWithExactly(webexRequestStub, {
374
+ method: 'PUT',
375
+ uri: 'fakeMeetingSelfUrl/media',
376
+ body: createExpectedLocalMuteBody({audioMuted: false, videoMuted: true}),
377
+ });
378
+
379
+ // check also the result once Locus replies to local mute
380
+ const fakeLocusResponse = { response: 'ok'};
381
+ requestsToLocus[1].resolve(fakeLocusResponse);
382
+ await testUtils.flushPromises();
383
+ assert.deepEqual(result, fakeLocusResponse);
384
+ });
385
+ });
386
+
387
+ it('sends LocalMute request if Offer was already sent and Locus replied (confluence state is "completed")', async () => {
388
+ let result;
389
+
390
+ // send roap offer and ensure it's completed
391
+ sendRoapMessage('OFFER');
392
+ ensureQueueProcessingIsStarted();
393
+ requestsToLocus[0].resolve({});
394
+ await testUtils.flushPromises();
395
+ webexRequestStub.resetHistory();
396
+
397
+ // now send local mute
398
+ sendLocalMute({audioMuted: false, videoMuted: true})
399
+ .then((response) => {
400
+ result = response;
401
+ });
402
+
403
+ ensureQueueProcessingIsStarted();
404
+
405
+ // it should be sent out
406
+ assert.calledOnceWithExactly(webexRequestStub, {
407
+ method: 'PUT',
408
+ uri: 'fakeMeetingSelfUrl/media',
409
+ body: createExpectedLocalMuteBody({audioMuted: false, videoMuted: true}),
410
+ });
411
+ });
412
+
413
+ });
414
+ })