@webex/internal-plugin-llm 3.11.0 → 3.12.0-llmrefactor.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,391 @@
1
+ import MockWebex from '@webex/test-helper-mock-webex';
2
+ import {assert} from '@webex/test-helper-chai';
3
+ import sinon from 'sinon';
4
+ import Mercury from '@webex/internal-plugin-mercury';
5
+ import {LLMPlugin} from '@webex/internal-plugin-llm/src/llm-plugin';
6
+ import LLMChannel from '@webex/internal-plugin-llm/src/llm';
7
+
8
+ describe('plugin-llm', () => {
9
+ describe('LLMPlugin (Factory Pattern)', () => {
10
+ let webex;
11
+ let plugin;
12
+
13
+ beforeEach(() => {
14
+ webex = new MockWebex({
15
+ children: {
16
+ mercury: Mercury,
17
+ },
18
+ });
19
+
20
+ webex.internal.feature = {
21
+ getFeature: sinon.stub().resolves(true),
22
+ };
23
+
24
+ plugin = new LLMPlugin({}, {parent: webex});
25
+ });
26
+
27
+ afterEach(() => sinon.restore());
28
+
29
+ describe('#createChannel', () => {
30
+ it('creates a new LLMChannel instance', () => {
31
+ const channel = plugin.createChannel();
32
+
33
+ assert.instanceOf(channel, LLMChannel);
34
+ });
35
+
36
+ it('adds the channel to the channels registry', () => {
37
+ const channel = plugin.createChannel();
38
+
39
+ const channels = plugin.getAllChannels();
40
+
41
+ assert.equal(channels.size, 1);
42
+ assert.isTrue(channels.has(channel));
43
+ });
44
+
45
+ it('creates independent channels for multiple calls', () => {
46
+ const channel1 = plugin.createChannel();
47
+ const channel2 = plugin.createChannel();
48
+
49
+ assert.notStrictEqual(channel1, channel2);
50
+ assert.equal(plugin.getAllChannels().size, 2);
51
+ });
52
+ });
53
+
54
+ describe('#getAllChannels', () => {
55
+ it('returns an empty set when no channels exist', () => {
56
+ const channels = plugin.getAllChannels();
57
+
58
+ assert.equal(channels.size, 0);
59
+ });
60
+
61
+ it('returns a copy of the channels set', () => {
62
+ const channel = plugin.createChannel();
63
+ const channels = plugin.getAllChannels();
64
+
65
+ // Verify it's a copy, not the same reference
66
+ channels.delete(channel);
67
+ assert.equal(plugin.getAllChannels().size, 1);
68
+ });
69
+ });
70
+
71
+ describe('#disconnectAllChannels', () => {
72
+ it('disconnects all active channels', async () => {
73
+ const channel1 = plugin.createChannel();
74
+ const channel2 = plugin.createChannel();
75
+
76
+ sinon.stub(channel1, 'disconnect').resolves();
77
+ sinon.stub(channel2, 'disconnect').resolves();
78
+
79
+ await plugin.disconnectAllChannels({code: 1000, reason: 'cleanup'});
80
+
81
+ sinon.assert.calledOnceWithExactly(channel1.disconnect, {code: 1000, reason: 'cleanup'});
82
+ sinon.assert.calledOnceWithExactly(channel2.disconnect, {code: 1000, reason: 'cleanup'});
83
+ });
84
+
85
+ it('works with no active channels', async () => {
86
+ await plugin.disconnectAllChannels({code: 1000, reason: 'cleanup'});
87
+
88
+ assert.equal(plugin.getAllChannels().size, 0);
89
+ });
90
+ });
91
+
92
+ describe('#getChannelByDatachannelUrl', () => {
93
+ it('returns channel matching datachannel URL', () => {
94
+ const channel = plugin.createChannel();
95
+
96
+ sinon
97
+ .stub(channel, 'getDatachannelUrl')
98
+ .returns('https://board.wbx2.com/datachannel/api/v1/locus/123/registrations');
99
+
100
+ const result = plugin.getChannelByDatachannelUrl(
101
+ 'https://board.wbx2.com/datachannel/api/v1/locus/123/registrations/events'
102
+ );
103
+
104
+ assert.equal(result, channel);
105
+ });
106
+
107
+ it('returns undefined when no match', () => {
108
+ const channel = plugin.createChannel();
109
+
110
+ sinon.stub(channel, 'getDatachannelUrl').returns('https://board.wbx2.com/datachannel/123');
111
+
112
+ const result = plugin.getChannelByDatachannelUrl('https://unknown.example.com');
113
+
114
+ assert.equal(result, undefined);
115
+ });
116
+
117
+ it('returns undefined when no channels exist', () => {
118
+ const result = plugin.getChannelByDatachannelUrl('https://example.com');
119
+
120
+ assert.equal(result, undefined);
121
+ });
122
+
123
+ it('skips channels with no datachannel URL set', () => {
124
+ const channel = plugin.createChannel();
125
+
126
+ sinon.stub(channel, 'getDatachannelUrl').returns(undefined);
127
+
128
+ const result = plugin.getChannelByDatachannelUrl('https://example.com');
129
+
130
+ assert.equal(result, undefined);
131
+ });
132
+ });
133
+
134
+ describe('#isDataChannelTokenEnabled', () => {
135
+ it('returns true when feature flag is enabled', async () => {
136
+ webex.internal.feature.getFeature.resolves(true);
137
+
138
+ const result = await plugin.isDataChannelTokenEnabled();
139
+
140
+ assert.equal(result, true);
141
+ sinon.assert.calledOnceWithExactly(
142
+ webex.internal.feature.getFeature,
143
+ 'developer',
144
+ 'data-channel-with-jwt-token'
145
+ );
146
+ });
147
+
148
+ it('returns false when feature flag is disabled', async () => {
149
+ webex.internal.feature.getFeature.resolves(false);
150
+
151
+ const result = await plugin.isDataChannelTokenEnabled();
152
+
153
+ assert.equal(result, false);
154
+ });
155
+ });
156
+
157
+ describe('channel isolation multi-meeting scenarios', () => {
158
+ it('disconnecting one channel does not affect another channel', async () => {
159
+ const channel1 = plugin.createChannel();
160
+ const channel2 = plugin.createChannel();
161
+
162
+ // Stub disconnect on channel1 only - simulating the real behavior where
163
+ // disconnect clears the connected state
164
+ sinon.stub(channel1, 'disconnect').callsFake(async function () {
165
+ this.connected = false;
166
+ });
167
+
168
+ // Simulate both channels being connected
169
+ channel1.connected = true;
170
+ channel2.connected = true;
171
+
172
+ // Disconnect channel1 with permanent code (simulating meeting end)
173
+ await channel1.disconnect({code: 3050, reason: 'done (permanent)'});
174
+
175
+ // Channel2 should still be connected - its state is independent
176
+ assert.equal(channel2.isConnected(), true);
177
+
178
+ // Channel1 should be disconnected
179
+ assert.equal(channel1.isConnected(), false);
180
+
181
+ // Verify that disconnect was called on channel1
182
+ sinon.assert.calledOnceWithExactly(channel1.disconnect, {
183
+ code: 3050,
184
+ reason: 'done (permanent)',
185
+ });
186
+ });
187
+
188
+ it('channels for same locus URL are still independent instances', async () => {
189
+ const sameLocus = 'https://locus.example.com/loci/shared-meeting';
190
+ const sameDc = 'https://datachannel.example.com/dc/shared';
191
+
192
+ const channel1 = plugin.createChannel();
193
+ const channel2 = plugin.createChannel();
194
+
195
+ // Both channels have the same locus URL (the scenario in the bug)
196
+ sinon.stub(channel1, 'getLocusUrl').returns(sameLocus);
197
+ sinon.stub(channel1, 'getDatachannelUrl').returns(sameDc);
198
+ sinon.stub(channel2, 'getLocusUrl').returns(sameLocus);
199
+ sinon.stub(channel2, 'getDatachannelUrl').returns(sameDc);
200
+
201
+ // They are separate instances despite same URLs
202
+ assert.notStrictEqual(channel1, channel2);
203
+
204
+ // Both are tracked independently
205
+ const channels = plugin.getAllChannels();
206
+
207
+ assert.equal(channels.size, 2);
208
+ assert.isTrue(channels.has(channel1));
209
+ assert.isTrue(channels.has(channel2));
210
+ });
211
+
212
+ it('simulates overlapping meeting lifecycle without cross-channel impact', async () => {
213
+ // Scenario: Meeting A is ending while Meeting B is starting for the same locus
214
+ const sameLocus = 'https://locus.example.com/loci/abc123';
215
+ const sameDc = 'https://datachannel.example.com/dc/abc123';
216
+
217
+ // Meeting A has an active channel
218
+ const channelA = plugin.createChannel();
219
+
220
+ sinon.stub(channelA, 'getLocusUrl').returns(sameLocus);
221
+ sinon.stub(channelA, 'getDatachannelUrl').returns(sameDc);
222
+ sinon.stub(channelA, 'disconnect').resolves();
223
+ channelA.connected = true;
224
+
225
+ // Meeting B creates its own channel for the same locus
226
+ const channelB = plugin.createChannel();
227
+
228
+ sinon.stub(channelB, 'registerAndConnect').resolves();
229
+ sinon.stub(channelB, 'isConnected').returns(false);
230
+
231
+ // Meeting A ends and disconnects with permanent code
232
+ await channelA.disconnect({code: 3050, reason: 'done (permanent)'});
233
+
234
+ // Meeting B connects - this should work because it has its own channel
235
+ await channelB.registerAndConnect(sameLocus, sameDc);
236
+
237
+ // Assert: channelA's disconnect was called
238
+ sinon.assert.calledOnceWithExactly(channelA.disconnect, {
239
+ code: 3050,
240
+ reason: 'done (permanent)',
241
+ });
242
+
243
+ // Assert: channelB's registerAndConnect was called (not blocked by A's disconnect)
244
+ sinon.assert.calledOnceWithExactly(channelB.registerAndConnect, sameLocus, sameDc);
245
+ });
246
+
247
+ it('individual channel disconnect removes only that channel from registry', async () => {
248
+ const channel1 = plugin.createChannel();
249
+ const channel2 = plugin.createChannel();
250
+ const channel3 = plugin.createChannel();
251
+
252
+ assert.equal(plugin.getAllChannels().size, 3);
253
+
254
+ // Disconnect channel2 - real disconnect triggers onDisconnect callback
255
+ await channel2.disconnect({code: 3050, reason: 'meeting ended'});
256
+
257
+ // Only channel2 should be removed from registry
258
+ const channels = plugin.getAllChannels();
259
+
260
+ assert.equal(channels.size, 2);
261
+ assert.isTrue(channels.has(channel1));
262
+ assert.isFalse(channels.has(channel2));
263
+ assert.isTrue(channels.has(channel3));
264
+ });
265
+
266
+ it('getChannelByDatachannelUrl returns correct channel when multiple exist for similar URLs', () => {
267
+ const channel1 = plugin.createChannel();
268
+ const channel2 = plugin.createChannel();
269
+
270
+ // Different locus IDs in the URL
271
+ sinon
272
+ .stub(channel1, 'getDatachannelUrl')
273
+ .returns('https://board.wbx2.com/datachannel/api/v1/locus/meeting-111/registrations');
274
+ sinon
275
+ .stub(channel2, 'getDatachannelUrl')
276
+ .returns('https://board.wbx2.com/datachannel/api/v1/locus/meeting-222/registrations');
277
+
278
+ // Request for meeting-111 should return channel1
279
+ const result1 = plugin.getChannelByDatachannelUrl(
280
+ 'https://board.wbx2.com/datachannel/api/v1/locus/meeting-111/registrations/events'
281
+ );
282
+
283
+ assert.equal(result1, channel1);
284
+
285
+ // Request for meeting-222 should return channel2
286
+ const result2 = plugin.getChannelByDatachannelUrl(
287
+ 'https://board.wbx2.com/datachannel/api/v1/locus/meeting-222/registrations/events'
288
+ );
289
+
290
+ assert.equal(result2, channel2);
291
+ });
292
+
293
+ it('multi-webinar PS token refresh routes to correct meeting (webinar A vs B coexistence)', async () => {
294
+ // Scenario: User hosts webinar A and joins webinar B as promoted panelist.
295
+ // Both webinars have practice session (PS) channels with different locus URLs.
296
+ // When A's PS token expires, the refresh must use A's locus URL, not B's.
297
+ //
298
+ // This test verifies URL-based routing, not actual JWT expiry. The real flow is:
299
+ // 1. Request to datachannel URL fails with 401/403 (expired token)
300
+ // 2. Interceptor extracts the request URL from the error
301
+ // 3. Interceptor calls getChannelByDatachannelUrl(requestUrl) to find the channel
302
+ // 4. Interceptor calls channel.refreshDataChannelToken() to get new token
303
+ // 5. Interceptor retries the original request
304
+ //
305
+ // We test steps 3-4: given a request URL, does the correct channel's refresh get called?
306
+
307
+ const webinarALocusUrl = 'https://locus.wbx2.com/loci/webinar-aaa-111';
308
+ const webinarBLocusUrl = 'https://locus.wbx2.com/loci/webinar-bbb-222';
309
+ const webinarAPSDatachannelUrl =
310
+ 'https://board.wbx2.com/datachannel/api/v1/locus/webinar-aaa-111/practiceSession/registrations';
311
+ const webinarBPSDatachannelUrl =
312
+ 'https://board.wbx2.com/datachannel/api/v1/locus/webinar-bbb-222/practiceSession/registrations';
313
+
314
+ const psChannelA = plugin.createChannel();
315
+ const psChannelB = plugin.createChannel();
316
+
317
+ // Mock meeting refresh handlers that track which meeting's handler was invoked
318
+ const refreshCallLog = [];
319
+ const mockMeetingARefresh = () => {
320
+ refreshCallLog.push({meeting: 'A', locusUrl: webinarALocusUrl});
321
+
322
+ return Promise.resolve({body: {datachannelToken: 'new-token-for-A'}});
323
+ };
324
+ const mockMeetingBRefresh = () => {
325
+ refreshCallLog.push({meeting: 'B', locusUrl: webinarBLocusUrl});
326
+
327
+ return Promise.resolve({body: {datachannelToken: 'new-token-for-B'}});
328
+ };
329
+
330
+ sinon.stub(psChannelA, 'getDatachannelUrl').returns(webinarAPSDatachannelUrl);
331
+ sinon.stub(psChannelB, 'getDatachannelUrl').returns(webinarBPSDatachannelUrl);
332
+ psChannelA.setRefreshHandler(mockMeetingARefresh);
333
+ psChannelB.setRefreshHandler(mockMeetingBRefresh);
334
+
335
+ // --- Simulate interceptor handling a 401 on webinar A's request ---
336
+ const failingRequestUrlA = webinarAPSDatachannelUrl + '/events';
337
+ const channelForARequest = plugin.getChannelByDatachannelUrl(failingRequestUrlA);
338
+
339
+ assert.equal(channelForARequest, psChannelA);
340
+
341
+ await channelForARequest.refreshDataChannelToken();
342
+
343
+ assert.equal(refreshCallLog.length, 1);
344
+ assert.equal(refreshCallLog[0].meeting, 'A');
345
+ assert.equal(refreshCallLog[0].locusUrl, webinarALocusUrl);
346
+
347
+ // --- Simulate interceptor handling a 401 on webinar B's request ---
348
+ refreshCallLog.length = 0;
349
+
350
+ const failingRequestUrlB = webinarBPSDatachannelUrl + '/events';
351
+ const channelForBRequest = plugin.getChannelByDatachannelUrl(failingRequestUrlB);
352
+
353
+ assert.equal(channelForBRequest, psChannelB);
354
+
355
+ await channelForBRequest.refreshDataChannelToken();
356
+
357
+ assert.equal(refreshCallLog.length, 1);
358
+ assert.equal(refreshCallLog[0].meeting, 'B');
359
+ assert.equal(refreshCallLog[0].locusUrl, webinarBLocusUrl);
360
+ });
361
+
362
+ it('PS channel refresh handlers remain independent after subsequent channel creation', async () => {
363
+ // Verify that creating channel B does not affect channel A's refresh handler
364
+
365
+ const psChannelA = plugin.createChannel();
366
+ const mockRefreshA = sinon.stub().resolves({body: {datachannelToken: 'token-A'}});
367
+
368
+ psChannelA.setRefreshHandler(mockRefreshA);
369
+
370
+ // Create channel B after A (simulating User 1 getting promoted in webinar B)
371
+ const psChannelB = plugin.createChannel();
372
+ const mockRefreshB = sinon.stub().resolves({body: {datachannelToken: 'token-B'}});
373
+
374
+ psChannelB.setRefreshHandler(mockRefreshB);
375
+
376
+ // Verify A's handler was not affected by B's setup
377
+ const resultA = await psChannelA.refreshDataChannelToken();
378
+
379
+ assert.equal(resultA.body.datachannelToken, 'token-A');
380
+ sinon.assert.calledOnce(mockRefreshA);
381
+ sinon.assert.notCalled(mockRefreshB);
382
+
383
+ // Verify B has its own handler
384
+ const resultB = await psChannelB.refreshDataChannelToken();
385
+
386
+ assert.equal(resultB.body.datachannelToken, 'token-B');
387
+ sinon.assert.calledOnce(mockRefreshB);
388
+ });
389
+ });
390
+ });
391
+ });