llm-switcher 1.2.7 → 1.2.9

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/tests/cdp.mjs CHANGED
@@ -82,6 +82,7 @@ export class Page {
82
82
  this.dialogs = []; // { type, message } for every alert, confirm and prompt
83
83
  this.dialogPolicy = 'accept';
84
84
  this.inflight = new Map(); // requestId -> url, so a stuck request can be named
85
+ this.requests = []; // { method, url } for every request, so a double submit is countable
85
86
  this.ws.addEventListener('message', (ev) => this.#onMessage(JSON.parse(ev.data)));
86
87
  }
87
88
 
@@ -93,7 +94,10 @@ export class Page {
93
94
  });
94
95
  this.on('Runtime.exceptionThrown', (e) => this.problems.push(`exception: ${e.exceptionDetails.exception?.description || e.exceptionDetails.text}`));
95
96
  this.on('Log.entryAdded', (e) => { if (e.entry.level === 'error') this.problems.push(`log: ${e.entry.text} ${e.entry.url || ''}`.trim()); });
96
- this.on('Network.requestWillBeSent', (e) => this.inflight.set(e.requestId, e.request.url));
97
+ this.on('Network.requestWillBeSent', (e) => {
98
+ this.inflight.set(e.requestId, e.request.url);
99
+ this.requests.push({ method: e.request.method, url: e.request.url });
100
+ });
97
101
  // A new document ends every request of the old one, and no event says so for some of them.
98
102
  this.on('Page.frameNavigated', (e) => { if (!e.frame.parentId) this.inflight.clear(); });
99
103
  this.on('Network.loadingFinished', (e) => this.inflight.delete(e.requestId));
@@ -132,7 +136,22 @@ export class Page {
132
136
  close() { try { this.ws.close(); } catch { /* already closed */ } }
133
137
 
134
138
  // Forget what happened so far, so a test asserts only on the action it just made.
135
- clearProblems() { this.problems.length = 0; this.badResponses.length = 0; this.dialogs.length = 0; }
139
+ clearProblems() {
140
+ this.problems.length = 0;
141
+ this.badResponses.length = 0;
142
+ this.dialogs.length = 0;
143
+ this.requests.length = 0;
144
+ }
145
+
146
+ // Every request of one method whose URL holds `part`. A double submit is two entries here.
147
+ sent(method, part) { return this.requests.filter(r => r.method === method && r.url.includes(part)); }
148
+
149
+ // A real viewport change, the way a narrow window is, so a media query takes effect.
150
+ async resize(width, height) {
151
+ await this.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
152
+ // A layout pass runs on the next frame, so a measurement taken at once reads the old box.
153
+ await this.evaluate('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))');
154
+ }
136
155
 
137
156
  // Wait until no request is in flight for `quiet` ms. A click starts a request, and the page then
138
157
  // starts a second one to read the state back, so one quiet moment is not enough.
@@ -260,6 +260,11 @@ function startUpstream() {
260
260
  let body = '';
261
261
  req.on('data', c => { body += c; });
262
262
  req.on('end', () => {
263
+ // The Bifrost lookup reads one model entry; it is not a probe request.
264
+ if (req.method === 'GET' && req.url.includes('/models/')) {
265
+ res.writeHead(404, { 'Content-Type': 'application/json' });
266
+ return res.end('{}');
267
+ }
263
268
  const json = body ? JSON.parse(body) : {};
264
269
  received.push({ url: req.url, headers: req.headers, body: json });
265
270
  if (JSON.stringify(json.messages || '').includes('ERR_400')) {
@@ -667,4 +667,120 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
667
667
  await expectScreenMatchesDisk('after outside edit');
668
668
  });
669
669
  });
670
+ // Found by a human-behaviour pass over the running dashboard on 2026-09-29: a real browser, real
671
+ // clicks and real keys, never a value set through the page's own JavaScript. Each test here is the
672
+ // reproduction of one finding from that pass.
673
+ describe('what a person notices when pressing the dashboard hard', () => {
674
+ const editButtons = () => page.evaluate(
675
+ `[...document.querySelectorAll('#profiles-grid .pcard button')].map(b => b.textContent.trim()).filter(t => /Edit/.test(t))`
676
+ );
677
+
678
+ it('gives a card back its normal Edit button after a close that saved nothing', async () => {
679
+ await editProfile('intact-claude');
680
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
681
+ await page.key('Escape');
682
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "none"', 'the dialog to close');
683
+ assert.deepEqual((await editButtons()).filter(t => /Editing/.test(t)), [],
684
+ 'a dialog closed without saving left the card in the editing state');
685
+ });
686
+
687
+ it('moves the keyboard into the dialog when it opens', async () => {
688
+ await editProfile('intact-claude');
689
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
690
+ const inside = await page.evaluate(
691
+ `document.getElementById('profile-modal-overlay').contains(document.activeElement)`
692
+ );
693
+ assert.equal(inside, true, 'the focus stayed behind the overlay, so the first Tab leaves the dialog');
694
+ });
695
+
696
+ it('never shows the connection result of the profile opened before', async () => {
697
+ await editProfile('intact-claude');
698
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
699
+ await act(() => page.clickText('#profile-modal-overlay button', 'Test Connection'));
700
+ await page.waitFor(`document.getElementById('test-result').textContent.trim().length > 0`, 'a result');
701
+ await page.key('Escape');
702
+ await act(() => page.clickText('button', '+ Add Profile'));
703
+ assert.equal(await page.text('#test-result'), '',
704
+ 'an untested new profile showed the connection result of the profile opened before it');
705
+ });
706
+
707
+ it('sends one save for one double-click on Save Profile', async () => {
708
+ await act(() => page.clickText('button', '+ Add Profile'));
709
+ await page.type('#p-key', 'dbl');
710
+ await page.type('#p-name', 'Double click');
711
+ await page.type('#p-url', 'https://dbl.example.io/v1');
712
+ await page.type('#p-key-val', 'sk-dbl');
713
+ page.clearProblems();
714
+ const save = '#profile-modal-overlay button[type="submit"]';
715
+ await page.click(save);
716
+ await page.click(save);
717
+ await page.idle();
718
+ assert.equal(page.sent('POST', '/api/save-profile').length, 1,
719
+ 'a double-click sent the save twice, because the button is not disabled while it runs');
720
+ });
721
+
722
+ it('says so when a model search matches nothing', async () => {
723
+ await open('#/models');
724
+ await page.waitFor(`document.querySelectorAll('#catalog-claude-items .row').length > 0`, 'the catalog rows');
725
+ await page.type('#catalog-search', 'zzz-nothing-matches-this-zzz');
726
+ await page.waitFor(
727
+ `[...document.querySelectorAll('#catalog-claude-items .row, #catalog-codex-items .row')].every(r => r.offsetParent === null)`,
728
+ 'every row hidden'
729
+ );
730
+ const shown = await page.evaluate(`(() => {
731
+ const visible = (e) => e.offsetParent !== null;
732
+ const message = [...document.querySelectorAll('#view-models *')]
733
+ .some(e => visible(e) && /no match|nothing matches|no model/i.test(e.textContent));
734
+ const badge = document.getElementById('badge-claude-count');
735
+ return { message, badge: badge && visible(badge) ? badge.textContent.trim() : '' };
736
+ })()`);
737
+ assert.equal(shown.message, true, 'an empty result looked like a broken page: no message at all');
738
+ assert.doesNotMatch(shown.badge, /^[1-9]/, `the header kept the unfiltered count: "${shown.badge}"`);
739
+ });
740
+
741
+ it('keeps the navigation reachable on a narrow window', async () => {
742
+ await page.resize(400, 900);
743
+ try {
744
+ const box = await page.evaluate(`(() => {
745
+ const aside = document.querySelector('aside'), nav = document.querySelector('aside nav');
746
+ const a = aside.getBoundingClientRect(), n = nav.getBoundingClientRect();
747
+ return { navHeight: n.height, navBottom: n.bottom, asideBottom: a.bottom, asideHeight: a.height };
748
+ })()`);
749
+ assert.ok(box.navHeight > 0, 'the navigation has no height at 400 px');
750
+ assert.ok(box.navBottom <= box.asideBottom + 1,
751
+ `the navigation is drawn outside the sidebar (nav bottom ${box.navBottom}, sidebar bottom ${box.asideBottom}, sidebar height ${box.asideHeight})`);
752
+ assert.equal(await page.visible('aside nav a'), true, 'no navigation link is visible at 400 px');
753
+ } finally {
754
+ await page.resize(1280, 900);
755
+ }
756
+ });
757
+
758
+ it('does not let a long profile name break the dialog header', async () => {
759
+ const cfg = fx.config();
760
+ cfg.profiles['intact-claude'].name = 'N'.repeat(300);
761
+ fx.writeConfig(cfg);
762
+ await open('#/routes');
763
+ await editProfile('intact-claude');
764
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
765
+ const t = await page.evaluate(`(() => {
766
+ const e = document.getElementById('form-title');
767
+ return { scroll: e.scrollWidth, client: e.clientWidth };
768
+ })()`);
769
+ assert.ok(t.scroll <= t.client + 1,
770
+ `the header text is wider than its box (${t.scroll} > ${t.client}), so it clips mid-character`);
771
+ });
772
+
773
+ it('opens the payload preview while auto-refresh is on', async () => {
774
+ const sent = await fx.messages();
775
+ assert.equal(sent.status, 200, sent.text);
776
+ await open('#/logs');
777
+ await page.waitFor(`document.querySelectorAll('#inspector-logs-container details').length >= 1`, 'at least one log row with a preview');
778
+ assert.equal(await page.evaluate(`document.getElementById('chk-auto-refresh-logs').checked`), true,
779
+ 'this test needs the default, which is auto-refresh on');
780
+ // One click, the way a person clicks. A control that the poll detaches fails right here.
781
+ await page.click('#inspector-logs-container details summary');
782
+ const opened = await page.evaluate(`Boolean(document.querySelector('#inspector-logs-container details[open]'))`);
783
+ assert.equal(opened, true, 'the poll replaces the row, so the preview cannot be opened while it runs');
784
+ });
785
+ });
670
786
  });
@@ -68,9 +68,23 @@ function startUpstream() {
68
68
  req.on('data', c => { body += c; });
69
69
  req.on('end', () => {
70
70
  const json = body ? JSON.parse(body) : {};
71
- received.push({ url: req.url, headers: req.headers, body: json });
71
+ received.push({ url: req.url, headers: req.headers, body: json, raw: body });
72
72
  const lastUser = JSON.stringify(json.messages?.at(-1) ?? json.contents?.at(-1) ?? '');
73
73
 
74
+ // A stand-in intact: claude/* is a Claude Code account, the rest is not.
75
+ if (req.method === 'GET' && req.url.startsWith('/intact/v1/models/')) {
76
+ const id = decodeURIComponent(req.url.slice('/intact/v1/models/'.length));
77
+ res.writeHead(200, { 'Content-Type': 'application/json' });
78
+ return res.end(JSON.stringify({ id, owned_by: id.split('/')[0], ...(id.startsWith('claude/') ? { bifrost_ua: 'claude-cli/' } : {}) }));
79
+ }
80
+ if (req.url.startsWith('/intact/v1/messages')) {
81
+ res.writeHead(200, { 'Content-Type': 'application/json' });
82
+ return res.end(JSON.stringify({ id: 'msg_n', type: 'message', role: 'assistant', model: json.model, content: [{ type: 'text', text: 'bifrost ok' }], stop_reason: 'end_turn', usage: { input_tokens: 3, output_tokens: 2 } }));
83
+ }
84
+ if (req.url.startsWith('/intact/v1/chat/completions')) {
85
+ res.writeHead(200, { 'Content-Type': 'application/json' });
86
+ return res.end(JSON.stringify({ id: 'c1', object: 'chat.completion', model: json.model, choices: [{ index: 0, message: { role: 'assistant', content: 'converted ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }));
87
+ }
74
88
  if (req.url.startsWith('/chat/v1/chat/completions')) {
75
89
  if (lastUser.includes('RATE_LIMIT')) {
76
90
  res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
@@ -202,6 +216,7 @@ before(async () => {
202
216
  pub: { name: 'Mock Public', mode: 'convert', inFormat: 'responses', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-pub', publicModels: ['gpt-5.6-sol', 'gpt-5.2'], defaultModels: { main: 'ag/mock-flash' }, model1M: { main: true } },
203
217
  roles: { name: 'Mock Public Roles', mode: 'convert', inFormat: 'responses', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-roles', publicModels: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'], defaultModels: { main: 'ag/mock-flash', review: 'ag/mock-review', subagent: 'ag/mock-low' } },
204
218
  native: { name: 'Mock Strict OpenAI', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', thinkingMode: 'native', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-native', defaultModels: models },
219
+ intactcc: { name: 'Mock intact', mode: 'convert', tool: 'claude', outFormat: 'openai-chat', thinkingMode: 'off', baseURL: `${base}/intact/v1`, apiKey: 'sk-intact-user', defaultModels: { opus: 'claude/claude-opus-5', sonnet: 'gem/flash', haiku: 'claude/claude-haiku-4-5', fable: 'gem/flash' } },
205
220
  ant: { name: 'Mock Anthropic', mode: 'direct', inFormat: 'auto', outFormat: 'anthropic', baseURL: `${base}/ant`, apiKey: 'sk-secret-ant', defaultModels: models }
206
221
  }
207
222
  };
@@ -470,7 +485,7 @@ test('save-profile rejects a model name that could act as a command', async () =
470
485
  assert.equal(ok.status, 200);
471
486
  });
472
487
 
473
- test('Codex WS transport: upstream 429 becomes response.failed with rate_limit_exceeded', async () => { const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/v1/responses`);
488
+ test('Codex WS transport: upstream 429 becomes response.failed with rate_limit_exceeded', { skip: typeof WebSocket === 'undefined' && 'global WebSocket needs Node 22+' }, async () => { const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/v1/responses`);
474
489
  await new Promise((resolve, reject) => {
475
490
  const t = setTimeout(() => reject(new Error('ws open timeout')), 5000);
476
491
  ws.addEventListener('open', () => { clearTimeout(t); resolve(); }, { once: true });
@@ -577,6 +592,40 @@ test('Direct Anthropic passthrough strips hop-by-hop headers and blocks client c
577
592
  assert.equal(up.body.model, 'up-opus');
578
593
  });
579
594
 
595
+ test('Bifrost: Claude Code to a Claude Code account on intact changes only the key', async () => {
596
+ // Orphaned tool_result and a thinking block: the healer and thinkingMode=off would both change these.
597
+ const body = { model: 'claude-opus-4-6', max_tokens: 10, thinking: { type: 'adaptive' }, metadata: { user_id: 'u1' },
598
+ messages: [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'gone', content: 'x' }] }] };
599
+ const res = await post('/v1/messages?beta=true', body, {
600
+ 'x-llm-profile': 'intactcc', 'user-agent': 'claude-cli/2.1.300 (external, cli)', 'x-api-key': 'client-own-key',
601
+ authorization: 'Bearer client-oauth', 'anthropic-beta': 'oauth-2025-04-20,new-beta', 'anthropic-dangerous-direct-browser-access': 'true', 'x-app': 'cli'
602
+ });
603
+ assert.equal(res.status, 200);
604
+ assert.equal((await res.json()).content[0].text, 'bifrost ok');
605
+ const up = received.at(-1);
606
+ assert.equal(up.url, '/intact/v1/messages?beta=true');
607
+ assert.equal(up.headers['x-api-key'], 'sk-intact-user');
608
+ assert.equal(up.headers.authorization, undefined);
609
+ assert.equal(up.headers['user-agent'], 'claude-cli/2.1.300 (external, cli)');
610
+ assert.equal(up.headers['anthropic-beta'], 'oauth-2025-04-20,new-beta');
611
+ assert.equal(up.headers['anthropic-dangerous-direct-browser-access'], 'true');
612
+ assert.equal(up.headers['x-app'], 'cli');
613
+ assert.equal(up.raw, JSON.stringify({ ...body, model: 'claude/claude-opus-5' }));
614
+ });
615
+
616
+ test('Bifrost stays off for a model that is not a Claude Code account', async () => {
617
+ const res = await post('/v1/messages', { model: 'claude-sonnet-5', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
618
+ { 'x-llm-profile': 'intactcc', 'user-agent': 'claude-cli/2.1.300 (external, cli)' });
619
+ assert.equal(res.status, 200);
620
+ assert.equal(received.at(-1).url, '/intact/v1/chat/completions');
621
+ });
622
+
623
+ test('Bifrost stays off for a client that is not Claude Code', async () => {
624
+ await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
625
+ { 'x-llm-profile': 'intactcc', 'user-agent': 'some-sdk/1.0' });
626
+ assert.equal(received.at(-1).url, '/intact/v1/chat/completions');
627
+ });
628
+
580
629
  test('Healer: orphaned tool_result and missing tool_result produce a valid chat history', async () => {
581
630
  await post('/v1/messages', {
582
631
  model: 'claude-opus-4-6', max_tokens: 50, messages: [
@@ -0,0 +1,119 @@
1
+ // The launch hook: what a coding tool shows the person about the switcher at the start of a session.
2
+ //
3
+ // The shim toast fires only when the shim runs, and it needs the shim directory first on PATH. A
4
+ // hook runs inside the tool itself, so it reports even when the launcher is the person's own script.
5
+ // Both tools read one JSON object from stdout and show `systemMessage` to the person.
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import http from 'node:http';
12
+ import { execFileSync } from 'node:child_process';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const HOOK = path.join(ROOT, 'hook-status.mjs');
17
+ const { identityProof } = await import(pathToFileURL(path.join(ROOT, 'state.mjs')).href);
18
+
19
+ function tmpState(t) {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-hook-'));
21
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
22
+ // The proof is an HMAC over the admin token, so the fake gateway needs the same file.
23
+ fs.writeFileSync(path.join(dir, 'admin.token'), 'tok-for-the-hook-test', { mode: 0o600 });
24
+ return dir;
25
+ }
26
+
27
+ function writeConfig(dir, port) {
28
+ fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({
29
+ port,
30
+ activeProfiles: { claude: 'cl', codex: null },
31
+ profiles: {
32
+ cl: {
33
+ name: 'Intact Claude', baseURL: 'https://intact.example.io/v1', apiKey: 'sk-secret',
34
+ defaultModels: { opus: 'gemini-3.8-flash' }, model1M: { opus: true }
35
+ }
36
+ }
37
+ }), { mode: 0o600 });
38
+ }
39
+
40
+ // A gateway of this build: it answers /health with a proof over the challenge.
41
+ function fakeGateway(t, port) {
42
+ const srv = http.createServer((req, res) => {
43
+ const nonce = new URL(req.url, 'http://127.0.0.1').searchParams.get('challenge') || '';
44
+ const body = { proxy: 'llm-switcher', port, pid: process.pid };
45
+ body.proof = identityProof(nonce, { role: 'gateway', port, pid: body.pid }, 'tok-for-the-hook-test');
46
+ res.writeHead(200, { 'content-type': 'application/json' });
47
+ res.end(JSON.stringify(body));
48
+ });
49
+ return new Promise((resolve) => srv.listen(port, '127.0.0.1', () => {
50
+ t.after(() => new Promise(r => srv.close(r)));
51
+ resolve(srv);
52
+ }));
53
+ }
54
+
55
+ function freePort() {
56
+ return new Promise((resolve) => {
57
+ const s = http.createServer();
58
+ s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
59
+ });
60
+ }
61
+
62
+ function runHook(dir, tool) {
63
+ const out = execFileSync(process.execPath, [HOOK, tool], {
64
+ encoding: 'utf8', timeout: 20000,
65
+ env: {
66
+ ...process.env,
67
+ LLM_SWITCHER_STATE_DIR: dir,
68
+ LLM_SWITCHER_CONFIG: path.join(dir, 'config.json'),
69
+ LLM_SWITCHER_PORT: ''
70
+ }
71
+ });
72
+ return { raw: out, json: JSON.parse(out.trim() || '{}') };
73
+ }
74
+
75
+ test('a tool that is not routed gets no message at all', async (t) => {
76
+ const dir = tmpState(t);
77
+ writeConfig(dir, await freePort());
78
+ // codex has no profile, so its route file is empty. This is the state after `switch off codex`.
79
+ fs.writeFileSync(path.join(dir, 'active.flag'), 'active');
80
+ fs.writeFileSync(path.join(dir, 'route-codex.txt'), '');
81
+ const r = runHook(dir, 'codex');
82
+ assert.equal(r.json.systemMessage, undefined, 'a tool on its official endpoint says nothing');
83
+ });
84
+
85
+ test('a routed tool with a live gateway reports where its traffic goes', async (t) => {
86
+ const dir = tmpState(t);
87
+ const port = await freePort();
88
+ writeConfig(dir, port);
89
+ await fakeGateway(t, port);
90
+ fs.writeFileSync(path.join(dir, 'active.flag'), 'active');
91
+ fs.writeFileSync(path.join(dir, 'route-claude.txt'), 'claude -> cl | intact.example.io | gemini-3.8-flash | 1M\n');
92
+ const r = runHook(dir, 'claude');
93
+ assert.match(r.json.systemMessage, /LLM Switcher/, 'the message names the switcher');
94
+ assert.match(r.json.systemMessage, /intact\.example\.io/, 'and the host the traffic goes to');
95
+ assert.match(r.json.systemMessage, /gemini-3\.8-flash/, 'and the model');
96
+ assert.doesNotMatch(r.json.systemMessage, /sk-secret/, 'and never the API key');
97
+ });
98
+
99
+ test('a routed tool with a dead gateway is warned that it cannot reach the provider', async (t) => {
100
+ const dir = tmpState(t);
101
+ const port = await freePort(); // nothing listens on it
102
+ writeConfig(dir, port);
103
+ fs.writeFileSync(path.join(dir, 'active.flag'), 'active');
104
+ fs.writeFileSync(path.join(dir, 'route-claude.txt'), 'claude -> cl | intact.example.io | gemini-3.8-flash | 1M\n');
105
+ const r = runHook(dir, 'claude');
106
+ assert.match(r.json.systemMessage, /does not answer|not running/i, 'the message says the gateway is down');
107
+ assert.match(r.json.systemMessage, new RegExp(String(port)), 'and names the port');
108
+ assert.match(r.json.systemMessage, /switch on/, 'and names the command that fixes it');
109
+ });
110
+
111
+ test('a broken state never fails the session: exit 0 and parseable output', async (t) => {
112
+ const dir = tmpState(t);
113
+ fs.writeFileSync(path.join(dir, 'config.json'), '{ this is not json', { mode: 0o600 });
114
+ fs.writeFileSync(path.join(dir, 'active.flag'), 'active');
115
+ const r = runHook(dir, 'claude');
116
+ assert.equal(typeof r.json, 'object', 'the tool still gets one JSON object');
117
+ const bad = runHook(dir, 'not-a-tool');
118
+ assert.equal(bad.json.systemMessage, undefined, 'an unknown tool name says nothing');
119
+ });
@@ -0,0 +1,132 @@
1
+ // Installing the launch hook into the two coding tools, without editing any configuration of theirs.
2
+ //
3
+ // Claude Code loads any folder under a skills directory that holds `.claude-plugin/plugin.json` as
4
+ // a plugin, on the next session, with no marketplace and no install step. Codex loads
5
+ // `$CODEX_HOME/hooks.json` by itself. So both tools get the hook from a file of their own, and
6
+ // `settings.json` and `config.toml` are never touched.
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ const { renderClaudePlugin, renderClaudeHooks, renderCodexHooks, installPlugin, uninstallPlugin, pluginStatus } =
16
+ await import(pathToFileURL(path.join(ROOT, 'plugin.mjs')).href);
17
+
18
+ function dirs(t) {
19
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-plug-'));
20
+ t.after(() => fs.rmSync(home, { recursive: true, force: true }));
21
+ const claudeSkillsDir = path.join(home, '.claude', 'skills');
22
+ const codexHome = path.join(home, '.codex');
23
+ fs.mkdirSync(claudeSkillsDir, { recursive: true });
24
+ fs.mkdirSync(codexHome, { recursive: true });
25
+ return { home, claudeSkillsDir, codexHome, opts: { claudeSkillsDir, codexHome } };
26
+ }
27
+
28
+ const readJson = (f) => JSON.parse(fs.readFileSync(f, 'utf8'));
29
+
30
+ test('the Claude plugin manifest and hooks name the hook script and the tool claude', () => {
31
+ const manifest = JSON.parse(renderClaudePlugin());
32
+ assert.equal(typeof manifest.name, 'string');
33
+ assert.ok(manifest.name.length > 0, 'a plugin needs a name, it becomes the skills-dir id');
34
+ assert.ok(manifest.version, 'and a version');
35
+ const hooks = JSON.parse(renderClaudeHooks());
36
+ const entry = hooks.hooks.SessionStart[0].hooks[0];
37
+ assert.equal(entry.type, 'command');
38
+ assert.match(JSON.stringify(entry), /hook-status\.mjs/);
39
+ assert.match(JSON.stringify(entry), /"claude"/, 'the claude plugin asks about claude');
40
+ assert.doesNotMatch(JSON.stringify(entry), /codex/);
41
+ });
42
+
43
+ test('the Codex hooks file asks about codex and matches a fresh start or a resume', () => {
44
+ const hooks = JSON.parse(renderCodexHooks());
45
+ const group = hooks.hooks.SessionStart[0];
46
+ assert.match(group.matcher, /startup/, 'the first open of a session');
47
+ assert.match(group.matcher, /resume/, 'and a resumed session, which is the bypass this exists for');
48
+ const entry = group.hooks[0];
49
+ assert.equal(entry.type, 'command');
50
+ assert.match(entry.command, /hook-status\.mjs/);
51
+ assert.match(entry.command, /codex/);
52
+ });
53
+
54
+ test('install writes a plugin for Claude Code and a hooks file for Codex, and repeats cleanly', (t) => {
55
+ const { claudeSkillsDir, codexHome, opts } = dirs(t);
56
+ const first = installPlugin(opts);
57
+ assert.deepEqual(first.failed, [], 'nothing failed');
58
+ assert.deepEqual(first.installed.sort(), ['claude', 'codex']);
59
+
60
+ const manifest = path.join(claudeSkillsDir, 'llm-switcher-status', '.claude-plugin', 'plugin.json');
61
+ assert.ok(fs.existsSync(manifest), 'the manifest is what makes the folder a plugin');
62
+ assert.ok(fs.existsSync(path.join(claudeSkillsDir, 'llm-switcher-status', 'hooks', 'hooks.json')));
63
+ assert.ok(fs.existsSync(path.join(codexHome, 'hooks.json')));
64
+
65
+ // The whole point: neither tool's own configuration file is created or changed.
66
+ assert.equal(fs.existsSync(path.join(codexHome, 'config.toml')), false, 'config.toml is never written');
67
+
68
+ const second = installPlugin(opts);
69
+ assert.deepEqual(second.failed, [], 'a second install is a no-op that still succeeds');
70
+ assert.equal(JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')).hooks.SessionStart.length, 1,
71
+ 'and it does not add a second copy of our entry');
72
+ });
73
+
74
+ test('install keeps a Codex hook that belongs to someone else', (t) => {
75
+ const { codexHome, opts } = dirs(t);
76
+ fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
77
+ description: 'the rule gate of this machine',
78
+ hooks: {
79
+ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'node rule-gate.js' }] }],
80
+ SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: 'node someone-else.js' }] }]
81
+ }
82
+ }, null, 2));
83
+
84
+ installPlugin(opts);
85
+ const after = readJson(path.join(codexHome, 'hooks.json'));
86
+ assert.equal(after.hooks.PreToolUse.length, 1, 'another event is untouched');
87
+ assert.match(JSON.stringify(after.hooks.PreToolUse), /rule-gate/);
88
+ const commands = after.hooks.SessionStart.flatMap(g => g.hooks.map(h => h.command));
89
+ assert.ok(commands.some(c => /someone-else\.js/.test(c)), 'the other SessionStart hook survives');
90
+ assert.ok(commands.some(c => /hook-status\.mjs/.test(c)), 'and ours is added beside it');
91
+
92
+ const removed = uninstallPlugin(opts);
93
+ assert.deepEqual(removed.failed, []);
94
+ const end = readJson(path.join(codexHome, 'hooks.json'));
95
+ const endCommands = JSON.stringify(end);
96
+ assert.match(endCommands, /someone-else\.js/, 'uninstall leaves the other hook in place');
97
+ assert.match(endCommands, /rule-gate/);
98
+ assert.doesNotMatch(endCommands, /hook-status\.mjs/, 'and takes only ours away');
99
+ });
100
+
101
+ test('install refuses a Codex hooks file it cannot parse, and changes nothing', (t) => {
102
+ const { codexHome, opts } = dirs(t);
103
+ const f = path.join(codexHome, 'hooks.json');
104
+ fs.writeFileSync(f, '{ not json at all');
105
+ const r = installPlugin(opts);
106
+ assert.equal(fs.readFileSync(f, 'utf8'), '{ not json at all', 'a file that may hold real hooks is never overwritten');
107
+ assert.ok(r.failed.some(x => x.tool === 'codex'), 'and the refusal is reported');
108
+ assert.ok(r.installed.includes('claude'), 'while the other tool still gets its plugin');
109
+ });
110
+
111
+ test('install leaves a folder that is not our plugin alone', (t) => {
112
+ const { claudeSkillsDir, opts } = dirs(t);
113
+ const dir = path.join(claudeSkillsDir, 'llm-switcher-status');
114
+ fs.mkdirSync(path.join(dir, '.claude-plugin'), { recursive: true });
115
+ fs.writeFileSync(path.join(dir, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'someone-elses-work' }));
116
+ const r = installPlugin(opts);
117
+ assert.match(fs.readFileSync(path.join(dir, '.claude-plugin', 'plugin.json'), 'utf8'), /someone-elses-work/);
118
+ assert.ok(r.failed.some(x => x.tool === 'claude'));
119
+ });
120
+
121
+ test('status reports each tool, and says plainly when nothing is installed', (t) => {
122
+ const { opts } = dirs(t);
123
+ const before = pluginStatus(opts);
124
+ assert.equal(before.claude.installed, false);
125
+ assert.equal(before.codex.installed, false);
126
+ installPlugin(opts);
127
+ const after = pluginStatus(opts);
128
+ assert.equal(after.claude.installed, true);
129
+ assert.equal(after.codex.installed, true);
130
+ assert.match(after.claude.path, /llm-switcher-status/);
131
+ assert.match(after.codex.path, /hooks\.json$/);
132
+ });
@@ -324,7 +324,7 @@ test('Real User Sim 3: Gemini / Vertex healing of empty & complex tool schemas',
324
324
  await post('/api/switch', { tool: 'claude', profile: 'chat' });
325
325
  });
326
326
 
327
- test('Real User Sim 4: Codex WebSocket turns (warmup -> prompt -> apply_patch -> continue)', async () => {
327
+ test('Real User Sim 4: Codex WebSocket turns (warmup -> prompt -> apply_patch -> continue)', { skip: typeof WebSocket === 'undefined' && 'global WebSocket needs Node 22+' }, async () => {
328
328
  const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/v1/responses`);
329
329
  const messages = [];
330
330
 
@@ -387,7 +387,7 @@ test('Real User Sim 4: Codex WebSocket turns (warmup -> prompt -> apply_patch ->
387
387
  ws.close();
388
388
  });
389
389
 
390
- test('Real User Sim 5: High-concurrency dual-tool execution (Claude streaming + Codex WS simultaneously)', async () => {
390
+ test('Real User Sim 5: High-concurrency dual-tool execution (Claude streaming + Codex WS simultaneously)', { skip: typeof WebSocket === 'undefined' && 'global WebSocket needs Node 22+' }, async () => {
391
391
  const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/v1/responses`);
392
392
  await new Promise(r => { ws.onopen = r; });
393
393
  const wsMessages = [];
@@ -16,6 +16,8 @@ const HAS_XMLLINT = (() => { try { execFileSync('xmllint', ['--version'], { stdi
16
16
  test('serviceEnv passes only the variables that change where the gateway reads and writes', () => {
17
17
  const env = serviceEnv({ CLAUDE_CONFIG_DIR: '/c', LLM_SWITCHER_CONFIG: '/cfg.json', LLM_SWITCHER_BLINDFOLD_CERTS: '', HOME: '/h', PATH: '/bin' });
18
18
  assert.deepEqual(env, [['CLAUDE_CONFIG_DIR', '/c'], ['LLM_SWITCHER_CONFIG', '/cfg.json']]);
19
+ // The data folder override must reach the service too, or it reads another config.json.
20
+ assert.deepEqual(serviceEnv({ LLM_SWITCHER_HOME: '/data' }), [['LLM_SWITCHER_HOME', '/data']]);
19
21
  });
20
22
 
21
23
  test('systemd unit quotes the command and carries the environment', () => {