@webex/plugin-meetings 3.11.0 → 3.12.0-mobius-socket.2

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 (175) hide show
  1. package/dist/aiEnableRequest/index.js +184 -0
  2. package/dist/aiEnableRequest/index.js.map +1 -0
  3. package/dist/aiEnableRequest/utils.js +36 -0
  4. package/dist/aiEnableRequest/utils.js.map +1 -0
  5. package/dist/annotation/index.js +14 -5
  6. package/dist/annotation/index.js.map +1 -1
  7. package/dist/breakouts/breakout.js +1 -1
  8. package/dist/breakouts/index.js +1 -1
  9. package/dist/config.js +7 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/constants.js +28 -6
  12. package/dist/constants.js.map +1 -1
  13. package/dist/hashTree/constants.js +3 -1
  14. package/dist/hashTree/constants.js.map +1 -1
  15. package/dist/hashTree/hashTree.js +18 -0
  16. package/dist/hashTree/hashTree.js.map +1 -1
  17. package/dist/hashTree/hashTreeParser.js +868 -419
  18. package/dist/hashTree/hashTreeParser.js.map +1 -1
  19. package/dist/hashTree/types.js +4 -2
  20. package/dist/hashTree/types.js.map +1 -1
  21. package/dist/hashTree/utils.js +10 -0
  22. package/dist/hashTree/utils.js.map +1 -1
  23. package/dist/index.js +11 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/interceptors/constant.js +12 -0
  26. package/dist/interceptors/constant.js.map +1 -0
  27. package/dist/interceptors/dataChannelAuthToken.js +290 -0
  28. package/dist/interceptors/dataChannelAuthToken.js.map +1 -0
  29. package/dist/interceptors/index.js +7 -0
  30. package/dist/interceptors/index.js.map +1 -1
  31. package/dist/interceptors/utils.js +27 -0
  32. package/dist/interceptors/utils.js.map +1 -0
  33. package/dist/interpretation/index.js +2 -2
  34. package/dist/interpretation/index.js.map +1 -1
  35. package/dist/interpretation/siLanguage.js +1 -1
  36. package/dist/locus-info/controlsUtils.js +5 -3
  37. package/dist/locus-info/controlsUtils.js.map +1 -1
  38. package/dist/locus-info/index.js +522 -131
  39. package/dist/locus-info/index.js.map +1 -1
  40. package/dist/locus-info/selfUtils.js +1 -0
  41. package/dist/locus-info/selfUtils.js.map +1 -1
  42. package/dist/locus-info/types.js.map +1 -1
  43. package/dist/media/MediaConnectionAwaiter.js +57 -1
  44. package/dist/media/MediaConnectionAwaiter.js.map +1 -1
  45. package/dist/media/properties.js +4 -2
  46. package/dist/media/properties.js.map +1 -1
  47. package/dist/meeting/in-meeting-actions.js +7 -1
  48. package/dist/meeting/in-meeting-actions.js.map +1 -1
  49. package/dist/meeting/index.js +1304 -928
  50. package/dist/meeting/index.js.map +1 -1
  51. package/dist/meeting/request.js +50 -0
  52. package/dist/meeting/request.js.map +1 -1
  53. package/dist/meeting/request.type.js.map +1 -1
  54. package/dist/meeting/util.js +133 -3
  55. package/dist/meeting/util.js.map +1 -1
  56. package/dist/meetings/index.js +117 -48
  57. package/dist/meetings/index.js.map +1 -1
  58. package/dist/member/index.js +10 -0
  59. package/dist/member/index.js.map +1 -1
  60. package/dist/member/util.js +10 -0
  61. package/dist/member/util.js.map +1 -1
  62. package/dist/metrics/constants.js +6 -1
  63. package/dist/metrics/constants.js.map +1 -1
  64. package/dist/multistream/mediaRequestManager.js +9 -60
  65. package/dist/multistream/mediaRequestManager.js.map +1 -1
  66. package/dist/multistream/remoteMediaManager.js +11 -0
  67. package/dist/multistream/remoteMediaManager.js.map +1 -1
  68. package/dist/multistream/sendSlotManager.js +116 -2
  69. package/dist/multistream/sendSlotManager.js.map +1 -1
  70. package/dist/reachability/index.js +18 -10
  71. package/dist/reachability/index.js.map +1 -1
  72. package/dist/reactions/reactions.type.js.map +1 -1
  73. package/dist/reconnection-manager/index.js +0 -1
  74. package/dist/reconnection-manager/index.js.map +1 -1
  75. package/dist/types/aiEnableRequest/index.d.ts +5 -0
  76. package/dist/types/aiEnableRequest/utils.d.ts +2 -0
  77. package/dist/types/config.d.ts +4 -0
  78. package/dist/types/constants.d.ts +23 -1
  79. package/dist/types/hashTree/constants.d.ts +1 -0
  80. package/dist/types/hashTree/hashTree.d.ts +7 -0
  81. package/dist/types/hashTree/hashTreeParser.d.ts +122 -14
  82. package/dist/types/hashTree/types.d.ts +3 -0
  83. package/dist/types/hashTree/utils.d.ts +6 -0
  84. package/dist/types/index.d.ts +1 -0
  85. package/dist/types/interceptors/constant.d.ts +5 -0
  86. package/dist/types/interceptors/dataChannelAuthToken.d.ts +43 -0
  87. package/dist/types/interceptors/index.d.ts +2 -1
  88. package/dist/types/interceptors/utils.d.ts +1 -0
  89. package/dist/types/locus-info/index.d.ts +60 -8
  90. package/dist/types/locus-info/types.d.ts +7 -0
  91. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  92. package/dist/types/media/properties.d.ts +2 -1
  93. package/dist/types/meeting/in-meeting-actions.d.ts +6 -0
  94. package/dist/types/meeting/index.d.ts +72 -7
  95. package/dist/types/meeting/request.d.ts +16 -1
  96. package/dist/types/meeting/request.type.d.ts +5 -0
  97. package/dist/types/meeting/util.d.ts +31 -0
  98. package/dist/types/meetings/index.d.ts +4 -2
  99. package/dist/types/member/index.d.ts +1 -0
  100. package/dist/types/member/util.d.ts +5 -0
  101. package/dist/types/metrics/constants.d.ts +5 -0
  102. package/dist/types/multistream/mediaRequestManager.d.ts +0 -23
  103. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  104. package/dist/types/reactions/reactions.type.d.ts +1 -0
  105. package/dist/types/webinar/utils.d.ts +6 -0
  106. package/dist/webinar/index.js +438 -163
  107. package/dist/webinar/index.js.map +1 -1
  108. package/dist/webinar/utils.js +25 -0
  109. package/dist/webinar/utils.js.map +1 -0
  110. package/package.json +24 -23
  111. package/src/aiEnableRequest/README.md +84 -0
  112. package/src/aiEnableRequest/index.ts +170 -0
  113. package/src/aiEnableRequest/utils.ts +25 -0
  114. package/src/annotation/index.ts +27 -7
  115. package/src/config.ts +4 -0
  116. package/src/constants.ts +29 -1
  117. package/src/hashTree/constants.ts +1 -0
  118. package/src/hashTree/hashTree.ts +17 -0
  119. package/src/hashTree/hashTreeParser.ts +761 -260
  120. package/src/hashTree/types.ts +4 -0
  121. package/src/hashTree/utils.ts +9 -0
  122. package/src/index.ts +8 -1
  123. package/src/interceptors/constant.ts +6 -0
  124. package/src/interceptors/dataChannelAuthToken.ts +170 -0
  125. package/src/interceptors/index.ts +2 -1
  126. package/src/interceptors/utils.ts +16 -0
  127. package/src/interpretation/index.ts +2 -2
  128. package/src/locus-info/controlsUtils.ts +11 -0
  129. package/src/locus-info/index.ts +579 -113
  130. package/src/locus-info/selfUtils.ts +1 -0
  131. package/src/locus-info/types.ts +8 -0
  132. package/src/media/MediaConnectionAwaiter.ts +41 -1
  133. package/src/media/properties.ts +3 -1
  134. package/src/meeting/in-meeting-actions.ts +12 -0
  135. package/src/meeting/index.ts +389 -87
  136. package/src/meeting/request.ts +42 -0
  137. package/src/meeting/request.type.ts +6 -0
  138. package/src/meeting/util.ts +160 -2
  139. package/src/meetings/index.ts +157 -44
  140. package/src/member/index.ts +10 -0
  141. package/src/member/util.ts +12 -0
  142. package/src/metrics/constants.ts +6 -0
  143. package/src/multistream/mediaRequestManager.ts +4 -54
  144. package/src/multistream/remoteMediaManager.ts +13 -0
  145. package/src/multistream/sendSlotManager.ts +97 -3
  146. package/src/reachability/index.ts +9 -0
  147. package/src/reactions/reactions.type.ts +1 -0
  148. package/src/reconnection-manager/index.ts +0 -1
  149. package/src/webinar/index.ts +265 -6
  150. package/src/webinar/utils.ts +16 -0
  151. package/test/unit/spec/aiEnableRequest/index.ts +981 -0
  152. package/test/unit/spec/aiEnableRequest/utils.ts +130 -0
  153. package/test/unit/spec/annotation/index.ts +69 -7
  154. package/test/unit/spec/hashTree/hashTree.ts +66 -0
  155. package/test/unit/spec/hashTree/hashTreeParser.ts +2321 -175
  156. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +210 -0
  157. package/test/unit/spec/interceptors/utils.ts +75 -0
  158. package/test/unit/spec/locus-info/controlsUtils.js +29 -0
  159. package/test/unit/spec/locus-info/index.js +1134 -55
  160. package/test/unit/spec/media/MediaConnectionAwaiter.ts +41 -1
  161. package/test/unit/spec/media/properties.ts +12 -3
  162. package/test/unit/spec/meeting/in-meeting-actions.ts +8 -2
  163. package/test/unit/spec/meeting/index.js +884 -152
  164. package/test/unit/spec/meeting/request.js +70 -0
  165. package/test/unit/spec/meeting/utils.js +438 -26
  166. package/test/unit/spec/meetings/index.js +653 -32
  167. package/test/unit/spec/member/index.js +28 -4
  168. package/test/unit/spec/member/util.js +65 -27
  169. package/test/unit/spec/multistream/mediaRequestManager.ts +2 -85
  170. package/test/unit/spec/multistream/remoteMediaManager.ts +30 -0
  171. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  172. package/test/unit/spec/reachability/index.ts +23 -0
  173. package/test/unit/spec/reconnection-manager/index.js +4 -8
  174. package/test/unit/spec/webinar/index.ts +534 -37
  175. package/test/unit/spec/webinar/utils.ts +39 -0
@@ -1,9 +1,11 @@
1
- import {assert, expect} from '@webex/test-helper-chai';
1
+ import {assert} from '@webex/test-helper-chai';
2
2
  import LoggerProxy from '@webex/plugin-meetings/src/common/logs/logger-proxy';
3
3
  import Webinar from '@webex/plugin-meetings/src/webinar';
4
4
  import MockWebex from '@webex/test-helper-mock-webex';
5
5
  import uuid from 'uuid';
6
6
  import sinon from 'sinon';
7
+ import {DataChannelTokenType} from '@webex/internal-plugin-llm';
8
+ import {LLM_PRACTICE_SESSION, SHARE_STATUS} from '@webex/plugin-meetings/src/constants';
7
9
 
8
10
  describe('plugin-meetings', () => {
9
11
  describe('Webinar', () => {
@@ -26,7 +28,20 @@ describe('plugin-meetings', () => {
26
28
  webex.meetings = {};
27
29
  webex.credentials.getUserToken = getUserTokenStub;
28
30
  webex.meetings.getMeetingByType = sinon.stub();
29
-
31
+ webex.internal.voicea.announce = sinon.stub();
32
+
33
+ webex.internal.llm = {
34
+ getDatachannelToken: sinon.stub().returns(undefined),
35
+ setDatachannelToken: sinon.stub(),
36
+ isDataChannelTokenEnabled: sinon.stub().resolves(false),
37
+ isConnected: sinon.stub().returns(false),
38
+ disconnectLLM: sinon.stub().resolves(),
39
+ off: sinon.stub(),
40
+ on: sinon.stub(),
41
+ getLocusUrl: sinon.stub().returns('old-locus-url'),
42
+ getDatachannelUrl: sinon.stub().returns('old-dc-url'),
43
+ registerAndConnect: sinon.stub().resolves('REGISTER_AND_CONNECT_RESULT'),
44
+ };
30
45
  });
31
46
 
32
47
  afterEach(() => {
@@ -147,20 +162,399 @@ describe('plugin-meetings', () => {
147
162
  assert.equal(result.isPromoted, false, 'should not indicate promotion');
148
163
  assert.equal(result.isDemoted, false, 'should not indicate demotion');
149
164
  });
165
+
166
+ it('handles missing role payload safely', () => {
167
+ const updateStatusByRoleStub = sinon.stub(webinar, 'updateStatusByRole');
168
+
169
+ const result = webinar.updateRoleChanged(undefined);
170
+
171
+ assert.equal(webinar.selfIsPanelist, false);
172
+ assert.equal(webinar.selfIsAttendee, false);
173
+ assert.equal(webinar.canManageWebcast, false);
174
+ assert.deepEqual(result, {isPromoted: false, isDemoted: false});
175
+ assert.calledOnceWithExactly(updateStatusByRoleStub, {isPromoted: false, isDemoted: false});
176
+ });
177
+ });
178
+
179
+ describe('#cleanUp', () => {
180
+ it('delegates to cleanupPSDataChannel', () => {
181
+ const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
182
+
183
+ webinar.cleanUp();
184
+
185
+ assert.calledOnceWithExactly(cleanupPSDataChannelStub);
186
+ });
187
+ });
188
+
189
+ describe('#cleanupPSDataChannel', () => {
190
+ let meeting;
191
+
192
+ beforeEach(() => {
193
+ meeting = {
194
+ processRelayEvent: sinon.stub(),
195
+ };
196
+
197
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
198
+ });
199
+
200
+ it('disconnects the practice session channel and removes the relay listener', async () => {
201
+ await webinar.cleanupPSDataChannel();
202
+
203
+ assert.calledOnceWithExactly(
204
+ webex.internal.llm.disconnectLLM,
205
+ {code: 3050, reason: 'done (permanent)'},
206
+ LLM_PRACTICE_SESSION
207
+ );
208
+ assert.calledOnceWithExactly(
209
+ webex.internal.llm.off,
210
+ `event:relay.event:${LLM_PRACTICE_SESSION}`,
211
+ meeting.processRelayEvent
212
+ );
213
+ });
214
+
215
+ it('removes a pending online listener if one exists', async () => {
216
+ const listener = sinon.stub();
217
+ webinar._pendingOnlineListener = listener;
218
+
219
+ await webinar.cleanupPSDataChannel();
220
+
221
+ assert.calledWith(webex.internal.llm.off, 'online', listener);
222
+ assert.isNull(webinar._pendingOnlineListener);
223
+ });
224
+
225
+ it('skips online listener removal when none is pending', async () => {
226
+ webinar._pendingOnlineListener = null;
227
+
228
+ await webinar.cleanupPSDataChannel();
229
+
230
+ // 'off' should only be called for the relay event, not for 'online'
231
+ const onlineOffCalls = webex.internal.llm.off.args.filter(([event]) => event === 'online');
232
+ assert.equal(onlineOffCalls.length, 0);
233
+ });
234
+ });
235
+
236
+ describe('#updatePSDataChannel', () => {
237
+ let meeting;
238
+ let processRelayEvent;
239
+
240
+ beforeEach(() => {
241
+ processRelayEvent = sinon.stub();
242
+ meeting = {
243
+ isJoined: sinon.stub().returns(true),
244
+ processRelayEvent,
245
+ locusInfo: {
246
+ url: 'locus-url',
247
+ info: {practiceSessionDatachannelUrl: 'dc-url'},
248
+ },
249
+ };
250
+
251
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
252
+
253
+ // Default session is connected by default; practice session is not
254
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
255
+ return sessionId !== LLM_PRACTICE_SESSION;
256
+ });
257
+
258
+ // Token is pre-saved into LLM by saveDataChannelToken
259
+ webex.internal.llm.getDatachannelToken = sinon.stub().callsFake((tokenType) => {
260
+ if (tokenType === DataChannelTokenType.PracticeSession) return 'ps-token';
261
+ return undefined;
262
+ });
263
+
264
+ // Ensure connect path is eligible
265
+ webinar.selfIsPanelist = true;
266
+ webinar.practiceSessionEnabled = true;
267
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
268
+ webex.internal.voicea.updateSubchannelSubscriptions = sinon.stub();
269
+ });
270
+
271
+ it('refreshes practice-session token before register when cached token is missing', async () => {
272
+ webex.internal.llm.isDataChannelTokenEnabled.resolves(true);
273
+ webex.internal.llm.getDatachannelToken = sinon.stub().callsFake((tokenType) => {
274
+ if (tokenType === DataChannelTokenType.PracticeSession) return undefined;
275
+
276
+ return undefined;
277
+ });
278
+ meeting.refreshDataChannelToken = sinon.stub().resolves({
279
+ body: {
280
+ datachannelToken: 'ps-token-from-refresh',
281
+ dataChannelTokenType: DataChannelTokenType.PracticeSession,
282
+ },
283
+ });
284
+
285
+ await webinar.updatePSDataChannel();
286
+
287
+ assert.calledOnceWithExactly(meeting.refreshDataChannelToken);
288
+ assert.calledWithExactly(
289
+ webex.internal.llm.setDatachannelToken,
290
+ 'ps-token-from-refresh',
291
+ DataChannelTokenType.PracticeSession
292
+ );
293
+ assert.calledWith(
294
+ webex.internal.llm.registerAndConnect,
295
+ 'locus-url',
296
+ 'dc-url',
297
+ 'ps-token-from-refresh',
298
+ LLM_PRACTICE_SESSION
299
+ );
300
+ });
301
+
302
+ it('does not reconnect if practice-session eligibility changes during async token refresh', async () => {
303
+ webex.internal.llm.isDataChannelTokenEnabled.resolves(true);
304
+ webex.internal.llm.getDatachannelToken = sinon.stub().returns(undefined);
305
+
306
+ let resolveRefresh;
307
+ meeting.refreshDataChannelToken = sinon.stub().returns(
308
+ new Promise((resolve) => {
309
+ resolveRefresh = resolve;
310
+ })
311
+ );
312
+
313
+ const updatePromise = webinar.updatePSDataChannel();
314
+
315
+ webinar.practiceSessionEnabled = false;
316
+
317
+ resolveRefresh({
318
+ body: {
319
+ datachannelToken: 'stale-ps-token',
320
+ dataChannelTokenType: DataChannelTokenType.PracticeSession,
321
+ },
322
+ });
323
+
324
+ const result = await updatePromise;
325
+
326
+ assert.isUndefined(result);
327
+ assert.notCalled(webex.internal.llm.registerAndConnect);
328
+ });
329
+
330
+ it('no-ops when practice session join eligibility is false', async () => {
331
+ webinar.practiceSessionEnabled = false;
332
+ const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
333
+
334
+ const result = await webinar.updatePSDataChannel();
335
+
336
+ assert.isUndefined(result);
337
+ assert.calledOnceWithExactly(cleanupPSDataChannelStub);
338
+ assert.notCalled(webex.internal.llm.registerAndConnect);
339
+ });
340
+
341
+ it('no-ops when meeting is not joined', async () => {
342
+ meeting.isJoined.returns(false);
343
+ const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
344
+
345
+ const result = await webinar.updatePSDataChannel();
346
+
347
+ assert.isUndefined(result);
348
+ assert.calledOnceWithExactly(cleanupPSDataChannelStub);
349
+ assert.notCalled(webex.internal.llm.registerAndConnect);
350
+ });
351
+
352
+ it('no-ops when practiceSessionDatachannelUrl is missing', async () => {
353
+ meeting.locusInfo.info.practiceSessionDatachannelUrl = undefined;
354
+
355
+ const result = await webinar.updatePSDataChannel();
356
+
357
+ assert.isUndefined(result);
358
+ assert.notCalled(webex.internal.llm.registerAndConnect);
359
+ });
360
+
361
+ it('no-ops when already connected to the same endpoints', async () => {
362
+ webex.internal.llm.isConnected.returns(true);
363
+ webex.internal.llm.getLocusUrl.returns('locus-url');
364
+ webex.internal.llm.getDatachannelUrl.returns('dc-url');
365
+ const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
366
+
367
+ const result = await webinar.updatePSDataChannel();
368
+
369
+ assert.isUndefined(result);
370
+ assert.notCalled(cleanupPSDataChannelStub);
371
+ assert.notCalled(webex.internal.llm.registerAndConnect);
372
+ });
373
+
374
+ it('connects when eligible', async () => {
375
+ const result = await webinar.updatePSDataChannel();
376
+
377
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
378
+ assert.calledWith(
379
+ webex.internal.llm.registerAndConnect,
380
+ 'locus-url',
381
+ 'dc-url',
382
+ 'ps-token',
383
+ LLM_PRACTICE_SESSION
384
+ );
385
+ assert.calledOnceWithExactly(webex.internal.voicea.announce);
386
+ assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
387
+ });
388
+
389
+ it('uses token from LLM', async () => {
390
+ webex.internal.llm.getDatachannelToken = sinon.stub().callsFake((tokenType) => {
391
+ if (tokenType === DataChannelTokenType.PracticeSession) return 'cached-token';
392
+ return undefined;
393
+ });
394
+
395
+ await webinar.updatePSDataChannel();
396
+
397
+ assert.calledWithExactly(
398
+ webex.internal.llm.getDatachannelToken,
399
+ DataChannelTokenType.PracticeSession
400
+ );
401
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
402
+ assert.calledWith(
403
+ webex.internal.llm.registerAndConnect,
404
+ 'locus-url',
405
+ 'dc-url',
406
+ 'cached-token',
407
+ LLM_PRACTICE_SESSION
408
+ );
409
+ });
410
+
411
+ it('cleans up the existing practice session channel before reconnecting to new endpoints', async () => {
412
+ webex.internal.llm.isConnected.returns(true);
413
+ const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
414
+
415
+ await webinar.updatePSDataChannel();
416
+
417
+ assert.calledOnceWithExactly(cleanupPSDataChannelStub);
418
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
419
+ });
420
+
421
+ it('rebinds relay listener after successful connect', async () => {
422
+ await webinar.updatePSDataChannel();
423
+
424
+ assert.calledWith(
425
+ webex.internal.llm.off,
426
+ `event:relay.event:${LLM_PRACTICE_SESSION}`,
427
+ processRelayEvent
428
+ );
429
+ assert.calledWith(
430
+ webex.internal.llm.on,
431
+ `event:relay.event:${LLM_PRACTICE_SESSION}`,
432
+ processRelayEvent
433
+ );
434
+ });
435
+
436
+ it('subscribes to transcription when caption intent is enabled', async () => {
437
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(true);
438
+
439
+ await webinar.updatePSDataChannel();
440
+
441
+ assert.calledOnceWithExactly(webex.internal.voicea.updateSubchannelSubscriptions, { subscribe: ['transcription'] });
442
+ });
443
+
444
+ it('does not subscribe to transcription when caption intent is disabled', async () => {
445
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
446
+
447
+ await webinar.updatePSDataChannel();
448
+
449
+ assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
450
+ });
451
+
452
+ it('defers connect when default session is not yet connected', async () => {
453
+ // Default session is not connected initially
454
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
455
+
456
+ const result = await webinar.updatePSDataChannel();
457
+
458
+ // Should return undefined immediately (deferred)
459
+ assert.isUndefined(result);
460
+ // Should register an 'online' listener but NOT call registerAndConnect yet
461
+ assert.calledWith(webex.internal.llm.on, 'online', sinon.match.func);
462
+ assert.notCalled(webex.internal.llm.registerAndConnect);
463
+ // Should store the pending listener
464
+ assert.isNotNull(webinar._pendingOnlineListener);
465
+ });
466
+
467
+ it('does not register duplicate online listeners on repeated calls', async () => {
468
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
469
+
470
+ await webinar.updatePSDataChannel();
471
+ await webinar.updatePSDataChannel();
472
+ await webinar.updatePSDataChannel();
473
+
474
+ // Only one 'online' listener should have been registered
475
+ const onlineCalls = webex.internal.llm.on.args.filter(([event]) => event === 'online');
476
+ assert.equal(onlineCalls.length, 1, 'should register exactly one online listener');
477
+ });
478
+
479
+ it('re-invokes updatePSDataChannel when default session comes online', async () => {
480
+ // Default session is not connected initially
481
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
482
+
483
+ const updatePSDataChannelSpy = sinon.spy(webinar, 'updatePSDataChannel');
484
+
485
+ // First call defers
486
+ await webinar.updatePSDataChannel();
487
+
488
+ // Capture the 'online' listener
489
+ const onlineCall = webex.internal.llm.on.args.find(([event]) => event === 'online');
490
+ assert.isDefined(onlineCall, 'should have registered an online listener');
491
+
492
+ // Now simulate default session coming online
493
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
494
+ return sessionId !== LLM_PRACTICE_SESSION;
495
+ });
496
+
497
+ // Fire the captured listener
498
+ onlineCall[1]();
499
+
500
+ // The listener should have cleared itself, removed itself, and re-called updatePSDataChannel
501
+ assert.isNull(webinar._pendingOnlineListener);
502
+ assert.calledWith(webex.internal.llm.off, 'online', sinon.match.func);
503
+ assert.equal(updatePSDataChannelSpy.callCount, 2);
504
+ });
505
+
506
+ it('does not reconnect with stale data if demoted before default session comes online', async () => {
507
+ // Default session is not connected initially
508
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
509
+
510
+ await webinar.updatePSDataChannel();
511
+
512
+ // Capture the 'online' listener
513
+ const onlineCall = webex.internal.llm.on.args.find(([event]) => event === 'online');
514
+ assert.isDefined(onlineCall);
515
+
516
+ // Simulate demotion while waiting
517
+ webinar.selfIsPanelist = false;
518
+
519
+ // Now default session comes online
520
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
521
+ return sessionId !== LLM_PRACTICE_SESSION;
522
+ });
523
+
524
+ // Fire the listener — re-invokes updatePSDataChannel which will see isPracticeSession = false
525
+ onlineCall[1]();
526
+
527
+ // Should NOT have called registerAndConnect since the user is no longer eligible
528
+ assert.notCalled(webex.internal.llm.registerAndConnect);
529
+ });
530
+
531
+ it('proceeds immediately when default session is already connected', async () => {
532
+ // Default session already connected, practice session not
533
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
534
+ return sessionId !== LLM_PRACTICE_SESSION;
535
+ });
536
+
537
+ const result = await webinar.updatePSDataChannel();
538
+
539
+ // The 'online' listener is registered then immediately removed since default session is already connected
540
+ assert.calledWith(webex.internal.llm.on, 'online', sinon.match.func);
541
+ assert.calledWith(webex.internal.llm.off, 'online', sinon.match.func);
542
+ assert.isNull(webinar._pendingOnlineListener);
543
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
544
+ assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
545
+ });
150
546
  });
151
547
 
152
548
  describe('#updateStatusByRole', () => {
153
- let updateLLMConnection;
154
549
  let updateMediaShares;
155
550
  beforeEach(() => {
156
- // @ts-ignore
157
- updateLLMConnection = sinon.stub();
158
551
  updateMediaShares = sinon.stub()
159
552
  webinar.webex.meetings = {
160
553
  getMeetingByType: sinon.stub().returns({
161
554
  id: 'meeting-id',
162
- updateLLMConnection: updateLLMConnection,
163
- shareStatus: 'whiteboard_share_active',
555
+ isJoined: sinon.stub().returns(false),
556
+ updateLLMConnection: sinon.stub(),
557
+ shareStatus: SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE,
164
558
  locusInfo: {
165
559
  mediaShares: 'mediaShares',
166
560
  updateMediaShares: updateMediaShares
@@ -173,40 +567,20 @@ describe('plugin-meetings', () => {
173
567
  sinon.restore();
174
568
  });
175
569
 
176
- it('trigger updateLLMConnection if PS started', () => {
177
-
178
- webinar.practiceSessionEnabled = true;
179
- const roleChange = {isPromoted: true, isDemoted: false};
180
-
181
- const result = webinar.updateStatusByRole(roleChange);
182
-
183
- assert.calledOnce(updateLLMConnection);
184
- });
185
-
186
- it('Not trigger updateLLMConnection if PS not started', () => {
187
-
188
- webinar.practiceSessionEnabled = false;
189
- const roleChange = {isPromoted: true, isDemoted: false};
190
-
191
- const result = webinar.updateStatusByRole(roleChange);
192
-
193
- assert.notCalled(updateLLMConnection);
194
- });
195
-
196
570
  it('trigger updateMediaShares if promoted', () => {
197
571
 
198
572
  const roleChange = {isPromoted: true, isDemoted: false};
199
573
 
200
- const result = webinar.updateStatusByRole(roleChange);
574
+ webinar.updateStatusByRole(roleChange);
201
575
 
202
- assert.calledOnce(updateMediaShares);
576
+ assert.calledOnceWithExactly(updateMediaShares, 'mediaShares', true);
203
577
  });
204
578
 
205
579
  it('Not trigger updateMediaShares if no role change', () => {
206
580
 
207
581
  const roleChange = {isPromoted: false, isDemoted: false};
208
582
 
209
- const result = webinar.updateStatusByRole(roleChange);
583
+ webinar.updateStatusByRole(roleChange);
210
584
 
211
585
  assert.notCalled(updateMediaShares);
212
586
  });
@@ -214,18 +588,18 @@ describe('plugin-meetings', () => {
214
588
 
215
589
  const roleChange = {isPromoted: true, isDemoted: false};
216
590
 
217
- const result = webinar.updateStatusByRole(roleChange);
591
+ webinar.updateStatusByRole(roleChange);
218
592
 
219
- assert.calledOnce(updateMediaShares);
593
+ assert.calledOnceWithExactly(updateMediaShares, 'mediaShares', true);
220
594
  });
221
595
 
222
596
  it('trigger updateMediaShares if is attendee with whiteboard share', () => {
223
597
 
224
598
  const roleChange = {isPromoted: false, isDemoted: true};
225
599
 
226
- const result = webinar.updateStatusByRole(roleChange);
600
+ webinar.updateStatusByRole(roleChange);
227
601
 
228
- assert.calledOnce(updateMediaShares);
602
+ assert.calledOnceWithExactly(updateMediaShares, 'mediaShares', true);
229
603
  });
230
604
 
231
605
  it('Not trigger updateMediaShares if is attendee with screen share', () => {
@@ -233,8 +607,9 @@ describe('plugin-meetings', () => {
233
607
  webinar.webex.meetings = {
234
608
  getMeetingByType: sinon.stub().returns({
235
609
  id: 'meeting-id',
236
- updateLLMConnection: updateLLMConnection,
237
- shareStatus: 'remote_share_active',
610
+ isJoined: sinon.stub().returns(false),
611
+ updateLLMConnection: sinon.stub(),
612
+ shareStatus: SHARE_STATUS.REMOTE_SHARE_ACTIVE,
238
613
  locusInfo: {
239
614
  mediaShares: 'mediaShares',
240
615
  updateMediaShares: updateMediaShares
@@ -244,10 +619,18 @@ describe('plugin-meetings', () => {
244
619
 
245
620
  const roleChange = {isPromoted: false, isDemoted: true};
246
621
 
247
- const result = webinar.updateStatusByRole(roleChange);
622
+ webinar.updateStatusByRole(roleChange);
248
623
 
249
624
  assert.notCalled(updateMediaShares);
250
625
  });
626
+
627
+ it('updates PS data channel based on join eligibility', () => {
628
+ const updatePSDataChannelStub = sinon.stub(webinar, 'updatePSDataChannel').resolves();
629
+
630
+ webinar.updateStatusByRole({isPromoted: false, isDemoted: false});
631
+
632
+ assert.calledOnceWithExactly(updatePSDataChannelStub);
633
+ });
251
634
  });
252
635
 
253
636
  describe("#setPracticeSessionState", () => {
@@ -323,6 +706,14 @@ describe('plugin-meetings', () => {
323
706
 
324
707
  assert.equal(webinar.practiceSessionEnabled, false);
325
708
  });
709
+ it('triggers PS data channel update using computed eligibility', () => {
710
+ webinar.selfIsPanelist = true;
711
+ const updatePSDataChannelStub = sinon.stub(webinar, 'updatePSDataChannel').resolves();
712
+
713
+ webinar.updatePracticeSessionStatus({enabled: true});
714
+
715
+ assert.calledOnceWithExactly(updatePSDataChannelStub);
716
+ });
326
717
  });
327
718
 
328
719
  describe("#startWebcast", () => {
@@ -631,5 +1022,111 @@ describe('plugin-meetings', () => {
631
1022
  }
632
1023
  });
633
1024
  });
1025
+
1026
+ describe("#searchLargeScaleWebinarAttendees", () => {
1027
+ const attendeeSearchUrl = 'https://locusUrl/attendees/search';
1028
+ const params = {
1029
+ queryString: 'queryString',
1030
+ limit: 50,
1031
+ next: null,
1032
+ };
1033
+ beforeEach(() => {
1034
+ // @ts-ignore
1035
+ webinar.webex.meetings = {
1036
+ getMeetingByType: sinon.stub().returns({
1037
+ id: 'meeting-id',
1038
+ locusInfo: {
1039
+ links:{
1040
+ resources: {
1041
+ attendeeSearch: {
1042
+ url: attendeeSearchUrl
1043
+ }
1044
+ }
1045
+ }
1046
+ }
1047
+ })
1048
+ };
1049
+ });
1050
+
1051
+ it('throws an error if attendeeSearchUrl is not available', async () => {
1052
+ webinar.webex.meetings = {
1053
+ getMeetingByType: sinon.stub().returns({
1054
+ id: 'meeting-id',
1055
+ locusInfo: {
1056
+ links:{
1057
+ resources: {
1058
+ attendeeSearch: {
1059
+ url: null
1060
+ }
1061
+ }
1062
+ }
1063
+ }
1064
+ })
1065
+ };
1066
+ try {
1067
+ await webinar.searchLargeScaleWebinarAttendees(params);
1068
+ assert.fail('searchLargeScaleWebinarAttendees should throw an error');
1069
+ } catch (error) {
1070
+ assert.equal(error.message,'Meeting:webinar5k#Attendee search url is not available', 'should throw the correct error');
1071
+ }
1072
+ });
1073
+
1074
+ it('sends a GET request to search the large scale webinar attendees', async () => {
1075
+ const result = await webinar.searchLargeScaleWebinarAttendees(params);
1076
+ assert.calledOnce(webex.request);
1077
+ assert.calledWith(webex.request, {
1078
+ method: 'GET',
1079
+ uri: `${attendeeSearchUrl}?search_text=${encodeURIComponent(params.queryString)}&limit=50`,
1080
+ headers: {
1081
+ authorization: 'test-token',
1082
+ trackingId: 'webex-js-sdk_test-uuid',
1083
+ },
1084
+ });
1085
+ assert.equal(
1086
+ result,
1087
+ 'REQUEST_RETURN_VALUE',
1088
+ 'should return the resolved value from the request'
1089
+ );
1090
+ });
1091
+
1092
+ it('queryString is empty string', async () => {
1093
+ params.queryString = '';
1094
+ const result = await webinar.searchLargeScaleWebinarAttendees(params);
1095
+ assert.calledOnce(webex.request);
1096
+ assert.calledWith(webex.request, {
1097
+ method: 'GET',
1098
+ uri: `${attendeeSearchUrl}?limit=50`,
1099
+ headers: {
1100
+ authorization: 'test-token',
1101
+ trackingId: 'webex-js-sdk_test-uuid',
1102
+ },
1103
+ });
1104
+ assert.equal(
1105
+ result,
1106
+ 'REQUEST_RETURN_VALUE',
1107
+ 'should return the resolved value from the request'
1108
+ );
1109
+ });
1110
+
1111
+ it('handles API call failures gracefully', async () => {
1112
+ webex.request.rejects(new Error('API_ERROR'));
1113
+ const errorLogger = sinon.stub(LoggerProxy.logger, 'error');
1114
+
1115
+ try {
1116
+ await webinar.searchLargeScaleWebinarAttendees(params);
1117
+ assert.fail('searchLargeScaleWebinarAttendees should throw an error');
1118
+ } catch (error) {
1119
+ assert.equal(error.message, 'API_ERROR', 'should throw the correct error');
1120
+ assert.calledOnce(errorLogger);
1121
+ assert.calledWith(
1122
+ errorLogger,
1123
+ 'Meeting:webinar5k#searchLargeScaleWebinarAttendees failed',
1124
+ sinon.match.instanceOf(Error)
1125
+ );
1126
+ } finally {
1127
+ errorLogger.restore();
1128
+ }
1129
+ });
1130
+ });
634
1131
  })
635
1132
  })
@@ -0,0 +1,39 @@
1
+ import chai from 'chai';
2
+ import {sanitizeParams} from '@webex/plugin-meetings/src/webinar/utils';
3
+
4
+ const {assert} = chai;
5
+
6
+ describe('plugin-meetings', () => {
7
+ describe('webinar utils', () => {
8
+ describe('#sanitizeParams', () => {
9
+ it('sanitizes params by removing undefined, "", or null values', () => {
10
+ const input = {
11
+ a: 1,
12
+ b: undefined,
13
+ c: null,
14
+ d: 'test',
15
+ e: false,
16
+ f: '',
17
+ };
18
+ const expectedOutput = {
19
+ a: 1,
20
+ d: 'test',
21
+ e: false,
22
+ };
23
+ const result = sanitizeParams(input);
24
+ assert.deepEqual(result, expectedOutput);
25
+ });
26
+
27
+ it('returns an empty object when all values are invalid', () => {
28
+ const input = {
29
+ a: undefined,
30
+ b: null,
31
+ c: '',
32
+ };
33
+ const expectedOutput = {};
34
+ const result = sanitizeParams(input);
35
+ assert.deepEqual(result, expectedOutput);
36
+ });
37
+ });
38
+ });
39
+ });