@webex/plugin-meetings 3.11.0-webex-services-ready.1 → 3.12.0-mobius-socket.1

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 (171) 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 +1293 -929
  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/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 +72 -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 +5 -0
  100. package/dist/types/multistream/mediaRequestManager.d.ts +0 -23
  101. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  102. package/dist/types/reactions/reactions.type.d.ts +1 -0
  103. package/dist/types/webinar/utils.d.ts +6 -0
  104. package/dist/webinar/index.js +438 -163
  105. package/dist/webinar/index.js.map +1 -1
  106. package/dist/webinar/utils.js +25 -0
  107. package/dist/webinar/utils.js.map +1 -0
  108. package/package.json +24 -23
  109. package/src/aiEnableRequest/README.md +84 -0
  110. package/src/aiEnableRequest/index.ts +170 -0
  111. package/src/aiEnableRequest/utils.ts +25 -0
  112. package/src/annotation/index.ts +27 -7
  113. package/src/config.ts +4 -0
  114. package/src/constants.ts +29 -1
  115. package/src/hashTree/constants.ts +1 -0
  116. package/src/hashTree/hashTree.ts +17 -0
  117. package/src/hashTree/hashTreeParser.ts +761 -260
  118. package/src/hashTree/types.ts +4 -0
  119. package/src/hashTree/utils.ts +9 -0
  120. package/src/index.ts +8 -1
  121. package/src/interceptors/constant.ts +6 -0
  122. package/src/interceptors/dataChannelAuthToken.ts +170 -0
  123. package/src/interceptors/index.ts +2 -1
  124. package/src/interceptors/utils.ts +16 -0
  125. package/src/interpretation/index.ts +2 -2
  126. package/src/locus-info/controlsUtils.ts +11 -0
  127. package/src/locus-info/index.ts +579 -113
  128. package/src/locus-info/selfUtils.ts +1 -0
  129. package/src/locus-info/types.ts +8 -0
  130. package/src/media/MediaConnectionAwaiter.ts +41 -1
  131. package/src/media/properties.ts +3 -1
  132. package/src/meeting/in-meeting-actions.ts +12 -0
  133. package/src/meeting/index.ts +372 -86
  134. package/src/meeting/request.ts +42 -0
  135. package/src/meeting/request.type.ts +6 -0
  136. package/src/meeting/util.ts +160 -2
  137. package/src/meetings/index.ts +157 -44
  138. package/src/member/index.ts +10 -0
  139. package/src/member/util.ts +12 -0
  140. package/src/metrics/constants.ts +6 -0
  141. package/src/multistream/mediaRequestManager.ts +4 -54
  142. package/src/multistream/remoteMediaManager.ts +13 -0
  143. package/src/multistream/sendSlotManager.ts +97 -3
  144. package/src/reactions/reactions.type.ts +1 -0
  145. package/src/reconnection-manager/index.ts +0 -1
  146. package/src/webinar/index.ts +265 -6
  147. package/src/webinar/utils.ts +16 -0
  148. package/test/unit/spec/aiEnableRequest/index.ts +981 -0
  149. package/test/unit/spec/aiEnableRequest/utils.ts +130 -0
  150. package/test/unit/spec/annotation/index.ts +69 -7
  151. package/test/unit/spec/hashTree/hashTree.ts +66 -0
  152. package/test/unit/spec/hashTree/hashTreeParser.ts +2321 -175
  153. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +210 -0
  154. package/test/unit/spec/interceptors/utils.ts +75 -0
  155. package/test/unit/spec/locus-info/controlsUtils.js +29 -0
  156. package/test/unit/spec/locus-info/index.js +1134 -55
  157. package/test/unit/spec/media/MediaConnectionAwaiter.ts +41 -1
  158. package/test/unit/spec/media/properties.ts +12 -3
  159. package/test/unit/spec/meeting/in-meeting-actions.ts +8 -2
  160. package/test/unit/spec/meeting/index.js +829 -121
  161. package/test/unit/spec/meeting/request.js +70 -0
  162. package/test/unit/spec/meeting/utils.js +438 -26
  163. package/test/unit/spec/meetings/index.js +653 -32
  164. package/test/unit/spec/member/index.js +28 -4
  165. package/test/unit/spec/member/util.js +65 -27
  166. package/test/unit/spec/multistream/mediaRequestManager.ts +2 -85
  167. package/test/unit/spec/multistream/remoteMediaManager.ts +30 -0
  168. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  169. package/test/unit/spec/reconnection-manager/index.js +4 -8
  170. package/test/unit/spec/webinar/index.ts +534 -37
  171. package/test/unit/spec/webinar/utils.ts +39 -0
@@ -38,6 +38,7 @@ import {
38
38
  import {
39
39
  ConnectionState,
40
40
  MediaConnectionEventNames,
41
+ MediaCodecMimeType,
41
42
  StatsAnalyzerEventNames,
42
43
  StatsMonitorEventNames,
43
44
  Errors,
@@ -82,6 +83,7 @@ import Mercury from '@webex/internal-plugin-mercury';
82
83
  import Breakouts from '@webex/plugin-meetings/src/breakouts';
83
84
  import SimultaneousInterpretation from '@webex/plugin-meetings/src/interpretation';
84
85
  import Webinar from '@webex/plugin-meetings/src/webinar';
86
+ import AIEnableRequest from '@webex/plugin-meetings/src/aiEnableRequest';
85
87
  import {REACTION_RELAY_TYPES} from '../../../../src/reactions/constants';
86
88
  import locus from '../fixture/locus';
87
89
  import {
@@ -123,7 +125,6 @@ import {EVENT_TRIGGERS as VOICEAEVENTS} from '@webex/internal-plugin-voicea';
123
125
  import {createBrbState} from '@webex/plugin-meetings/src/meeting/brbState';
124
126
  import JoinForbiddenError from '../../../../src/common/errors/join-forbidden-error';
125
127
  import {EventEmitter} from 'stream';
126
-
127
128
  describe('plugin-meetings', () => {
128
129
  const logger = {
129
130
  info: () => {},
@@ -265,7 +266,9 @@ describe('plugin-meetings', () => {
265
266
  stopReachability: sinon.stub(),
266
267
  isSubnetReachable: sinon.stub().returns(true),
267
268
  };
269
+ webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
268
270
  webex.internal.llm.on = sinon.stub();
271
+ webex.internal.voicea.announce = sinon.stub();
269
272
  webex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies(
270
273
  {},
271
274
  {parent: webex}
@@ -376,6 +379,7 @@ describe('plugin-meetings', () => {
376
379
  assert.instanceOf(meeting.breakouts, Breakouts);
377
380
  assert.instanceOf(meeting.simultaneousInterpretation, SimultaneousInterpretation);
378
381
  assert.instanceOf(meeting.webinar, Webinar);
382
+ assert.instanceOf(meeting.aiEnableRequest, AIEnableRequest);
379
383
  });
380
384
 
381
385
  it('should call the callback with the meeting that has id already set', () => {
@@ -738,7 +742,9 @@ describe('plugin-meetings', () => {
738
742
  let supportsRTCPeerConnectionStub;
739
743
 
740
744
  beforeEach(() => {
741
- supportsRTCPeerConnectionStub = sinon.stub(WebCapabilities, 'supportsRTCPeerConnection').returns(CapabilityState.CAPABLE);
745
+ supportsRTCPeerConnectionStub = sinon
746
+ .stub(WebCapabilities, 'supportsRTCPeerConnection')
747
+ .returns(CapabilityState.CAPABLE);
742
748
 
743
749
  meeting.join = sinon.stub().callsFake((joinOptions) => {
744
750
  meeting.isMultistream = joinOptions.enableMultistream;
@@ -1011,33 +1017,53 @@ describe('plugin-meetings', () => {
1011
1017
  );
1012
1018
  });
1013
1019
 
1014
- it('should call leave() if addMediaInternal() fails ', async () => {
1020
+ it('should call leave() if addMediaInternal() fails with a browser media error (TypeError)', async () => {
1015
1021
  const addMediaError = new Error('fake addMedia error');
1016
- addMediaError.name = 'TypeError';
1022
+ addMediaError.name = 'TypeError'; // This makes it a browser media error
1017
1023
 
1018
- const rejectError = {
1019
- error: {
1020
- body: {
1021
- errorCode: 2729,
1022
- message: 'fake addMedia error',
1023
- name: 'TypeError'
1024
- }
1025
- }
1026
- };
1027
- meeting.addMediaInternal.rejects(addMediaError);
1028
- sinon.stub(meeting, 'leave').resolves();
1024
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1025
+ meeting.addMediaInternal = sinon.stub().rejects(addMediaError);
1029
1026
 
1030
- await assert.isRejected(
1027
+ // When a browser media error occurs, it gets transformed into a special structure
1028
+ const rejectedError = await assert.isRejected(
1031
1029
  meeting.joinWithMedia({
1032
1030
  joinOptions,
1033
1031
  mediaOptions,
1034
- }),
1035
- rejectError
1032
+ })
1036
1033
  );
1037
1034
 
1035
+ // Verify the error was transformed with errorCode 2729
1036
+ assert.equal(rejectedError.error.body.errorCode, 2729);
1037
+ assert.equal(rejectedError.error.body.message, 'fake addMedia error');
1038
+ assert.equal(rejectedError.error.body.name, 'TypeError');
1039
+
1038
1040
  assert.calledOnce(meeting.join);
1039
1041
  assert.calledOnce(meeting.addMediaInternal);
1042
+ assert.calledOnce(leaveStub);
1043
+ assert.calledOnceWithExactly(leaveStub, {
1044
+ resourceId: undefined,
1045
+ reason: 'joinWithMedia failure',
1046
+ });
1047
+
1048
+ // Browser media errors don't retry, so behavioral metric is sent only once
1049
+ // NOTE: The error gets transformed, so the metric receives undefined for message/stack/name
1050
+ // because they're now nested in error.body instead of at the top level
1040
1051
  assert.calledOnce(Metrics.sendBehavioralMetric);
1052
+ assert.calledWith(
1053
+ Metrics.sendBehavioralMetric,
1054
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1055
+ {
1056
+ correlation_id: meeting.correlationId,
1057
+ locus_id: meeting.locusUrl.split('/').pop(),
1058
+ reason: undefined, // transformed error doesn't have .message at top level
1059
+ stack: undefined, // transformed error doesn't have .stack at top level
1060
+ leaveErrorReason: undefined,
1061
+ isRetry: false,
1062
+ },
1063
+ {
1064
+ type: undefined, // transformed error doesn't have .name at top level
1065
+ }
1066
+ );
1041
1067
  });
1042
1068
 
1043
1069
  it('should not call leave() if addMediaInternal() fails the first time and succeeds the second time and should only call join() once', async () => {
@@ -1249,8 +1275,14 @@ describe('plugin-meetings', () => {
1249
1275
  });
1250
1276
 
1251
1277
  [
1252
- {errorName: 'SdpOfferCreationError', description: 'if we fail to create the offer on first attempt'},
1253
- {errorName: 'WebrtcApiNotAvailableError', description: 'if RTCPeerConnection is not available'},
1278
+ {
1279
+ errorName: 'SdpOfferCreationError',
1280
+ description: 'if we fail to create the offer on first attempt',
1281
+ },
1282
+ {
1283
+ errorName: 'WebrtcApiNotAvailableError',
1284
+ description: 'if RTCPeerConnection is not available',
1285
+ },
1254
1286
  ].forEach(({errorName, description}) => {
1255
1287
  it(`should not attempt a retry ${description}`, async () => {
1256
1288
  const addMediaError = new Error('fake addMedia error');
@@ -1290,7 +1322,7 @@ describe('plugin-meetings', () => {
1290
1322
  resourceId: undefined,
1291
1323
  reason: 'joinWithMedia failure',
1292
1324
  });
1293
- })
1325
+ });
1294
1326
  });
1295
1327
 
1296
1328
  it('should ignore sendVideo/receiveVideo when videoEnabled is false', async () => {
@@ -1521,6 +1553,22 @@ describe('plugin-meetings', () => {
1521
1553
  EVENT_TRIGGERS.MEETING_STOPPED_RECEIVING_TRANSCRIPTION
1522
1554
  );
1523
1555
  });
1556
+
1557
+ it('should stop listening to voicea events even when transcription is undefined', () => {
1558
+ meeting.transcription = undefined;
1559
+ meeting.stopTranscription();
1560
+ assert.equal(webex.internal.voicea.off.callCount, 4);
1561
+ assert.equal(meeting.areVoiceaEventsSetup, false);
1562
+ assert.calledWith(
1563
+ TriggerProxy.trigger,
1564
+ sinon.match.instanceOf(Meeting),
1565
+ {
1566
+ file: 'meeting/index',
1567
+ function: 'triggerStopReceivingTranscriptionEvent',
1568
+ },
1569
+ EVENT_TRIGGERS.MEETING_STOPPED_RECEIVING_TRANSCRIPTION
1570
+ );
1571
+ });
1524
1572
  });
1525
1573
 
1526
1574
  describe('#setCaptionLanguage', () => {
@@ -1882,16 +1930,64 @@ describe('plugin-meetings', () => {
1882
1930
  fakeProcessedReaction
1883
1931
  );
1884
1932
  });
1933
+
1934
+ it('should process if participantId does not exist in membersCollection but has displayName in Webinar', () => {
1935
+ LoggerProxy.logger.warn = sinon.stub();
1936
+ meeting.isReactionsSupported = sinon.stub().returns(true);
1937
+ meeting.config.receiveReactions = true;
1938
+ meeting.locusInfo.info = {isWebinar: true};
1939
+ const fakeSendersName = 'Fake reactors name';
1940
+ const fakeReactionPayload = {
1941
+ type: 'fake_type',
1942
+ codepoints: 'fake_codepoints',
1943
+ shortcodes: 'fake_shortcodes',
1944
+ tone: {
1945
+ type: 'fake_tone_type',
1946
+ codepoints: 'fake_tone_codepoints',
1947
+ shortcodes: 'fake_tone_shortcodes',
1948
+ },
1949
+ };
1950
+ const fakeSenderPayload = {
1951
+ displayName: 'Fake reactors name',
1952
+ participantId: 'fake_participant_id',
1953
+ };
1954
+ const fakeProcessedReaction = {
1955
+ reaction: fakeReactionPayload,
1956
+ sender: {
1957
+ id: fakeSenderPayload.participantId,
1958
+ name: fakeSendersName,
1959
+ },
1960
+ };
1961
+ const fakeRelayEvent = {
1962
+ data: {
1963
+ relayType: REACTION_RELAY_TYPES.REACTION,
1964
+ reaction: fakeReactionPayload,
1965
+ sender: fakeSenderPayload,
1966
+ },
1967
+ };
1968
+ meeting.processRelayEvent(fakeRelayEvent);
1969
+ assert.calledWith(
1970
+ TriggerProxy.trigger,
1971
+ sinon.match.instanceOf(Meeting),
1972
+ {
1973
+ file: 'meeting/index',
1974
+ function: 'join',
1975
+ },
1976
+ EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
1977
+ fakeProcessedReaction
1978
+ );
1979
+ });
1885
1980
  });
1886
1981
 
1887
1982
  describe('#handleLLMOnline', () => {
1888
1983
  beforeEach(() => {
1889
1984
  webex.internal.llm.off = sinon.stub();
1985
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
1986
+ webex.internal.voicea.updateSubchannelSubscriptions = sinon.stub();
1890
1987
  });
1891
1988
 
1892
- it('turns off llm online, emits transcription connected events', () => {
1989
+ it('emits transcription connected events', () => {
1893
1990
  meeting.handleLLMOnline();
1894
- assert.calledOnceWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
1895
1991
  assert.calledWith(
1896
1992
  TriggerProxy.trigger,
1897
1993
  sinon.match.instanceOf(Meeting),
@@ -1902,6 +1998,24 @@ describe('plugin-meetings', () => {
1902
1998
  EVENT_TRIGGERS.MEETING_TRANSCRIPTION_CONNECTED
1903
1999
  );
1904
2000
  });
2001
+
2002
+ it('restores transcription subscription when caption intent is enabled', () => {
2003
+ webex.internal.voicea.getIsCaptionBoxOn.returns(true);
2004
+
2005
+ meeting.handleLLMOnline();
2006
+
2007
+ assert.calledOnceWithExactly(webex.internal.voicea.updateSubchannelSubscriptions, {
2008
+ subscribe: ['transcription'],
2009
+ });
2010
+ });
2011
+
2012
+ it('does not restore transcription subscription when caption intent is disabled', () => {
2013
+ webex.internal.voicea.getIsCaptionBoxOn.returns(false);
2014
+
2015
+ meeting.handleLLMOnline();
2016
+
2017
+ assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
2018
+ });
1905
2019
  });
1906
2020
 
1907
2021
  describe('#join', () => {
@@ -1921,6 +2035,7 @@ describe('plugin-meetings', () => {
1921
2035
  it('should have #join', () => {
1922
2036
  assert.exists(meeting.join);
1923
2037
  });
2038
+
1924
2039
  beforeEach(() => {
1925
2040
  setCorrelationIdSpy = sinon.spy(meeting, 'setCorrelationId');
1926
2041
  meeting.setLocus = sinon.stub().returns(true);
@@ -2074,7 +2189,6 @@ describe('plugin-meetings', () => {
2074
2189
  await meeting.join().catch(() => {
2075
2190
  assert.calledOnce(MeetingUtil.joinMeeting);
2076
2191
 
2077
- // Assert that client.locus.join.response error event is not sent from this function, it is now emitted from MeetingUtil.joinMeeting
2078
2192
  assert.calledOnce(webex.internal.newMetrics.submitClientEvent);
2079
2193
  assert.calledWithMatch(webex.internal.newMetrics.submitClientEvent, {
2080
2194
  name: 'client.call.initiated',
@@ -2106,6 +2220,7 @@ describe('plugin-meetings', () => {
2106
2220
  });
2107
2221
  });
2108
2222
  });
2223
+
2109
2224
  describe('lmm, transcription & permissionTokenRefresh decoupling', () => {
2110
2225
  beforeEach(() => {
2111
2226
  sandbox.stub(MeetingUtil, 'joinMeeting').returns(Promise.resolve(joinMeetingResult));
@@ -2176,7 +2291,6 @@ describe('plugin-meetings', () => {
2176
2291
  const locusInfoParseStub = sinon.stub(meeting.locusInfo, 'parse');
2177
2292
  sinon.stub(meeting, 'isJoined').returns(true);
2178
2293
 
2179
- // Set up llm.on stub to capture the registered listener when updateLLMConnection is called
2180
2294
  let locusLLMEventListener;
2181
2295
  meeting.webex.internal.llm.on = sinon.stub().callsFake((eventName, callback) => {
2182
2296
  if (eventName === 'event:locus.state_message') {
@@ -2185,16 +2299,12 @@ describe('plugin-meetings', () => {
2185
2299
  });
2186
2300
  meeting.webex.internal.llm.off = sinon.stub();
2187
2301
 
2188
- // we need the real meeting.updateLLMConnection not the mock
2189
2302
  meeting.updateLLMConnection.restore();
2190
2303
 
2191
- // Call updateLLMConnection to register the listener
2192
2304
  await meeting.updateLLMConnection();
2193
2305
 
2194
- // Verify the listener was registered and we captured it
2195
2306
  assert.isDefined(locusLLMEventListener, 'LLM event listener should be registered');
2196
2307
 
2197
- // Now trigger the event
2198
2308
  const eventData = {
2199
2309
  eventType: 'locus.state_message',
2200
2310
  stateElementsMessage: {
@@ -2214,13 +2324,10 @@ describe('plugin-meetings', () => {
2214
2324
  sinon.stub(meeting.webex.internal.llm, 'hasEverConnected').value(true);
2215
2325
  sinon.stub(meeting.webex.internal.llm, 'registerAndConnect').resolves({});
2216
2326
 
2217
- // Restore the real updateLLMConnection
2218
2327
  meeting.updateLLMConnection.restore();
2219
2328
 
2220
- // Call updateLLMConnection to start the timer
2221
2329
  await meeting.updateLLMConnection();
2222
2330
 
2223
- // Fast forward time by 3 minutes
2224
2331
  fakeClock.tick(3 * 60 * 1000);
2225
2332
 
2226
2333
  assert.calledWith(
@@ -2245,18 +2352,14 @@ describe('plugin-meetings', () => {
2245
2352
  .stub(meeting.webex.internal.llm, 'getDatachannelUrl')
2246
2353
  .returns('https://datachannel1.example.com');
2247
2354
 
2248
- // Restore the real updateLLMConnection
2249
2355
  meeting.updateLLMConnection.restore();
2250
2356
 
2251
- // First, connect LLM and start the timer
2252
2357
  isJoinedStub.returns(true);
2253
2358
  meeting.webex.internal.llm.isConnected.returns(false);
2254
2359
  await meeting.updateLLMConnection();
2255
2360
 
2256
- // Verify timer was started
2257
2361
  assert.exists(meeting.llmHealthCheckTimer);
2258
2362
 
2259
- // Now simulate that we're no longer joined
2260
2363
  isJoinedStub.returns(false);
2261
2364
  meeting.webex.internal.llm.isConnected.returns(true);
2262
2365
 
@@ -2264,10 +2367,8 @@ describe('plugin-meetings', () => {
2264
2367
 
2265
2368
  assert.calledOnce(meeting.webex.internal.llm.disconnectLLM);
2266
2369
 
2267
- // Verify the timer was cleared (should be undefined)
2268
2370
  assert.isUndefined(meeting.llmHealthCheckTimer);
2269
2371
 
2270
- // Fast forward time to ensure no metric is sent
2271
2372
  Metrics.sendBehavioralMetric.resetHistory();
2272
2373
  fakeClock.tick(3 * 60 * 1000);
2273
2374
 
@@ -2302,7 +2403,6 @@ describe('plugin-meetings', () => {
2302
2403
  .stub()
2303
2404
  .rejects(new CaptchaError('bad captcha'));
2304
2405
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2305
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil, 'joinMeetingOptions');
2306
2406
 
2307
2407
  try {
2308
2408
  await meeting.join();
@@ -2316,8 +2416,7 @@ describe('plugin-meetings', () => {
2316
2416
  );
2317
2417
  assert.instanceOf(error, CaptchaError);
2318
2418
  assert.equal(error.message, 'bad captcha');
2319
- // should not get to the end promise chain, which does do the join
2320
- assert.notCalled(joinMeetingOptionsSpy);
2419
+ assert.notCalled(MeetingUtil.joinMeeting);
2321
2420
  }
2322
2421
  });
2323
2422
 
@@ -2326,7 +2425,6 @@ describe('plugin-meetings', () => {
2326
2425
  .stub()
2327
2426
  .rejects(new PasswordError('bad password'));
2328
2427
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2329
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil.joinMeetingOptions);
2330
2428
 
2331
2429
  try {
2332
2430
  await meeting.join();
@@ -2340,8 +2438,7 @@ describe('plugin-meetings', () => {
2340
2438
  );
2341
2439
  assert.instanceOf(error, PasswordError);
2342
2440
  assert.equal(error.message, 'bad password');
2343
- // should not get to the end promise chain, which does do the join
2344
- assert.notCalled(joinMeetingOptionsSpy);
2441
+ assert.notCalled(MeetingUtil.joinMeeting);
2345
2442
  }
2346
2443
  });
2347
2444
 
@@ -2350,7 +2447,6 @@ describe('plugin-meetings', () => {
2350
2447
  .stub()
2351
2448
  .rejects(new PermissionError('bad permission'));
2352
2449
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2353
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil.joinMeetingOptions);
2354
2450
 
2355
2451
  try {
2356
2452
  await meeting.join();
@@ -2364,14 +2460,14 @@ describe('plugin-meetings', () => {
2364
2460
  );
2365
2461
  assert.instanceOf(error, PermissionError);
2366
2462
  assert.equal(error.message, 'bad permission');
2367
- // should not get to the end promise chain, which does do the join
2368
- assert.notCalled(joinMeetingOptionsSpy);
2463
+ assert.notCalled(MeetingUtil.joinMeeting);
2369
2464
  }
2370
2465
  });
2371
2466
  });
2372
2467
  });
2373
2468
  });
2374
2469
 
2470
+
2375
2471
  describe('#addMedia', () => {
2376
2472
  const muteStateStub = {
2377
2473
  handleClientRequest: sinon.stub().returns(Promise.resolve(true)),
@@ -3028,6 +3124,111 @@ describe('plugin-meetings', () => {
3028
3124
  checkWorking({allowMediaInLobby: true});
3029
3125
  });
3030
3126
 
3127
+ const setupLobbyTest = () => {
3128
+ meeting.roap.doTurnDiscovery = sinon
3129
+ .stub()
3130
+ .resolves({turnServerInfo: undefined, turnDiscoverySkippedReason: undefined});
3131
+
3132
+ meeting.meetingState = 'ACTIVE';
3133
+ meeting.locusInfo.parsedLocus = {self: {state: 'IDLE'}};
3134
+ meeting.isUserUnadmitted = true;
3135
+
3136
+ // Mock locusMediaRequest
3137
+ meeting.locusMediaRequest = {
3138
+ send: sinon.stub().resolves(),
3139
+ isConfluenceCreated: sinon.stub().returns(false),
3140
+ };
3141
+
3142
+ sinon.stub(RemoteMediaManagerModule, 'RemoteMediaManager').returns({
3143
+ start: sinon.stub().resolves(),
3144
+ on: sinon.stub(),
3145
+ logAllReceiveSlots: sinon.stub(),
3146
+ });
3147
+
3148
+ meeting.isMultistream = true;
3149
+
3150
+ const createFakeStream = (id) => ({
3151
+ on: sinon.stub(),
3152
+ off: sinon.stub(),
3153
+ userMuted: false,
3154
+ systemMuted: false,
3155
+ get muted() {
3156
+ return this.userMuted || this.systemMuted;
3157
+ },
3158
+ setUnmuteAllowed: sinon.stub(),
3159
+ setUserMuted: sinon.stub(),
3160
+ outputStream: {
3161
+ getTracks: () => [{id}],
3162
+ },
3163
+ getSettings: sinon.stub().returns({}),
3164
+ });
3165
+
3166
+ return {
3167
+ fakeMicrophoneStream: createFakeStream('fake mic'),
3168
+ fakeCameraStream: createFakeStream('fake camera'),
3169
+ };
3170
+ };
3171
+
3172
+ it('should not publish any local streams when in the lobby and allowPublishMediaInLobby is false', async () => {
3173
+ const {fakeMicrophoneStream, fakeCameraStream} = setupLobbyTest();
3174
+
3175
+ const publishStreamStub = sinon.stub();
3176
+ fakeMediaConnection.createSendSlot = sinon.stub().returns({
3177
+ publishStream: publishStreamStub,
3178
+ unpublishStream: sinon.stub(),
3179
+ setNamedMediaGroups: sinon.stub(),
3180
+ });
3181
+
3182
+ await meeting.addMedia({
3183
+ allowMediaInLobby: true,
3184
+ allowPublishMediaInLobby: false,
3185
+ audioEnabled: true,
3186
+ videoEnabled: true,
3187
+ localStreams: {
3188
+ microphone: fakeMicrophoneStream,
3189
+ camera: fakeCameraStream,
3190
+ },
3191
+ });
3192
+
3193
+ assert.notCalled(publishStreamStub);
3194
+ });
3195
+
3196
+ it('should publish local streams when in the lobby and allowPublishMediaInLobby is true', async () => {
3197
+ const {fakeMicrophoneStream, fakeCameraStream} = setupLobbyTest();
3198
+
3199
+ const audioSlot = {
3200
+ publishStream: sinon.stub(),
3201
+ unpublishStream: sinon.stub(),
3202
+ setNamedMediaGroups: sinon.stub(),
3203
+ };
3204
+ const videoSlot = {
3205
+ publishStream: sinon.stub(),
3206
+ unpublishStream: sinon.stub(),
3207
+ setNamedMediaGroups: sinon.stub(),
3208
+ };
3209
+
3210
+ fakeMediaConnection.createSendSlot = sinon.stub().callsFake((mediaType) => {
3211
+ if (mediaType === 'AUDIO-MAIN') {
3212
+ return audioSlot;
3213
+ }
3214
+ return videoSlot;
3215
+ });
3216
+
3217
+ await meeting.addMedia({
3218
+ allowMediaInLobby: true,
3219
+ allowPublishMediaInLobby: true,
3220
+ audioEnabled: true,
3221
+ videoEnabled: true,
3222
+ localStreams: {
3223
+ microphone: fakeMicrophoneStream,
3224
+ camera: fakeCameraStream,
3225
+ },
3226
+ });
3227
+
3228
+ assert.calledOnceWithExactly(audioSlot.publishStream, fakeMicrophoneStream);
3229
+ assert.calledOnceWithExactly(videoSlot.publishStream, fakeCameraStream);
3230
+ });
3231
+
3031
3232
  it('should create rtcMetrics and pass them to Media.createMediaConnection()', async () => {
3032
3233
  const setIntervalOriginal = window.setInterval;
3033
3234
  window.setInterval = sinon.stub().returns(1);
@@ -6218,7 +6419,10 @@ describe('plugin-meetings', () => {
6218
6419
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
6219
6420
  meeting.unsetPeerConnections = sinon.stub().returns(true);
6220
6421
  meeting.logger.error = sinon.stub().returns(true);
6221
- meeting.updateLLMConnection = sinon.stub().returns(Promise.resolve());
6422
+ meeting.clearMeetingData = sinon.stub().callsFake(async () => {
6423
+ meeting.audio = null;
6424
+ meeting.video = null;
6425
+ });
6222
6426
  webex.internal.voicea.off = sinon.stub().returns(true);
6223
6427
  meeting.stopTranscription = sinon.stub();
6224
6428
  meeting.transcription = {};
@@ -6245,9 +6449,7 @@ describe('plugin-meetings', () => {
6245
6449
  assert.calledOnce(meeting.closePeerConnections);
6246
6450
  assert.calledOnce(meeting.unsetRemoteStreams);
6247
6451
  assert.calledOnce(meeting.unsetPeerConnections);
6248
- assert.calledOnce(meeting.stopTranscription);
6249
- assert.calledOnce(meeting.annotation.deregisterEvents);
6250
- assert.calledWith(webex.internal.llm.off, 'event:relay.event', meeting.processRelayEvent);
6452
+ assert.calledOnce(meeting.clearMeetingData);
6251
6453
  });
6252
6454
 
6253
6455
  it('should reset call diagnostic latencies correctly', async () => {
@@ -8248,7 +8450,10 @@ describe('plugin-meetings', () => {
8248
8450
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
8249
8451
  meeting.unsetPeerConnections = sinon.stub().returns(true);
8250
8452
  meeting.logger.error = sinon.stub().returns(true);
8251
- meeting.updateLLMConnection = sinon.stub().returns(Promise.resolve());
8453
+ meeting.clearMeetingData = sinon.stub().callsFake(async () => {
8454
+ meeting.audio = null;
8455
+ meeting.video = null;
8456
+ });
8252
8457
  meeting.transcription = {};
8253
8458
  meeting.stopTranscription = sinon.stub();
8254
8459
 
@@ -8274,10 +8479,7 @@ describe('plugin-meetings', () => {
8274
8479
  assert.calledOnce(meeting?.closePeerConnections);
8275
8480
  assert.calledOnce(meeting?.unsetRemoteStreams);
8276
8481
  assert.calledOnce(meeting?.unsetPeerConnections);
8277
- assert.calledOnce(meeting?.stopTranscription);
8278
-
8279
- assert.called(meeting.annotation.deregisterEvents);
8280
- assert.calledWith(webex.internal.llm.off, 'event:relay.event', meeting.processRelayEvent);
8482
+ assert.calledOnce(meeting?.clearMeetingData);
8281
8483
  });
8282
8484
  });
8283
8485
 
@@ -9014,8 +9216,8 @@ describe('plugin-meetings', () => {
9014
9216
  const fakeMultistreamRoapMediaConnection = {
9015
9217
  createSendSlot: () => {
9016
9218
  return {
9017
- setCodecParameters: sinon.stub().resolves(),
9018
- deleteCodecParameters: sinon.stub().resolves(),
9219
+ setCustomCodecParameters: sinon.stub().resolves(),
9220
+ markCustomCodecParametersForDeletion: sinon.stub().resolves(),
9019
9221
  };
9020
9222
  },
9021
9223
  };
@@ -9038,27 +9240,29 @@ describe('plugin-meetings', () => {
9038
9240
  }
9039
9241
  );
9040
9242
 
9041
- it('should set the codec parameters when shouldEnableMusicMode is true', async () => {
9243
+ it('should set custom codec parameters when shouldEnableMusicMode is true', async () => {
9042
9244
  await meeting.enableMusicMode(true);
9043
9245
  assert.calledOnceWithExactly(
9044
- meeting.sendSlotManager.getSlot(MediaType.AudioMain).setCodecParameters,
9246
+ meeting.sendSlotManager.getSlot(MediaType.AudioMain).setCustomCodecParameters,
9247
+ MediaCodecMimeType.OPUS,
9045
9248
  {
9046
9249
  maxaveragebitrate: '64000',
9047
9250
  maxplaybackrate: '48000',
9048
9251
  }
9049
9252
  );
9050
9253
  assert.notCalled(
9051
- meeting.sendSlotManager.getSlot(MediaType.AudioMain).deleteCodecParameters
9254
+ meeting.sendSlotManager.getSlot(MediaType.AudioMain).markCustomCodecParametersForDeletion
9052
9255
  );
9053
9256
  });
9054
9257
 
9055
- it('should set the codec parameters when shouldEnableMusicMode is false', async () => {
9258
+ it('should mark custom codec parameters for deletion when shouldEnableMusicMode is false', async () => {
9056
9259
  await meeting.enableMusicMode(false);
9057
9260
  assert.calledOnceWithExactly(
9058
- meeting.sendSlotManager.getSlot(MediaType.AudioMain).deleteCodecParameters,
9261
+ meeting.sendSlotManager.getSlot(MediaType.AudioMain).markCustomCodecParametersForDeletion,
9262
+ MediaCodecMimeType.OPUS,
9059
9263
  ['maxaveragebitrate', 'maxplaybackrate']
9060
9264
  );
9061
- assert.notCalled(meeting.sendSlotManager.getSlot(MediaType.AudioMain).setCodecParameters);
9265
+ assert.notCalled(meeting.sendSlotManager.getSlot(MediaType.AudioMain).setCustomCodecParameters);
9062
9266
  });
9063
9267
  });
9064
9268
 
@@ -9149,7 +9353,10 @@ describe('plugin-meetings', () => {
9149
9353
 
9150
9354
  // check that the right things were called by the callback
9151
9355
  assert.calledOnceWithExactly(meeting.waitForRemoteSDPAnswer);
9152
- assert.calledOnceWithExactly(meeting.mediaProperties.waitForMediaConnectionConnected);
9356
+ assert.calledOnceWithExactly(
9357
+ meeting.mediaProperties.waitForMediaConnectionConnected,
9358
+ meeting.correlationId
9359
+ );
9153
9360
  });
9154
9361
  });
9155
9362
 
@@ -10210,14 +10417,24 @@ describe('plugin-meetings', () => {
10210
10417
  );
10211
10418
  done();
10212
10419
  });
10213
- it('listens to the self admitted guest event', (done) => {
10420
+ it('listens to the self admitted guest event without blocking on token prefetch', async () => {
10214
10421
  meeting.stopKeepAlive = sinon.stub();
10215
10422
  meeting.updateLLMConnection = sinon.stub();
10423
+ let resolvePrefetch;
10424
+
10425
+ meeting.ensureDefaultDatachannelTokenAfterAdmit = sinon
10426
+ .stub()
10427
+ .returns(new Promise((resolve) => {
10428
+ resolvePrefetch = resolve;
10429
+ }));
10216
10430
  meeting.rtcMetrics = {
10217
10431
  sendNextMetrics: sinon.stub(),
10218
10432
  };
10433
+
10219
10434
  meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ADMITTED_GUEST', test1);
10435
+
10220
10436
  assert.calledOnceWithExactly(meeting.stopKeepAlive);
10437
+ assert.calledOnceWithExactly(meeting.ensureDefaultDatachannelTokenAfterAdmit);
10221
10438
  assert.calledThrice(TriggerProxy.trigger);
10222
10439
  assert.calledWith(
10223
10440
  TriggerProxy.trigger,
@@ -10236,7 +10453,11 @@ describe('plugin-meetings', () => {
10236
10453
  correlation_id: meeting.correlationId,
10237
10454
  }
10238
10455
  );
10239
- done();
10456
+
10457
+ resolvePrefetch(false);
10458
+ await Promise.resolve();
10459
+
10460
+ assert.calledOnce(meeting.updateLLMConnection);
10240
10461
  });
10241
10462
 
10242
10463
  it('listens to the breakouts changed event', () => {
@@ -10323,6 +10544,21 @@ describe('plugin-meetings', () => {
10323
10544
  EVENT_TRIGGERS.MEETING_INTERPRETATION_UPDATE
10324
10545
  );
10325
10546
  });
10547
+
10548
+ it('listens to the self id changed event and updates aiEnableRequest', () => {
10549
+ meeting.aiEnableRequest = {
10550
+ selfParticipantIdUpdate: sinon.stub(),
10551
+ };
10552
+
10553
+ const payload = {selfId: 'participant-test-123'};
10554
+
10555
+ meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ID_CHANGED', payload);
10556
+
10557
+ assert.calledOnceWithExactly(
10558
+ meeting.aiEnableRequest.selfParticipantIdUpdate,
10559
+ payload.selfId
10560
+ );
10561
+ });
10326
10562
  });
10327
10563
 
10328
10564
  describe('#setUpBreakoutsListener', () => {
@@ -10570,6 +10806,24 @@ describe('plugin-meetings', () => {
10570
10806
  );
10571
10807
  });
10572
10808
 
10809
+ it('listens to MEETING_CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED', async () => {
10810
+ const aiSummaryNotification = {example: 'value'};
10811
+
10812
+ await meeting.locusInfo.emitScoped(
10813
+ {function: 'test', file: 'test'},
10814
+ LOCUSINFO.EVENTS.CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED,
10815
+ {aiSummaryNotification}
10816
+ );
10817
+
10818
+ assert.calledWith(
10819
+ TriggerProxy.trigger,
10820
+ meeting,
10821
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
10822
+ EVENT_TRIGGERS.MEETING_CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED,
10823
+ {aiSummaryNotification}
10824
+ );
10825
+ });
10826
+
10573
10827
  it('listens to MEETING_CONTROLS_MEETING_FULL_UPDATED', async () => {
10574
10828
  const state = {example: 'value'};
10575
10829
 
@@ -10842,6 +11096,9 @@ describe('plugin-meetings', () => {
10842
11096
  meeting.simultaneousInterpretation = {
10843
11097
  approvalUrlUpdate: sinon.stub().returns(undefined),
10844
11098
  };
11099
+ meeting.aiEnableRequest = {
11100
+ approvalUrlUpdate: sinon.stub().returns(undefined),
11101
+ };
10845
11102
 
10846
11103
  meeting.locusInfo.emit(
10847
11104
  {function: 'test', file: 'test'},
@@ -10861,6 +11118,10 @@ describe('plugin-meetings', () => {
10861
11118
  meeting.simultaneousInterpretation.approvalUrlUpdate,
10862
11119
  newLocusServices.services.approval.url
10863
11120
  );
11121
+ assert.calledWith(
11122
+ meeting.aiEnableRequest.approvalUrlUpdate,
11123
+ newLocusServices.services.approval.url
11124
+ );
10864
11125
  assert.calledOnce(meeting.recordingController.setSessionId);
10865
11126
  done();
10866
11127
  });
@@ -11266,6 +11527,41 @@ describe('plugin-meetings', () => {
11266
11527
  });
11267
11528
  });
11268
11529
 
11530
+ describe('localConstraintsChangeHandler', () => {
11531
+ it('calls updatePreferredBitrateKbps when not multistream', () => {
11532
+ meeting.isMultistream = false;
11533
+ meeting.mediaProperties.webrtcMediaConnection = {
11534
+ updatePreferredBitrateKbps: sinon.stub(),
11535
+ };
11536
+
11537
+ meeting.localConstraintsChangeHandler();
11538
+
11539
+ assert.calledOnce(
11540
+ meeting.mediaProperties.webrtcMediaConnection.updatePreferredBitrateKbps
11541
+ );
11542
+ });
11543
+
11544
+ it('does not call updatePreferredBitrateKbps when multistream', () => {
11545
+ meeting.isMultistream = true;
11546
+ meeting.mediaProperties.webrtcMediaConnection = {
11547
+ updatePreferredBitrateKbps: sinon.stub(),
11548
+ };
11549
+
11550
+ meeting.localConstraintsChangeHandler();
11551
+
11552
+ assert.notCalled(
11553
+ meeting.mediaProperties.webrtcMediaConnection.updatePreferredBitrateKbps
11554
+ );
11555
+ });
11556
+
11557
+ it('does not throw when webrtcMediaConnection is undefined', () => {
11558
+ meeting.isMultistream = false;
11559
+ meeting.mediaProperties.webrtcMediaConnection = undefined;
11560
+
11561
+ assert.doesNotThrow(() => meeting.localConstraintsChangeHandler());
11562
+ });
11563
+ });
11564
+
11269
11565
  describe('#parseMeetingInfo', () => {
11270
11566
  const checkParseMeetingInfo = (expectedInfoToParse) => {
11271
11567
  assert.equal(meeting.conversationUrl, expectedInfoToParse.conversationUrl);
@@ -11645,6 +11941,7 @@ describe('plugin-meetings', () => {
11645
11941
  let canUnsetDisallowUnmuteSpy;
11646
11942
  let canUserRaiseHandSpy;
11647
11943
  let bothLeaveAndEndMeetingAvailableSpy;
11944
+ let requireHostEndMeetingBeforeLeaveSpy;
11648
11945
  let canUserLowerAllHandsSpy;
11649
11946
  let canUserLowerSomeoneElsesHandSpy;
11650
11947
  let waitingForOthersToJoinSpy;
@@ -11656,6 +11953,8 @@ describe('plugin-meetings', () => {
11656
11953
  let canMoveToLobbySpy;
11657
11954
  let isSpokenLanguageAutoDetectionEnabledSpy;
11658
11955
  let showAutoEndMeetingWarningSpy;
11956
+ let canAttendeeRequestAiAssistantEnabledSpy;
11957
+ let attendeeRequestAiAssistantDeclinedAllSpy;
11659
11958
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11660
11959
 
11661
11960
  beforeEach(() => {
@@ -11676,6 +11975,10 @@ describe('plugin-meetings', () => {
11676
11975
  MeetingUtil,
11677
11976
  'bothLeaveAndEndMeetingAvailable'
11678
11977
  );
11978
+ requireHostEndMeetingBeforeLeaveSpy = sinon.spy(
11979
+ MeetingUtil,
11980
+ 'requireHostEndMeetingBeforeLeave'
11981
+ );
11679
11982
  canUserLowerSomeoneElsesHandSpy = sinon.spy(MeetingUtil, 'canUserLowerSomeoneElsesHand');
11680
11983
  waitingForOthersToJoinSpy = sinon.spy(MeetingUtil, 'waitingForOthersToJoin');
11681
11984
  canSendReactionsSpy = sinon.spy(MeetingUtil, 'canSendReactions');
@@ -11692,12 +11995,22 @@ describe('plugin-meetings', () => {
11692
11995
  MeetingUtil,
11693
11996
  'isSpokenLanguageAutoDetectionEnabled'
11694
11997
  );
11998
+ canAttendeeRequestAiAssistantEnabledSpy = sinon.spy(
11999
+ MeetingUtil,
12000
+ 'canAttendeeRequestAiAssistantEnabled'
12001
+ );
12002
+ attendeeRequestAiAssistantDeclinedAllSpy = sinon.spy(
12003
+ MeetingUtil,
12004
+ 'attendeeRequestAiAssistantDeclinedAll'
12005
+ );
11695
12006
  });
11696
12007
 
11697
12008
  afterEach(() => {
11698
12009
  inMeetingActionsSetSpy.restore();
11699
12010
  waitingForOthersToJoinSpy.restore();
11700
12011
  showAutoEndMeetingWarningSpy.restore();
12012
+ canAttendeeRequestAiAssistantEnabledSpy.restore();
12013
+ attendeeRequestAiAssistantDeclinedAllSpy.restore();
11701
12014
  });
11702
12015
 
11703
12016
  forEach(
@@ -12221,6 +12534,7 @@ describe('plugin-meetings', () => {
12221
12534
  const userDisplayHints = ['LOCK_CONTROL_UNLOCK'];
12222
12535
  meeting.userDisplayHints = ['LOCK_CONTROL_UNLOCK'];
12223
12536
  meeting.meetingInfo.supportVoIP = true;
12537
+ meeting.roles = [];
12224
12538
 
12225
12539
  meeting.updateMeetingActions();
12226
12540
 
@@ -12236,6 +12550,7 @@ describe('plugin-meetings', () => {
12236
12550
  assert.calledWith(canUnsetDisallowUnmuteSpy, userDisplayHints);
12237
12551
  assert.calledWith(canUserRaiseHandSpy, userDisplayHints);
12238
12552
  assert.calledWith(bothLeaveAndEndMeetingAvailableSpy, userDisplayHints);
12553
+ assert.calledWith(requireHostEndMeetingBeforeLeaveSpy, userDisplayHints);
12239
12554
  assert.calledWith(canUserLowerAllHandsSpy, userDisplayHints);
12240
12555
  assert.calledWith(canUserLowerSomeoneElsesHandSpy, userDisplayHints);
12241
12556
  assert.calledWith(waitingForOthersToJoinSpy, userDisplayHints);
@@ -12247,6 +12562,12 @@ describe('plugin-meetings', () => {
12247
12562
  assert.calledWith(canMoveToLobbySpy, userDisplayHints);
12248
12563
  assert.calledWith(showAutoEndMeetingWarningSpy, userDisplayHints);
12249
12564
  assert.calledWith(isSpokenLanguageAutoDetectionEnabledSpy, userDisplayHints);
12565
+ assert.calledWith(
12566
+ canAttendeeRequestAiAssistantEnabledSpy,
12567
+ userDisplayHints,
12568
+ meeting.roles
12569
+ );
12570
+ assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
12250
12571
 
12251
12572
  assert.calledWith(ControlsOptionsUtil.hasHints, {
12252
12573
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -12389,33 +12710,159 @@ describe('plugin-meetings', () => {
12389
12710
 
12390
12711
  describe('#handleDataChannelUrlChange', () => {
12391
12712
  let updateLLMConnectionSpy;
12713
+ let updatePSDataChannelSpy;
12392
12714
 
12393
12715
  beforeEach(() => {
12394
12716
  updateLLMConnectionSpy = sinon.spy(meeting, 'updateLLMConnection');
12717
+ updatePSDataChannelSpy = sinon.stub(meeting.webinar, 'updatePSDataChannel').resolves();
12718
+ meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(false);
12395
12719
  });
12396
12720
 
12397
- const check = (url, expectedCalled) => {
12398
- meeting.handleDataChannelUrlChange(url);
12721
+ const check = (
12722
+ url,
12723
+ practiceSessionDatachannelUrl,
12724
+ {expectedMainCalled, expectedPracticeCalled}
12725
+ ) => {
12726
+ meeting.handleDataChannelUrlChange(url, practiceSessionDatachannelUrl);
12399
12727
 
12400
- if (expectedCalled) {
12728
+ if (expectedMainCalled) {
12401
12729
  assert.calledWith(updateLLMConnectionSpy);
12402
12730
  } else {
12403
12731
  assert.notCalled(updateLLMConnectionSpy);
12404
12732
  }
12733
+
12734
+ if (expectedPracticeCalled) {
12735
+ assert.calledWith(updatePSDataChannelSpy);
12736
+ } else {
12737
+ assert.notCalled(updatePSDataChannelSpy);
12738
+ }
12405
12739
  };
12406
12740
 
12407
12741
  it('calls deferred updateLLMConnection if datachannelURL is set and the enableAutomaticLLM is true', () => {
12408
12742
  meeting.config.enableAutomaticLLM = true;
12409
- check('some url', true);
12743
+ check('some url', undefined, {expectedMainCalled: true, expectedPracticeCalled: false});
12410
12744
  });
12411
12745
 
12412
12746
  it('does not call updateLLMConnection if datachannelURL is undefined', () => {
12413
12747
  meeting.config.enableAutomaticLLM = true;
12414
- check(undefined, false);
12748
+ check(undefined, undefined, {
12749
+ expectedMainCalled: false,
12750
+ expectedPracticeCalled: false,
12751
+ });
12415
12752
  });
12416
12753
 
12417
12754
  it('does not call updateLLMConnection if enableAutomaticLLM is false', () => {
12418
- check('some url', false);
12755
+ check('some url', 'some practice url', {
12756
+ expectedMainCalled: false,
12757
+ expectedPracticeCalled: false,
12758
+ });
12759
+ });
12760
+
12761
+ it('calls updatePSDataChannel when practice-session routing is active', () => {
12762
+ meeting.config.enableAutomaticLLM = true;
12763
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
12764
+
12765
+ check('some url', 'some practice url', {
12766
+ expectedMainCalled: true,
12767
+ expectedPracticeCalled: true,
12768
+ });
12769
+ });
12770
+
12771
+ it('does not call updatePSDataChannel when the main datachannelURL is undefined', () => {
12772
+ meeting.config.enableAutomaticLLM = true;
12773
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
12774
+
12775
+ check(undefined, 'some practice url', {
12776
+ expectedMainCalled: false,
12777
+ expectedPracticeCalled: false,
12778
+ });
12779
+ });
12780
+ });
12781
+
12782
+ describe('#saveDataChannelToken', () => {
12783
+ beforeEach(() => {
12784
+ webex.internal.llm.setDatachannelToken = sinon.stub();
12785
+ });
12786
+
12787
+ it('saves datachannelToken into LLM as Default', () => {
12788
+ meeting.saveDataChannelToken({
12789
+ locus: {
12790
+ self: {datachannelToken: 'default-token'},
12791
+ },
12792
+ });
12793
+
12794
+ assert.calledWithExactly(
12795
+ webex.internal.llm.setDatachannelToken,
12796
+ 'default-token',
12797
+ 'llm-default-session'
12798
+ );
12799
+ });
12800
+
12801
+ it('saves practiceSessionDatachannelToken into LLM as PracticeSession', () => {
12802
+ meeting.saveDataChannelToken({
12803
+ locus: {
12804
+ self: {practiceSessionDatachannelToken: 'ps-token'},
12805
+ },
12806
+ });
12807
+
12808
+ assert.calledWithExactly(
12809
+ webex.internal.llm.setDatachannelToken,
12810
+ 'ps-token',
12811
+ 'llm-practice-session'
12812
+ );
12813
+ });
12814
+
12815
+ it('saves both tokens when both are present', () => {
12816
+ meeting.saveDataChannelToken({
12817
+ locus: {
12818
+ self: {
12819
+ datachannelToken: 'default-token',
12820
+ practiceSessionDatachannelToken: 'ps-token',
12821
+ },
12822
+ },
12823
+ });
12824
+
12825
+ assert.calledTwice(webex.internal.llm.setDatachannelToken);
12826
+ assert.calledWithExactly(
12827
+ webex.internal.llm.setDatachannelToken,
12828
+ 'default-token',
12829
+ 'llm-default-session'
12830
+ );
12831
+ assert.calledWithExactly(
12832
+ webex.internal.llm.setDatachannelToken,
12833
+ 'ps-token',
12834
+ 'llm-practice-session'
12835
+ );
12836
+ });
12837
+
12838
+ it('does not call setDatachannelToken when no tokens are present', () => {
12839
+ meeting.saveDataChannelToken({locus: {self: {}}});
12840
+
12841
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
12842
+ });
12843
+
12844
+ it('handles undefined join gracefully', () => {
12845
+ meeting.saveDataChannelToken(undefined);
12846
+
12847
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
12848
+ });
12849
+
12850
+ it('handles missing locus.self gracefully', () => {
12851
+ meeting.saveDataChannelToken({locus: {}});
12852
+
12853
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
12854
+ });
12855
+ });
12856
+
12857
+ describe('#clearDataChannelToken', () => {
12858
+ beforeEach(() => {
12859
+ webex.internal.llm.resetDatachannelTokens = sinon.stub();
12860
+ });
12861
+
12862
+ it('calls resetDatachannelTokens on LLM', () => {
12863
+ meeting.clearDataChannelToken();
12864
+
12865
+ assert.calledOnce(webex.internal.llm.resetDatachannelTokens);
12419
12866
  });
12420
12867
  });
12421
12868
 
@@ -12424,16 +12871,20 @@ describe('plugin-meetings', () => {
12424
12871
  webex.internal.llm.isConnected = sinon.stub().returns(false);
12425
12872
  webex.internal.llm.getLocusUrl = sinon.stub();
12426
12873
  webex.internal.llm.getDatachannelUrl = sinon.stub();
12427
- webex.internal.llm.registerAndConnect = sinon
12428
- .stub()
12429
- .returns(Promise.resolve('something'));
12430
- webex.internal.llm.disconnectLLM = sinon.stub().returns(Promise.resolve());
12431
- meeting.webex.internal.llm.on = sinon.stub();
12432
- meeting.webex.internal.llm.off = sinon.stub();
12874
+ webex.internal.llm.registerAndConnect = sinon.stub().resolves('something');
12875
+ webex.internal.llm.disconnectLLM = sinon.stub().resolves();
12876
+ webex.internal.llm.on = sinon.stub();
12877
+ webex.internal.llm.off = sinon.stub();
12878
+ webex.internal.llm.getDatachannelToken = sinon.stub().returns(undefined);
12879
+ webex.internal.llm.setDatachannelToken = sinon.stub();
12880
+
12433
12881
  meeting.processRelayEvent = sinon.stub();
12882
+ meeting.processLocusLLMEvent = sinon.stub();
12883
+ meeting.clearLLMHealthCheckTimer = sinon.stub();
12884
+ meeting.startLLMHealthCheckTimer = sinon.stub();
12885
+
12434
12886
  meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(false);
12435
12887
  });
12436
-
12437
12888
  it('does not connect if the call is not joined yet', async () => {
12438
12889
  meeting.joinedWith = {state: 'any other state'};
12439
12890
  webex.internal.llm.getLocusUrl.returns('a url');
@@ -12447,31 +12898,21 @@ describe('plugin-meetings', () => {
12447
12898
  assert.equal(result, undefined);
12448
12899
  assert.notCalled(meeting.webex.internal.llm.on);
12449
12900
  });
12450
-
12451
12901
  it('returns undefined if llm is already connected and the locus url is unchanged', async () => {
12452
12902
  meeting.joinedWith = {state: 'JOINED'};
12453
- webex.internal.llm.isConnected.returns(true);
12454
- webex.internal.llm.getLocusUrl.returns('a url');
12455
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12456
-
12457
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12458
-
12459
- const result = await meeting.updateLLMConnection();
12460
-
12461
- assert.notCalled(webex.internal.llm.registerAndConnect);
12462
- assert.notCalled(webex.internal.llm.disconnectLLM);
12463
- assert.equal(result, undefined);
12464
- assert.notCalled(meeting.webex.internal.llm.on);
12465
- });
12466
-
12467
- it('connects if not already connected', async () => {
12468
- meeting.joinedWith = {state: 'JOINED'};
12469
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12903
+ meeting.locusInfo = {
12904
+ url: 'a url',
12905
+ info: {datachannelUrl: 'a datachannel url'},
12906
+ };
12470
12907
 
12471
12908
  const result = await meeting.updateLLMConnection();
12472
-
12473
12909
  assert.notCalled(webex.internal.llm.disconnectLLM);
12474
- assert.calledWith(webex.internal.llm.registerAndConnect, 'a url', 'a datachannel url');
12910
+ assert.calledWithExactly(
12911
+ webex.internal.llm.registerAndConnect,
12912
+ 'a url',
12913
+ 'a datachannel url',
12914
+ undefined
12915
+ );
12475
12916
  assert.equal(result, 'something');
12476
12917
  assert.calledWithExactly(
12477
12918
  meeting.webex.internal.llm.off,
@@ -12494,27 +12935,49 @@ describe('plugin-meetings', () => {
12494
12935
  meeting.processLocusLLMEvent
12495
12936
  );
12496
12937
  });
12938
+ it('connects if not already connected', async () => {
12939
+ meeting.joinedWith = {state: 'JOINED'};
12940
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12497
12941
 
12498
- it('disconnects if first if the locus url has changed', async () => {
12942
+ const result = await meeting.updateLLMConnection();
12943
+
12944
+ assert.notCalled(webex.internal.llm.disconnectLLM);
12945
+ assert.calledWithExactly(
12946
+ webex.internal.llm.registerAndConnect,
12947
+ 'a url',
12948
+ 'a datachannel url',
12949
+ undefined
12950
+ );
12951
+ assert.equal(result, 'something');
12952
+ });
12953
+ it('disconnects if the locus url has changed', async () => {
12499
12954
  meeting.joinedWith = {state: 'JOINED'};
12955
+
12500
12956
  webex.internal.llm.isConnected.returns(true);
12501
12957
  webex.internal.llm.getLocusUrl.returns('a url');
12502
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12503
12958
 
12504
- meeting.locusInfo = {url: 'a different url', info: {datachannelUrl: 'a datachannel url'}};
12959
+ meeting.locusInfo = {
12960
+ url: 'a different url',
12961
+ info: {datachannelUrl: 'a datachannel url'},
12962
+ self: {},
12963
+ };
12505
12964
 
12506
12965
  const result = await meeting.updateLLMConnection();
12507
12966
 
12508
- assert.calledWith(webex.internal.llm.disconnectLLM, {
12967
+ assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12509
12968
  code: 3050,
12510
12969
  reason: 'done (permanent)',
12511
12970
  });
12512
- assert.calledWith(
12971
+
12972
+ assert.calledWithExactly(
12513
12973
  webex.internal.llm.registerAndConnect,
12514
12974
  'a different url',
12515
- 'a datachannel url'
12975
+ 'a datachannel url',
12976
+ undefined
12516
12977
  );
12978
+
12517
12979
  assert.equal(result, 'something');
12980
+
12518
12981
  assert.calledWithExactly(
12519
12982
  meeting.webex.internal.llm.off,
12520
12983
  'event:relay.event',
@@ -12526,6 +12989,7 @@ describe('plugin-meetings', () => {
12526
12989
  meeting.processLocusLLMEvent
12527
12990
  );
12528
12991
  assert.callCount(meeting.webex.internal.llm.off, 4);
12992
+
12529
12993
  assert.calledWithExactly(
12530
12994
  meeting.webex.internal.llm.on,
12531
12995
  'event:relay.event',
@@ -12536,28 +13000,37 @@ describe('plugin-meetings', () => {
12536
13000
  'event:locus.state_message',
12537
13001
  meeting.processLocusLLMEvent
12538
13002
  );
13003
+ assert.isFalse(
13004
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13005
+ );
12539
13006
  });
12540
-
12541
- it('disconnects it first if the data channel url has changed', async () => {
13007
+ it('disconnects if the data channel url has changed', async () => {
12542
13008
  meeting.joinedWith = {state: 'JOINED'};
12543
13009
  webex.internal.llm.isConnected.returns(true);
12544
13010
  webex.internal.llm.getLocusUrl.returns('a url');
12545
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12546
13011
 
12547
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a different datachannel url'}};
13012
+ meeting.locusInfo = {
13013
+ url: 'a url',
13014
+ info: {datachannelUrl: 'a different datachannel url'},
13015
+ self: {},
13016
+ };
12548
13017
 
12549
13018
  const result = await meeting.updateLLMConnection();
12550
13019
 
12551
- assert.calledWith(webex.internal.llm.disconnectLLM, {
13020
+ assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12552
13021
  code: 3050,
12553
13022
  reason: 'done (permanent)',
12554
13023
  });
12555
- assert.calledWith(
13024
+
13025
+ assert.calledWithExactly(
12556
13026
  webex.internal.llm.registerAndConnect,
12557
13027
  'a url',
12558
- 'a different datachannel url'
13028
+ 'a different datachannel url',
13029
+ undefined
12559
13030
  );
13031
+
12560
13032
  assert.equal(result, 'something');
13033
+
12561
13034
  assert.calledWithExactly(
12562
13035
  meeting.webex.internal.llm.off,
12563
13036
  'event:relay.event',
@@ -12568,6 +13041,7 @@ describe('plugin-meetings', () => {
12568
13041
  'event:locus.state_message',
12569
13042
  meeting.processLocusLLMEvent
12570
13043
  );
13044
+
12571
13045
  assert.calledWithExactly(
12572
13046
  meeting.webex.internal.llm.on,
12573
13047
  'event:relay.event',
@@ -12578,8 +13052,10 @@ describe('plugin-meetings', () => {
12578
13052
  'event:locus.state_message',
12579
13053
  meeting.processLocusLLMEvent
12580
13054
  );
13055
+ assert.isFalse(
13056
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13057
+ );
12581
13058
  });
12582
-
12583
13059
  it('disconnects when the state is not JOINED', async () => {
12584
13060
  meeting.joinedWith = {state: 'any other state'};
12585
13061
  webex.internal.llm.isConnected.returns(true);
@@ -12589,9 +13065,38 @@ describe('plugin-meetings', () => {
12589
13065
 
12590
13066
  const result = await meeting.updateLLMConnection();
12591
13067
 
12592
- assert.calledWith(webex.internal.llm.disconnectLLM, undefined);
13068
+ assert.calledWith(webex.internal.llm.disconnectLLM, {
13069
+ code: 3050,
13070
+ reason: 'done (permanent)',
13071
+ });
12593
13072
  assert.notCalled(webex.internal.llm.registerAndConnect);
12594
13073
  assert.equal(result, undefined);
13074
+ assert.isFalse(
13075
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13076
+ );
13077
+ });
13078
+ it('rethrows disconnect errors during reconnect cleanup after removing relay listeners and timer', async () => {
13079
+ const disconnectError = new Error('disconnect failed');
13080
+
13081
+ meeting.joinedWith = {state: 'JOINED'};
13082
+ webex.internal.llm.isConnected.returns(true);
13083
+ webex.internal.llm.getLocusUrl.returns('a url');
13084
+ webex.internal.llm.disconnectLLM.rejects(disconnectError);
13085
+
13086
+ meeting.locusInfo = {
13087
+ url: 'a different url',
13088
+ info: {datachannelUrl: 'a datachannel url'},
13089
+ self: {},
13090
+ };
13091
+
13092
+ try {
13093
+ await meeting.updateLLMConnection();
13094
+ assert.fail('Expected updateLLMConnection to reject when disconnectLLM fails');
13095
+ } catch (error) {
13096
+ assert.equal(error, disconnectError);
13097
+ }
13098
+
13099
+ assert.notCalled(webex.internal.llm.registerAndConnect);
12595
13100
  assert.calledWithExactly(
12596
13101
  meeting.webex.internal.llm.off,
12597
13102
  'event:relay.event',
@@ -12602,22 +13107,159 @@ describe('plugin-meetings', () => {
12602
13107
  'event:locus.state_message',
12603
13108
  meeting.processLocusLLMEvent
12604
13109
  );
13110
+ assert.isFalse(
13111
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13112
+ );
13113
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
12605
13114
  });
12606
-
12607
- it('connect ps data channel if ps started in webinar', async () => {
13115
+ it('still need connect main session data channel when PS started', async () => {
12608
13116
  meeting.joinedWith = {state: 'JOINED'};
12609
13117
  meeting.locusInfo = {
12610
13118
  url: 'a url',
12611
13119
  info: {
12612
13120
  datachannelUrl: 'a datachannel url',
12613
- practiceSessionDatachannelUrl: 'a ps datachannel url',
13121
+ practiceSessionDatachannelUrl: 'ps-url',
12614
13122
  },
12615
13123
  };
12616
- meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(true);
13124
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
13125
+
12617
13126
  await meeting.updateLLMConnection();
12618
13127
 
12619
- assert.notCalled(webex.internal.llm.disconnectLLM);
12620
- assert.calledWith(webex.internal.llm.registerAndConnect, 'a url', 'a ps datachannel url');
13128
+ assert.calledWithExactly(
13129
+ webex.internal.llm.registerAndConnect,
13130
+ 'a url',
13131
+ 'a datachannel url',
13132
+ undefined
13133
+ );
13134
+ });
13135
+ it('passes dataChannelToken from LLM to registerAndConnect', async () => {
13136
+ meeting.joinedWith = {state: 'JOINED'};
13137
+ meeting.locusInfo = {
13138
+ url: 'a url',
13139
+ info: {datachannelUrl: 'a datachannel url'},
13140
+ };
13141
+
13142
+ webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('token-123');
13143
+
13144
+ await meeting.updateLLMConnection();
13145
+
13146
+ assert.calledWithExactly(
13147
+ webex.internal.llm.registerAndConnect,
13148
+ 'a url',
13149
+ 'a datachannel url',
13150
+ 'token-123'
13151
+ );
13152
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
13153
+ });
13154
+ it('passes undefined token when LLM has no token stored', async () => {
13155
+ meeting.joinedWith = {state: 'JOINED'};
13156
+ meeting.locusInfo = {
13157
+ url: 'a url',
13158
+ info: {datachannelUrl: 'a datachannel url'},
13159
+ };
13160
+
13161
+ webex.internal.llm.getDatachannelToken.returns(undefined);
13162
+
13163
+ await meeting.updateLLMConnection();
13164
+
13165
+ assert.calledWithExactly(
13166
+ webex.internal.llm.registerAndConnect,
13167
+ 'a url',
13168
+ 'a datachannel url',
13169
+ undefined
13170
+ );
13171
+
13172
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
13173
+ });
13174
+
13175
+ it('does not pass token when data channel with jwt token is disabled', async () => {
13176
+ meeting.joinedWith = {state: 'JOINED'};
13177
+ meeting.locusInfo = {
13178
+ url: 'a url',
13179
+ info: {datachannelUrl: 'a datachannel url'},
13180
+ };
13181
+
13182
+ webex.internal.llm.getDatachannelToken.returns(undefined);
13183
+ webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
13184
+
13185
+ await meeting.updateLLMConnection();
13186
+
13187
+ assert.calledWithExactly(
13188
+ webex.internal.llm.registerAndConnect,
13189
+ 'a url',
13190
+ 'a datachannel url',
13191
+ undefined
13192
+ );
13193
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
13194
+ });
13195
+
13196
+ describe('#clearMeetingData', () => {
13197
+ beforeEach(() => {
13198
+ webex.internal.llm.isConnected = sinon.stub().returns(true);
13199
+ webex.internal.llm.disconnectLLM = sinon.stub().resolves();
13200
+ webex.internal.llm.off = sinon.stub();
13201
+ meeting.annotation.deregisterEvents = sinon.stub();
13202
+ meeting.clearLLMHealthCheckTimer = sinon.stub();
13203
+ meeting.stopTranscription = sinon.stub();
13204
+ meeting.clearDataChannelToken = sinon.stub();
13205
+ meeting.shareStatus = 'no-share';
13206
+ });
13207
+
13208
+ it('disconnects llm and removes online and relay listeners during meeting data cleanup', async () => {
13209
+ await meeting.clearMeetingData();
13210
+
13211
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13212
+ code: 3050,
13213
+ reason: 'done (permanent)',
13214
+ });
13215
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13216
+ assert.calledWithExactly(
13217
+ webex.internal.llm.off,
13218
+ 'event:relay.event',
13219
+ meeting.processRelayEvent
13220
+ );
13221
+ assert.calledWithExactly(
13222
+ webex.internal.llm.off,
13223
+ 'event:locus.state_message',
13224
+ meeting.processLocusLLMEvent
13225
+ );
13226
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13227
+ assert.calledOnce(meeting.stopTranscription);
13228
+ assert.isUndefined(meeting.transcription);
13229
+ assert.calledOnce(meeting.clearDataChannelToken);
13230
+ assert.calledOnce(meeting.annotation.deregisterEvents);
13231
+ });
13232
+ it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
13233
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
13234
+
13235
+ await meeting.clearMeetingData();
13236
+
13237
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13238
+ assert.calledWithExactly(
13239
+ webex.internal.llm.off,
13240
+ 'event:relay.event',
13241
+ meeting.processRelayEvent
13242
+ );
13243
+ assert.calledWithExactly(
13244
+ webex.internal.llm.off,
13245
+ 'event:locus.state_message',
13246
+ meeting.processLocusLLMEvent
13247
+ );
13248
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13249
+ assert.calledOnce(meeting.stopTranscription);
13250
+ assert.isUndefined(meeting.transcription);
13251
+ assert.calledOnce(meeting.clearDataChannelToken);
13252
+ assert.calledOnce(meeting.annotation.deregisterEvents);
13253
+ });
13254
+ it('always calls stopTranscription even when transcription is undefined', async () => {
13255
+ meeting.transcription = undefined;
13256
+
13257
+ await meeting.clearMeetingData();
13258
+
13259
+ assert.calledOnce(meeting.stopTranscription);
13260
+ assert.isUndefined(meeting.transcription);
13261
+ assert.calledOnce(meeting.clearDataChannelToken);
13262
+ });
12621
13263
  });
12622
13264
  });
12623
13265
 
@@ -12628,6 +13270,7 @@ describe('plugin-meetings', () => {
12628
13270
 
12629
13271
  it('should read the locus object, set on the meeting and return null', () => {
12630
13272
  const dataSets = {someFakeStuff: 'dataSet'};
13273
+ const metadata = {some: 'metadata'};
12631
13274
 
12632
13275
  meeting.setLocus({
12633
13276
  mediaConnections: [test1],
@@ -12637,12 +13280,14 @@ describe('plugin-meetings', () => {
12637
13280
  mediaId: uuid3,
12638
13281
  locus: {host: {id: uuid4}},
12639
13282
  dataSets,
13283
+ metadata,
12640
13284
  });
12641
13285
  assert.calledOnce(meeting.locusInfo.initialSetup);
12642
13286
  assert.calledWith(meeting.locusInfo.initialSetup, {
12643
13287
  trigger: 'join-response',
12644
13288
  locus: {host: {id: uuid4}},
12645
13289
  dataSets,
13290
+ metadata,
12646
13291
  });
12647
13292
  assert.equal(meeting.mediaConnections, test1);
12648
13293
  assert.equal(meeting.locusUrl, url1);
@@ -14184,6 +14829,69 @@ describe('plugin-meetings', () => {
14184
14829
  assert.calledOnce(meeting.meetingRequest.keepAlive);
14185
14830
  });
14186
14831
  });
14832
+ describe('#refreshDataChannelToken()', () => {
14833
+ let meeting;
14834
+
14835
+ beforeEach(() => {
14836
+ meeting = Object.create(Meeting.prototype);
14837
+ meeting.locusUrl = 'https://locus.example.com';
14838
+ meeting.meetingRequest = {
14839
+ fetchDatachannelToken: sinon.stub().resolves({
14840
+ body: {datachannelToken: 'mock-token'},
14841
+ }),
14842
+ };
14843
+ meeting.members = {
14844
+ selfId: 'self-123',
14845
+ };
14846
+ meeting.webinar = {
14847
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(true),
14848
+ };
14849
+ });
14850
+
14851
+ it('calls fetchDatachannelToken with correct parameters', async () => {
14852
+ await meeting.refreshDataChannelToken();
14853
+
14854
+ sinon.assert.calledOnce(meeting.meetingRequest.fetchDatachannelToken);
14855
+
14856
+ sinon.assert.calledWith(meeting.meetingRequest.fetchDatachannelToken, {
14857
+ locusUrl: 'https://locus.example.com',
14858
+ requestingParticipantId: 'self-123',
14859
+ isPracticeSession: true,
14860
+ });
14861
+ });
14862
+
14863
+ it('returns the correct structured result', async () => {
14864
+ const result = await meeting.refreshDataChannelToken();
14865
+
14866
+ expect(result).to.deep.equal({
14867
+ body: {
14868
+ datachannelToken: 'mock-token',
14869
+ dataChannelTokenType: 'llm-practice-session',
14870
+ },
14871
+ });
14872
+ });
14873
+ });
14874
+ describe('#getDataChannelTokenType', () => {
14875
+ it('returns PracticeSession when webinar is in practice session mode', () => {
14876
+ meeting.webinar = {
14877
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(true),
14878
+ };
14879
+
14880
+ const result = meeting.getDataChannelTokenType();
14881
+
14882
+ expect(result).to.equal('llm-practice-session');
14883
+ });
14884
+
14885
+ it('returns Default when not in practice session mode', () => {
14886
+ meeting.webinar = {
14887
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(false),
14888
+ };
14889
+
14890
+ const result = meeting.getDataChannelTokenType();
14891
+
14892
+ expect(result).to.equal('llm-default-session');
14893
+ });
14894
+ });
14187
14895
  describe('#stopKeepAlive', () => {
14188
14896
  let clock;
14189
14897
  const defaultKeepAliveUrl = 'keep.alive.url';