llm-switcher 1.2.2 → 1.2.4

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.
@@ -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
- function workspace(t, port, profiles) {
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', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } };
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,110 @@
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
+ });
91
+
92
+ // The 1M box is locked only when the model's window is known and under 1M. An unknown window
93
+ // only warns: many gateways list no limits at all.
94
+ test('contextVerdict locks only a window known to be under 1M', () => {
95
+ const verdict = new Function(functionBody('contextVerdict') + '\n }\n return contextVerdict;')();
96
+ assert.equal(verdict(undefined).kind, 'unknown');
97
+ assert.equal(verdict({ output: 32000 }).kind, 'unknown');
98
+ assert.equal(verdict({ context: 200000 }).kind, 'small');
99
+ assert.equal(verdict({ context: 200000 }).window, 200000);
100
+ assert.equal(verdict({ input: 1000000 }).kind, 'ok');
101
+ assert.equal(verdict({ context: 1048576 }).kind, 'ok');
102
+ });
103
+
104
+ test('each 1M box names its slot and points at its note', () => {
105
+ const slots = functionBody('renderModelSlots');
106
+ assert.match(slots, /aria-label="1M context for \$\{escapeHtml\(s\.name\)\}"/);
107
+ assert.match(slots, /aria-describedby="p-\$\{s\.id\}-1m-note"/);
108
+ assert.match(slots, /class="slot-1m-note" id="p-\$\{s\.id\}-1m-note" role="status"/);
109
+ assert.doesNotMatch(html, /\.slot-1m-note:empty \{ display: none/);
110
+ });
@@ -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
+ });