@webex/plugin-meetings 3.11.0 → 3.12.0-next.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 (170) 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 +850 -410
  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 +1173 -877
  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 +2 -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/reachability/index.js +18 -10
  69. package/dist/reachability/index.js.map +1 -1
  70. package/dist/reactions/reactions.type.js.map +1 -1
  71. package/dist/reconnection-manager/index.js +0 -1
  72. package/dist/reconnection-manager/index.js.map +1 -1
  73. package/dist/types/aiEnableRequest/index.d.ts +5 -0
  74. package/dist/types/aiEnableRequest/utils.d.ts +2 -0
  75. package/dist/types/config.d.ts +4 -0
  76. package/dist/types/constants.d.ts +23 -1
  77. package/dist/types/hashTree/constants.d.ts +1 -0
  78. package/dist/types/hashTree/hashTree.d.ts +7 -0
  79. package/dist/types/hashTree/hashTreeParser.d.ts +122 -14
  80. package/dist/types/hashTree/types.d.ts +3 -0
  81. package/dist/types/hashTree/utils.d.ts +6 -0
  82. package/dist/types/index.d.ts +1 -0
  83. package/dist/types/interceptors/constant.d.ts +5 -0
  84. package/dist/types/interceptors/dataChannelAuthToken.d.ts +43 -0
  85. package/dist/types/interceptors/index.d.ts +2 -1
  86. package/dist/types/interceptors/utils.d.ts +1 -0
  87. package/dist/types/locus-info/index.d.ts +60 -8
  88. package/dist/types/locus-info/types.d.ts +7 -0
  89. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  90. package/dist/types/media/properties.d.ts +2 -1
  91. package/dist/types/meeting/in-meeting-actions.d.ts +6 -0
  92. package/dist/types/meeting/index.d.ts +61 -7
  93. package/dist/types/meeting/request.d.ts +16 -1
  94. package/dist/types/meeting/request.type.d.ts +5 -0
  95. package/dist/types/meeting/util.d.ts +31 -0
  96. package/dist/types/meetings/index.d.ts +4 -2
  97. package/dist/types/member/index.d.ts +1 -0
  98. package/dist/types/member/util.d.ts +5 -0
  99. package/dist/types/metrics/constants.d.ts +1 -0
  100. package/dist/types/multistream/mediaRequestManager.d.ts +0 -23
  101. package/dist/types/reactions/reactions.type.d.ts +1 -0
  102. package/dist/types/webinar/utils.d.ts +6 -0
  103. package/dist/webinar/index.js +291 -91
  104. package/dist/webinar/index.js.map +1 -1
  105. package/dist/webinar/utils.js +25 -0
  106. package/dist/webinar/utils.js.map +1 -0
  107. package/package.json +24 -23
  108. package/src/aiEnableRequest/README.md +84 -0
  109. package/src/aiEnableRequest/index.ts +170 -0
  110. package/src/aiEnableRequest/utils.ts +25 -0
  111. package/src/annotation/index.ts +27 -7
  112. package/src/config.ts +4 -0
  113. package/src/constants.ts +29 -1
  114. package/src/hashTree/constants.ts +1 -0
  115. package/src/hashTree/hashTree.ts +17 -0
  116. package/src/hashTree/hashTreeParser.ts +745 -252
  117. package/src/hashTree/types.ts +4 -0
  118. package/src/hashTree/utils.ts +9 -0
  119. package/src/index.ts +8 -1
  120. package/src/interceptors/constant.ts +6 -0
  121. package/src/interceptors/dataChannelAuthToken.ts +170 -0
  122. package/src/interceptors/index.ts +2 -1
  123. package/src/interceptors/utils.ts +16 -0
  124. package/src/interpretation/index.ts +2 -2
  125. package/src/locus-info/controlsUtils.ts +11 -0
  126. package/src/locus-info/index.ts +579 -113
  127. package/src/locus-info/selfUtils.ts +1 -0
  128. package/src/locus-info/types.ts +8 -0
  129. package/src/media/MediaConnectionAwaiter.ts +41 -1
  130. package/src/media/properties.ts +3 -1
  131. package/src/meeting/in-meeting-actions.ts +12 -0
  132. package/src/meeting/index.ts +291 -76
  133. package/src/meeting/request.ts +42 -0
  134. package/src/meeting/request.type.ts +6 -0
  135. package/src/meeting/util.ts +160 -2
  136. package/src/meetings/index.ts +157 -44
  137. package/src/member/index.ts +10 -0
  138. package/src/member/util.ts +12 -0
  139. package/src/metrics/constants.ts +1 -0
  140. package/src/multistream/mediaRequestManager.ts +4 -54
  141. package/src/multistream/remoteMediaManager.ts +13 -0
  142. package/src/reachability/index.ts +9 -0
  143. package/src/reactions/reactions.type.ts +1 -0
  144. package/src/reconnection-manager/index.ts +0 -1
  145. package/src/webinar/index.ts +191 -6
  146. package/src/webinar/utils.ts +16 -0
  147. package/test/unit/spec/aiEnableRequest/index.ts +981 -0
  148. package/test/unit/spec/aiEnableRequest/utils.ts +130 -0
  149. package/test/unit/spec/annotation/index.ts +69 -7
  150. package/test/unit/spec/hashTree/hashTree.ts +66 -0
  151. package/test/unit/spec/hashTree/hashTreeParser.ts +2225 -189
  152. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +210 -0
  153. package/test/unit/spec/interceptors/utils.ts +75 -0
  154. package/test/unit/spec/locus-info/controlsUtils.js +29 -0
  155. package/test/unit/spec/locus-info/index.js +1134 -55
  156. package/test/unit/spec/media/MediaConnectionAwaiter.ts +41 -1
  157. package/test/unit/spec/media/properties.ts +12 -3
  158. package/test/unit/spec/meeting/in-meeting-actions.ts +8 -2
  159. package/test/unit/spec/meeting/index.js +829 -115
  160. package/test/unit/spec/meeting/request.js +70 -0
  161. package/test/unit/spec/meeting/utils.js +438 -26
  162. package/test/unit/spec/meetings/index.js +653 -32
  163. package/test/unit/spec/member/index.js +28 -4
  164. package/test/unit/spec/member/util.js +65 -27
  165. package/test/unit/spec/multistream/mediaRequestManager.ts +2 -85
  166. package/test/unit/spec/multistream/remoteMediaManager.ts +30 -0
  167. package/test/unit/spec/reachability/index.ts +23 -0
  168. package/test/unit/spec/reconnection-manager/index.js +4 -8
  169. package/test/unit/spec/webinar/index.ts +474 -37
  170. 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,19 @@ 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
+ isConnected: sinon.stub().returns(false),
37
+ disconnectLLM: sinon.stub().resolves(),
38
+ off: sinon.stub(),
39
+ on: sinon.stub(),
40
+ getLocusUrl: sinon.stub().returns('old-locus-url'),
41
+ getDatachannelUrl: sinon.stub().returns('old-dc-url'),
42
+ registerAndConnect: sinon.stub().resolves('REGISTER_AND_CONNECT_RESULT'),
43
+ };
30
44
  });
31
45
 
32
46
  afterEach(() => {
@@ -147,20 +161,340 @@ describe('plugin-meetings', () => {
147
161
  assert.equal(result.isPromoted, false, 'should not indicate promotion');
148
162
  assert.equal(result.isDemoted, false, 'should not indicate demotion');
149
163
  });
164
+
165
+ it('handles missing role payload safely', () => {
166
+ const updateStatusByRoleStub = sinon.stub(webinar, 'updateStatusByRole');
167
+
168
+ const result = webinar.updateRoleChanged(undefined);
169
+
170
+ assert.equal(webinar.selfIsPanelist, false);
171
+ assert.equal(webinar.selfIsAttendee, false);
172
+ assert.equal(webinar.canManageWebcast, false);
173
+ assert.deepEqual(result, {isPromoted: false, isDemoted: false});
174
+ assert.calledOnceWithExactly(updateStatusByRoleStub, {isPromoted: false, isDemoted: false});
175
+ });
176
+ });
177
+
178
+ describe('#cleanUp', () => {
179
+ it('delegates to cleanupPSDataChannel', () => {
180
+ const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
181
+
182
+ webinar.cleanUp();
183
+
184
+ assert.calledOnceWithExactly(cleanupPSDataChannelStub);
185
+ });
186
+ });
187
+
188
+ describe('#cleanupPSDataChannel', () => {
189
+ let meeting;
190
+
191
+ beforeEach(() => {
192
+ meeting = {
193
+ processRelayEvent: sinon.stub(),
194
+ };
195
+
196
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
197
+ });
198
+
199
+ it('disconnects the practice session channel and removes the relay listener', async () => {
200
+ await webinar.cleanupPSDataChannel();
201
+
202
+ assert.calledOnceWithExactly(
203
+ webex.internal.llm.disconnectLLM,
204
+ {code: 3050, reason: 'done (permanent)'},
205
+ LLM_PRACTICE_SESSION
206
+ );
207
+ assert.calledOnceWithExactly(
208
+ webex.internal.llm.off,
209
+ `event:relay.event:${LLM_PRACTICE_SESSION}`,
210
+ meeting.processRelayEvent
211
+ );
212
+ });
213
+
214
+ it('removes a pending online listener if one exists', async () => {
215
+ const listener = sinon.stub();
216
+ webinar._pendingOnlineListener = listener;
217
+
218
+ await webinar.cleanupPSDataChannel();
219
+
220
+ assert.calledWith(webex.internal.llm.off, 'online', listener);
221
+ assert.isNull(webinar._pendingOnlineListener);
222
+ });
223
+
224
+ it('skips online listener removal when none is pending', async () => {
225
+ webinar._pendingOnlineListener = null;
226
+
227
+ await webinar.cleanupPSDataChannel();
228
+
229
+ // 'off' should only be called for the relay event, not for 'online'
230
+ const onlineOffCalls = webex.internal.llm.off.args.filter(([event]) => event === 'online');
231
+ assert.equal(onlineOffCalls.length, 0);
232
+ });
233
+ });
234
+
235
+ describe('#updatePSDataChannel', () => {
236
+ let meeting;
237
+ let processRelayEvent;
238
+
239
+ beforeEach(() => {
240
+ processRelayEvent = sinon.stub();
241
+ meeting = {
242
+ isJoined: sinon.stub().returns(true),
243
+ processRelayEvent,
244
+ locusInfo: {
245
+ url: 'locus-url',
246
+ info: {practiceSessionDatachannelUrl: 'dc-url'},
247
+ },
248
+ };
249
+
250
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
251
+
252
+ // Default session is connected by default; practice session is not
253
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
254
+ return sessionId !== LLM_PRACTICE_SESSION;
255
+ });
256
+
257
+ // Token is pre-saved into LLM by saveDataChannelToken
258
+ webex.internal.llm.getDatachannelToken = sinon.stub().callsFake((tokenType) => {
259
+ if (tokenType === DataChannelTokenType.PracticeSession) return 'ps-token';
260
+ return undefined;
261
+ });
262
+
263
+ // Ensure connect path is eligible
264
+ webinar.selfIsPanelist = true;
265
+ webinar.practiceSessionEnabled = true;
266
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
267
+ webex.internal.voicea.updateSubchannelSubscriptions = sinon.stub();
268
+ });
269
+
270
+ it('no-ops when practice session join eligibility is false', async () => {
271
+ webinar.practiceSessionEnabled = false;
272
+ const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
273
+
274
+ const result = await webinar.updatePSDataChannel();
275
+
276
+ assert.isUndefined(result);
277
+ assert.calledOnceWithExactly(cleanupPSDataChannelStub);
278
+ assert.notCalled(webex.internal.llm.registerAndConnect);
279
+ });
280
+
281
+ it('no-ops when meeting is not joined', async () => {
282
+ meeting.isJoined.returns(false);
283
+ const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
284
+
285
+ const result = await webinar.updatePSDataChannel();
286
+
287
+ assert.isUndefined(result);
288
+ assert.calledOnceWithExactly(cleanupPSDataChannelStub);
289
+ assert.notCalled(webex.internal.llm.registerAndConnect);
290
+ });
291
+
292
+ it('no-ops when practiceSessionDatachannelUrl is missing', async () => {
293
+ meeting.locusInfo.info.practiceSessionDatachannelUrl = undefined;
294
+
295
+ const result = await webinar.updatePSDataChannel();
296
+
297
+ assert.isUndefined(result);
298
+ assert.notCalled(webex.internal.llm.registerAndConnect);
299
+ });
300
+
301
+ it('no-ops when already connected to the same endpoints', async () => {
302
+ webex.internal.llm.isConnected.returns(true);
303
+ webex.internal.llm.getLocusUrl.returns('locus-url');
304
+ webex.internal.llm.getDatachannelUrl.returns('dc-url');
305
+ const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
306
+
307
+ const result = await webinar.updatePSDataChannel();
308
+
309
+ assert.isUndefined(result);
310
+ assert.notCalled(cleanupPSDataChannelStub);
311
+ assert.notCalled(webex.internal.llm.registerAndConnect);
312
+ });
313
+
314
+ it('connects when eligible', async () => {
315
+ const result = await webinar.updatePSDataChannel();
316
+
317
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
318
+ assert.calledWith(
319
+ webex.internal.llm.registerAndConnect,
320
+ 'locus-url',
321
+ 'dc-url',
322
+ 'ps-token',
323
+ LLM_PRACTICE_SESSION
324
+ );
325
+ assert.calledOnceWithExactly(webex.internal.voicea.announce);
326
+ assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
327
+ });
328
+
329
+ it('uses token from LLM', async () => {
330
+ webex.internal.llm.getDatachannelToken = sinon.stub().callsFake((tokenType) => {
331
+ if (tokenType === DataChannelTokenType.PracticeSession) return 'cached-token';
332
+ return undefined;
333
+ });
334
+
335
+ await webinar.updatePSDataChannel();
336
+
337
+ assert.calledWithExactly(
338
+ webex.internal.llm.getDatachannelToken,
339
+ DataChannelTokenType.PracticeSession
340
+ );
341
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
342
+ assert.calledWith(
343
+ webex.internal.llm.registerAndConnect,
344
+ 'locus-url',
345
+ 'dc-url',
346
+ 'cached-token',
347
+ LLM_PRACTICE_SESSION
348
+ );
349
+ });
350
+
351
+ it('cleans up the existing practice session channel before reconnecting to new endpoints', async () => {
352
+ webex.internal.llm.isConnected.returns(true);
353
+ const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
354
+
355
+ await webinar.updatePSDataChannel();
356
+
357
+ assert.calledOnceWithExactly(cleanupPSDataChannelStub);
358
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
359
+ });
360
+
361
+ it('rebinds relay listener after successful connect', async () => {
362
+ await webinar.updatePSDataChannel();
363
+
364
+ assert.calledWith(
365
+ webex.internal.llm.off,
366
+ `event:relay.event:${LLM_PRACTICE_SESSION}`,
367
+ processRelayEvent
368
+ );
369
+ assert.calledWith(
370
+ webex.internal.llm.on,
371
+ `event:relay.event:${LLM_PRACTICE_SESSION}`,
372
+ processRelayEvent
373
+ );
374
+ });
375
+
376
+ it('subscribes to transcription when caption intent is enabled', async () => {
377
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(true);
378
+
379
+ await webinar.updatePSDataChannel();
380
+
381
+ assert.calledOnceWithExactly(webex.internal.voicea.updateSubchannelSubscriptions, { subscribe: ['transcription'] });
382
+ });
383
+
384
+ it('does not subscribe to transcription when caption intent is disabled', async () => {
385
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
386
+
387
+ await webinar.updatePSDataChannel();
388
+
389
+ assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
390
+ });
391
+
392
+ it('defers connect when default session is not yet connected', async () => {
393
+ // Default session is not connected initially
394
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
395
+
396
+ const result = await webinar.updatePSDataChannel();
397
+
398
+ // Should return undefined immediately (deferred)
399
+ assert.isUndefined(result);
400
+ // Should register an 'online' listener but NOT call registerAndConnect yet
401
+ assert.calledWith(webex.internal.llm.on, 'online', sinon.match.func);
402
+ assert.notCalled(webex.internal.llm.registerAndConnect);
403
+ // Should store the pending listener
404
+ assert.isNotNull(webinar._pendingOnlineListener);
405
+ });
406
+
407
+ it('does not register duplicate online listeners on repeated calls', async () => {
408
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
409
+
410
+ await webinar.updatePSDataChannel();
411
+ await webinar.updatePSDataChannel();
412
+ await webinar.updatePSDataChannel();
413
+
414
+ // Only one 'online' listener should have been registered
415
+ const onlineCalls = webex.internal.llm.on.args.filter(([event]) => event === 'online');
416
+ assert.equal(onlineCalls.length, 1, 'should register exactly one online listener');
417
+ });
418
+
419
+ it('re-invokes updatePSDataChannel when default session comes online', async () => {
420
+ // Default session is not connected initially
421
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
422
+
423
+ const updatePSDataChannelSpy = sinon.spy(webinar, 'updatePSDataChannel');
424
+
425
+ // First call defers
426
+ await webinar.updatePSDataChannel();
427
+
428
+ // Capture the 'online' listener
429
+ const onlineCall = webex.internal.llm.on.args.find(([event]) => event === 'online');
430
+ assert.isDefined(onlineCall, 'should have registered an online listener');
431
+
432
+ // Now simulate default session coming online
433
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
434
+ return sessionId !== LLM_PRACTICE_SESSION;
435
+ });
436
+
437
+ // Fire the captured listener
438
+ onlineCall[1]();
439
+
440
+ // The listener should have cleared itself, removed itself, and re-called updatePSDataChannel
441
+ assert.isNull(webinar._pendingOnlineListener);
442
+ assert.calledWith(webex.internal.llm.off, 'online', sinon.match.func);
443
+ assert.equal(updatePSDataChannelSpy.callCount, 2);
444
+ });
445
+
446
+ it('does not reconnect with stale data if demoted before default session comes online', async () => {
447
+ // Default session is not connected initially
448
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
449
+
450
+ await webinar.updatePSDataChannel();
451
+
452
+ // Capture the 'online' listener
453
+ const onlineCall = webex.internal.llm.on.args.find(([event]) => event === 'online');
454
+ assert.isDefined(onlineCall);
455
+
456
+ // Simulate demotion while waiting
457
+ webinar.selfIsPanelist = false;
458
+
459
+ // Now default session comes online
460
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
461
+ return sessionId !== LLM_PRACTICE_SESSION;
462
+ });
463
+
464
+ // Fire the listener — re-invokes updatePSDataChannel which will see isPracticeSession = false
465
+ onlineCall[1]();
466
+
467
+ // Should NOT have called registerAndConnect since the user is no longer eligible
468
+ assert.notCalled(webex.internal.llm.registerAndConnect);
469
+ });
470
+
471
+ it('proceeds immediately when default session is already connected', async () => {
472
+ // Default session already connected, practice session not
473
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
474
+ return sessionId !== LLM_PRACTICE_SESSION;
475
+ });
476
+
477
+ const result = await webinar.updatePSDataChannel();
478
+
479
+ // The 'online' listener is registered then immediately removed since default session is already connected
480
+ assert.calledWith(webex.internal.llm.on, 'online', sinon.match.func);
481
+ assert.calledWith(webex.internal.llm.off, 'online', sinon.match.func);
482
+ assert.isNull(webinar._pendingOnlineListener);
483
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
484
+ assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
485
+ });
150
486
  });
151
487
 
152
488
  describe('#updateStatusByRole', () => {
153
- let updateLLMConnection;
154
489
  let updateMediaShares;
155
490
  beforeEach(() => {
156
- // @ts-ignore
157
- updateLLMConnection = sinon.stub();
158
491
  updateMediaShares = sinon.stub()
159
492
  webinar.webex.meetings = {
160
493
  getMeetingByType: sinon.stub().returns({
161
494
  id: 'meeting-id',
162
- updateLLMConnection: updateLLMConnection,
163
- shareStatus: 'whiteboard_share_active',
495
+ isJoined: sinon.stub().returns(false),
496
+ updateLLMConnection: sinon.stub(),
497
+ shareStatus: SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE,
164
498
  locusInfo: {
165
499
  mediaShares: 'mediaShares',
166
500
  updateMediaShares: updateMediaShares
@@ -173,40 +507,20 @@ describe('plugin-meetings', () => {
173
507
  sinon.restore();
174
508
  });
175
509
 
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
510
  it('trigger updateMediaShares if promoted', () => {
197
511
 
198
512
  const roleChange = {isPromoted: true, isDemoted: false};
199
513
 
200
- const result = webinar.updateStatusByRole(roleChange);
514
+ webinar.updateStatusByRole(roleChange);
201
515
 
202
- assert.calledOnce(updateMediaShares);
516
+ assert.calledOnceWithExactly(updateMediaShares, 'mediaShares', true);
203
517
  });
204
518
 
205
519
  it('Not trigger updateMediaShares if no role change', () => {
206
520
 
207
521
  const roleChange = {isPromoted: false, isDemoted: false};
208
522
 
209
- const result = webinar.updateStatusByRole(roleChange);
523
+ webinar.updateStatusByRole(roleChange);
210
524
 
211
525
  assert.notCalled(updateMediaShares);
212
526
  });
@@ -214,18 +528,18 @@ describe('plugin-meetings', () => {
214
528
 
215
529
  const roleChange = {isPromoted: true, isDemoted: false};
216
530
 
217
- const result = webinar.updateStatusByRole(roleChange);
531
+ webinar.updateStatusByRole(roleChange);
218
532
 
219
- assert.calledOnce(updateMediaShares);
533
+ assert.calledOnceWithExactly(updateMediaShares, 'mediaShares', true);
220
534
  });
221
535
 
222
536
  it('trigger updateMediaShares if is attendee with whiteboard share', () => {
223
537
 
224
538
  const roleChange = {isPromoted: false, isDemoted: true};
225
539
 
226
- const result = webinar.updateStatusByRole(roleChange);
540
+ webinar.updateStatusByRole(roleChange);
227
541
 
228
- assert.calledOnce(updateMediaShares);
542
+ assert.calledOnceWithExactly(updateMediaShares, 'mediaShares', true);
229
543
  });
230
544
 
231
545
  it('Not trigger updateMediaShares if is attendee with screen share', () => {
@@ -233,8 +547,9 @@ describe('plugin-meetings', () => {
233
547
  webinar.webex.meetings = {
234
548
  getMeetingByType: sinon.stub().returns({
235
549
  id: 'meeting-id',
236
- updateLLMConnection: updateLLMConnection,
237
- shareStatus: 'remote_share_active',
550
+ isJoined: sinon.stub().returns(false),
551
+ updateLLMConnection: sinon.stub(),
552
+ shareStatus: SHARE_STATUS.REMOTE_SHARE_ACTIVE,
238
553
  locusInfo: {
239
554
  mediaShares: 'mediaShares',
240
555
  updateMediaShares: updateMediaShares
@@ -244,10 +559,18 @@ describe('plugin-meetings', () => {
244
559
 
245
560
  const roleChange = {isPromoted: false, isDemoted: true};
246
561
 
247
- const result = webinar.updateStatusByRole(roleChange);
562
+ webinar.updateStatusByRole(roleChange);
248
563
 
249
564
  assert.notCalled(updateMediaShares);
250
565
  });
566
+
567
+ it('updates PS data channel based on join eligibility', () => {
568
+ const updatePSDataChannelStub = sinon.stub(webinar, 'updatePSDataChannel').resolves();
569
+
570
+ webinar.updateStatusByRole({isPromoted: false, isDemoted: false});
571
+
572
+ assert.calledOnceWithExactly(updatePSDataChannelStub);
573
+ });
251
574
  });
252
575
 
253
576
  describe("#setPracticeSessionState", () => {
@@ -323,6 +646,14 @@ describe('plugin-meetings', () => {
323
646
 
324
647
  assert.equal(webinar.practiceSessionEnabled, false);
325
648
  });
649
+ it('triggers PS data channel update using computed eligibility', () => {
650
+ webinar.selfIsPanelist = true;
651
+ const updatePSDataChannelStub = sinon.stub(webinar, 'updatePSDataChannel').resolves();
652
+
653
+ webinar.updatePracticeSessionStatus({enabled: true});
654
+
655
+ assert.calledOnceWithExactly(updatePSDataChannelStub);
656
+ });
326
657
  });
327
658
 
328
659
  describe("#startWebcast", () => {
@@ -631,5 +962,111 @@ describe('plugin-meetings', () => {
631
962
  }
632
963
  });
633
964
  });
965
+
966
+ describe("#searchLargeScaleWebinarAttendees", () => {
967
+ const attendeeSearchUrl = 'https://locusUrl/attendees/search';
968
+ const params = {
969
+ queryString: 'queryString',
970
+ limit: 50,
971
+ next: null,
972
+ };
973
+ beforeEach(() => {
974
+ // @ts-ignore
975
+ webinar.webex.meetings = {
976
+ getMeetingByType: sinon.stub().returns({
977
+ id: 'meeting-id',
978
+ locusInfo: {
979
+ links:{
980
+ resources: {
981
+ attendeeSearch: {
982
+ url: attendeeSearchUrl
983
+ }
984
+ }
985
+ }
986
+ }
987
+ })
988
+ };
989
+ });
990
+
991
+ it('throws an error if attendeeSearchUrl is not available', async () => {
992
+ webinar.webex.meetings = {
993
+ getMeetingByType: sinon.stub().returns({
994
+ id: 'meeting-id',
995
+ locusInfo: {
996
+ links:{
997
+ resources: {
998
+ attendeeSearch: {
999
+ url: null
1000
+ }
1001
+ }
1002
+ }
1003
+ }
1004
+ })
1005
+ };
1006
+ try {
1007
+ await webinar.searchLargeScaleWebinarAttendees(params);
1008
+ assert.fail('searchLargeScaleWebinarAttendees should throw an error');
1009
+ } catch (error) {
1010
+ assert.equal(error.message,'Meeting:webinar5k#Attendee search url is not available', 'should throw the correct error');
1011
+ }
1012
+ });
1013
+
1014
+ it('sends a GET request to search the large scale webinar attendees', async () => {
1015
+ const result = await webinar.searchLargeScaleWebinarAttendees(params);
1016
+ assert.calledOnce(webex.request);
1017
+ assert.calledWith(webex.request, {
1018
+ method: 'GET',
1019
+ uri: `${attendeeSearchUrl}?search_text=${encodeURIComponent(params.queryString)}&limit=50`,
1020
+ headers: {
1021
+ authorization: 'test-token',
1022
+ trackingId: 'webex-js-sdk_test-uuid',
1023
+ },
1024
+ });
1025
+ assert.equal(
1026
+ result,
1027
+ 'REQUEST_RETURN_VALUE',
1028
+ 'should return the resolved value from the request'
1029
+ );
1030
+ });
1031
+
1032
+ it('queryString is empty string', async () => {
1033
+ params.queryString = '';
1034
+ const result = await webinar.searchLargeScaleWebinarAttendees(params);
1035
+ assert.calledOnce(webex.request);
1036
+ assert.calledWith(webex.request, {
1037
+ method: 'GET',
1038
+ uri: `${attendeeSearchUrl}?limit=50`,
1039
+ headers: {
1040
+ authorization: 'test-token',
1041
+ trackingId: 'webex-js-sdk_test-uuid',
1042
+ },
1043
+ });
1044
+ assert.equal(
1045
+ result,
1046
+ 'REQUEST_RETURN_VALUE',
1047
+ 'should return the resolved value from the request'
1048
+ );
1049
+ });
1050
+
1051
+ it('handles API call failures gracefully', async () => {
1052
+ webex.request.rejects(new Error('API_ERROR'));
1053
+ const errorLogger = sinon.stub(LoggerProxy.logger, 'error');
1054
+
1055
+ try {
1056
+ await webinar.searchLargeScaleWebinarAttendees(params);
1057
+ assert.fail('searchLargeScaleWebinarAttendees should throw an error');
1058
+ } catch (error) {
1059
+ assert.equal(error.message, 'API_ERROR', 'should throw the correct error');
1060
+ assert.calledOnce(errorLogger);
1061
+ assert.calledWith(
1062
+ errorLogger,
1063
+ 'Meeting:webinar5k#searchLargeScaleWebinarAttendees failed',
1064
+ sinon.match.instanceOf(Error)
1065
+ );
1066
+ } finally {
1067
+ errorLogger.restore();
1068
+ }
1069
+ });
1070
+ });
634
1071
  })
635
1072
  })
@@ -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
+ });