llm-switcher 1.2.2 → 1.2.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.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -0
- package/blindfold/make-certs.sh +3 -1
- package/catalog.mjs +132 -42
- package/mcp.mjs +2 -2
- package/package.json +1 -1
- package/proxy.mjs +8 -6
- package/scripts/run-tests.mjs +3 -1
- package/state.mjs +58 -4
- package/switch.mjs +28 -4
- package/tests/blindfold-e2e.test.mjs +2 -2
- package/tests/blindfold-task5.test.mjs +1 -1
- package/tests/blindfold.task3.test.mjs +2 -2
- package/tests/blindfold.wire.test.mjs +3 -1
- package/tests/catalog.test.mjs +117 -19
- package/tests/codex-daemon.test.mjs +92 -0
- package/tests/lifecycle.test.mjs +49 -37
- package/tests/make-certs.test.mjs +22 -0
- package/tests/real-user-sim.test.mjs +3 -0
- package/tests/shim.test.mjs +8 -3
- package/tests/switch.test.mjs +30 -24
- package/tests/ui.test.mjs +90 -0
- package/tests/version.test.mjs +78 -0
- package/ui.html +285 -83
- package/version.mjs +53 -0
package/tests/switch.test.mjs
CHANGED
|
@@ -1,31 +1,37 @@
|
|
|
1
1
|
// CLI tests: switch.mjs runs as a child with its own config, launch files and Claude dir
|
|
2
2
|
// (audit F06, BR-02, F27, F30, F46).
|
|
3
|
-
import { test } from 'node:test';
|
|
3
|
+
import { test, after } from 'node:test';
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import net from 'node:net';
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
9
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
11
|
|
|
12
12
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
13
|
const POSIX = process.platform !== 'win32';
|
|
14
14
|
|
|
15
|
+
// Blindfold mode needs a CA. Without its own directory the CLI reads the certificates of the checkout.
|
|
16
|
+
const CERTS = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-cli-certs-'));
|
|
17
|
+
if (POSIX) execFileSync('bash', [path.join(ROOT, 'blindfold', 'make-certs.sh'), CERTS], { stdio: 'ignore' });
|
|
18
|
+
after(() => fs.rmSync(CERTS, { recursive: true, force: true }));
|
|
19
|
+
|
|
15
20
|
const freePort = () => new Promise(r => {
|
|
16
21
|
const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); });
|
|
17
22
|
});
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
// A free interceptor port too: the default 3457 belongs to a switcher that may run on this machine.
|
|
25
|
+
async function workspace(t, port, profiles) {
|
|
20
26
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-cli-'));
|
|
21
27
|
const cfgPath = path.join(dir, 'config.json');
|
|
22
28
|
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
23
|
-
port, activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
|
|
29
|
+
port, blindfold: { port: await freePort() }, activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
|
|
24
30
|
profiles: profiles || { plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } } }
|
|
25
31
|
}, null, 2), { mode: 0o600 });
|
|
26
32
|
// HOME too: `switch on` installs shims under the home directory, and a test must never rewrite the real ones.
|
|
27
33
|
const home = path.join(dir, 'home');
|
|
28
|
-
const ws = { dir, cfgPath, home, env: { ...process.env, HOME: home, USERPROFILE: home, LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: dir, CLAUDE_CONFIG_DIR: path.join(dir, 'claude'), LLM_SWITCHER_PORT: '', PORT: '' } };
|
|
34
|
+
const ws = { dir, cfgPath, home, env: { ...process.env, HOME: home, USERPROFILE: home, LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: dir, LLM_SWITCHER_BLINDFOLD_CERTS: CERTS, CLAUDE_CONFIG_DIR: path.join(dir, 'claude'), LLM_SWITCHER_PORT: '', PORT: '' } };
|
|
29
35
|
t.after(async () => {
|
|
30
36
|
await run(ws, ['off']).catch(() => {});
|
|
31
37
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -52,7 +58,7 @@ function run(ws, args, { onLine, env = {} } = {}) {
|
|
|
52
58
|
const readCfg = (ws) => JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8'));
|
|
53
59
|
|
|
54
60
|
test('profile names are printed without terminal control sequences', { skip: !POSIX && 'posix' }, async (t) => {
|
|
55
|
-
const ws = workspace(t, await freePort(), {
|
|
61
|
+
const ws = await workspace(t, await freePort(), {
|
|
56
62
|
evil: { name: 'Evil\u001b[2J\u001b]0;pwned\u0007', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1\u001b[31m', apiKey: 'k', defaultModels: { opus: 'o' } }
|
|
57
63
|
});
|
|
58
64
|
const r = await run(ws, ['status']);
|
|
@@ -64,7 +70,7 @@ test('profile names are printed without terminal control sequences', { skip: !PO
|
|
|
64
70
|
test('-p is the global port option at any position, never the port command', { skip: !POSIX && 'posix' }, async (t) => {
|
|
65
71
|
const port = await freePort();
|
|
66
72
|
const other = await freePort();
|
|
67
|
-
const ws = workspace(t, port);
|
|
73
|
+
const ws = await workspace(t, port);
|
|
68
74
|
const r = await run(ws, ['-p', String(other), 'status']);
|
|
69
75
|
assert.equal(r.status, 0, r.stderr);
|
|
70
76
|
assert.match(r.stdout, /=== LLM Switcher Status ===/);
|
|
@@ -74,7 +80,7 @@ test('-p is the global port option at any position, never the port command', { s
|
|
|
74
80
|
});
|
|
75
81
|
|
|
76
82
|
test('switch on keeps a change that another writer saved while it waited for the gateway', { skip: !POSIX && 'posix' }, async (t) => {
|
|
77
|
-
const ws = workspace(t, await freePort());
|
|
83
|
+
const ws = await workspace(t, await freePort());
|
|
78
84
|
let edited = false;
|
|
79
85
|
const r = await run(ws, ['on', 'plain'], {
|
|
80
86
|
onLine: (line) => {
|
|
@@ -95,7 +101,7 @@ test('switch on keeps a change that another writer saved while it waited for the
|
|
|
95
101
|
|
|
96
102
|
test('switch port refuses a port that another process holds and leaves the gateway where it was', { skip: !POSIX && 'posix' }, async (t) => {
|
|
97
103
|
const port = await freePort();
|
|
98
|
-
const ws = workspace(t, port);
|
|
104
|
+
const ws = await workspace(t, port);
|
|
99
105
|
assert.equal((await run(ws, ['on', 'plain'])).status, 0);
|
|
100
106
|
const squatter = net.createServer(s => s.end('HTTP/1.1 200 OK\r\n\r\nnot the switcher'));
|
|
101
107
|
const held = await new Promise(r => squatter.listen(0, '127.0.0.1', () => r(squatter.address().port)));
|
|
@@ -111,7 +117,7 @@ test('switch port refuses a port that another process holds and leaves the gatew
|
|
|
111
117
|
test('switch port puts config.json and the gateway back when the new gateway does not come up', { skip: !POSIX && 'posix' }, async (t) => {
|
|
112
118
|
const port = await freePort();
|
|
113
119
|
const next = await freePort();
|
|
114
|
-
const ws = workspace(t, port);
|
|
120
|
+
const ws = await workspace(t, port);
|
|
115
121
|
assert.equal((await run(ws, ['on', 'plain'])).status, 0);
|
|
116
122
|
// The new port is free at the check and taken right after the old gateway stops.
|
|
117
123
|
const squatter = net.createServer(s => s.destroy());
|
|
@@ -128,7 +134,7 @@ test('switch port puts config.json and the gateway back when the new gateway doe
|
|
|
128
134
|
// A shim bakes in the state dir. A temp state dir, deleted later, would make every shim source a path that
|
|
129
135
|
// another account can re-create (audit follow-up attacker-1).
|
|
130
136
|
test('switch on installs no shims when the launch files live outside the checkout', { skip: !POSIX && 'posix' }, async (t) => {
|
|
131
|
-
const ws = workspace(t, await freePort());
|
|
137
|
+
const ws = await workspace(t, await freePort());
|
|
132
138
|
const r = await run(ws, ['on', 'plain']);
|
|
133
139
|
assert.equal(r.status, 0, r.stderr);
|
|
134
140
|
assert.match(r.stdout, /\[Shim\] Not installed automatically/);
|
|
@@ -143,7 +149,7 @@ const slowStart = (script, ms) => ({ NODE_OPTIONS: `--import=data:text/javascrip
|
|
|
143
149
|
test('switch port stops the new gateway it started when it rolls back', { skip: !POSIX && 'posix' }, async (t) => {
|
|
144
150
|
const port = await freePort();
|
|
145
151
|
const next = await freePort();
|
|
146
|
-
const ws = workspace(t, port);
|
|
152
|
+
const ws = await workspace(t, port);
|
|
147
153
|
assert.equal((await run(ws, ['on', 'plain'])).status, 0);
|
|
148
154
|
const r = await run(ws, ['port', String(next)], { env: slowStart('proxy.mjs', 7000) });
|
|
149
155
|
assert.notEqual(r.status, 0, r.stdout);
|
|
@@ -158,8 +164,8 @@ test('switch port stops the new gateway it started when it rolls back', { skip:
|
|
|
158
164
|
// Without publicModels the gateway has no official name to give Codex: no model catalog, no OpenAI-Model
|
|
159
165
|
// header, and Codex prints false "metadata not found" and "high-risk cyber activity" warnings.
|
|
160
166
|
test('switch codex and switch doctor warn when the Codex profile has no publicModels', { skip: !POSIX && 'posix' }, async (t) => {
|
|
161
|
-
const base = { mode: 'convert',
|
|
162
|
-
const ws = workspace(t, await freePort(), { bare: { name: 'Bare', ...base }, named: { name: 'Named', ...base, publicModels: ['gpt-5.6-sol'] } });
|
|
167
|
+
const base = { mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } };
|
|
168
|
+
const ws = await workspace(t, await freePort(), { bare: { name: 'Bare', ...base }, named: { name: 'Named', ...base, publicModels: ['gpt-5.6-sol'] } });
|
|
163
169
|
const on = await run(ws, ['codex', 'bare']);
|
|
164
170
|
assert.equal(on.status, 0, on.stderr);
|
|
165
171
|
assert.match(on.stdout + on.stderr, /\[WARN\].*"bare".*publicModels/);
|
|
@@ -189,7 +195,7 @@ const failCASInject = {
|
|
|
189
195
|
|
|
190
196
|
test('with migrationError set, running "switch <p>" asserts exit 1, stderr containing "Migration error", and no gateway answering on the port afterwards', { skip: !POSIX && 'posix' }, async (t) => {
|
|
191
197
|
const port = await freePort();
|
|
192
|
-
const ws = workspace(t, port, { plain: { inFormat: 'anthropic' } });
|
|
198
|
+
const ws = await workspace(t, port, { plain: { inFormat: 'anthropic' } });
|
|
193
199
|
const r = await run(ws, ['plain'], { env: failCASInject });
|
|
194
200
|
assert.equal(r.status, 1, r.stdout + r.stderr);
|
|
195
201
|
assert.match(r.stderr, /Migration error/);
|
|
@@ -218,7 +224,7 @@ test('A5: save-profile refuses inFormat and legacy blindfold fields with 400', a
|
|
|
218
224
|
|
|
219
225
|
test('R7: gateway process startup and reconcile refuse while getMigrationCollision is non-null', { skip: !POSIX && 'posix' }, async (t) => {
|
|
220
226
|
const port = await freePort();
|
|
221
|
-
const ws = workspace(t, port);
|
|
227
|
+
const ws = await workspace(t, port);
|
|
222
228
|
// Write a colliding config into workspace
|
|
223
229
|
const collidingCfg = {
|
|
224
230
|
port,
|
|
@@ -314,7 +320,7 @@ test('calling /api/switch, /api/toggle, /api/save-profile, /api/delete-profile w
|
|
|
314
320
|
// probe from this workspace can never reach the interceptor of another install on the machine.
|
|
315
321
|
async function collisionWs(t, extra = {}) {
|
|
316
322
|
const port = await freePort();
|
|
317
|
-
const ws = workspace(t, port);
|
|
323
|
+
const ws = await workspace(t, port);
|
|
318
324
|
const bfPort = await freePort();
|
|
319
325
|
fs.writeFileSync(ws.cfgPath, JSON.stringify({
|
|
320
326
|
port,
|
|
@@ -451,7 +457,7 @@ test('R7: during collision, switch doctor exits 0', async (t) => {
|
|
|
451
457
|
// accepts the request and then has nowhere to send it.
|
|
452
458
|
test('A6: switch codex <claude-profile> exits non-zero and changes nothing', async (t) => {
|
|
453
459
|
const port = await freePort();
|
|
454
|
-
const ws = workspace(t, port, {
|
|
460
|
+
const ws = await workspace(t, port, {
|
|
455
461
|
claudeOnly: { name: 'C', mode: 'convert', tool: 'claude', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'x' } },
|
|
456
462
|
codexOnly: { name: 'K', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'y' } }
|
|
457
463
|
});
|
|
@@ -492,7 +498,7 @@ const failCASThenClean = {
|
|
|
492
498
|
|
|
493
499
|
test('with migrationError set, running "switch off <tool>" uses CAS setting activeProfiles.<tool>=null, empties env-<tool>.sh, syncs interceptor, and exits 0', async (t) => {
|
|
494
500
|
const port = await freePort();
|
|
495
|
-
const ws = workspace(t, port);
|
|
501
|
+
const ws = await workspace(t, port);
|
|
496
502
|
fs.writeFileSync(ws.cfgPath, JSON.stringify({
|
|
497
503
|
port,
|
|
498
504
|
activeProfile: 'plain',
|
|
@@ -515,7 +521,7 @@ test('with migrationError set, running "switch off <tool>" uses CAS setting acti
|
|
|
515
521
|
|
|
516
522
|
test('R1: bare switch off under migrationError writes both pointers null through the CAS writer; after 3 forced CAS failures it exits 1 and the gateway still answers', async (t) => {
|
|
517
523
|
const port = await freePort();
|
|
518
|
-
const ws = workspace(t, port);
|
|
524
|
+
const ws = await workspace(t, port);
|
|
519
525
|
fs.writeFileSync(ws.cfgPath, JSON.stringify({
|
|
520
526
|
port,
|
|
521
527
|
activeProfile: 'plain',
|
|
@@ -540,7 +546,7 @@ test('R1: bare switch off under migrationError writes both pointers null through
|
|
|
540
546
|
|
|
541
547
|
test('R7e: CLI mutating command exits non-zero with error on stderr when saveConfig refuses under getMigrationError', async (t) => {
|
|
542
548
|
const port = await freePort();
|
|
543
|
-
const ws = workspace(t, port, { plain: { inFormat: 'anthropic' } });
|
|
549
|
+
const ws = await workspace(t, port, { plain: { inFormat: 'anthropic' } });
|
|
544
550
|
const r = await run(ws, ['on', 'plain'], { env: failCASInject });
|
|
545
551
|
assert.equal(r.status, 1, r.stdout + r.stderr);
|
|
546
552
|
assert.match(r.stderr, /Migration error/);
|
|
@@ -554,7 +560,7 @@ test('R7e: CLI mutating command exits non-zero with error on stderr when saveCon
|
|
|
554
560
|
test('R1: port-change path exits 1 before stopProxy is called under migrationError', async (t) => {
|
|
555
561
|
const port = await freePort();
|
|
556
562
|
const other = await freePort();
|
|
557
|
-
const ws = workspace(t, port, { plain: { inFormat: 'anthropic' } });
|
|
563
|
+
const ws = await workspace(t, port, { plain: { inFormat: 'anthropic' } });
|
|
558
564
|
await startGateway(t, ws.dir, ws.cfgPath, port, failCASInject);
|
|
559
565
|
|
|
560
566
|
const r = await run(ws, ['port', String(other)], { env: failCASInject });
|
|
@@ -571,7 +577,7 @@ test('R1: port-change path exits 1 before stopProxy is called under migrationErr
|
|
|
571
577
|
// parsing while it waited for the gateway.
|
|
572
578
|
test('R7e: parse guard blocks writes and exits non-zero when lastLoadError is set', async (t) => {
|
|
573
579
|
const port = await freePort();
|
|
574
|
-
const ws = workspace(t, port);
|
|
580
|
+
const ws = await workspace(t, port);
|
|
575
581
|
fs.writeFileSync(ws.cfgPath, JSON.stringify({
|
|
576
582
|
port,
|
|
577
583
|
blindfold: { port: await freePort() },
|
|
@@ -599,7 +605,7 @@ test('R7e: parse guard blocks writes and exits non-zero when lastLoadError is se
|
|
|
599
605
|
|
|
600
606
|
test('R7e: saves work again after the file is fixed', async (t) => {
|
|
601
607
|
const port = await freePort();
|
|
602
|
-
const ws = workspace(t, port, { plain: { inFormat: 'anthropic' } });
|
|
608
|
+
const ws = await workspace(t, port, { plain: { inFormat: 'anthropic' } });
|
|
603
609
|
fs.writeFileSync(ws.cfgPath, '{"port": ');
|
|
604
610
|
const broken = await run(ws, ['plain']);
|
|
605
611
|
assert.notEqual(broken.status, 0, broken.stdout);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const html = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui.html'), 'utf8');
|
|
8
|
+
|
|
9
|
+
function functionBody(name) {
|
|
10
|
+
const start = html.indexOf(`function ${name}(`);
|
|
11
|
+
assert.ok(start >= 0, `${name} not found in ui.html`);
|
|
12
|
+
return html.slice(start, html.indexOf('\n }\n', start));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The UI holds a redacted config, so a saved key reaches the server as MASKED_KEY.
|
|
16
|
+
// The server resolves it only from the profile named in `key`.
|
|
17
|
+
test('Browse Models sends the profile key, so a masked API key resolves to the stored one', () => {
|
|
18
|
+
assert.match(functionBody('fetchModelList'), /key:\s*formLoadedKey/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Every admin write endpoint answers { success: true } (proxy.mjs). A check on `ok` reads undefined
|
|
22
|
+
// and reports a failure after the write already reached disk.
|
|
23
|
+
test('the UI reads `success` from every admin write endpoint', () => {
|
|
24
|
+
const writes = ['/api/switch', '/api/toggle', '/api/save-profile', '/api/delete-profile', '/api/catalog/refresh', '/api/logs/clear'];
|
|
25
|
+
for (const endpoint of writes) {
|
|
26
|
+
let at = html.indexOf(`api('${endpoint}'`);
|
|
27
|
+
assert.ok(at >= 0, `${endpoint} is not called`);
|
|
28
|
+
for (; at >= 0; at = html.indexOf(`api('${endpoint}'`, at + 1)) {
|
|
29
|
+
const check = html.slice(at, html.indexOf('\n', html.indexOf('if (data.', at)));
|
|
30
|
+
assert.match(check, /if \(data\.success\)/, `${endpoint} is checked with the wrong field`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('Enter in a profile field does not submit the form', () => {
|
|
36
|
+
assert.match(html, /id="profile-form"[^>]*onkeydown="blockImplicitSubmit\(event\)"/);
|
|
37
|
+
const guard = functionBody('blockImplicitSubmit');
|
|
38
|
+
assert.match(guard, /e\.key === 'Enter'/);
|
|
39
|
+
assert.match(guard, /preventDefault\(\)/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the overlay closes the dialog only for a press that started on the overlay', () => {
|
|
43
|
+
assert.match(html, /id="profile-modal-overlay"[^>]*onmousedown="overlayPressed = event\.target === this"/);
|
|
44
|
+
assert.match(html, /id="profile-modal-overlay"[^>]*onclick="if \(overlayPressed && event\.target === this\) closeProfileModal\(\)"/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('Escape inside a dialog field is left to the field', () => {
|
|
48
|
+
const start = html.indexOf("window.addEventListener('keydown'");
|
|
49
|
+
const handler = html.slice(start, html.indexOf('});', start));
|
|
50
|
+
assert.match(handler, /closest(\?\.)?\('input, select, textarea'\)/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// X1: one searchable combobox per slot replaces the shared datalist and the chip box.
|
|
54
|
+
test('each model slot is its own combobox with its own listbox', () => {
|
|
55
|
+
const slotInput = html.match(/<input type="text" id="p-\$\{s\.id\}"[^>]*>/)?.[0] || '';
|
|
56
|
+
assert.match(slotInput, /role="combobox"/);
|
|
57
|
+
assert.match(slotInput, /aria-controls="p-\$\{s\.id\}-list"/);
|
|
58
|
+
assert.match(slotInput, /aria-autocomplete="list"/);
|
|
59
|
+
assert.doesNotMatch(slotInput, /list="model-options"/, 'no shared datalist');
|
|
60
|
+
assert.match(html, /<ul class="combo-list" id="p-\$\{s\.id\}-list" role="listbox"/);
|
|
61
|
+
for (const gone of ['id="model-browser"', '<datalist id="model-options"', 'function selectModel(', 'function renderModelBrowser(', 'id="btn-fetch-models"']) {
|
|
62
|
+
assert.ok(!html.includes(gone), `${gone} is still in ui.html`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('opening the Model Slots tab loads the provider models once per source', () => {
|
|
67
|
+
assert.match(functionBody('switchTab'), /if \(tabId === 'tab-models'\) ensureModelsLoaded\(\)/);
|
|
68
|
+
const ensure = functionBody('ensureModelsLoaded');
|
|
69
|
+
assert.match(ensure, /modelsSourceKey\(\)/);
|
|
70
|
+
assert.match(ensure, /fetchModelList\(\)/);
|
|
71
|
+
assert.match(html, /id="models-status"[^>]*aria-live="polite"/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the combobox keeps the keyboard inside the field', () => {
|
|
75
|
+
const keys = functionBody('onComboKeydown');
|
|
76
|
+
for (const k of ['ArrowDown', 'ArrowUp', 'Enter', 'Escape']) assert.match(keys, new RegExp(`'${k}'`));
|
|
77
|
+
assert.match(keys, /stopPropagation\(\)/, 'Escape and Enter do not reach the dialog');
|
|
78
|
+
// An option is chosen on mousedown, before the field loses the focus.
|
|
79
|
+
assert.match(functionBody('renderComboOptions'), /onmousedown/);
|
|
80
|
+
// scrollIntoView also scrolls the dialog body and pushes the other slots out of view.
|
|
81
|
+
assert.doesNotMatch(functionBody('setComboActive'), /scrollIntoView/);
|
|
82
|
+
assert.match(functionBody('placeCombo'), /maxHeight/, 'the list fits the room it opens into');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// A profile without outFormat routes by the model. Loading it as 'openai-chat' made every save write
|
|
86
|
+
// that value, which changed the routing of a profile the user did not touch.
|
|
87
|
+
test('a profile without outFormat keeps auto routing through a save', () => {
|
|
88
|
+
assert.match(html, /<select id="p-outformat">\s*<option value="">/);
|
|
89
|
+
assert.match(html, /getElementById\('p-outformat'\)\.value = p\.outFormat \|\| '';/);
|
|
90
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The running version and the update notice. The registry is a local server, never npm.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import { CURRENT_VERSION, isNewer, checkForUpdate } from '../version.mjs';
|
|
9
|
+
|
|
10
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
11
|
+
|
|
12
|
+
async function registry(handler) {
|
|
13
|
+
let hits = 0;
|
|
14
|
+
const server = http.createServer((req, res) => { hits++; handler(req, res); });
|
|
15
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
16
|
+
return { url: `http://127.0.0.1:${server.address().port}/llm-switcher/latest`, hits: () => hits, close: () => server.close() };
|
|
17
|
+
}
|
|
18
|
+
const tmpState = () => fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-version-'));
|
|
19
|
+
|
|
20
|
+
test('the current version is the one in package.json', () => {
|
|
21
|
+
assert.equal(CURRENT_VERSION, pkg.version);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('isNewer compares numerically, and a prerelease is not an update', () => {
|
|
25
|
+
assert.equal(isNewer('1.2.10', '1.2.9'), true);
|
|
26
|
+
assert.equal(isNewer('2.0.0', '1.9.9'), true);
|
|
27
|
+
assert.equal(isNewer('1.2.2', '1.2.2'), false);
|
|
28
|
+
assert.equal(isNewer('1.2.1', '1.2.2'), false);
|
|
29
|
+
assert.equal(isNewer('1.3.0-beta.1', '1.2.2'), false);
|
|
30
|
+
assert.equal(isNewer('garbage', '1.2.2'), false);
|
|
31
|
+
assert.equal(isNewer(null, '1.2.2'), false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a newer registry version is reported with the update command, and cached', async () => {
|
|
35
|
+
const reg = await registry((req, res) => res.end(JSON.stringify({ version: '99.0.0' })));
|
|
36
|
+
const stateDir = tmpState();
|
|
37
|
+
try {
|
|
38
|
+
const r = await checkForUpdate({ stateDir, url: reg.url });
|
|
39
|
+
assert.equal(r.current, CURRENT_VERSION);
|
|
40
|
+
assert.equal(r.latest, '99.0.0');
|
|
41
|
+
assert.equal(r.updateAvailable, true);
|
|
42
|
+
assert.match(r.updateCommand, /^(npm install -g llm-switcher@latest|git -C ".+" pull)$/);
|
|
43
|
+
await checkForUpdate({ stateDir, url: reg.url });
|
|
44
|
+
assert.equal(reg.hits(), 1, 'the second check reads the cache');
|
|
45
|
+
} finally {
|
|
46
|
+
reg.close();
|
|
47
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('the same version is no update; a stale cache asks the registry again', async () => {
|
|
52
|
+
const reg = await registry((req, res) => res.end(JSON.stringify({ version: CURRENT_VERSION })));
|
|
53
|
+
const stateDir = tmpState();
|
|
54
|
+
try {
|
|
55
|
+
fs.writeFileSync(path.join(stateDir, 'version-check.json'), JSON.stringify({ checkedAt: 0, latest: '99.0.0' }));
|
|
56
|
+
const r = await checkForUpdate({ stateDir, url: reg.url });
|
|
57
|
+
assert.equal(reg.hits(), 1);
|
|
58
|
+
assert.equal(r.updateAvailable, false);
|
|
59
|
+
assert.equal(r.latest, CURRENT_VERSION);
|
|
60
|
+
} finally {
|
|
61
|
+
reg.close();
|
|
62
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an unreachable or broken registry gives no notice and never throws', async () => {
|
|
67
|
+
const reg = await registry((req, res) => { res.statusCode = 500; res.end('down'); });
|
|
68
|
+
const stateDir = tmpState();
|
|
69
|
+
try {
|
|
70
|
+
const r = await checkForUpdate({ stateDir, url: reg.url });
|
|
71
|
+
assert.deepEqual([r.current, r.latest, r.updateAvailable], [CURRENT_VERSION, null, false]);
|
|
72
|
+
const closed = await checkForUpdate({ stateDir: tmpState(), url: 'http://127.0.0.1:9/x' });
|
|
73
|
+
assert.equal(closed.updateAvailable, false);
|
|
74
|
+
} finally {
|
|
75
|
+
reg.close();
|
|
76
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|