@webex/plugin-meetings 3.12.0-mobius-socket.1 → 3.12.0-mobius-socket.3

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 (145) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +15 -2
  3. package/dist/aiEnableRequest/index.js.map +1 -1
  4. package/dist/breakouts/breakout.js +8 -3
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +3 -2
  7. package/dist/breakouts/index.js.map +1 -1
  8. package/dist/config.js +1 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +6 -3
  11. package/dist/constants.js.map +1 -1
  12. package/dist/controls-options-manager/constants.js +11 -1
  13. package/dist/controls-options-manager/constants.js.map +1 -1
  14. package/dist/controls-options-manager/index.js +38 -24
  15. package/dist/controls-options-manager/index.js.map +1 -1
  16. package/dist/controls-options-manager/util.js +91 -0
  17. package/dist/controls-options-manager/util.js.map +1 -1
  18. package/dist/hashTree/constants.js +10 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +651 -382
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +22 -0
  23. package/dist/hashTree/utils.js.map +1 -1
  24. package/dist/index.js +7 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/interceptors/locusRetry.js +23 -8
  27. package/dist/interceptors/locusRetry.js.map +1 -1
  28. package/dist/interpretation/index.js +10 -1
  29. package/dist/interpretation/index.js.map +1 -1
  30. package/dist/interpretation/siLanguage.js +1 -1
  31. package/dist/locus-info/controlsUtils.js +4 -1
  32. package/dist/locus-info/controlsUtils.js.map +1 -1
  33. package/dist/locus-info/index.js +289 -87
  34. package/dist/locus-info/index.js.map +1 -1
  35. package/dist/locus-info/types.js +19 -0
  36. package/dist/locus-info/types.js.map +1 -1
  37. package/dist/media/properties.js +1 -0
  38. package/dist/media/properties.js.map +1 -1
  39. package/dist/meeting/in-meeting-actions.js +3 -1
  40. package/dist/meeting/in-meeting-actions.js.map +1 -1
  41. package/dist/meeting/index.js +848 -582
  42. package/dist/meeting/index.js.map +1 -1
  43. package/dist/meeting/util.js +19 -2
  44. package/dist/meeting/util.js.map +1 -1
  45. package/dist/meetings/index.js +205 -77
  46. package/dist/meetings/index.js.map +1 -1
  47. package/dist/meetings/meetings.types.js +6 -1
  48. package/dist/meetings/meetings.types.js.map +1 -1
  49. package/dist/meetings/request.js +39 -0
  50. package/dist/meetings/request.js.map +1 -1
  51. package/dist/meetings/util.js +67 -5
  52. package/dist/meetings/util.js.map +1 -1
  53. package/dist/member/index.js +10 -0
  54. package/dist/member/index.js.map +1 -1
  55. package/dist/member/types.js.map +1 -1
  56. package/dist/member/util.js +3 -0
  57. package/dist/member/util.js.map +1 -1
  58. package/dist/metrics/constants.js +4 -1
  59. package/dist/metrics/constants.js.map +1 -1
  60. package/dist/multistream/receiveSlot.js +9 -0
  61. package/dist/multistream/receiveSlot.js.map +1 -1
  62. package/dist/reactions/reactions.type.js.map +1 -1
  63. package/dist/recording-controller/index.js +1 -3
  64. package/dist/recording-controller/index.js.map +1 -1
  65. package/dist/types/config.d.ts +1 -0
  66. package/dist/types/constants.d.ts +2 -0
  67. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  68. package/dist/types/controls-options-manager/index.d.ts +10 -0
  69. package/dist/types/hashTree/constants.d.ts +1 -0
  70. package/dist/types/hashTree/hashTreeParser.d.ts +83 -16
  71. package/dist/types/hashTree/utils.d.ts +11 -0
  72. package/dist/types/index.d.ts +2 -0
  73. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  74. package/dist/types/locus-info/index.d.ts +46 -6
  75. package/dist/types/locus-info/types.d.ts +21 -1
  76. package/dist/types/media/properties.d.ts +1 -0
  77. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  78. package/dist/types/meeting/index.d.ts +65 -1
  79. package/dist/types/meeting/util.d.ts +8 -0
  80. package/dist/types/meetings/index.d.ts +20 -2
  81. package/dist/types/meetings/meetings.types.d.ts +15 -0
  82. package/dist/types/meetings/request.d.ts +14 -0
  83. package/dist/types/member/index.d.ts +1 -0
  84. package/dist/types/member/types.d.ts +1 -0
  85. package/dist/types/member/util.d.ts +1 -0
  86. package/dist/types/metrics/constants.d.ts +3 -0
  87. package/dist/types/reactions/reactions.type.d.ts +3 -0
  88. package/dist/webinar/index.js +68 -17
  89. package/dist/webinar/index.js.map +1 -1
  90. package/package.json +22 -22
  91. package/src/aiEnableRequest/index.ts +16 -0
  92. package/src/breakouts/breakout.ts +3 -1
  93. package/src/breakouts/index.ts +1 -0
  94. package/src/config.ts +1 -0
  95. package/src/constants.ts +5 -1
  96. package/src/controls-options-manager/constants.ts +14 -1
  97. package/src/controls-options-manager/index.ts +47 -24
  98. package/src/controls-options-manager/util.ts +81 -1
  99. package/src/hashTree/constants.ts +9 -0
  100. package/src/hashTree/hashTreeParser.ts +375 -197
  101. package/src/hashTree/utils.ts +17 -0
  102. package/src/index.ts +5 -0
  103. package/src/interceptors/locusRetry.ts +25 -4
  104. package/src/interpretation/index.ts +25 -8
  105. package/src/locus-info/controlsUtils.ts +3 -1
  106. package/src/locus-info/index.ts +291 -97
  107. package/src/locus-info/types.ts +25 -1
  108. package/src/media/properties.ts +1 -0
  109. package/src/meeting/in-meeting-actions.ts +4 -0
  110. package/src/meeting/index.ts +260 -23
  111. package/src/meeting/util.ts +20 -2
  112. package/src/meetings/index.ts +109 -43
  113. package/src/meetings/meetings.types.ts +19 -0
  114. package/src/meetings/request.ts +43 -0
  115. package/src/meetings/util.ts +80 -1
  116. package/src/member/index.ts +10 -0
  117. package/src/member/types.ts +1 -0
  118. package/src/member/util.ts +3 -0
  119. package/src/metrics/constants.ts +3 -0
  120. package/src/multistream/receiveSlot.ts +18 -0
  121. package/src/reactions/reactions.type.ts +3 -0
  122. package/src/recording-controller/index.ts +1 -2
  123. package/src/webinar/index.ts +88 -21
  124. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  125. package/test/unit/spec/breakouts/breakout.ts +9 -3
  126. package/test/unit/spec/breakouts/index.ts +2 -0
  127. package/test/unit/spec/controls-options-manager/index.js +140 -29
  128. package/test/unit/spec/controls-options-manager/util.js +165 -0
  129. package/test/unit/spec/hashTree/hashTreeParser.ts +1263 -157
  130. package/test/unit/spec/hashTree/utils.ts +88 -1
  131. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  132. package/test/unit/spec/interpretation/index.ts +26 -4
  133. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  134. package/test/unit/spec/locus-info/index.js +475 -81
  135. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  136. package/test/unit/spec/meeting/index.js +902 -14
  137. package/test/unit/spec/meeting/muteState.js +3 -0
  138. package/test/unit/spec/meeting/utils.js +33 -0
  139. package/test/unit/spec/meetings/index.js +309 -10
  140. package/test/unit/spec/meetings/request.js +141 -0
  141. package/test/unit/spec/meetings/utils.js +161 -0
  142. package/test/unit/spec/member/index.js +7 -0
  143. package/test/unit/spec/member/util.js +24 -0
  144. package/test/unit/spec/recording-controller/index.js +9 -8
  145. package/test/unit/spec/webinar/index.ts +81 -16
@@ -0,0 +1,141 @@
1
+ import 'jsdom-global/register';
2
+ import sinon from 'sinon';
3
+ import {assert} from '@webex/test-helper-chai';
4
+ import MockWebex from '@webex/test-helper-mock-webex';
5
+ import Meetings from '@webex/plugin-meetings';
6
+ import ParameterError from '@webex/plugin-meetings/src/common/errors/parameter';
7
+ import MeetingRequest from '@webex/plugin-meetings/src/meetings/request';
8
+ import {SitePreferenceSelectOption} from '@webex/plugin-meetings/src/meetings/meetings.types';
9
+
10
+ const multipartSitePrefixList = ['.my.', '.mydmz.', '.mybts.', '.mydev.', '.myats2.', '.myats.'];
11
+
12
+ describe('plugin-meetings/meetings/request', () => {
13
+ let meetingRequest;
14
+ let request;
15
+
16
+ beforeEach(() => {
17
+ const webex = new MockWebex({
18
+ children: {
19
+ meetings: Meetings,
20
+ },
21
+ });
22
+
23
+ request = sinon.stub().resolves({
24
+ body: {
25
+ scheduling: {
26
+ supportScheduleWebinar: true,
27
+ webinarWebLink: 'https://go.webex.com/webappng/sites/go/webinar/scheduler',
28
+ },
29
+ },
30
+ });
31
+
32
+ meetingRequest = new MeetingRequest(
33
+ {},
34
+ {
35
+ parent: webex,
36
+ }
37
+ );
38
+ meetingRequest.request = request;
39
+ meetingRequest.config.meetings.multipartSitePrefixList = multipartSitePrefixList;
40
+ });
41
+
42
+ afterEach(() => {
43
+ sinon.restore();
44
+ });
45
+
46
+ describe('#fetchSitePreferencesMeViaSite', () => {
47
+ const assertRequest = (expectedOptions) => {
48
+ assert.calledOnceWithExactly(request, expectedOptions);
49
+ };
50
+
51
+ it('throws a parameter error when no Webex site is available', () => {
52
+ assert.throws(
53
+ () => meetingRequest.fetchSitePreferencesMeViaSite(),
54
+ ParameterError,
55
+ 'No siteUrl available. Call register() before fetching site preferences or provide options.siteUrl.'
56
+ );
57
+ assert.notCalled(request);
58
+ });
59
+
60
+ it('fetches scheduling preferences by default', async () => {
61
+ const result = await meetingRequest.fetchSitePreferencesMeViaSite({siteUrl: 'go.webex.com'});
62
+
63
+ assert.deepEqual(result, {
64
+ scheduling: {
65
+ supportScheduleWebinar: true,
66
+ webinarWebLink: 'https://go.webex.com/webappng/sites/go/webinar/scheduler',
67
+ },
68
+ });
69
+ assertRequest({
70
+ method: 'GET',
71
+ uri: 'https://go.webex.com/wbxappapi/v1/users/me/preference?select=scheduling&siteurl=go',
72
+ });
73
+ });
74
+
75
+ it('derives the site name for my.webex.com sites', async () => {
76
+ await meetingRequest.fetchSitePreferencesMeViaSite({siteUrl: 'go.my.webex.com'});
77
+
78
+ assertRequest({
79
+ method: 'GET',
80
+ uri: 'https://go.my.webex.com/wbxappapi/v1/users/me/preference?select=scheduling&siteurl=go.my',
81
+ });
82
+ });
83
+
84
+ it('uses the configured multipart site prefix list to derive the site name', async () => {
85
+ meetingRequest.config.meetings.multipartSitePrefixList = ['.custom.'];
86
+
87
+ await meetingRequest.fetchSitePreferencesMeViaSite({siteUrl: 'go.my.webex.com'});
88
+
89
+ assertRequest({
90
+ method: 'GET',
91
+ uri: 'https://go.my.webex.com/wbxappapi/v1/users/me/preference?select=scheduling&siteurl=go',
92
+ });
93
+ });
94
+
95
+ it('falls back to the first label when no multipart site prefix list is configured', async () => {
96
+ delete meetingRequest.config.meetings.multipartSitePrefixList;
97
+
98
+ await meetingRequest.fetchSitePreferencesMeViaSite({siteUrl: 'go.my.webex.com'});
99
+
100
+ assertRequest({
101
+ method: 'GET',
102
+ uri: 'https://go.my.webex.com/wbxappapi/v1/users/me/preference?select=scheduling&siteurl=go',
103
+ });
104
+ });
105
+
106
+ it('supports custom site name overrides', async () => {
107
+ await meetingRequest.fetchSitePreferencesMeViaSite({
108
+ siteUrl: 'go.my.webex.com',
109
+ siteName: 'custom-site',
110
+ });
111
+
112
+ assertRequest({
113
+ method: 'GET',
114
+ uri: 'https://go.my.webex.com/wbxappapi/v1/users/me/preference?select=scheduling&siteurl=custom-site',
115
+ });
116
+ });
117
+
118
+ it('supports enum-backed preference sections', async () => {
119
+ await meetingRequest.fetchSitePreferencesMeViaSite({
120
+ siteUrl: 'go.webex.com',
121
+ selectOptions: [SitePreferenceSelectOption.SCHEDULING],
122
+ });
123
+
124
+ assertRequest({
125
+ method: 'GET',
126
+ uri: 'https://go.webex.com/wbxappapi/v1/users/me/preference?select=scheduling&siteurl=go',
127
+ });
128
+ });
129
+
130
+ it('does not suppress request errors', async () => {
131
+ const error = new Error('site preferences failed');
132
+
133
+ request.rejects(error);
134
+
135
+ await assert.isRejected(
136
+ meetingRequest.fetchSitePreferencesMeViaSite({siteUrl: 'go.webex.com'}),
137
+ 'site preferences failed'
138
+ );
139
+ });
140
+ });
141
+ });
@@ -5,6 +5,8 @@ import MeetingsUtil from '@webex/plugin-meetings/src/meetings/util';
5
5
  import Metrics from '@webex/plugin-meetings/src/metrics';
6
6
  import BEHAVIORAL_METRICS from '@webex/plugin-meetings/src/metrics/constants';
7
7
 
8
+ const multipartSitePrefixList = ['.my.', '.mydmz.', '.mybts.', '.mydev.', '.myats2.', '.myats.'];
9
+
8
10
  describe('plugin-meetings', () => {
9
11
  beforeEach(() => {
10
12
  sinon.stub(Metrics, 'sendBehavioralMetric');
@@ -75,6 +77,28 @@ describe('plugin-meetings', () => {
75
77
  });
76
78
  });
77
79
 
80
+ describe('#getSiteName', () => {
81
+ it('gets the site name from a standard Webex site', () => {
82
+ assert.equal(MeetingsUtil.getSiteName('go.webex.com', multipartSitePrefixList), 'go');
83
+ });
84
+
85
+ it('gets the site name from a my Webex site', () => {
86
+ assert.equal(MeetingsUtil.getSiteName('go.my.webex.com', multipartSitePrefixList), 'go.my');
87
+ });
88
+
89
+ it('uses the configured multipart site prefix list', () => {
90
+ assert.equal(MeetingsUtil.getSiteName('go.custom.webex.com', ['.custom.']), 'go.custom');
91
+ });
92
+
93
+ it('falls back to the first label when the multipart site prefix list does not match', () => {
94
+ assert.equal(MeetingsUtil.getSiteName('go.my.webex.com', ['.custom.']), 'go');
95
+ });
96
+
97
+ it('returns null when the site is empty', () => {
98
+ assert.equal(MeetingsUtil.getSiteName('', multipartSitePrefixList), null);
99
+ });
100
+ });
101
+
78
102
  describe('#getThisDevice', () => {
79
103
  it('return null if no devices in self', () => {
80
104
  const newLocus = {};
@@ -128,6 +152,143 @@ describe('plugin-meetings', () => {
128
152
  };
129
153
  assert.equal(MeetingsUtil.isBreakoutLocusDTO(newLocus), false);
130
154
  });
155
+
156
+ it('returns true if newLocus.info.isBreakout is true', () => {
157
+ const newLocus = {
158
+ info: {
159
+ isBreakout: true,
160
+ },
161
+ };
162
+ assert.equal(MeetingsUtil.isBreakoutLocusDTO(newLocus), true);
163
+ });
164
+
165
+ it('returns false if newLocus.info.isBreakout is false', () => {
166
+ const newLocus = {
167
+ info: {
168
+ isBreakout: false,
169
+ },
170
+ };
171
+ assert.equal(MeetingsUtil.isBreakoutLocusDTO(newLocus), false);
172
+ });
173
+
174
+ it('returns true if both sessionType is BREAKOUT and info.isBreakout is true', () => {
175
+ const newLocus = {
176
+ controls: {
177
+ breakout: {
178
+ sessionType: 'BREAKOUT',
179
+ },
180
+ },
181
+ info: {
182
+ isBreakout: true,
183
+ },
184
+ };
185
+ assert.equal(MeetingsUtil.isBreakoutLocusDTO(newLocus), true);
186
+ });
187
+ });
188
+
189
+ describe('#isMainAssociatedWithBreakout', () => {
190
+ it('returns true when breakout control url matches main locus breakout url', () => {
191
+ const mainLocus = {
192
+ url: 'main-locus-url',
193
+ controls: {
194
+ breakout: {
195
+ url: 'breakout-control-url',
196
+ },
197
+ },
198
+ };
199
+ const breakoutLocus = {
200
+ controls: {
201
+ breakout: {
202
+ url: 'breakout-control-url',
203
+ },
204
+ },
205
+ };
206
+
207
+ assert.equal(MeetingsUtil.isMainAssociatedWithBreakout(mainLocus, breakoutLocus), true);
208
+ });
209
+
210
+ it('returns true when breakout self device replaces the main locus url', () => {
211
+ const mainLocus = {
212
+ url: 'main-locus-url',
213
+ controls: {},
214
+ };
215
+ const breakoutLocus = {
216
+ controls: {
217
+ breakout: {
218
+ url: 'other-breakout-url',
219
+ },
220
+ },
221
+ self: {
222
+ deviceUrl: 'device-url-1',
223
+ devices: [
224
+ {
225
+ url: 'device-url-1',
226
+ replaces: [{locusUrl: 'main-locus-url'}],
227
+ },
228
+ ],
229
+ },
230
+ };
231
+
232
+ assert.equal(MeetingsUtil.isMainAssociatedWithBreakout(mainLocus, breakoutLocus), true);
233
+ });
234
+
235
+ it('returns false when breakout locus is not associated with the main locus', () => {
236
+ const mainLocus = {
237
+ url: 'main-locus-url',
238
+ controls: {
239
+ breakout: {
240
+ url: 'breakout-control-url',
241
+ },
242
+ },
243
+ };
244
+ const breakoutLocus = {
245
+ controls: {
246
+ breakout: {
247
+ url: 'different-breakout-url',
248
+ },
249
+ },
250
+ self: {
251
+ deviceUrl: 'device-url-1',
252
+ devices: [
253
+ {
254
+ url: 'device-url-1',
255
+ replaces: [{locusUrl: 'another-main-locus-url'}],
256
+ },
257
+ ],
258
+ },
259
+ };
260
+
261
+ assert.equal(MeetingsUtil.isMainAssociatedWithBreakout(mainLocus, breakoutLocus), false);
262
+ });
263
+ });
264
+
265
+ describe('#isWholeMeetingEnded', () => {
266
+ [
267
+ {description: 'state is INACTIVE with no endMeetingReason', fullState: {state: 'INACTIVE'}, expected: true},
268
+ {description: 'state is INACTIVE with endMeetingReason OTHER', fullState: {state: 'INACTIVE', endMeetingReason: 'SOME_OTHER_REASON'}, expected: true},
269
+ {description: 'state is INACTIVE with endMeetingReason BREAKOUT_ENDED', fullState: {state: 'INACTIVE', endMeetingReason: 'BREAKOUT_ENDED'}, expected: false},
270
+ {description: 'state is not INACTIVE', fullState: {state: 'ACTIVE', endMeetingReason: 'SOME_OTHER_REASON'}, expected: false},
271
+ ].forEach(({description, fullState, expected}) => {
272
+ it(`returns ${expected} when ${description}`, () => {
273
+ assert.equal(MeetingsUtil.isWholeMeetingEnded(fullState), expected);
274
+ });
275
+ });
276
+ });
277
+
278
+ describe('#isSelfMovedOrBreakoutEnded', () => {
279
+ [
280
+ {description: 'locus is undefined', locus: undefined, expected: false},
281
+ {description: 'self state is JOINED', locus: {self: {state: 'JOINED', reason: 'OTHER'}}, expected: false},
282
+ {description: 'self state is LEFT with reason MOVED', locus: {self: {state: 'LEFT', reason: 'MOVED'}}, expected: true},
283
+ {description: 'fullState is INACTIVE with BREAKOUT_ENDED', locus: {self: {state: 'LEFT', reason: 'OTHER'}, fullState: {state: 'INACTIVE', endMeetingReason: 'BREAKOUT_ENDED'}}, expected: true},
284
+ {description: 'fullState is INACTIVE with different endMeetingReason', locus: {self: {state: 'LEFT', reason: 'OTHER'}, fullState: {state: 'INACTIVE', endMeetingReason: 'SOME_OTHER_REASON'}}, expected: false},
285
+ {description: 'fullState is missing', locus: {self: {state: 'LEFT', reason: 'OTHER'}}, expected: false},
286
+ {description: 'endMeetingReason is missing', locus: {self: {state: 'LEFT', reason: 'OTHER'}, fullState: {state: 'INACTIVE'}}, expected: false},
287
+ ].forEach(({description, locus, expected}) => {
288
+ it(`returns ${expected} when ${description}`, () => {
289
+ assert.equal(MeetingsUtil.isSelfMovedOrBreakoutEnded(locus), expected);
290
+ });
291
+ });
131
292
  });
132
293
 
133
294
  describe('#joinedOnThisDevice', () => {
@@ -59,6 +59,13 @@ describe('member', () => {
59
59
  assert.calledOnceWithExactly(MemberUtil.isPresenterAssignmentProhibited, participant);
60
60
  });
61
61
 
62
+ it('checks that processParticipant calls isAttendeeAssignmentProhibited', () => {
63
+ sinon.spy(MemberUtil, 'isAttendeeAssignmentProhibited');
64
+ member.processParticipant(participant);
65
+
66
+ assert.calledOnceWithExactly(MemberUtil.isAttendeeAssignmentProhibited, participant);
67
+ });
68
+
62
69
  it('checks that processParticipant calls canApproveAIEnablement', () => {
63
70
  sinon.spy(MemberUtil, 'canApproveAIEnablement');
64
71
  member.processParticipant(participant);
@@ -643,6 +643,30 @@ describe('plugin-meetings', () => {
643
643
  assert.isUndefined(MemberUtil.isPresenterAssignmentProhibited(participant));
644
644
  });
645
645
  });
646
+
647
+ describe('MemberUtil.isAttendeeAssignmentProhibited', () => {
648
+ it('returns true when attendeeAssignmentNotAllowed is true', () => {
649
+ const participant = {
650
+ attendeeAssignmentNotAllowed: true,
651
+ };
652
+
653
+ assert.isTrue(MemberUtil.isAttendeeAssignmentProhibited(participant));
654
+ });
655
+
656
+ it('returns false when attendeeAssignmentNotAllowed is false', () => {
657
+ const participant = {
658
+ attendeeAssignmentNotAllowed: false,
659
+ };
660
+
661
+ assert.isFalse(MemberUtil.isAttendeeAssignmentProhibited(participant));
662
+ });
663
+
664
+ it('returns false when attendeeAssignmentNotAllowed is undefined', () => {
665
+ const participant = {};
666
+
667
+ assert.isFalse(MemberUtil.isAttendeeAssignmentProhibited(participant));
668
+ });
669
+ });
646
670
  });
647
671
 
648
672
  describe('extractMediaStatus', () => {
@@ -35,6 +35,7 @@ describe('plugin-meetings', () => {
35
35
  beforeEach(() => {
36
36
  request = {
37
37
  request: sinon.stub().returns(Promise.resolve()),
38
+ locusDeltaRequest: sinon.stub().returns(Promise.resolve()),
38
39
  };
39
40
 
40
41
  controller = new RecordingController(request);
@@ -69,13 +70,13 @@ describe('plugin-meetings', () => {
69
70
 
70
71
  const result = controller.startRecording();
71
72
 
72
- assert.calledWith(request.request, {
73
+ assert.calledWith(request.locusDeltaRequest, {
73
74
  uri: `${locusUrl}/controls`,
74
75
  body: {record: {recording: true, paused: false}},
75
76
  method: HTTP_VERBS.PATCH,
76
77
  });
77
78
 
78
- assert.deepEqual(result, request.request.firstCall.returnValue);
79
+ assert.deepEqual(result, request.locusDeltaRequest.firstCall.returnValue);
79
80
  });
80
81
  });
81
82
 
@@ -103,13 +104,13 @@ describe('plugin-meetings', () => {
103
104
 
104
105
  const result = controller.stopRecording();
105
106
 
106
- assert.calledWith(request.request, {
107
+ assert.calledWith(request.locusDeltaRequest, {
107
108
  uri: `${locusUrl}/controls`,
108
109
  body: {record: {recording: false, paused: false}},
109
110
  method: HTTP_VERBS.PATCH,
110
111
  });
111
112
 
112
- assert.deepEqual(result, request.request.firstCall.returnValue);
113
+ assert.deepEqual(result, request.locusDeltaRequest.firstCall.returnValue);
113
114
  });
114
115
  });
115
116
 
@@ -139,13 +140,13 @@ describe('plugin-meetings', () => {
139
140
 
140
141
  const result = controller.pauseRecording();
141
142
 
142
- assert.calledWith(request.request, {
143
+ assert.calledWith(request.locusDeltaRequest, {
143
144
  uri: `${locusUrl}/controls`,
144
145
  body: {record: {recording: true, paused: true}},
145
146
  method: HTTP_VERBS.PATCH,
146
147
  });
147
148
 
148
- assert.deepEqual(result, request.request.firstCall.returnValue);
149
+ assert.deepEqual(result, request.locusDeltaRequest.firstCall.returnValue);
149
150
  });
150
151
  });
151
152
 
@@ -176,13 +177,13 @@ describe('plugin-meetings', () => {
176
177
 
177
178
  const result = controller.resumeRecording();
178
179
 
179
- assert.calledWith(request.request, {
180
+ assert.calledWith(request.locusDeltaRequest, {
180
181
  uri: `${locusUrl}/controls`,
181
182
  body: {record: {recording: true, paused: false}},
182
183
  method: HTTP_VERBS.PATCH,
183
184
  });
184
185
 
185
- assert.deepEqual(result, request.request.firstCall.returnValue);
186
+ assert.deepEqual(result, request.locusDeltaRequest.firstCall.returnValue);
186
187
  });
187
188
  });
188
189
  });
@@ -176,6 +176,42 @@ describe('plugin-meetings', () => {
176
176
  });
177
177
  });
178
178
 
179
+ describe('#getValidatedWebinarMeeting', () => {
180
+ it('returns the meeting when its locusUrl matches the webinar locusUrl', () => {
181
+ const meeting = {locusUrl: 'locusUrl'};
182
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
183
+ webinar.locusUrl = 'locusUrl';
184
+
185
+ assert.equal(webinar.getValidatedWebinarMeeting(), meeting);
186
+ });
187
+
188
+ it('returns undefined and warns when the resolved meeting locusUrl does not match', () => {
189
+ const warnStub = sinon.stub(LoggerProxy.logger, 'warn');
190
+ const meeting = {locusUrl: 'other-locus-url'};
191
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
192
+ webinar.locusUrl = 'locusUrl';
193
+
194
+ assert.isUndefined(webinar.getValidatedWebinarMeeting());
195
+ assert.calledOnce(warnStub);
196
+ });
197
+
198
+ it('returns undefined when no meeting is resolved', () => {
199
+ webex.meetings.getMeetingByType = sinon.stub().returns(undefined);
200
+
201
+ assert.isUndefined(webinar.getValidatedWebinarMeeting());
202
+ });
203
+
204
+ it('returns undefined and warns when webinar locusUrl is not yet initialized', () => {
205
+ const warnStub = sinon.stub(LoggerProxy.logger, 'warn');
206
+ const meeting = {locusUrl: 'some-url'};
207
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
208
+ webinar.locusUrl = undefined;
209
+
210
+ assert.isUndefined(webinar.getValidatedWebinarMeeting());
211
+ assert.calledOnce(warnStub);
212
+ });
213
+ });
214
+
179
215
  describe('#cleanUp', () => {
180
216
  it('delegates to cleanupPSDataChannel', () => {
181
217
  const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
@@ -187,17 +223,14 @@ describe('plugin-meetings', () => {
187
223
  });
188
224
 
189
225
  describe('#cleanupPSDataChannel', () => {
190
- let meeting;
226
+ let relayListener;
191
227
 
192
228
  beforeEach(() => {
193
- meeting = {
194
- processRelayEvent: sinon.stub(),
195
- };
196
-
197
- webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
229
+ relayListener = sinon.stub();
230
+ webinar._practiceSessionRelayListener = relayListener;
198
231
  });
199
232
 
200
- it('disconnects the practice session channel and removes the relay listener', async () => {
233
+ it('disconnects the practice session channel and removes the tracked relay listener', async () => {
201
234
  await webinar.cleanupPSDataChannel();
202
235
 
203
236
  assert.calledOnceWithExactly(
@@ -208,8 +241,28 @@ describe('plugin-meetings', () => {
208
241
  assert.calledOnceWithExactly(
209
242
  webex.internal.llm.off,
210
243
  `event:relay.event:${LLM_PRACTICE_SESSION}`,
211
- meeting.processRelayEvent
244
+ relayListener
212
245
  );
246
+ assert.isNull(webinar._practiceSessionRelayListener);
247
+ });
248
+
249
+ it('skips relay listener removal when no listener has been tracked', async () => {
250
+ webinar._practiceSessionRelayListener = null;
251
+
252
+ await webinar.cleanupPSDataChannel();
253
+
254
+ const relayOffCalls = webex.internal.llm.off.args.filter(
255
+ ([event]) => event === `event:relay.event:${LLM_PRACTICE_SESSION}`
256
+ );
257
+ assert.equal(relayOffCalls.length, 0);
258
+ });
259
+
260
+ it('does not consult the meeting collection during cleanup', async () => {
261
+ webex.meetings.getMeetingByType = sinon.stub();
262
+
263
+ await webinar.cleanupPSDataChannel();
264
+
265
+ assert.notCalled(webex.meetings.getMeetingByType);
213
266
  });
214
267
 
215
268
  it('removes a pending online listener if one exists', async () => {
@@ -240,6 +293,7 @@ describe('plugin-meetings', () => {
240
293
  beforeEach(() => {
241
294
  processRelayEvent = sinon.stub();
242
295
  meeting = {
296
+ locusUrl: 'locusUrl',
243
297
  isJoined: sinon.stub().returns(true),
244
298
  processRelayEvent,
245
299
  locusInfo: {
@@ -418,19 +472,30 @@ describe('plugin-meetings', () => {
418
472
  assert.calledOnce(webex.internal.llm.registerAndConnect);
419
473
  });
420
474
 
421
- it('rebinds relay listener after successful connect', async () => {
475
+ it('tracks and binds the relay listener after successful connect', async () => {
422
476
  await webinar.updatePSDataChannel();
423
477
 
478
+ // Stores the exact listener reference for deterministic cleanup
479
+ assert.equal(webinar._practiceSessionRelayListener, processRelayEvent);
424
480
  assert.calledWith(
425
- webex.internal.llm.off,
481
+ webex.internal.llm.on,
426
482
  `event:relay.event:${LLM_PRACTICE_SESSION}`,
427
483
  processRelayEvent
428
484
  );
485
+ });
486
+
487
+ it('removes a previously tracked relay listener before re-binding on reconnect', async () => {
488
+ const previousListener = sinon.stub();
489
+ webinar._practiceSessionRelayListener = previousListener;
490
+
491
+ await webinar.updatePSDataChannel();
492
+
429
493
  assert.calledWith(
430
- webex.internal.llm.on,
494
+ webex.internal.llm.off,
431
495
  `event:relay.event:${LLM_PRACTICE_SESSION}`,
432
- processRelayEvent
496
+ previousListener
433
497
  );
498
+ assert.equal(webinar._practiceSessionRelayListener, processRelayEvent);
434
499
  });
435
500
 
436
501
  it('subscribes to transcription when caption intent is enabled', async () => {
@@ -551,7 +616,7 @@ describe('plugin-meetings', () => {
551
616
  updateMediaShares = sinon.stub()
552
617
  webinar.webex.meetings = {
553
618
  getMeetingByType: sinon.stub().returns({
554
- id: 'meeting-id',
619
+ id: 'meeting-id', locusUrl: 'locusUrl',
555
620
  isJoined: sinon.stub().returns(false),
556
621
  updateLLMConnection: sinon.stub(),
557
622
  shareStatus: SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE,
@@ -606,7 +671,7 @@ describe('plugin-meetings', () => {
606
671
 
607
672
  webinar.webex.meetings = {
608
673
  getMeetingByType: sinon.stub().returns({
609
- id: 'meeting-id',
674
+ id: 'meeting-id', locusUrl: 'locusUrl',
610
675
  isJoined: sinon.stub().returns(false),
611
676
  updateLLMConnection: sinon.stub(),
612
677
  shareStatus: SHARE_STATUS.REMOTE_SHARE_ACTIVE,
@@ -1034,7 +1099,7 @@ describe('plugin-meetings', () => {
1034
1099
  // @ts-ignore
1035
1100
  webinar.webex.meetings = {
1036
1101
  getMeetingByType: sinon.stub().returns({
1037
- id: 'meeting-id',
1102
+ id: 'meeting-id', locusUrl: 'locusUrl',
1038
1103
  locusInfo: {
1039
1104
  links:{
1040
1105
  resources: {
@@ -1051,7 +1116,7 @@ describe('plugin-meetings', () => {
1051
1116
  it('throws an error if attendeeSearchUrl is not available', async () => {
1052
1117
  webinar.webex.meetings = {
1053
1118
  getMeetingByType: sinon.stub().returns({
1054
- id: 'meeting-id',
1119
+ id: 'meeting-id', locusUrl: 'locusUrl',
1055
1120
  locusInfo: {
1056
1121
  links:{
1057
1122
  resources: {