@webex/plugin-meetings 3.12.0-next.44 → 3.12.0-next.45

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.
@@ -98,13 +98,49 @@ const Webinar = WebexPlugin.extend({
98
98
  return {isPromoted, isDemoted};
99
99
  },
100
100
 
101
+ /**
102
+ * Resolves the meeting associated with this webinar instance, guarded against the
103
+ * meetingId pointer drifting onto an unrelated transient meeting (e.g. an inbound
104
+ * 1:1 call) that may exist in the meeting collection. Returns the meeting only when
105
+ * its locusUrl matches this webinar's tracked locusUrl. Returns undefined (with a
106
+ * warning) when the meeting cannot be resolved or when the webinar's locusUrl has
107
+ * not been initialized yet — callers must treat this as "no owned meeting" rather
108
+ * than fall through to an unvalidated lookup.
109
+ * @returns {object|undefined}
110
+ */
111
+ getValidatedWebinarMeeting() {
112
+ const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
113
+
114
+ if (!meeting) {
115
+ return undefined;
116
+ }
117
+
118
+ if (!this.locusUrl) {
119
+ LoggerProxy.logger.warn(
120
+ `Webinar:index#getValidatedWebinarMeeting --> skipping; webinar locusUrl is not yet initialized for meetingId ${this.meetingId}`
121
+ );
122
+
123
+ return undefined;
124
+ }
125
+
126
+ if (meeting.locusUrl !== this.locusUrl) {
127
+ LoggerProxy.logger.warn(
128
+ `Webinar:index#getValidatedWebinarMeeting --> skipping; meeting ${this.meetingId} locusUrl ${meeting.locusUrl} does not match webinar locusUrl ${this.locusUrl}`
129
+ );
130
+
131
+ return undefined;
132
+ }
133
+
134
+ return meeting;
135
+ },
136
+
101
137
  /**
102
138
  * should join practice session data channel or not
103
139
  * @param {Object} {isPromoted: boolean, isDemoted: boolean}} Role transition states
104
140
  * @returns {void}
105
141
  */
106
142
  updateStatusByRole({isPromoted, isDemoted}) {
107
- const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
143
+ const meeting = this.getValidatedWebinarMeeting();
108
144
 
109
145
  if (
110
146
  (isDemoted && meeting?.shareStatus === SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE) ||
@@ -128,6 +164,9 @@ const Webinar = WebexPlugin.extend({
128
164
 
129
165
  /**
130
166
  * Disconnects the practice session data channel and removes its relay listener.
167
+ * The listener reference removed here is the exact callback captured at subscribe
168
+ * time (see updatePSDataChannel) so that cleanup is correct even if the underlying
169
+ * meeting can no longer be resolved (e.g. locusUrl mismatch).
131
170
  * @returns {Promise<void>}
132
171
  */
133
172
  async cleanupPSDataChannel() {
@@ -137,8 +176,6 @@ const Webinar = WebexPlugin.extend({
137
176
  this._pendingOnlineListener = null;
138
177
  }
139
178
 
140
- const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
141
-
142
179
  // @ts-ignore - Fix type
143
180
  await this.webex.internal.llm.disconnectLLM(
144
181
  {
@@ -147,15 +184,21 @@ const Webinar = WebexPlugin.extend({
147
184
  },
148
185
  LLM_PRACTICE_SESSION
149
186
  );
150
- // @ts-ignore - Fix type
151
- this.webex.internal.llm.off(
152
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
153
- meeting?.processRelayEvent
154
- );
187
+
188
+ if (this._practiceSessionRelayListener) {
189
+ // @ts-ignore - Fix type
190
+ this.webex.internal.llm.off(
191
+ `event:relay.event:${LLM_PRACTICE_SESSION}`,
192
+ this._practiceSessionRelayListener
193
+ );
194
+ this._practiceSessionRelayListener = null;
195
+ }
155
196
  },
156
197
 
157
198
  /**
158
199
  * Ensures practice-session token exists before registering the practice LLM channel.
200
+ * Caller is responsible for passing a meeting that has already been resolved via
201
+ * getValidatedWebinarMeeting() — this method does not re-validate ownership.
159
202
  * @param {object} meeting
160
203
  * @returns {Promise<string|undefined>}
161
204
  */
@@ -211,7 +254,7 @@ const Webinar = WebexPlugin.extend({
211
254
  this._updatePSDataChannelSequence = (this._updatePSDataChannelSequence || 0) + 1;
212
255
  const invocationSequence = this._updatePSDataChannelSequence;
213
256
 
214
- const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
257
+ const meeting = this.getValidatedWebinarMeeting();
215
258
  const isPracticeSession = meeting?.isJoined() && this.isJoinPracticeSessionDataChannel();
216
259
 
217
260
  if (!isPracticeSession) {
@@ -312,15 +355,21 @@ const Webinar = WebexPlugin.extend({
312
355
  LLM_PRACTICE_SESSION
313
356
  )
314
357
  .then((registerAndConnectResult) => {
315
- // @ts-ignore - Fix type
316
- this.webex.internal.llm.off(
317
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
318
- meeting?.processRelayEvent
319
- );
358
+ // Track the exact listener reference so cleanupPSDataChannel can
359
+ // unsubscribe deterministically, even if the meeting can no longer
360
+ // be resolved at cleanup time.
361
+ if (this._practiceSessionRelayListener) {
362
+ // @ts-ignore - Fix type
363
+ this.webex.internal.llm.off(
364
+ `event:relay.event:${LLM_PRACTICE_SESSION}`,
365
+ this._practiceSessionRelayListener
366
+ );
367
+ }
368
+ this._practiceSessionRelayListener = meeting?.processRelayEvent;
320
369
  // @ts-ignore - Fix type
321
370
  this.webex.internal.llm.on(
322
371
  `event:relay.event:${LLM_PRACTICE_SESSION}`,
323
- meeting?.processRelayEvent
372
+ this._practiceSessionRelayListener
324
373
  );
325
374
  // @ts-ignore - Fix type
326
375
  this.webex.internal.voicea?.announce?.();
@@ -532,7 +581,14 @@ const Webinar = WebexPlugin.extend({
532
581
  * @returns {Promise}
533
582
  */
534
583
  async searchLargeScaleWebinarAttendees(payload) {
535
- const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
584
+ const meeting = this.getValidatedWebinarMeeting();
585
+ if (!meeting) {
586
+ LoggerProxy.logger.error(
587
+ 'Meeting:webinar5k#searchLargeScaleWebinarAttendees failed --> webinar meeting could not be validated'
588
+ );
589
+ throw new Error('Meeting:webinar5k#Webinar meeting is not resolvable for the current locus');
590
+ }
591
+
536
592
  const rawParams = {
537
593
  search_text: payload?.queryString,
538
594
  limit: payload?.limit ?? DEFAULT_LARGE_SCALE_WEBINAR_ATTENDEE_SEARCH_LIMIT,
@@ -540,7 +596,9 @@ const Webinar = WebexPlugin.extend({
540
596
  };
541
597
  const attendeeSearchUrl = meeting?.locusInfo?.links?.resources?.attendeeSearch?.url;
542
598
  if (!attendeeSearchUrl) {
543
- LoggerProxy.logger.error(`Meeting:webinar5k#searchLargeScaleWebinarAttendees failed`);
599
+ LoggerProxy.logger.error(
600
+ 'Meeting:webinar5k#searchLargeScaleWebinarAttendees failed --> attendee search url unavailable'
601
+ );
544
602
  throw new Error('Meeting:webinar5k#Attendee search url is not available');
545
603
  }
546
604
 
@@ -13703,6 +13703,131 @@ describe('plugin-meetings', () => {
13703
13703
  assert.notCalled(webex.internal.llm.setDatachannelToken);
13704
13704
  });
13705
13705
 
13706
+ describe('ownership tag', () => {
13707
+ beforeEach(() => {
13708
+ // Make the owner stub dynamic so setOwnerMeetingId() writes
13709
+ // propagate back to getOwnerMeetingId() reads. This mirrors the
13710
+ // real LLM singleton behavior so the finally-block release in
13711
+ // cleanupLLMConneciton is reflected in subsequent reads.
13712
+ webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
13713
+ webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
13714
+ webex.internal.llm.getOwnerMeetingId.returns(id);
13715
+ });
13716
+ });
13717
+
13718
+ it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
13719
+ meeting.joinedWith = {state: 'JOINED'};
13720
+ webex.internal.llm.isConnected.returns(true);
13721
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
13722
+ // Locus/datachannel URL mismatch is the *normal* case when
13723
+ // another meeting owns the live socket -- each meeting has its
13724
+ // own locus URL. URL mismatch must NOT trigger a reclaim,
13725
+ // because doing so would tear down the owning meeting's healthy
13726
+ // LLM socket and break its data channel.
13727
+ webex.internal.llm.getLocusUrl.returns('owner-locus-url');
13728
+ webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
13729
+ meeting.locusInfo = {
13730
+ url: 'a different url',
13731
+ info: {datachannelUrl: 'a different datachannel url'},
13732
+ self: {},
13733
+ };
13734
+
13735
+ const result = await meeting.updateLLMConnection();
13736
+
13737
+ assert.equal(result, undefined);
13738
+ assert.notCalled(webex.internal.llm.disconnectLLM);
13739
+ assert.notCalled(webex.internal.llm.registerAndConnect);
13740
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
13741
+ assert.notCalled(meeting.startLLMHealthCheckTimer);
13742
+ });
13743
+
13744
+
13745
+ it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
13746
+ meeting.joinedWith = {state: 'JOINED'};
13747
+ webex.internal.llm.isConnected.returns(true);
13748
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
13749
+ webex.internal.llm.getLocusUrl.returns('a url');
13750
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
13751
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
13752
+ meeting.locusInfo = {
13753
+ url: 'a different url',
13754
+ info: {datachannelUrl: 'a datachannel url'},
13755
+ self: {},
13756
+ };
13757
+
13758
+ try {
13759
+ await meeting.updateLLMConnection();
13760
+ } catch (e) {
13761
+ /* updateLLMConnection may reject when cleanup throws */
13762
+ }
13763
+
13764
+ // The owner-eligible finally branch must release the tag so a
13765
+ // subsequent reconnect attempt from any meeting is not blocked.
13766
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
13767
+ });
13768
+
13769
+ it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
13770
+ meeting.joinedWith = {state: 'JOINED'};
13771
+ webex.internal.llm.isConnected.returns(true);
13772
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
13773
+ webex.internal.llm.getLocusUrl.returns('a url');
13774
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
13775
+ meeting.locusInfo = {
13776
+ url: 'a different url',
13777
+ info: {datachannelUrl: 'a datachannel url'},
13778
+ self: {},
13779
+ };
13780
+
13781
+ await meeting.updateLLMConnection();
13782
+
13783
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13784
+ code: 3050,
13785
+ reason: 'done (permanent)',
13786
+ });
13787
+ assert.calledWithExactly(
13788
+ webex.internal.llm.registerAndConnect,
13789
+ 'a different url',
13790
+ 'a datachannel url',
13791
+ undefined
13792
+ );
13793
+ // setOwnerMeetingId is called twice: first with undefined in
13794
+ // cleanupLLMConneciton's finally block (so a failed disconnect
13795
+ // cannot leave a stale owner), then with this meeting's id
13796
+ // after registerAndConnect resolves.
13797
+ assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
13798
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
13799
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
13800
+ });
13801
+
13802
+ it('claims ownership after successful registerAndConnect on initial connect', async () => {
13803
+ meeting.joinedWith = {state: 'JOINED'};
13804
+ webex.internal.llm.isConnected.returns(false);
13805
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
13806
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13807
+
13808
+ await meeting.updateLLMConnection();
13809
+
13810
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
13811
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
13812
+ });
13813
+
13814
+ it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
13815
+ // Defensive path: if the LLM reports not-connected but an old
13816
+ // ownerId is still present (e.g. race before a successful
13817
+ // connections.delete), this meeting can still claim a fresh
13818
+ // connection.
13819
+ meeting.joinedWith = {state: 'JOINED'};
13820
+ webex.internal.llm.isConnected.returns(false);
13821
+ webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
13822
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13823
+
13824
+ await meeting.updateLLMConnection();
13825
+
13826
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
13827
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
13828
+ });
13829
+ });
13830
+
13706
13831
  describe('#clearMeetingData', () => {
13707
13832
  beforeEach(() => {
13708
13833
  webex.internal.llm.isConnected = sinon.stub().returns(true);
@@ -13763,6 +13888,64 @@ describe('plugin-meetings', () => {
13763
13888
  assert.notCalled(meeting.stopTranscription);
13764
13889
  assert.notCalled(meeting.annotation.deregisterEvents);
13765
13890
  });
13891
+
13892
+ describe('ownership tag', () => {
13893
+ beforeEach(() => {
13894
+ webex.internal.llm.getOwnerMeetingId = sinon.stub();
13895
+ });
13896
+
13897
+ it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
13898
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
13899
+
13900
+ await meeting.clearMeetingData();
13901
+
13902
+ assert.notCalled(webex.internal.llm.disconnectLLM);
13903
+ // Shared data-channel auth tokens belong to the owner meeting's
13904
+ // live LLM session and must not be wiped by a non-owner
13905
+ // teardown, otherwise the owner's next reconnect would lose
13906
+ // its Data-Channel-Auth-Token.
13907
+ assert.notCalled(meeting.clearDataChannelToken);
13908
+ // Listeners owned by *this* Meeting instance must still be
13909
+ // removed so a leaving subordinate meeting stops receiving
13910
+ // relay/locus events from the shared singleton.
13911
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13912
+ assert.calledWithExactly(
13913
+ webex.internal.llm.off,
13914
+ 'event:relay.event',
13915
+ meeting.processRelayEvent
13916
+ );
13917
+ assert.calledWithExactly(
13918
+ webex.internal.llm.off,
13919
+ 'event:locus.state_message',
13920
+ meeting.processLocusLLMEvent
13921
+ );
13922
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13923
+ });
13924
+
13925
+ it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
13926
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
13927
+
13928
+ await meeting.clearMeetingData();
13929
+
13930
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13931
+ code: 3050,
13932
+ reason: 'done (permanent)',
13933
+ });
13934
+ assert.calledOnce(meeting.clearDataChannelToken);
13935
+ });
13936
+
13937
+ it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
13938
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
13939
+
13940
+ await meeting.clearMeetingData();
13941
+
13942
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13943
+ code: 3050,
13944
+ reason: 'done (permanent)',
13945
+ });
13946
+ assert.calledOnce(meeting.clearDataChannelToken);
13947
+ });
13948
+ });
13766
13949
  });
13767
13950
  });
13768
13951
 
@@ -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: {