llm-switcher 1.3.8 → 1.4.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.
@@ -797,6 +797,7 @@ test('A5: migration splits shared auto profile into -claude and -codex halves wi
797
797
  name: 'Shared Profile',
798
798
  apiKey: 'secret-key',
799
799
  defaultModels: { opus: 'opus-model', sonnet: 'sonnet-model', subagent: 'sub-model' },
800
+ // The retired 1M flags are in an old config: the split must not carry them into a half.
800
801
  model1M: { opus: true, sonnet: false }
801
802
  }
802
803
  }
@@ -819,10 +820,8 @@ test('A5: migration splits shared auto profile into -claude and -codex halves wi
819
820
  assert.equal(codex.defaultModels.main, 'opus-model');
820
821
  assert.equal(codex.defaultModels.review, 'sonnet-model');
821
822
  assert.equal(codex.defaultModels.subagent, 'sub-model');
822
- assert.equal(claude.model1M.opus, true);
823
- assert.equal(claude.model1M.sonnet, false);
824
- assert.equal(codex.model1M.main, true);
825
- assert.equal(codex.model1M.review, false);
823
+ assert.equal(claude.model1M, undefined, 'the retired 1M flags do not survive the split');
824
+ assert.equal(codex.model1M, undefined);
826
825
  });
827
826
 
828
827
  test('A5: R7 row 4 splits profile with Claude/no-tool fields active for responses with slot fallbacks', () => {
@@ -881,7 +880,7 @@ test('A5: migration preserves legacy "default" key on both halves during split',
881
880
  assert.equal(res.config.profiles['combo-codex'].defaultModels.default, 'chat-default');
882
881
  });
883
882
 
884
- test('A5: migration never fills slot whose value is empty string or false', () => {
883
+ test('A5: migration never fills slot whose value is empty string', () => {
885
884
  const raw = {
886
885
  activeProfile: 'combo',
887
886
  profiles: {
@@ -895,7 +894,7 @@ test('A5: migration never fills slot whose value is empty string or false', () =
895
894
  const res = migrateConfigInMemory(raw);
896
895
  const codex = res.config.profiles['combo-codex'];
897
896
  assert.equal(codex.defaultModels.main, '');
898
- assert.equal(codex.model1M.main, false);
897
+ assert.equal(codex.model1M, undefined, 'and the retired flags do not come along');
899
898
  });
900
899
 
901
900
  test('A5: migration splits renamed command-word profile as <key>-profile-claude and <key>-profile-codex', () => {
@@ -1610,7 +1609,7 @@ test('each routed tool gets a route line; a tool that is off gets an empty one',
1610
1609
  assert.match(r.on.claude, /\bcl\b/, 'the line names the profile that serves claude');
1611
1610
  assert.match(r.on.claude, /intact\.example\.io/, 'and the host the traffic goes to');
1612
1611
  assert.match(r.on.claude, /gemini-3\.8-flash/, 'and the model it maps to');
1613
- assert.match(r.on.claude, /1M/, 'and the 1M window, because that changes what the session costs');
1612
+ assert.doesNotMatch(r.on.claude, /1M/, 'and no window: the tool sizes the session from the model');
1614
1613
  assert.match(r.on.codex, /\bcx\b/);
1615
1614
  assert.match(r.on.codex, /other\.example\.io/);
1616
1615
  assert.doesNotMatch(r.on.codex, /intact\.example\.io/, 'one tool never shows the other tool route');
@@ -0,0 +1,280 @@
1
+ // The tool-name layer: a model answers in the vocabulary it knows, the caller only runs the tools it
2
+ // declared. Sources for every name here are collected in docs/tool-vocabulary.md.
3
+ import test from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { createToolVocab, NULL_TOOL_VOCAB, tokenize } from '../toolvocab.mjs';
6
+ import {
7
+ anthropicToIR, responsesToIR, irToChatBody, buildAnthropicMessage, buildChatMessage, buildResponsesMessage,
8
+ createAnthropicStream, createChatStream, createResponsesStream
9
+ } from '../formats.mjs';
10
+
11
+ const claudeTools = () => anthropicToIR({
12
+ model: 'claude-opus-4-6', messages: [{ role: 'user', content: 'go' }],
13
+ tools: [
14
+ { name: 'Read', input_schema: { properties: { file_path: { type: 'string' }, offset: { type: 'number' } } } },
15
+ { name: 'Bash', input_schema: { properties: { command: { type: 'string' } } } },
16
+ { name: 'Edit', input_schema: { properties: { file_path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' } } } }
17
+ ]
18
+ });
19
+
20
+ // A model that answers `read` when the caller declared `Read` ends the turn with
21
+ // "No such tool available: read". The name comes back in the caller's words.
22
+ test('a name the model reached for comes back in the caller own words', () => {
23
+ const vocab = createToolVocab(claudeTools().tools);
24
+ assert.equal(vocab.name('read'), 'Read');
25
+ assert.equal(vocab.name('shell'), 'Bash');
26
+ assert.equal(vocab.name('str_replace_editor'), 'Edit');
27
+ assert.equal(vocab.name('read_file'), 'Read', 'the name Codex is trained on');
28
+ assert.equal(vocab.name('run_shell_command'), 'Bash', 'the name Gemini is trained on');
29
+ assert.equal(vocab.name('execute_command'), 'Bash');
30
+ });
31
+
32
+ test('a name the caller already declared is never touched', () => {
33
+ const vocab = createToolVocab(claudeTools().tools);
34
+ for (const own of ['Read', 'Bash', 'Edit']) assert.equal(vocab.name(own), own);
35
+ assert.equal(vocab.name('WebFetch'), 'WebFetch', 'a name it did not declare and that says nothing');
36
+ assert.equal(vocab.name('totally_unknown'), 'totally_unknown');
37
+ });
38
+
39
+ // Two declared tools of the same kind: which one the model meant is not knowable, and the wrong one
40
+ // runs. Claude Code on Windows declares both Bash and PowerShell.
41
+ test('two tools of the same kind are left alone rather than one being chosen', () => {
42
+ const ir = anthropicToIR({
43
+ model: 'x', messages: [],
44
+ tools: [
45
+ { name: 'Bash', input_schema: { properties: {} } },
46
+ { name: 'PowerShell', input_schema: { properties: {} } },
47
+ { name: 'Read', input_schema: { properties: {} } }
48
+ ]
49
+ });
50
+ const vocab = createToolVocab(ir.tools);
51
+ assert.equal(vocab.name('shell'), 'shell', 'no shell tool was renamed');
52
+ assert.equal(vocab.name('read'), 'Read', 'the unambiguous one still is');
53
+ });
54
+
55
+ // mcp__github__create_issue becoming a local Write produces a call the caller cannot detect as wrong:
56
+ // it runs, and it writes a file instead of opening an issue.
57
+ test('a namespaced name never leaves its namespace', () => {
58
+ const ir = anthropicToIR({
59
+ model: 'x', messages: [],
60
+ tools: [
61
+ { name: 'Write', input_schema: { properties: {} } },
62
+ { name: 'mcp__github__create_issue', input_schema: { properties: {} } }
63
+ ]
64
+ });
65
+ const vocab = createToolVocab(ir.tools);
66
+ assert.equal(vocab.name('mcp__github__create_issue'), 'mcp__github__create_issue');
67
+ assert.equal(vocab.name('mcp__github__write_file'), 'mcp__github__write_file');
68
+ });
69
+
70
+ // Who runs the tool decides whether a name may move at all. Anthropic runs a server tool and its
71
+ // result reaches the caller without the caller executing anything; handing it back as a client tool
72
+ // moves the work to the user's machine, where nothing can see that it happened.
73
+ test('a tool the provider runs is neither renamed nor offered as a client tool', () => {
74
+ const ir = anthropicToIR({
75
+ model: 'claude-opus-4-6', messages: [],
76
+ tools: [
77
+ { name: 'Read', input_schema: { properties: { file_path: { type: 'string' } } } },
78
+ { name: 'Bash', input_schema: { properties: { command: { type: 'string' } } } },
79
+ { type: 'web_search_20260209', name: 'web_search' }
80
+ ]
81
+ });
82
+ assert.deepEqual(ir.tools.map(t => [t.name, t.side]), [
83
+ ['Read', 'client'], ['Bash', 'client'], ['web_search', 'provider']
84
+ ]);
85
+ const vocab = createToolVocab(ir.tools);
86
+ assert.equal(vocab.name('web_search'), 'web_search', 'a server tool is never a rename target');
87
+ });
88
+
89
+ // The schema is the caller's to declare, so the argument keys are matched against it rather than
90
+ // against a table of what each tool is called.
91
+ test('arguments are renamed only against the caller own schema, and a falsy value survives', () => {
92
+ const vocab = createToolVocab(claudeTools().tools);
93
+ const read = vocab.args('read', JSON.stringify({ path: 'a.ts', offset: 0 }));
94
+ assert.deepEqual(JSON.parse(read), { file_path: 'a.ts', offset: 0 }, 'offset 0 is a value, not an absence');
95
+
96
+ // A tool whose schema already has the key: nothing is touched.
97
+ const bash = vocab.args('shell', JSON.stringify({ command: 'ls' }));
98
+ assert.deepEqual(JSON.parse(bash), { command: 'ls' });
99
+
100
+ // A key the schema does not name stays: the caller may still read it.
101
+ const edit = vocab.args('Edit', JSON.stringify({ file_path: 'a', note: 'keep me' }));
102
+ assert.deepEqual(JSON.parse(edit), { file_path: 'a', note: 'keep me' });
103
+
104
+ // Arguments cut off by the upstream are passed through, not rewritten.
105
+ const broken = vocab.args('read', '{"path": "a.t');
106
+ assert.equal(broken, '{"path": "a.t');
107
+ });
108
+
109
+ // The whole turn, non-streaming, through the emitter the caller reads.
110
+ test('a non-streaming answer hands the caller its own tool name and its own arguments', () => {
111
+ const ir = claudeTools();
112
+ const vocab = createToolVocab(ir.tools);
113
+ const body = buildAnthropicMessage({
114
+ model: 'claude-opus-4-6', text: [], think: [], finish: 'tool_calls',
115
+ tools: [{ id: 't1', name: 'read', args: '{"path":"a.ts","offset":0}' }], vocab
116
+ });
117
+ const use = body.content.find(c => c.type === 'tool_use');
118
+ assert.equal(use.name, 'Read');
119
+ assert.deepEqual(use.input, { file_path: 'a.ts', offset: 0 });
120
+ assert.equal(body.stop_reason, 'tool_use');
121
+
122
+ const chat = buildChatMessage({ model: 'm', text: [], tools: [{ id: 't1', name: 'shell', args: '{"command":"ls"}' }], vocab });
123
+ const call = chat.choices[0].message.tool_calls[0].function;
124
+ assert.equal(call.name, 'Bash');
125
+ assert.equal(call.arguments, '{"command":"ls"}');
126
+
127
+ const codex = buildResponsesMessage({ model: 'm', text: [], tools: [{ id: 'c1', name: 'read', args: '{"path":"a"}' }], vocab });
128
+ assert.equal(codex.output.find(i => i.type === 'function_call').name, 'Read');
129
+ });
130
+
131
+ // With no declared tools there is nothing to map, so the layer costs nothing and changes nothing.
132
+ test('a request that declared no tools is passed through untouched', () => {
133
+ const vocab = createToolVocab([]);
134
+ assert.equal(vocab.name('read'), 'read');
135
+ assert.equal(vocab.args('read', '{"path":"a"}'), '{"path":"a"}');
136
+ const body = buildAnthropicMessage({ model: 'm', text: [], tools: [{ id: 't', name: 'read', args: '{}' }], vocab });
137
+ assert.equal(body.content[0].name, 'read');
138
+ assert.equal(NULL_TOOL_VOCAB.name('x'), 'x');
139
+ });
140
+
141
+ // Streaming: the name arrives first and is fixed at once. The arguments arrive in pieces, so a call
142
+ // whose keys are renamed is held back and handed over whole, before the block closes.
143
+ test('a streamed answer renames the name at once and holds the arguments only when it must', () => {
144
+ const vocab = createToolVocab(claudeTools().tools);
145
+
146
+ const plain = [];
147
+ const a = createAnthropicStream((e, d) => plain.push([e, d]), 'm', { vocab });
148
+ a.start();
149
+ a.tool({ index: 0, id: 't1', name: 'Bash', args: '{"command":' });
150
+ a.tool({ index: 0, args: '"ls"}' });
151
+ a.finish('tool_calls', {});
152
+ assert.deepEqual(plain.find(([e]) => e === 'content_block_start')[1].content_block.name, 'Bash');
153
+ // A call the caller already names is not buffered at all: every fragment goes straight out.
154
+ assert.deepEqual(
155
+ plain.filter(([, d]) => d.delta?.type === 'input_json_delta').map(([, d]) => d.delta.partial_json),
156
+ ['{"command":', '"ls"}']
157
+ );
158
+
159
+ const fixed = [];
160
+ const b = createAnthropicStream((e, d) => fixed.push([e, d]), 'm', { vocab });
161
+ b.start();
162
+ b.tool({ index: 0, id: 't1', name: 'read', args: '{"path"' });
163
+ b.tool({ index: 0, args: ':"a.ts","offset":0}' });
164
+ b.finish('tool_calls', {});
165
+ assert.equal(fixed.find(([e]) => e === 'content_block_start')[1].content_block.name, 'Read');
166
+ const deltas = fixed.filter(([, d]) => d.delta?.type === 'input_json_delta').map(([, d]) => d.delta.partial_json);
167
+ assert.deepEqual(deltas, ['{"file_path":"a.ts","offset":0}'], 'one delta, with the caller own key');
168
+ // The block never closes with arguments still pending.
169
+ const stopAt = fixed.findIndex(([e, d]) => e === 'content_block_stop' && d.type === 'content_block_stop');
170
+ const lastArgAt = fixed.findIndex(([, d]) => d.delta?.type === 'input_json_delta');
171
+ assert.ok(lastArgAt < stopAt, 'the arguments arrive before the block stops');
172
+ });
173
+
174
+ // Codex runs a tool when the whole item arrives, so a renamed call carries its repaired arguments
175
+ // with that item and streams no argument deltas of its own.
176
+ test('a streamed Codex call is repaired in the item Codex runs', () => {
177
+ const ir = responsesToIR({
178
+ model: 'gpt-5.6-sol', input: 'go',
179
+ tools: [{ type: 'function', name: 'Read', parameters: { properties: { file_path: { type: 'string' } } } },
180
+ { type: 'function', name: 'Bash', parameters: { properties: { command: { type: 'string' } } } }]
181
+ });
182
+ const vocab = createToolVocab(ir.tools);
183
+ const events = [];
184
+ const r = createResponsesStream((e, d) => events.push([e, d]), 'm', { vocab, toolMeta: ir.toolMeta });
185
+ r.start();
186
+ r.tool({ index: 0, id: 'c1', name: 'read', args: '{"path":"a.ts"' });
187
+ r.tool({ index: 0, args: '}' });
188
+ r.finish('tool_calls', {});
189
+ const added = events.find(([e]) => e === 'response.output_item.added')[1].item;
190
+ const done = events.find(([e]) => e === 'response.output_item.done')[1].item;
191
+ assert.equal(added.name, 'Read');
192
+ assert.equal(added.arguments, '', 'nothing is streamed for a call whose keys are renamed');
193
+ assert.equal(done.arguments, '{"file_path":"a.ts"}');
194
+ assert.equal(events.some(([e]) => e === 'response.function_call_arguments.delta'), false);
195
+ });
196
+
197
+ test('tokenize reads a name the way the model that coined it saw it', () => {
198
+ assert.deepEqual(tokenize('TodoWrite'), ['Todo', 'Write']);
199
+ assert.deepEqual(tokenize('str_replace_editor'), ['str', 'replace', 'editor']);
200
+ assert.deepEqual(tokenize('mcp__fs__read'), ['mcp', 'fs', 'read']);
201
+ assert.deepEqual(tokenize('filePath'), ['file', 'Path']);
202
+ });
203
+
204
+ // ---- steering the model instead of repairing every call ----
205
+ //
206
+ // A model answers with the tool names it was trained on, whatever the caller declared. Naming the
207
+ // caller's names in the prompt is cheaper than repairing each such call, but only where the caller
208
+ // has not already named them: a second list for a prompt that already carries one is a second thing
209
+ // for the model to weigh, and this is where the collision would come from.
210
+
211
+ function noticeIn(system, toolNames, opts = {}) {
212
+ const ir = anthropicToIR({
213
+ model: 'gpt-5.6-sol', messages: [{ role: 'user', content: 'go' }],
214
+ ...(system ? { system } : {}),
215
+ tools: toolNames.map(n => ({ name: n, input_schema: { properties: {} } }))
216
+ });
217
+ const body = irToChatBody(ir, 'gpt-5.6-sol', opts);
218
+ const sys = body.messages.find(m => m.role === 'system');
219
+ return sys ? sys.content : '';
220
+ }
221
+
222
+ test('a caller that never names its tools gets the list, and nothing else changes', () => {
223
+ const sys = noticeIn('', ['Read', 'Bash', 'Edit']);
224
+ assert.match(sys, /use exactly these names/i);
225
+ for (const n of ['Read', 'Bash', 'Edit']) assert.ok(sys.includes(n), `${n} is missing from the notice`);
226
+ });
227
+
228
+ // Claude Code names its tools at length in its own prompt. A second list would compete with it, so
229
+ // nothing is added and the caller's text is passed through byte for byte.
230
+ test('a prompt that already names every tool gets nothing added', () => {
231
+ const prompt = 'You are Claude Code. Use Read to view a file, Bash to run a command, Edit to change one.';
232
+ assert.equal(noticeIn(prompt, ['Read', 'Bash', 'Edit']), prompt);
233
+ });
234
+
235
+ test('a prompt that names some of them gets only the rest', () => {
236
+ const sys = noticeIn('Use Read to view a file.', ['Read', 'Bash']);
237
+ assert.match(sys, /^Use Read to view a file\./);
238
+ assert.ok(sys.includes('Bash'), 'the name the prompt did not carry is added');
239
+ assert.ok(!/Read, Bash/.test(sys), 'and the one it already had is not repeated');
240
+ });
241
+
242
+ test('one tool cannot be confused with another, so nothing is said', () => {
243
+ assert.equal(noticeIn('', ['Read']), '');
244
+ assert.equal(noticeIn('', []), '');
245
+ });
246
+
247
+ // A tool the provider runs is not in the list: the caller never executes it, so naming it would only
248
+ // invite the model to call something the gateway dropped.
249
+ test('a tool the provider runs is never named', () => {
250
+ const ir = anthropicToIR({
251
+ model: 'x', messages: [],
252
+ tools: [
253
+ { name: 'Read', input_schema: { properties: {} } },
254
+ { name: 'Bash', input_schema: { properties: {} } },
255
+ { type: 'web_search_20260209', name: 'web_search' }
256
+ ]
257
+ });
258
+ const sys = irToChatBody(ir, 'gpt-5.6-sol').messages.find(m => m.role === 'system').content;
259
+ assert.ok(!sys.includes('web_search'), `a server tool was named: ${sys}`);
260
+ });
261
+
262
+ // native never touches the system prompt, and that is a contract with the people who chose it; off
263
+ // sends no reasoning parameters at all, and a tool name is neither.
264
+ test('native and off add nothing, whatever the caller declared', () => {
265
+ assert.equal(noticeIn('', ['Read', 'Bash'], { thinkingMode: 'native' }), '');
266
+ assert.equal(noticeIn('', ['Read', 'Bash'], { thinkingMode: 'off' }), '');
267
+ });
268
+
269
+ // The notice and the think guide are two different instructions about two different things, and a
270
+ // non-reasoning model that was asked to think gets both.
271
+ test('the notice and the think guide coexist', () => {
272
+ const ir = anthropicToIR({
273
+ model: 'gpt-4o', messages: [{ role: 'user', content: 'go' }],
274
+ thinking: { type: 'enabled', budget_tokens: 2048 },
275
+ tools: [{ name: 'Read', input_schema: { properties: {} } }, { name: 'Bash', input_schema: { properties: {} } }]
276
+ });
277
+ const sys = irToChatBody(ir, 'gpt-4o').messages.find(m => m.role === 'system').content;
278
+ assert.ok(sys.includes('<think> and </think>'), `the think guide is missing: ${sys}`);
279
+ assert.match(sys, /use exactly these names/i);
280
+ });
package/tests/ui.test.mjs CHANGED
@@ -89,22 +89,29 @@ test('a profile without outFormat keeps auto routing through a save', () => {
89
89
  assert.match(html, /getElementById\('p-outformat'\)\.value = p\.outFormat \|\| '';/);
90
90
  });
91
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');
92
+ // The window is the model's own, so the dashboard reports it and offers no switch. A model the
93
+ // gateway lists with no window says nothing, and a compact window below the whole one is the
94
+ // point the tool compresses at.
95
+ test('windowLine reports the window the gateway lists, and nothing when it lists none', () => {
96
+ // functionBody stops before the closing brace, so a helper it calls is closed here.
97
+ const source = `${functionBody('fmtTokens')}\n }\n${functionBody('windowLine')}\n }\n return windowLine;`;
98
+ const withLimits = limits => new Function('loadedLimits', source)(limits);
99
+ const line = withLimits({});
100
+ assert.equal(line(undefined), '');
101
+ assert.equal(line('bare'), '', 'a model with no limits says nothing');
102
+ const loaded = { bare: { output: 32000 }, big: { context: 1048576 }, codex: { context: 872000, compact: 272000 } };
103
+ const listed = withLimits(loaded);
104
+ assert.equal(listed('big'), '1M context');
105
+ assert.equal(listed('codex'), '872K context, compacts at 272K');
106
+ assert.equal(listed('bare'), '', 'limits without a window are not a window');
102
107
  });
103
108
 
104
- test('each 1M box names its slot and points at its note', () => {
109
+ test('the 1M switch is gone from the slots, the card and the payload', () => {
105
110
  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/);
111
+ assert.doesNotMatch(slots, /type="checkbox"/, 'a model slot offers no window switch');
112
+ assert.match(slots, /class="slot-note" id="p-\$\{s\.id\}-note" role="status"/);
113
+ assert.match(slots, /oninput="updateWindowNotes\(\)"/, 'typing a model refreshes its window');
114
+ assert.doesNotMatch(html, /id="warn-1m-callout"/);
115
+ assert.doesNotMatch(functionBody('collectCurrentSlotValues'), /model1M\s*[,}]/);
116
+ assert.doesNotMatch(html, /profilePayload\.model1M/);
110
117
  });
@@ -1,14 +1,29 @@
1
- // The update itself: a git checkout pulls fast-forward only, an npm install asks the registry.
2
- // Every remote here is a local bare repository or a local HTTP server.
1
+ // The update itself: npm is asked first and installs the version the registry names; a git
2
+ // checkout is the fallback, and pulls fast-forward only. Every remote here is a local bare
3
+ // repository or a local HTTP server.
3
4
  import { test } from 'node:test';
4
5
  import assert from 'node:assert/strict';
5
6
  import fs from 'node:fs';
6
7
  import http from 'node:http';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
9
- import { execFileSync } from 'node:child_process';
10
+ import { execFile, execFileSync } from 'node:child_process';
11
+ import { promisify } from 'node:util';
10
12
  import { applyUpdate } from '../update.mjs';
11
13
 
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ // Records every command in the order it is asked. git runs for real, because the checkout has to
17
+ // move before the fallback can be observed. npm's install is recorded and not run: the version a
18
+ // fixture registry names is a number from the test, not one the real registry serves, and a real
19
+ // install would put a package on the machine running the suite.
20
+ const recording = (calls) => async (cmd, args, opts) => {
21
+ calls.push([cmd, ...args]);
22
+ if (cmd === 'npm' || cmd === 'cmd.exe') return '';
23
+ const { stdout } = await execFileAsync(cmd, args, { encoding: 'utf8', ...opts });
24
+ return stdout;
25
+ };
26
+
12
27
  const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
13
28
  const ID = ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false'];
14
29
 
@@ -50,14 +65,14 @@ function fixture(t) {
50
65
  test('a checkout behind its upstream pulls and reports the new version', async (t) => {
51
66
  const fx = fixture(t);
52
67
  fx.release('1.0.1');
53
- const r = await applyUpdate({ root: fx.install });
68
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
54
69
  assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
55
70
  assert.equal(JSON.parse(fs.readFileSync(path.join(fx.install, 'package.json'), 'utf8')).version, '1.0.1');
56
71
  });
57
72
 
58
73
  test('a checkout level with its upstream does nothing', async (t) => {
59
74
  const fx = fixture(t);
60
- const r = await applyUpdate({ root: fx.install });
75
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
61
76
  assert.equal(r.updated, false);
62
77
  assert.match(r.reason, /latest/i);
63
78
  });
@@ -66,7 +81,7 @@ test('a gateway that runs older code than its checkout restarts into it, with no
66
81
  const fx = fixture(t);
67
82
  fx.release('1.0.1');
68
83
  git(fx.install, 'pull', '-q', '--ff-only');
69
- const r = await applyUpdate({ root: fx.install, running: '1.0.0' });
84
+ const r = await applyUpdate({ root: fx.install, running: '1.0.0', registryUrl: await registry(t, '1.0.1') });
70
85
  assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
71
86
  });
72
87
 
@@ -75,7 +90,7 @@ test('a checkout with local edits is left alone, even when upstream moved', asyn
75
90
  fx.release('1.0.1');
76
91
  const before = fx.head();
77
92
  fs.appendFileSync(path.join(fx.install, 'package.json'), ' ');
78
- const r = await applyUpdate({ root: fx.install });
93
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
79
94
  assert.equal(r.updated, false);
80
95
  assert.match(r.reason, /local changes/i);
81
96
  assert.equal(fx.head(), before);
@@ -85,7 +100,7 @@ test('a checkout that is only ahead of upstream does nothing', async (t) => {
85
100
  const fx = fixture(t);
86
101
  fs.writeFileSync(path.join(fx.install, 'local.txt'), 'x');
87
102
  commit(fx.install, 'local work');
88
- const r = await applyUpdate({ root: fx.install });
103
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
89
104
  assert.equal(r.updated, false);
90
105
  });
91
106
 
@@ -95,7 +110,7 @@ test('a diverged checkout fails and is never merged', async (t) => {
95
110
  commit(fx.install, 'local work');
96
111
  fx.release('1.0.1');
97
112
  const before = fx.head();
98
- await assert.rejects(applyUpdate({ root: fx.install }), /fast-forward|diverged/i);
113
+ await assert.rejects(applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') }), /fast-forward|diverged/i);
99
114
  assert.equal(fx.head(), before);
100
115
  });
101
116
 
@@ -159,3 +174,71 @@ test('an unreachable registry is not an update', async (t) => {
159
174
  assert.equal(r.updated, false);
160
175
  assert.match(r.reason, /registry/i);
161
176
  });
177
+
178
+ // ---- npm first, and the checkout only when npm cannot serve it ----
179
+
180
+ test('a release on npm is installed before the checkout is asked at all', async (t) => {
181
+ const fx = fixture(t);
182
+ fx.release('1.0.1');
183
+ const calls = [];
184
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run: recording(calls) });
185
+
186
+ const order = calls.map(c => c[0]);
187
+ assert.deepEqual(order.slice(0, 2), ['npm', 'git'], `npm first, then the checkout: ${order.join(' ')}`);
188
+ assert.deepEqual(calls[0].slice(-3), ['install', '-g', 'llm-switcher@1.0.1']);
189
+ // npm put the release in the global prefix, which is not this directory, so the checkout is what
190
+ // moved -- and that is the version this gateway runs, which is the only reason the pull happened.
191
+ assert.match(r.reason, /Pulled 1 commit/);
192
+ assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
193
+ });
194
+
195
+ test('a checkout level with its upstream runs no git pull when npm had nothing newer', async (t) => {
196
+ const fx = fixture(t);
197
+ const calls = [];
198
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0'), run: recording(calls) });
199
+
200
+ assert.equal(calls.some(c => c[0] === 'npm' && c.includes('install')), false, 'no install when npm is not newer');
201
+ assert.equal(calls.some(c => c.includes('pull')), false, 'nothing to pull');
202
+ assert.match(r.reason, /latest/i);
203
+ });
204
+
205
+ test('npm holding a release the checkout cannot take is said plainly, not hidden by "latest"', async (t) => {
206
+ const fx = fixture(t);
207
+ // The checkout is level, and the registry is a version ahead of it: the two answers disagree.
208
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.4.0'), run: recording([]) });
209
+
210
+ assert.equal(r.updated, false);
211
+ assert.match(r.reason, /npm installed v1\.4\.0, but not into/);
212
+ assert.match(r.reason, /latest/i, 'the checkout answer is kept as well');
213
+ });
214
+
215
+ test('a checkout is still updated by git when the registry cannot be reached', async (t) => {
216
+ const fx = fixture(t);
217
+ fx.release('1.0.1');
218
+ const r = await applyUpdate({ root: fx.install, registryUrl: 'http://127.0.0.1:9/' });
219
+
220
+ assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
221
+ });
222
+
223
+ test('an npm install that fails does not stop a checkout from updating', async (t) => {
224
+ const fx = fixture(t);
225
+ fx.release('1.0.1');
226
+ const logs = [];
227
+ // npm refuses the version a fixture registry names, the way a real registry would.
228
+ const run = async (cmd, args, opts) => {
229
+ if (cmd === 'npm') throw new Error('npm install failed: notarget No matching version found');
230
+ return recording([])(cmd, args, opts);
231
+ };
232
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run, logger: m => logs.push(m) });
233
+
234
+ assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
235
+ assert.match(r.reason, /Pulled 1 commit/);
236
+ // The npm failure is said out loud, not swallowed into a success nobody can explain.
237
+ assert.match(logs.join('\n'), /npm could not install it here/);
238
+ });
239
+
240
+ test('an npm install behind the registry still fails loudly when there is no checkout', async (t) => {
241
+ const root = npmInstall(t, '1.0.0');
242
+ const run = async () => { throw new Error('npm install failed: EACCES'); };
243
+ await assert.rejects(applyUpdate({ root, registryUrl: await registry(t, '1.0.1'), run }), /npm install failed/);
244
+ });