@webex/internal-plugin-llm 3.11.0 → 3.12.0-auth-prejoin-fetch.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.
- package/README.md +83 -12
- package/dist/constants.js +9 -1
- package/dist/constants.js.map +1 -1
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -1
- package/dist/llm.js +532 -51
- package/dist/llm.js.map +1 -1
- package/dist/llm.types.js +10 -0
- package/dist/llm.types.js.map +1 -1
- package/package.json +6 -6
- package/src/constants.ts +13 -0
- package/src/index.ts +5 -0
- package/src/llm.ts +539 -34
- package/src/llm.types.ts +75 -6
- package/test/unit/spec/llm.js +736 -51
package/test/unit/spec/llm.js
CHANGED
|
@@ -3,6 +3,7 @@ import {assert} from '@webex/test-helper-chai';
|
|
|
3
3
|
import sinon from 'sinon';
|
|
4
4
|
import Mercury from '@webex/internal-plugin-mercury';
|
|
5
5
|
import LLMService from '@webex/internal-plugin-llm';
|
|
6
|
+
import LLMChannel from '@webex/internal-plugin-llm/src/llm';
|
|
6
7
|
|
|
7
8
|
describe('plugin-llm', () => {
|
|
8
9
|
const locusUrl = 'locusUrl';
|
|
@@ -19,47 +20,248 @@ describe('plugin-llm', () => {
|
|
|
19
20
|
},
|
|
20
21
|
});
|
|
21
22
|
|
|
23
|
+
webex.internal.feature = {
|
|
24
|
+
setFeature: sinon.stub().resolves({value: true}),
|
|
25
|
+
getFeature: sinon.stub().resolves(true),
|
|
26
|
+
};
|
|
27
|
+
|
|
22
28
|
llmService = webex.internal.llm;
|
|
23
|
-
llmService.
|
|
24
|
-
llmService.connected = true;
|
|
25
|
-
});
|
|
29
|
+
llmService.webSocketUrl = 'wss://example.com/socket';
|
|
26
30
|
llmService.disconnect = sinon.stub().resolves(true);
|
|
27
31
|
llmService.request = sinon.stub().resolves({
|
|
28
32
|
headers: {},
|
|
29
33
|
body: {
|
|
30
34
|
binding: 'binding',
|
|
31
|
-
webSocketUrl: '
|
|
35
|
+
webSocketUrl: 'wss://example.com/socket',
|
|
32
36
|
},
|
|
33
37
|
});
|
|
38
|
+
const sockets = new Map();
|
|
39
|
+
|
|
40
|
+
llmService.connect = sinon.stub().callsFake((url, sessionId) => {
|
|
41
|
+
sockets.set(sessionId, {connected: true});
|
|
42
|
+
llmService.getSocket = sinon.stub().callsFake((sid) => sockets.get(sid));
|
|
43
|
+
});
|
|
44
|
+
llmService.connections.set('llm-default-session',{
|
|
45
|
+
webSocketUrl: 'wss://example.com/socket',
|
|
46
|
+
})
|
|
34
47
|
});
|
|
35
48
|
|
|
49
|
+
afterEach(() => sinon.restore());
|
|
50
|
+
|
|
36
51
|
describe('#registerAndConnect', () => {
|
|
37
52
|
it('registers connection', async () => {
|
|
38
|
-
llmService.register = sinon.stub().
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
llmService.register = sinon.stub().callsFake(async () => {
|
|
54
|
+
llmService.binding = 'binding';
|
|
55
|
+
llmService.webSocketUrl = 'wss://example.com/socket';
|
|
56
|
+
return {
|
|
57
|
+
body: {
|
|
58
|
+
binding: 'binding',
|
|
59
|
+
webSocketUrl: 'wss://example.com/socket',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.equal(llmService.isConnected('llm-default-session'), false);
|
|
65
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl,undefined);
|
|
66
|
+
assert.equal(llmService.isConnected('llm-default-session'), true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("doesn't register connection for invalid input", async () => {
|
|
70
|
+
llmService.register = sinon.stub().callsFake(async () => {
|
|
71
|
+
llmService.binding = 'binding';
|
|
72
|
+
llmService.webSocketUrl = 'wss://example.com/socket';
|
|
73
|
+
return {
|
|
74
|
+
body: {
|
|
75
|
+
binding: 'binding',
|
|
76
|
+
webSocketUrl: 'wss://example.com/socket',
|
|
77
|
+
},
|
|
78
|
+
};
|
|
43
79
|
});
|
|
80
|
+
|
|
81
|
+
await llmService.registerAndConnect();
|
|
44
82
|
assert.equal(llmService.isConnected(), false);
|
|
45
|
-
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('registers connection with token', async () => {
|
|
86
|
+
llmService.register = sinon.stub().callsFake(async () => {
|
|
87
|
+
llmService.binding = 'binding';
|
|
88
|
+
llmService.webSocketUrl = 'wss://example.com/socket';
|
|
89
|
+
return {
|
|
90
|
+
body: {
|
|
91
|
+
binding: 'binding',
|
|
92
|
+
webSocketUrl: 'wss://example.com/socket',
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
assert.equal(llmService.isConnected(), false);
|
|
98
|
+
|
|
99
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl,'abc123');
|
|
100
|
+
|
|
101
|
+
sinon.assert.calledOnceWithExactly(
|
|
102
|
+
llmService.register,
|
|
103
|
+
datachannelUrl,
|
|
104
|
+
'abc123',
|
|
105
|
+
'llm-default-session'
|
|
106
|
+
);
|
|
107
|
+
|
|
46
108
|
assert.equal(llmService.isConnected(), true);
|
|
47
109
|
});
|
|
48
110
|
|
|
49
|
-
it(
|
|
50
|
-
llmService.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
111
|
+
it('connects with subscriptionAwareSubchannels when token enabled', async () => {
|
|
112
|
+
llmService.isDataChannelTokenEnabled = sinon.stub().returns(true);
|
|
113
|
+
|
|
114
|
+
llmService.register = sinon.stub().callsFake(async () => {
|
|
115
|
+
llmService.binding = 'binding';
|
|
116
|
+
llmService.webSocketUrl = 'wss://example.com/socket';
|
|
117
|
+
return {
|
|
118
|
+
body: {
|
|
119
|
+
binding: 'binding',
|
|
120
|
+
webSocketUrl: 'wss://example.com/socket',
|
|
121
|
+
},
|
|
122
|
+
};
|
|
55
123
|
});
|
|
56
|
-
|
|
124
|
+
|
|
125
|
+
const buildSpy = sinon.spy(LLMService, 'buildUrlWithAwareSubchannels');
|
|
126
|
+
|
|
127
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl,'abc123');
|
|
128
|
+
|
|
129
|
+
sinon.assert.calledOnce(buildSpy);
|
|
130
|
+
sinon.assert.calledOnce(llmService.connect);
|
|
131
|
+
|
|
132
|
+
const calledUrl = llmService.connect.getCall(0).args[0];
|
|
133
|
+
assert.include(calledUrl, 'subscriptionAwareSubchannels=');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('connects without subscriptionAwareSubchannels when token disabled', async () => {
|
|
137
|
+
llmService.isDataChannelTokenEnabled = sinon.stub().returns(false);
|
|
138
|
+
|
|
139
|
+
llmService.register = sinon.stub().callsFake(async () => {
|
|
140
|
+
llmService.binding = 'binding';
|
|
141
|
+
llmService.webSocketUrl = 'wss://example.com/socket';
|
|
142
|
+
return {
|
|
143
|
+
body: {
|
|
144
|
+
binding: 'binding',
|
|
145
|
+
webSocketUrl: 'wss://example.com/socket',
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const buildSpy = sinon.spy(LLMService, 'buildUrlWithAwareSubchannels');
|
|
151
|
+
|
|
152
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl);
|
|
153
|
+
|
|
154
|
+
sinon.assert.notCalled(buildSpy);
|
|
155
|
+
sinon.assert.calledOnce(llmService.connect);
|
|
156
|
+
|
|
157
|
+
const calledUrl = llmService.connect.getCall(0).args[0];
|
|
158
|
+
assert.equal(calledUrl, llmService.webSocketUrl);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('connects without subscriptionAwareSubchannels when token enabled BUT token missing', async () => {
|
|
162
|
+
llmService.isDataChannelTokenEnabled = sinon.stub().resolves(true);
|
|
163
|
+
|
|
164
|
+
const buildSpy = sinon.spy(LLMService, 'buildUrlWithAwareSubchannels');
|
|
165
|
+
|
|
166
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined);
|
|
167
|
+
|
|
168
|
+
sinon.assert.calledOnce(buildSpy);
|
|
169
|
+
sinon.assert.calledOnce(llmService.connect);
|
|
170
|
+
|
|
171
|
+
const calledUrl = llmService.connect.getCall(0).args[0];
|
|
172
|
+
assert.include(calledUrl, 'subscriptionAwareSubchannels=');
|
|
173
|
+
|
|
174
|
+
buildSpy.restore();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('rejects when the registration response has no websocket URL', async () => {
|
|
178
|
+
llmService.connections.set('llm-default-session', {});
|
|
179
|
+
llmService.register = sinon.stub().resolves();
|
|
180
|
+
|
|
181
|
+
await assert.isRejected(
|
|
182
|
+
llmService.registerAndConnect(locusUrl, datachannelUrl),
|
|
183
|
+
/returned no websocket URL/
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
sinon.assert.notCalled(llmService.connect);
|
|
57
187
|
assert.equal(llmService.isConnected(), false);
|
|
58
188
|
});
|
|
189
|
+
|
|
190
|
+
it('attaches the measured datachannel timing to the error when the websocket connect fails', async () => {
|
|
191
|
+
const clock = sinon.useFakeTimers();
|
|
192
|
+
|
|
193
|
+
llmService.isDataChannelTokenEnabled = sinon.stub().resolves(false);
|
|
194
|
+
llmService.register = sinon.stub().callsFake(async () => {
|
|
195
|
+
clock.tick(37);
|
|
196
|
+
const sessionData = llmService.connections.get('llm-default-session') || {};
|
|
197
|
+
sessionData.webSocketUrl = 'wss://example.com/socket';
|
|
198
|
+
sessionData.binding = 'binding';
|
|
199
|
+
llmService.connections.set('llm-default-session', sessionData);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const connectError = new Error('websocket connect failed');
|
|
203
|
+
llmService.connect = sinon.stub().rejects(connectError);
|
|
204
|
+
|
|
205
|
+
let caughtError;
|
|
206
|
+
try {
|
|
207
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
caughtError = error;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
assert.equal(caughtError, connectError);
|
|
213
|
+
assert.deepEqual(caughtError.timing, {clientLLMDatachannelResponseTime: 37});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('attaches the measured datachannel timing to the error when the response has no websocket URL', async () => {
|
|
217
|
+
const clock = sinon.useFakeTimers();
|
|
218
|
+
|
|
219
|
+
llmService.register = sinon.stub().callsFake(async () => {
|
|
220
|
+
clock.tick(29);
|
|
221
|
+
// register() resolves but the response leaves the session without a websocket URL.
|
|
222
|
+
llmService.connections.set('llm-default-session', {
|
|
223
|
+
locusUrl,
|
|
224
|
+
datachannelUrl,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
llmService.connect = sinon.stub();
|
|
229
|
+
|
|
230
|
+
let caughtError;
|
|
231
|
+
try {
|
|
232
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
caughtError = error;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
sinon.assert.notCalled(llmService.connect);
|
|
238
|
+
assert.instanceOf(caughtError, Error);
|
|
239
|
+
assert.match(caughtError.message, /returned no websocket URL/);
|
|
240
|
+
assert.deepEqual(caughtError.timing, {clientLLMDatachannelResponseTime: 29});
|
|
241
|
+
});
|
|
59
242
|
});
|
|
60
243
|
|
|
61
244
|
describe('#register', () => {
|
|
62
|
-
|
|
245
|
+
beforeEach(() => {
|
|
246
|
+
llmService.isDataChannelTokenEnabled = sinon.stub();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('registers connection with token header', async () => {
|
|
250
|
+
llmService.isDataChannelTokenEnabled.resolves(true);
|
|
251
|
+
await llmService.register(datachannelUrl, 'abc123');
|
|
252
|
+
|
|
253
|
+
sinon.assert.calledOnceWithExactly(
|
|
254
|
+
llmService.request,
|
|
255
|
+
sinon.match({
|
|
256
|
+
method: 'POST',
|
|
257
|
+
url: `${datachannelUrl}`,
|
|
258
|
+
body: {deviceUrl: webex.internal.device.url},
|
|
259
|
+
headers: {'Data-Channel-Auth-Token': 'abc123'},
|
|
260
|
+
})
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('registers connection without token header when none provided', async () => {
|
|
63
265
|
await llmService.register(datachannelUrl);
|
|
64
266
|
|
|
65
267
|
sinon.assert.calledOnceWithExactly(
|
|
@@ -68,21 +270,40 @@ describe('plugin-llm', () => {
|
|
|
68
270
|
method: 'POST',
|
|
69
271
|
url: `${datachannelUrl}`,
|
|
70
272
|
body: {deviceUrl: webex.internal.device.url},
|
|
273
|
+
headers: {},
|
|
71
274
|
})
|
|
72
275
|
);
|
|
276
|
+
});
|
|
73
277
|
|
|
74
|
-
|
|
278
|
+
it('registers connection without token header when toggle disabled', async () => {
|
|
279
|
+
llmService.isDataChannelTokenEnabled.resolves(false);
|
|
280
|
+
|
|
281
|
+
await llmService.register(datachannelUrl,'abc123');
|
|
282
|
+
sinon.assert.calledOnceWithExactly(
|
|
283
|
+
llmService.request,
|
|
284
|
+
sinon.match({
|
|
285
|
+
method: 'POST',
|
|
286
|
+
url: `${datachannelUrl}`,
|
|
287
|
+
body: {deviceUrl: webex.internal.device.url},
|
|
288
|
+
headers: {},
|
|
289
|
+
})
|
|
290
|
+
);
|
|
75
291
|
});
|
|
76
292
|
});
|
|
77
293
|
|
|
78
294
|
describe('#getLocusUrl', () => {
|
|
79
295
|
it('gets LocusUrl', async () => {
|
|
80
|
-
llmService.register = sinon.stub().
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
296
|
+
llmService.register = sinon.stub().callsFake(async () => {
|
|
297
|
+
llmService.binding = 'binding';
|
|
298
|
+
llmService.webSocketUrl = 'wss://example.com/socket';
|
|
299
|
+
return {
|
|
300
|
+
body: {
|
|
301
|
+
binding: 'binding',
|
|
302
|
+
webSocketUrl: 'wss://example.com/socket',
|
|
303
|
+
},
|
|
304
|
+
};
|
|
85
305
|
});
|
|
306
|
+
|
|
86
307
|
await llmService.registerAndConnect(locusUrl, datachannelUrl);
|
|
87
308
|
assert.equal(llmService.getLocusUrl(), locusUrl);
|
|
88
309
|
});
|
|
@@ -90,11 +311,15 @@ describe('plugin-llm', () => {
|
|
|
90
311
|
|
|
91
312
|
describe('#getDatachannelUrl', () => {
|
|
92
313
|
it('gets dataChannel Url', async () => {
|
|
93
|
-
llmService.register = sinon.stub().
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
314
|
+
llmService.register = sinon.stub().callsFake(async () => {
|
|
315
|
+
llmService.binding = 'binding';
|
|
316
|
+
llmService.webSocketUrl = 'wss://example.com/socket';
|
|
317
|
+
return {
|
|
318
|
+
body: {
|
|
319
|
+
binding: 'binding',
|
|
320
|
+
webSocketUrl: 'wss://example.com/socket',
|
|
321
|
+
},
|
|
322
|
+
};
|
|
98
323
|
});
|
|
99
324
|
await llmService.registerAndConnect(locusUrl, datachannelUrl);
|
|
100
325
|
assert.equal(llmService.getDatachannelUrl(), datachannelUrl);
|
|
@@ -112,42 +337,502 @@ describe('plugin-llm', () => {
|
|
|
112
337
|
});
|
|
113
338
|
});
|
|
114
339
|
|
|
115
|
-
describe('disconnectLLM', () => {
|
|
340
|
+
describe('#disconnectLLM', () => {
|
|
116
341
|
let instance;
|
|
117
342
|
|
|
118
343
|
beforeEach(() => {
|
|
119
344
|
instance = {
|
|
120
345
|
disconnect: jest.fn(() => Promise.resolve()),
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
disconnectLLM: function (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
346
|
+
connections: new Map([
|
|
347
|
+
['llm-default-session', { foo: 'bar', datachannelToken: 'session-token' }],
|
|
348
|
+
]),
|
|
349
|
+
|
|
350
|
+
disconnectLLM: function (
|
|
351
|
+
options,
|
|
352
|
+
sessionId = 'llm-default-session',
|
|
353
|
+
ownerMeetingId = 'meeting-1'
|
|
354
|
+
) {
|
|
355
|
+
if (!ownerMeetingId) {
|
|
356
|
+
return Promise.reject(new Error('ownerMeetingId is required'));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return this.disconnect(options, sessionId).then(() => {
|
|
360
|
+
this.connections.delete(sessionId);
|
|
131
361
|
});
|
|
132
|
-
}
|
|
362
|
+
},
|
|
133
363
|
};
|
|
134
364
|
});
|
|
135
365
|
|
|
136
|
-
it('
|
|
137
|
-
await instance.disconnectLLM({});
|
|
366
|
+
it('calls disconnect and clears session connection (including token stored in session)', async () => {
|
|
367
|
+
await instance.disconnectLLM({ code: 3000, reason: 'bye' }, 'llm-default-session', 'meeting-1');
|
|
368
|
+
|
|
369
|
+
expect(instance.disconnect).toHaveBeenCalledWith(
|
|
370
|
+
{ code: 3000, reason: 'bye' },
|
|
371
|
+
'llm-default-session'
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
expect(instance.connections.has('llm-default-session')).toBe(false);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('disconnectLLM supports legacy call with options only', async () => {
|
|
378
|
+
llmService.disconnect = sinon.stub().resolves(true);
|
|
379
|
+
|
|
380
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 'llm-default-session');
|
|
381
|
+
|
|
382
|
+
const options = {code: 1000, reason: 'legacy'};
|
|
383
|
+
const disconnected = await llmService.disconnectLLM(options);
|
|
384
|
+
|
|
385
|
+
assert.equal(disconnected, true);
|
|
386
|
+
sinon.assert.calledOnceWithExactly(llmService.disconnect, options, 'llm-default-session');
|
|
387
|
+
assert.equal(llmService.getAllConnections().has('llm-default-session'), false);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('disconnectLLM supports legacy call with options and sessionId', async () => {
|
|
391
|
+
llmService.disconnect = sinon.stub().resolves(true);
|
|
392
|
+
|
|
393
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
|
|
394
|
+
|
|
395
|
+
const options = {code: 1000, reason: 'legacy'};
|
|
396
|
+
const disconnected = await llmService.disconnectLLM(options, 's1');
|
|
397
|
+
|
|
398
|
+
assert.equal(disconnected, true);
|
|
399
|
+
sinon.assert.calledOnceWithExactly(llmService.disconnect, options, 's1');
|
|
400
|
+
assert.equal(llmService.getAllConnections().has('s1'), false);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('disconnectLLM treats null sessionId as default session', async () => {
|
|
404
|
+
llmService.disconnect = sinon.stub().resolves(true);
|
|
405
|
+
|
|
406
|
+
await llmService.registerAndConnect(
|
|
407
|
+
locusUrl,
|
|
408
|
+
datachannelUrl,
|
|
409
|
+
undefined,
|
|
410
|
+
'llm-default-session'
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const options = {code: 1000, reason: 'legacy-null-session'};
|
|
414
|
+
const disconnected = await llmService.disconnectLLM(options, null);
|
|
415
|
+
|
|
416
|
+
assert.equal(disconnected, true);
|
|
417
|
+
sinon.assert.calledOnceWithExactly(llmService.disconnect, options, 'llm-default-session');
|
|
418
|
+
assert.equal(llmService.getAllConnections().has('llm-default-session'), false);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('propagates disconnect errors', async () => {
|
|
422
|
+
instance.disconnect.mockRejectedValue(new Error('disconnect failed'));
|
|
423
|
+
|
|
424
|
+
await expect(
|
|
425
|
+
instance.disconnectLLM({ code: 3000, reason: 'bye' }, 'llm-default-session', 'meeting-1')
|
|
426
|
+
).rejects.toThrow('disconnect failed');
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('#setRefreshHandler', () => {
|
|
431
|
+
beforeEach(() => {
|
|
432
|
+
llmService.setRefreshHandler = LLMChannel.prototype.setRefreshHandler.bind(llmService);
|
|
433
|
+
llmService.refreshDataChannelToken = LLMChannel.prototype.refreshDataChannelToken.bind(llmService);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('defaults to llm-default-session when sessionId is omitted', () => {
|
|
437
|
+
const handler = sinon.stub().resolves({ body: { datachannelToken: 'legacyToken' } });
|
|
438
|
+
|
|
439
|
+
llmService.setRefreshHandler(handler);
|
|
440
|
+
|
|
441
|
+
return llmService.refreshDataChannelToken().then((result) => {
|
|
442
|
+
assert.equal(result.body.datachannelToken, 'legacyToken');
|
|
443
|
+
sinon.assert.calledOnce(handler);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('stores the provided handler', () => {
|
|
448
|
+
const handler = sinon.stub().resolves({ body: { datachannelToken: 'newToken' } });
|
|
449
|
+
llmService.setRefreshHandler(handler, 'llm-default-session');
|
|
450
|
+
|
|
451
|
+
return llmService.refreshDataChannelToken().then((result) => {
|
|
452
|
+
assert.equal(result.body.datachannelToken, 'newToken');
|
|
453
|
+
sinon.assert.calledOnce(handler);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('stores handlers per session id', () => {
|
|
458
|
+
const handlerS1 = sinon.stub().resolves({ body: { datachannelToken: 'token-s1' } });
|
|
459
|
+
const handlerS2 = sinon.stub().resolves({ body: { datachannelToken: 'token-s2' } });
|
|
460
|
+
|
|
461
|
+
llmService.setRefreshHandler(handlerS1, 's1');
|
|
462
|
+
llmService.setRefreshHandler(handlerS2, 's2');
|
|
463
|
+
|
|
464
|
+
return Promise.all([
|
|
465
|
+
llmService.refreshDataChannelToken('s1'),
|
|
466
|
+
llmService.refreshDataChannelToken('s2'),
|
|
467
|
+
]).then(([resultS1, resultS2]) => {
|
|
468
|
+
assert.equal(resultS1.body.datachannelToken, 'token-s1');
|
|
469
|
+
assert.equal(resultS2.body.datachannelToken, 'token-s2');
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe('#isDataChannelTokenEnabled', () => {
|
|
475
|
+
it('works correctly', async () => {
|
|
476
|
+
webex.internal.feature.getFeature.returns(true);
|
|
477
|
+
|
|
478
|
+
const result = await llmService.isDataChannelTokenEnabled();
|
|
479
|
+
|
|
480
|
+
sinon.assert.calledOnceWithExactly(
|
|
481
|
+
webex.internal.feature.getFeature,
|
|
482
|
+
'developer',
|
|
483
|
+
'data-channel-with-jwt-token'
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
assert.equal(result, true);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
describe('#refreshDataChannelToken', () => {
|
|
491
|
+
beforeEach(() => {
|
|
492
|
+
llmService.setRefreshHandler = LLMChannel.prototype.setRefreshHandler.bind(llmService);
|
|
493
|
+
llmService.refreshDataChannelToken = LLMChannel.prototype.refreshDataChannelToken.bind(llmService);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('returns null and logs warn if no handler is set', async () => {
|
|
497
|
+
const warnSpy = llmService.logger.warn
|
|
498
|
+
|
|
499
|
+
const result = await llmService.refreshDataChannelToken();
|
|
500
|
+
|
|
501
|
+
assert.equal(result, null);
|
|
502
|
+
|
|
503
|
+
sinon.assert.calledOnce(warnSpy);
|
|
504
|
+
sinon.assert.calledWithMatch(
|
|
505
|
+
warnSpy,
|
|
506
|
+
sinon.match('LLM refreshHandler is not set for session')
|
|
507
|
+
);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('returns token when handler resolves', async () => {
|
|
511
|
+
const mockToken = { body: { datachannelToken: 'newToken', isPracticeSession: false } };
|
|
512
|
+
const handler = sinon.stub().resolves(mockToken);
|
|
513
|
+
|
|
514
|
+
llmService.setRefreshHandler(handler, 'llm-default-session');
|
|
515
|
+
|
|
516
|
+
const token = await llmService.refreshDataChannelToken();
|
|
517
|
+
|
|
518
|
+
assert.equal(token, mockToken);
|
|
519
|
+
sinon.assert.calledOnce(handler);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('uses the handler for the provided session id', async () => {
|
|
523
|
+
const mockToken = { body: { datachannelToken: 'session-token', isPracticeSession: true } };
|
|
524
|
+
const handler = sinon.stub().resolves(mockToken);
|
|
525
|
+
|
|
526
|
+
llmService.setRefreshHandler(handler, 's2');
|
|
527
|
+
|
|
528
|
+
const token = await llmService.refreshDataChannelToken('s2');
|
|
529
|
+
|
|
530
|
+
assert.equal(token, mockToken);
|
|
531
|
+
sinon.assert.calledOnce(handler);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('logs warn and returns null when handler rejects', async () => {
|
|
535
|
+
const handler = sinon.stub().rejects(new Error('throw error'));
|
|
536
|
+
llmService.setRefreshHandler(handler, 'llm-default-session');
|
|
537
|
+
|
|
538
|
+
const warnSpy = llmService.logger.warn
|
|
539
|
+
|
|
540
|
+
const result = await llmService.refreshDataChannelToken();
|
|
541
|
+
|
|
542
|
+
assert.equal(result, null);
|
|
543
|
+
|
|
544
|
+
sinon.assert.calledOnce(warnSpy);
|
|
545
|
+
sinon.assert.calledWithMatch(
|
|
546
|
+
warnSpy,
|
|
547
|
+
sinon.match('DataChannel token refresh failed'),
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe('#getDatachannelToken / #setDatachannelToken', () => {
|
|
553
|
+
it('sets and gets datachannel token', () => {
|
|
554
|
+
llmService.setDatachannelToken('abc123','llm-default-session');
|
|
555
|
+
assert.equal(llmService.getDatachannelToken('llm-default-session'), 'abc123');
|
|
556
|
+
llmService.setDatachannelToken('123abc','llm-practice-session');
|
|
557
|
+
assert.equal(llmService.getDatachannelToken('llm-practice-session'), '123abc');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('supports arbitrary session id token keys', () => {
|
|
561
|
+
llmService.setDatachannelToken('token-s1', 'session-custom-1');
|
|
562
|
+
|
|
563
|
+
assert.equal(llmService.getDatachannelToken('session-custom-1'), 'token-s1');
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe('#setOwnerMeetingId / #getOwnerMeetingId', () => {
|
|
569
|
+
it('stores and returns the owner meeting id for the default session', () => {
|
|
570
|
+
// beforeEach seeds connections with the default session entry
|
|
571
|
+
llmService.setOwnerMeetingId('meeting-1');
|
|
572
|
+
|
|
573
|
+
assert.equal(llmService.getOwnerMeetingId(), 'meeting-1');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('returns undefined when no owner has been set yet', () => {
|
|
577
|
+
assert.equal(llmService.getOwnerMeetingId(), undefined);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('is a no-op when there is no session data for the given sessionId', () => {
|
|
581
|
+
// Default session exists (seeded in beforeEach), but an arbitrary
|
|
582
|
+
// session id does not — setOwnerMeetingId must not create entries.
|
|
583
|
+
llmService.setOwnerMeetingId('meeting-1', 'unknown-session');
|
|
584
|
+
|
|
585
|
+
assert.equal(llmService.getOwnerMeetingId('unknown-session'), undefined);
|
|
586
|
+
});
|
|
138
587
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
588
|
+
it('allows clearing ownership by passing undefined', () => {
|
|
589
|
+
llmService.setOwnerMeetingId('meeting-1');
|
|
590
|
+
assert.equal(llmService.getOwnerMeetingId(), 'meeting-1');
|
|
591
|
+
|
|
592
|
+
llmService.setOwnerMeetingId(undefined);
|
|
593
|
+
|
|
594
|
+
assert.equal(llmService.getOwnerMeetingId(), undefined);
|
|
144
595
|
});
|
|
145
596
|
|
|
146
|
-
it('
|
|
147
|
-
|
|
597
|
+
it('tracks ownership per session id', () => {
|
|
598
|
+
llmService.connections.set('session-A', {webSocketUrl: 'wss://a'});
|
|
599
|
+
llmService.connections.set('session-B', {webSocketUrl: 'wss://b'});
|
|
600
|
+
|
|
601
|
+
llmService.setOwnerMeetingId('meeting-A', 'session-A');
|
|
602
|
+
llmService.setOwnerMeetingId('meeting-B', 'session-B');
|
|
603
|
+
|
|
604
|
+
assert.equal(llmService.getOwnerMeetingId('session-A'), 'meeting-A');
|
|
605
|
+
assert.equal(llmService.getOwnerMeetingId('session-B'), 'meeting-B');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('clears ownerMeetingId naturally when disconnectLLM deletes the session entry', async () => {
|
|
609
|
+
llmService.register = sinon.stub().callsFake(async () => ({
|
|
610
|
+
body: {binding: 'binding', webSocketUrl: 'wss://example.com/socket'},
|
|
611
|
+
}));
|
|
612
|
+
|
|
613
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl);
|
|
614
|
+
llmService.setOwnerMeetingId('meeting-1');
|
|
615
|
+
assert.equal(llmService.getOwnerMeetingId(), 'meeting-1');
|
|
616
|
+
|
|
617
|
+
await llmService.disconnectLLM(
|
|
618
|
+
{code: 3050, reason: 'done (permanent)'},
|
|
619
|
+
'llm-default-session',
|
|
620
|
+
'meeting-1'
|
|
621
|
+
);
|
|
148
622
|
|
|
149
|
-
|
|
623
|
+
// Session entry was deleted, so ownerMeetingId is gone.
|
|
624
|
+
assert.equal(llmService.getOwnerMeetingId(), undefined);
|
|
150
625
|
});
|
|
151
626
|
});
|
|
627
|
+
|
|
628
|
+
describe('multi-connection logic', () => {
|
|
629
|
+
const locusUrl2 = 'locusUrl2';
|
|
630
|
+
const datachannelUrl2 = 'datachannelUrl2';
|
|
631
|
+
|
|
632
|
+
it('tracks multiple sessions independently', async () => {
|
|
633
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
|
|
634
|
+
await llmService.registerAndConnect(locusUrl2, datachannelUrl2, undefined, 's2');
|
|
635
|
+
|
|
636
|
+
assert.equal(llmService.isConnected('s1'), true);
|
|
637
|
+
assert.equal(llmService.isConnected('s2'), true);
|
|
638
|
+
assert.equal(llmService.getLocusUrl('s1'), locusUrl);
|
|
639
|
+
assert.equal(llmService.getLocusUrl('s2'), locusUrl2);
|
|
640
|
+
assert.equal(llmService.getDatachannelUrl('s1'), datachannelUrl);
|
|
641
|
+
assert.equal(llmService.getDatachannelUrl('s2'), datachannelUrl2);
|
|
642
|
+
|
|
643
|
+
const all = llmService.getAllConnections();
|
|
644
|
+
assert.equal(all.has('s1'), true);
|
|
645
|
+
assert.equal(all.has('s2'), true);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('disconnectLLM clears only the targeted session', async () => {
|
|
649
|
+
llmService.disconnect = sinon.stub().resolves(true);
|
|
650
|
+
|
|
651
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
|
|
652
|
+
await llmService.registerAndConnect(locusUrl2, datachannelUrl2, undefined, 's2');
|
|
653
|
+
|
|
654
|
+
const options = {code: 1000, reason: 'test'};
|
|
655
|
+
const disconnected = await llmService.disconnectLLM(options, 's1', 'meeting-1');
|
|
656
|
+
|
|
657
|
+
assert.equal(disconnected, true);
|
|
658
|
+
sinon.assert.calledOnceWithExactly(llmService.disconnect, options, 's1');
|
|
659
|
+
|
|
660
|
+
const all = llmService.getAllConnections();
|
|
661
|
+
assert.equal(all.has('s1'), false);
|
|
662
|
+
assert.equal(all.has('s2'), true);
|
|
663
|
+
assert.equal(llmService.getDatachannelToken('s1'), undefined);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('disconnectLLM skips disconnect when ownerMeetingId does not match', async () => {
|
|
667
|
+
llmService.disconnect = sinon.stub().resolves(true);
|
|
668
|
+
|
|
669
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
|
|
670
|
+
llmService.setOwnerMeetingId('meeting-1', 's1');
|
|
671
|
+
|
|
672
|
+
const options = {code: 1000, reason: 'test'};
|
|
673
|
+
const disconnected = await llmService.disconnectLLM(options, 's1', 'meeting-2');
|
|
674
|
+
|
|
675
|
+
assert.equal(disconnected, false);
|
|
676
|
+
sinon.assert.notCalled(llmService.disconnect);
|
|
677
|
+
assert.equal(llmService.getAllConnections().has('s1'), true);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('disconnectAllLLM clears all sessions', async () => {
|
|
681
|
+
llmService.disconnectAll = sinon.stub().resolves(true);
|
|
682
|
+
|
|
683
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
|
|
684
|
+
await llmService.registerAndConnect(locusUrl2, datachannelUrl2, undefined, 's2');
|
|
685
|
+
|
|
686
|
+
await llmService.disconnectAllLLM({code: 1000, reason: 'all'});
|
|
687
|
+
|
|
688
|
+
sinon.assert.calledOnce(llmService.disconnectAll);
|
|
689
|
+
assert.equal(llmService.getAllConnections().size, 0);
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
describe('#getLocusUrlByDatachannelUrl', () => {
|
|
694
|
+
const locusUrl2 = 'https://locus-b.wbx2.com/locus/api/v1/loci/456';
|
|
695
|
+
const datachannelUrl2 = 'https://board-b.wbx2.com/datachannel/api/v1/locus/ps-encoded/registrations';
|
|
696
|
+
|
|
697
|
+
// Ampersand State.extend() does not always propagate prototype methods
|
|
698
|
+
// added to child classes, so we bind the method from the class prototype
|
|
699
|
+
// directly onto the test instance.
|
|
700
|
+
beforeEach(() => {
|
|
701
|
+
llmService.getLocusUrlByDatachannelUrl = LLMChannel.prototype.getLocusUrlByDatachannelUrl.bind(llmService);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('returns locusUrl when request URL matches a session datachannelUrl', async () => {
|
|
705
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
|
|
706
|
+
await llmService.registerAndConnect(locusUrl2, datachannelUrl2, undefined, 's2');
|
|
707
|
+
|
|
708
|
+
const result = llmService.getLocusUrlByDatachannelUrl(datachannelUrl2 + '/some-path');
|
|
709
|
+
|
|
710
|
+
assert.equal(result, locusUrl2);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('returns undefined when no session matches the request URL', async () => {
|
|
714
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
|
|
715
|
+
|
|
716
|
+
const result = llmService.getLocusUrlByDatachannelUrl('https://unknown.example.com/path');
|
|
717
|
+
|
|
718
|
+
assert.equal(result, undefined);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('matches locusUrl when request host is rewritten but pathname matches', async () => {
|
|
722
|
+
await llmService.registerAndConnect(locusUrl2, datachannelUrl2, undefined, 's2');
|
|
723
|
+
|
|
724
|
+
const rewrittenHostRequestUrl =
|
|
725
|
+
'https://hostmap-rewritten.example.com/datachannel/api/v1/locus/ps-encoded/registrations/events';
|
|
726
|
+
const result = llmService.getLocusUrlByDatachannelUrl(rewrittenHostRequestUrl);
|
|
727
|
+
|
|
728
|
+
assert.equal(result, locusUrl2);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('returns undefined when no connections exist', () => {
|
|
732
|
+
const result = llmService.getLocusUrlByDatachannelUrl(datachannelUrl);
|
|
733
|
+
|
|
734
|
+
assert.equal(result, undefined);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
describe('#getSessionIdByDatachannelUrl', () => {
|
|
739
|
+
const datachannelUrl2 = 'https://board-b.wbx2.com/datachannel/api/v1/locus/ps-encoded/registrations';
|
|
740
|
+
|
|
741
|
+
beforeEach(() => {
|
|
742
|
+
llmService.getSessionIdByDatachannelUrl = LLMChannel.prototype.getSessionIdByDatachannelUrl.bind(llmService);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it('returns sessionId when request URL matches a session datachannelUrl', async () => {
|
|
746
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
|
|
747
|
+
await llmService.registerAndConnect('https://locus-b.wbx2.com/locus/api/v1/loci/456', datachannelUrl2, undefined, 's2');
|
|
748
|
+
|
|
749
|
+
const result = llmService.getSessionIdByDatachannelUrl(datachannelUrl2 + '/some-path');
|
|
750
|
+
|
|
751
|
+
assert.equal(result, 's2');
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('returns undefined when no session matches the request URL', async () => {
|
|
755
|
+
await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
|
|
756
|
+
|
|
757
|
+
const result = llmService.getSessionIdByDatachannelUrl('https://unknown.example.com/path');
|
|
758
|
+
|
|
759
|
+
assert.equal(result, undefined);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('matches sessionId when request host is rewritten but pathname matches', async () => {
|
|
763
|
+
await llmService.registerAndConnect(
|
|
764
|
+
'https://locus-b.wbx2.com/locus/api/v1/loci/456',
|
|
765
|
+
datachannelUrl2,
|
|
766
|
+
undefined,
|
|
767
|
+
's2'
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const rewrittenHostRequestUrl =
|
|
771
|
+
'https://hostmap-rewritten.example.com/datachannel/api/v1/locus/ps-encoded/registrations/messages';
|
|
772
|
+
const result = llmService.getSessionIdByDatachannelUrl(rewrittenHostRequestUrl);
|
|
773
|
+
|
|
774
|
+
assert.equal(result, 's2');
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe('#registerAndConnect timing', () => {
|
|
779
|
+
it('returns timing data on successful connection', async () => {
|
|
780
|
+
const clock = sinon.useFakeTimers();
|
|
781
|
+
|
|
782
|
+
llmService.isDataChannelTokenEnabled = sinon.stub().resolves(false);
|
|
783
|
+
llmService.register = sinon.stub().callsFake(async () => {
|
|
784
|
+
clock.tick(37);
|
|
785
|
+
|
|
786
|
+
const sessionData = llmService.connections.get('llm-default-session') || {};
|
|
787
|
+
|
|
788
|
+
sessionData.webSocketUrl = 'wss://example.com/socket';
|
|
789
|
+
sessionData.binding = 'binding';
|
|
790
|
+
llmService.connections.set('llm-default-session', sessionData);
|
|
791
|
+
});
|
|
792
|
+
llmService.connect = sinon.stub().callsFake(async () => {
|
|
793
|
+
clock.tick(23);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const result = await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined);
|
|
797
|
+
|
|
798
|
+
assert.deepEqual(result, {
|
|
799
|
+
clientLLMDatachannelResponseTime: 37,
|
|
800
|
+
clientLLMWebSocketConnectTime: 23,
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('returns undefined when locusUrl is empty', async () => {
|
|
805
|
+
llmService.register = sinon.stub().resolves();
|
|
806
|
+
|
|
807
|
+
const result = await llmService.registerAndConnect('', datachannelUrl, undefined);
|
|
808
|
+
|
|
809
|
+
assert.isUndefined(result);
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
describe('#getWebSocketUrl', () => {
|
|
814
|
+
it('returns the websocket URL for default session', () => {
|
|
815
|
+
llmService.connections.set('llm-default-session', {
|
|
816
|
+
webSocketUrl: 'wss://test.example.com/ws',
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
assert.equal(llmService.getWebSocketUrl(), 'wss://test.example.com/ws');
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('returns undefined when no connection exists', () => {
|
|
823
|
+
llmService.connections.clear();
|
|
824
|
+
|
|
825
|
+
assert.isUndefined(llmService.getWebSocketUrl());
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('returns the websocket URL for custom session', () => {
|
|
829
|
+
llmService.connections.set('custom-session', {
|
|
830
|
+
webSocketUrl: 'wss://custom.example.com/ws',
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
assert.equal(llmService.getWebSocketUrl('custom-session'), 'wss://custom.example.com/ws');
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
152
837
|
});
|
|
153
838
|
});
|