llm-switcher 1.2.5 → 1.2.7

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.
@@ -0,0 +1,670 @@
1
+ // The dashboard, pressed like a person presses it: a real gateway, a real browser, real clicks.
2
+ // The other UI tests read the source of ui.html or call the API with a payload the test wrote.
3
+ // Neither can see a dashboard that sends the wrong payload, or that says "done" when nothing changed.
4
+ // So each test ends with the same check: what the page shows must equal what is on disk.
5
+ import { describe, it, before, after, beforeEach } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { launchBrowser, skipReason } from './cdp.mjs';
8
+ import { startDashboardFixture } from './dashboard-fixture.mjs';
9
+
10
+ const TOOLS = ['claude', 'codex'];
11
+ const LABEL = { claude: 'Claude', codex: 'Codex' };
12
+ const MASKED = '__LLM_SWITCHER_KEEP_KEY__';
13
+ // A profile's `tool` in config.json is one tool, or nothing for a profile that serves both.
14
+ const VALID_TOOLS = [undefined, null, 'claude', 'codex'];
15
+
16
+ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
17
+ let fx, browser, page;
18
+
19
+ before(async () => {
20
+ fx = await startDashboardFixture();
21
+ browser = await launchBrowser();
22
+ page = await browser.newPage();
23
+ });
24
+
25
+ after(async () => {
26
+ await browser?.close();
27
+ await fx?.stop();
28
+ });
29
+
30
+ beforeEach(async () => {
31
+ fx.upstream.mode = 'ok';
32
+ fx.writeConfig(fx.baseline());
33
+ await open('#/routes');
34
+ });
35
+
36
+ async function open(hash = '#/routes') {
37
+ page.dialogPolicy = 'accept';
38
+ await page.goto('about:blank'); // a fresh load every time, never a same-document #hash change
39
+ await page.goto(fx.dashboardUrl + hash);
40
+ await page.waitFor(`document.querySelectorAll('#profiles-grid .pcard').length === ${Object.keys(fx.config().profiles).length}`, 'the profile cards');
41
+ await page.idle();
42
+ page.clearProblems();
43
+ }
44
+
45
+ // Do what a person does, wait for the page to finish talking to the gateway, then check.
46
+ async function act(what) {
47
+ page.clearProblems();
48
+ await page.evaluate(`document.getElementById('toast').textContent = ''`);
49
+ await what();
50
+ await page.idle();
51
+ }
52
+
53
+ const toast = () => page.text('#toast');
54
+ const switchOf = (key) => `label.switch:has(input[aria-label*="(${key})"])`;
55
+ const cardNumber = (key) => Object.keys(fx.config().profiles).indexOf(key) + 1;
56
+ const modalOpen = () => page.visible('#profile-modal-overlay .modal');
57
+ const profiles = () => fx.config().profiles;
58
+ // What the page shows for the routes, read from the screen.
59
+ const readScreen = () => page.evaluate(`(() => {
60
+ const text = id => document.getElementById(id)?.textContent.trim();
61
+ return {
62
+ badges: { claude: text('badge-slot-claude'), codex: text('badge-slot-codex') },
63
+ selects: { claude: document.getElementById('select-slot-claude').value, codex: document.getElementById('select-slot-codex').value },
64
+ aside: text('status-aside-indicator'),
65
+ cards: [...document.querySelectorAll('#profiles-grid .pcard')].map(c => ({
66
+ key: c.querySelector('.mono.faint').textContent.trim(),
67
+ status: c.querySelector('.status').textContent.trim(),
68
+ checked: c.querySelector('.switch input').checked
69
+ }))
70
+ };
71
+ })()`);
72
+
73
+ // What the page must show, worked out from the config file alone.
74
+ function expectedScreen(cfg) {
75
+ const active = cfg.activeProfiles;
76
+ const on = TOOLS.filter(t => active[t]);
77
+ return {
78
+ badges: { claude: active.claude || 'OFF', codex: active.codex || 'OFF' },
79
+ selects: { claude: active.claude || '', codex: active.codex || '' },
80
+ aside: on.length ? `Active (${on.length})` : 'Idle',
81
+ cards: Object.keys(cfg.profiles).map(key => {
82
+ const tools = TOOLS.filter(t => active[t] === key);
83
+ return { key, status: tools.length ? `Active (${tools.map(t => LABEL[t]).join(', ')})` : 'Idle', checked: tools.length > 0 };
84
+ })
85
+ };
86
+ }
87
+
88
+ // No console error, no uncaught exception, no HTTP 4xx or 5xx that the test did not expect.
89
+ function expectQuiet(step, { allowStatus = [] } = {}) {
90
+ // Chromium also logs each HTTP error as a console line; a status the test allowed is not a problem.
91
+ const problems = page.problems.filter(p => {
92
+ const status = /Failed to load resource: the server responded with a status of (\d+)/.exec(p)?.[1];
93
+ return !(status && allowStatus.includes(Number(status)));
94
+ });
95
+ assert.deepEqual(problems, [], `${step}: the page reported problems`);
96
+ assert.deepEqual(page.badResponses.filter(r => !allowStatus.includes(r.status)), [], `${step}: the gateway answered with an error status`);
97
+ }
98
+
99
+ // The page, the gateway's own answer and the file on disk must all say the same thing.
100
+ async function expectScreenMatchesDisk(step, opts) {
101
+ const cfg = fx.config();
102
+ const served = (await fx.api('/api/status')).json;
103
+ assert.deepEqual(served.activeProfiles, cfg.activeProfiles, `${step}: the gateway reports routes that are not on disk`);
104
+ assert.deepEqual(await readScreen(), expectedScreen(cfg), `${step}: the page and the file disagree`);
105
+ expectQuiet(step, opts);
106
+ }
107
+
108
+ // ---- the profile form, the way a person fills it ----
109
+
110
+ const newProfile = () => page.clickText('#ph-actions button', '+ Add Profile');
111
+ const editProfile = (key) => page.click(`#profiles-grid .pcard:nth-child(${cardNumber(key)}) button`);
112
+ const saveForm = () => page.clickText('#profile-form .mf button', 'Save Profile');
113
+ const tab = (name) => page.click(`#tab-button-${name}`);
114
+ const useTemplate = (label) => page.clickText('#tab-general button', label);
115
+
116
+ async function fillForm({ key, name, url, apiKey, tool }) {
117
+ await tab('general');
118
+ if (key !== undefined) await page.type('#p-key', key);
119
+ if (name !== undefined) await page.type('#p-name', name);
120
+ if (url !== undefined) await page.type('#p-url', url);
121
+ if (apiKey !== undefined) await page.type('#p-key-val', apiKey);
122
+ if (tool !== undefined) { await tab('routing'); await page.select('#p-tool', tool); await tab('general'); }
123
+ }
124
+
125
+ // ---- routes ----
126
+
127
+ it('shows the routes that are on disk, with no error', async () => {
128
+ await expectScreenMatchesDisk('first load');
129
+ assert.match(await page.text('#app-version'), /^v\d+\.\d+\.\d+$/);
130
+ assert.equal(await page.visible('#update-notice'), true, 'the registry stand-in offers a newer release');
131
+ });
132
+
133
+ it('gives every element an id of its own', async () => {
134
+ const twice = await page.evaluate(`(() => { const ids = [...document.querySelectorAll('[id]')].map(e => e.id); return [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))]; })()`);
135
+ assert.deepEqual(twice, [], 'an id used twice makes getElementById and the labels point at the wrong element');
136
+ });
137
+
138
+ describe('the switch on a profile card', () => {
139
+ it('turns one profile off and on again for the tool it serves, and leaves the other tool alone', async () => {
140
+ await act(() => page.click(switchOf('intact-claude')));
141
+ assert.match(await toast(), /intact-claude deactivated/);
142
+ assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: 'intact-codex' });
143
+ await expectScreenMatchesDisk('claude profile off');
144
+
145
+ await act(() => page.click(switchOf('intact-claude')));
146
+ assert.match(await toast(), /intact-claude activated/);
147
+ assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex' });
148
+ await expectScreenMatchesDisk('claude profile back on');
149
+ });
150
+
151
+ it('turns a profile that serves both tools on for both, and off again', async () => {
152
+ await act(() => page.click(switchOf('shared')));
153
+ assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'shared' });
154
+ await expectScreenMatchesDisk('shared on');
155
+
156
+ await act(() => page.click(switchOf('shared')));
157
+ assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
158
+ await expectScreenMatchesDisk('shared off');
159
+ });
160
+
161
+ it('never says "deactivated" for a change it did not make, and shows what is really on disk', async () => {
162
+ // Another tab, or `switch` in a terminal, already turned the profile off: the card is stale.
163
+ const cfg = fx.config();
164
+ cfg.activeProfiles.claude = null;
165
+ fx.writeConfig(cfg);
166
+ await act(() => page.click(switchOf('intact-claude')));
167
+ const said = await toast();
168
+ assert.doesNotMatch(said, /deactivated/, `the toast claims a change: "${said}"`);
169
+ await expectScreenMatchesDisk('stale card', { allowStatus: [404, 409] });
170
+ });
171
+ });
172
+
173
+ describe('the route list of each tool', () => {
174
+ it('sets a route for one tool and can return it to the official endpoint', async () => {
175
+ await act(() => page.select('#select-slot-claude', 'shared'));
176
+ assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'intact-codex' });
177
+ await expectScreenMatchesDisk('claude to shared');
178
+
179
+ await act(() => page.select('#select-slot-codex', ''));
180
+ assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: null });
181
+ await expectScreenMatchesDisk('codex to official');
182
+ });
183
+
184
+ it('offers each tool only the profiles that can serve it', async () => {
185
+ const options = (id) => page.evaluate(`[...document.querySelectorAll('#${id} option')].map(o => o.value)`);
186
+ assert.deepEqual(await options('select-slot-claude'), ['', 'intact-claude', 'shared']);
187
+ assert.deepEqual(await options('select-slot-codex'), ['', 'intact-codex', 'shared']);
188
+ });
189
+ });
190
+
191
+ describe('the buttons that turn every route off and on', () => {
192
+ it('"Use official endpoints" in the page header turns every route off', async () => {
193
+ await act(() => page.clickText('#ph-actions button', 'Use official endpoints'));
194
+ assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
195
+ await expectScreenMatchesDisk('all off');
196
+ const labels = await page.evaluate(`[...document.querySelectorAll('#ph-actions button')].map(b => b.textContent.trim())`);
197
+ assert.deepEqual(labels, ['+ Add Profile', 'Activate compatible routes', 'Refresh']);
198
+ });
199
+
200
+ it('turning every route off and on again gives each tool its route back', async () => {
201
+ await act(() => page.clickText('#ph-actions button', 'Use official endpoints'));
202
+ await act(() => page.clickText('#ph-actions button', 'Activate compatible routes'));
203
+ assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex' }, 'off and on lost a route');
204
+ await expectScreenMatchesDisk('off and on');
205
+ });
206
+
207
+ it('"Toggle All" in the sidebar does the same as the header button', async () => {
208
+ await act(() => page.click('.prefs button.btn-outline'));
209
+ assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
210
+ await expectScreenMatchesDisk('sidebar toggle');
211
+ });
212
+ });
213
+
214
+ // ---- creating a profile ----
215
+
216
+ describe('creating a profile', () => {
217
+ // The form starts on "All compatible tools (auto)", so a person who only fills the name and the
218
+ // address gets that choice. It has to save, and the saved profile has to be one the gateway can use.
219
+ for (const tool of ['auto', 'claude', 'codex']) {
220
+ it(`saves a new profile for tool "${tool}" and the gateway can route to it`, async () => {
221
+ await act(() => newProfile());
222
+ assert.equal(await modalOpen(), true);
223
+ assert.equal(await page.text('#form-title'), 'Add New Provider Profile');
224
+ await fillForm({ key: `new-${tool}`, name: `New ${tool}`, url: `${fx.upstreamBase}/chat/v1`, apiKey: 'sk-new-key', tool });
225
+ await act(() => saveForm());
226
+
227
+ assert.match(await toast(), /saved successfully/, 'the save was refused');
228
+ assert.equal(await modalOpen(), false);
229
+ const saved = profiles()[`new-${tool}`];
230
+ assert.ok(saved, 'the profile is not on disk');
231
+ assert.equal(saved.apiKey, 'sk-new-key');
232
+ assert.ok(VALID_TOOLS.includes(saved.tool), `the profile is saved with tool ${JSON.stringify(saved.tool)}, which the gateway cannot route`);
233
+ expectQuiet('after save');
234
+
235
+ // A profile that was saved must be usable: turning it on has to assign at least one tool.
236
+ await act(() => page.click(switchOf(`new-${tool}`)));
237
+ const active = fx.config().activeProfiles;
238
+ assert.ok(Object.values(active).includes(`new-${tool}`), `turning on the saved profile changed nothing: ${JSON.stringify(active)}`);
239
+ await expectScreenMatchesDisk('new profile on');
240
+ });
241
+ }
242
+
243
+ for (const template of [['intact', 'intact'], ['9Router', '9router'], ['OpenRouter', 'openrouter'], ['Vertex AI', 'vertex'], ['Local Server', 'local']]) {
244
+ const [label, key] = template;
245
+ it(`the "${label}" template fills the form and saves`, async () => {
246
+ await act(() => newProfile());
247
+ await useTemplate(label);
248
+ assert.equal(await page.value('#p-key'), key, 'the template sets the key of a new profile');
249
+ assert.ok(['auto', 'claude', 'codex'].includes(await page.value('#p-tool')), `the template picks tool "${await page.value('#p-tool')}", which is not in the list`);
250
+ await act(() => saveForm());
251
+ assert.match(await toast(), /saved successfully/, 'the save was refused');
252
+ const saved = profiles()[key];
253
+ assert.ok(saved, 'the profile is not on disk');
254
+ assert.ok(VALID_TOOLS.includes(saved.tool), `the profile is saved with tool ${JSON.stringify(saved.tool)}`);
255
+ expectQuiet('after template save');
256
+ });
257
+ }
258
+
259
+ it('refuses a key with a space and an empty address without sending anything', async () => {
260
+ await act(() => newProfile());
261
+ await fillForm({ key: 'bad key', name: 'Bad', url: `${fx.upstreamBase}/v1` });
262
+ const before = JSON.stringify(fx.config());
263
+ await act(() => saveForm());
264
+ assert.equal(await modalOpen(), true, 'the dialog closed on an invalid key');
265
+ assert.equal(JSON.stringify(fx.config()), before);
266
+
267
+ await fillForm({ key: 'good-key', url: '' });
268
+ await act(() => saveForm());
269
+ assert.equal(await modalOpen(), true, 'the dialog closed without an address');
270
+ assert.equal(JSON.stringify(fx.config()), before);
271
+ expectQuiet('invalid form');
272
+ });
273
+
274
+ it('does not overwrite an existing profile that has the same key', async () => {
275
+ // The control: the same form with a free key saves. Without it, a refused save would pass below.
276
+ await act(() => newProfile());
277
+ await fillForm({ key: 'free-key', name: 'Free', url: 'http://127.0.0.1:1/v1', apiKey: 'sk-free', tool: 'claude' });
278
+ await act(() => saveForm());
279
+ assert.ok(profiles()['free-key'], 'the control save was refused, so this test proves nothing');
280
+
281
+ const before = JSON.stringify(profiles().shared);
282
+ page.dialogPolicy = 'dismiss';
283
+ await act(() => newProfile());
284
+ await fillForm({ key: 'shared', name: 'Another shared', url: 'http://127.0.0.1:1/v1', apiKey: 'sk-other', tool: 'claude' });
285
+ await act(() => saveForm());
286
+ assert.equal(JSON.stringify(profiles().shared), before, 'a new profile replaced the profile "shared" and its key without asking');
287
+ });
288
+ });
289
+
290
+ // ---- editing a profile ----
291
+
292
+ describe('editing a profile', () => {
293
+ for (const key of ['intact-claude', 'intact-codex', 'shared']) {
294
+ it(`opening "${key}" and saving with no change leaves it as it was`, async () => {
295
+ const before = profiles()[key];
296
+ await act(() => editProfile(key));
297
+ assert.equal(await modalOpen(), true);
298
+ await act(() => saveForm());
299
+ assert.match(await toast(), /saved successfully/, 'the save was refused');
300
+ assert.deepEqual(profiles()[key], before, 'a save with no edit changed the profile');
301
+ await expectScreenMatchesDisk('save with no change');
302
+ });
303
+ }
304
+
305
+ it('shows the name and address, keeps the key read only, and never puts the API key on the page', async () => {
306
+ await act(() => editProfile('intact-claude'));
307
+ assert.equal(await page.value('#p-key'), 'intact-claude');
308
+ assert.equal(await page.evaluate(`document.getElementById('p-key').readOnly`), true);
309
+ assert.equal(await page.value('#p-name'), 'Intact Claude');
310
+ assert.equal(await page.value('#p-key-val'), MASKED);
311
+ assert.equal(await page.evaluate(`document.documentElement.outerHTML.includes('sk-secret-claude')`), false, 'the secret is in the page');
312
+ assert.equal(JSON.stringify((await fx.api('/api/status')).json).includes('sk-secret'), false, 'the status answer carries a secret');
313
+ });
314
+
315
+ it('renames a profile and keeps its API key and its models', async () => {
316
+ const before = profiles()['intact-claude'];
317
+ await act(() => editProfile('intact-claude'));
318
+ await fillForm({ name: 'Renamed Claude' });
319
+ await act(() => saveForm());
320
+ const after = profiles()['intact-claude'];
321
+ assert.equal(after.name, 'Renamed Claude');
322
+ assert.deepEqual({ ...after, name: before.name }, before, 'a rename changed something else');
323
+ await expectScreenMatchesDisk('rename');
324
+ assert.match(await page.text(`#profiles-grid .pcard:nth-child(${cardNumber('intact-claude')}) .nm`), /Renamed Claude/);
325
+ });
326
+
327
+ it('replaces the API key when a new one is typed', async () => {
328
+ await act(() => editProfile('shared'));
329
+ await fillForm({ apiKey: 'sk-rotated' });
330
+ await act(() => saveForm());
331
+ assert.equal(profiles().shared.apiKey, 'sk-rotated');
332
+ });
333
+
334
+ it('takes a route away when the profile can no longer serve that tool', async () => {
335
+ await act(() => editProfile('intact-claude'));
336
+ await tab('routing');
337
+ await page.select('#p-tool', 'codex');
338
+ await act(() => saveForm());
339
+ assert.equal(fx.config().activeProfiles.claude, null, 'Claude still routes to a profile that only serves Codex');
340
+ await expectScreenMatchesDisk('tool changed');
341
+ });
342
+
343
+ for (const [how, close] of [
344
+ ['the close button', () => page.click('#profile-modal-overlay .mh button.icon')],
345
+ ['Cancel', () => page.clickText('#profile-form .mf button', 'Cancel')],
346
+ ['a press on the dark area', () => page.clickAt(5, 5)],
347
+ // Inside a text field Escape belongs to the field, so the focus leaves the field first.
348
+ ['the Escape key', async () => { await page.click('#form-title'); await page.key('Escape'); }]
349
+ ]) {
350
+ it(`closes with ${how} and saves nothing`, async () => {
351
+ const before = JSON.stringify(fx.config());
352
+ await act(() => editProfile('shared'));
353
+ await fillForm({ name: 'Typed but not saved' });
354
+ await act(() => close());
355
+ assert.equal(await modalOpen(), false, 'the dialog is still open');
356
+ assert.equal(JSON.stringify(fx.config()), before);
357
+ await act(() => editProfile('shared'));
358
+ assert.equal(await page.value('#p-name'), 'Shared Router', 'the form keeps text that was never saved');
359
+ });
360
+ }
361
+
362
+ it('asks before it throws away typed text, and says nothing when nothing was typed', async () => {
363
+ await act(() => editProfile('shared'));
364
+ await act(() => page.clickText('#profile-form .mf button', 'Cancel'));
365
+ assert.deepEqual(page.dialogs, [], 'a dialog asked about changes that were never made');
366
+ assert.equal(await modalOpen(), false);
367
+
368
+ page.dialogPolicy = 'dismiss';
369
+ await act(() => editProfile('shared'));
370
+ await fillForm({ name: 'Typed but not saved' });
371
+ await act(() => page.clickText('#profile-form .mf button', 'Cancel'));
372
+ assert.match(page.dialogs.at(-1)?.message ?? '', /Discard unsaved changes/, 'no question before the typed text was lost');
373
+ assert.equal(await modalOpen(), true, 'the dialog closed although the person said no');
374
+ assert.equal(await page.value('#p-name'), 'Typed but not saved');
375
+
376
+ page.dialogPolicy = 'accept';
377
+ await act(() => page.clickText('#profile-form .mf button', 'Cancel'));
378
+ assert.equal(await modalOpen(), false);
379
+ });
380
+
381
+ it('keeps typed text when the page reads the state again', async () => {
382
+ await act(() => editProfile('shared'));
383
+ await fillForm({ name: 'Half typed' });
384
+ await act(() => page.evaluate('loadStatus()')); // what a 409 does behind the dialog
385
+ assert.equal(await page.value('#p-name'), 'Half typed', 'a refresh wiped the text the person was typing');
386
+ });
387
+
388
+ it('does not save when Enter is pressed in a field', async () => {
389
+ const before = JSON.stringify(fx.config());
390
+ await act(() => editProfile('shared'));
391
+ await fillForm({ name: 'Half typed' });
392
+ await act(() => page.key('Enter'));
393
+ assert.equal(await modalOpen(), true);
394
+ assert.equal(JSON.stringify(fx.config()), before);
395
+ });
396
+ });
397
+
398
+ // ---- model slots ----
399
+
400
+ describe('model slots', () => {
401
+ const modelsStatus = () => page.text('#models-status span');
402
+ const comboOptions = (slot) => page.evaluate(`[...document.querySelectorAll('#p-${slot}-list .combo-opt .id')].map(e => e.textContent.trim())`);
403
+
404
+ it('lists the models of the provider when the tab opens', async () => {
405
+ await act(() => editProfile('intact-claude'));
406
+ await act(() => tab('models'));
407
+ await page.waitFor(`document.querySelector('#models-status')?.dataset.state === 'ready'`, 'the model list');
408
+ assert.equal(await modelsStatus(), '3 models');
409
+ assert.equal(await page.evaluate(`document.getElementById('models-status').dataset.state`), 'ready');
410
+ assert.deepEqual(fx.upstream.received.filter(r => r.url.endsWith('/models')).at(-1)?.headers.authorization, 'Bearer sk-secret-claude', 'the stored key was not sent for the list');
411
+ });
412
+
413
+ it('picks a model with the mouse, with the keyboard, and keeps a name the provider does not list', async () => {
414
+ await act(() => editProfile('intact-claude'));
415
+ await act(() => tab('models'));
416
+ await page.waitFor(`document.querySelector('#models-status')?.dataset.state === 'ready'`, 'the model list');
417
+
418
+ await page.click('.combo:has(#p-sonnet) .combo-toggle');
419
+ assert.deepEqual(await comboOptions('sonnet'), ['up-alpha', 'up-beta', 'up-gamma']);
420
+ await page.click('#p-sonnet-list .combo-opt:nth-child(2)');
421
+ assert.equal(await page.value('#p-sonnet'), 'up-beta');
422
+
423
+ await page.type('#p-opus', 'gam');
424
+ assert.deepEqual(await comboOptions('opus'), ['up-gamma'], 'typing does not filter the list');
425
+ await page.key('ArrowDown');
426
+ await page.key('Enter');
427
+ assert.equal(await page.value('#p-opus'), 'up-gamma');
428
+ assert.equal(await modalOpen(), true, 'Enter in a slot closed the dialog');
429
+
430
+ await page.type('#p-haiku', 'my-own-model');
431
+ await page.key('Escape');
432
+ assert.equal(await modalOpen(), true, 'Escape in a slot closed the dialog');
433
+ assert.equal(await page.value('#p-haiku'), 'my-own-model');
434
+
435
+ await act(() => saveForm());
436
+ const models = profiles()['intact-claude'].defaultModels;
437
+ assert.equal(models.sonnet, 'up-beta');
438
+ assert.equal(models.opus, 'up-gamma');
439
+ assert.equal(models.haiku, 'my-own-model');
440
+ });
441
+
442
+ it('locks the 1M box for a model under 1M, and saves the box for a model that has it', async () => {
443
+ await act(() => editProfile('intact-claude'));
444
+ await act(() => tab('models'));
445
+ await page.waitFor(`document.querySelector('#models-status')?.dataset.state === 'ready'`, 'the model list');
446
+ await page.type('#p-sonnet', 'up-alpha'); // window 200000
447
+ assert.equal(await page.evaluate(`document.getElementById('p-sonnet-1m').disabled`), true, 'the box is free for a 200K model');
448
+ assert.match(await page.text('#p-sonnet-1m-note'), /200K.*under 1M/);
449
+ await page.type('#p-opus', 'up-beta'); // window 1000000
450
+ assert.equal(await page.evaluate(`document.getElementById('p-opus-1m').disabled`), false);
451
+ await page.click('label:has(#p-opus-1m)');
452
+ assert.equal(await page.evaluate(`document.getElementById('p-opus-1m').checked`), true);
453
+ await act(() => saveForm());
454
+ assert.equal(profiles()['intact-claude'].model1M?.opus, true);
455
+ assert.notEqual(profiles()['intact-claude'].model1M?.sonnet, true);
456
+ });
457
+
458
+ it('says why the list did not load and loads it on Retry', async () => {
459
+ fx.upstream.mode = 'deny';
460
+ await act(() => editProfile('intact-claude'));
461
+ await act(() => tab('models'));
462
+ await page.waitFor(`document.querySelector('#models-status')?.dataset.state === 'error'`, 'the error state');
463
+ assert.match(await modelsStatus(), /Models not loaded: bad key/);
464
+ fx.upstream.mode = 'ok';
465
+ await act(() => page.clickText('#models-status button', 'Retry'));
466
+ await page.waitFor(`document.querySelector('#models-status')?.dataset.state === 'ready'`, 'the model list after Retry');
467
+ assert.equal(await modelsStatus(), '3 models');
468
+ });
469
+
470
+ it('clears a model slot and a 1M box that the person emptied', async () => {
471
+ const cfg = fx.config();
472
+ cfg.profiles['intact-claude'].model1M = { opus: true };
473
+ fx.writeConfig(cfg);
474
+ await open('#/routes');
475
+ await act(() => editProfile('intact-claude'));
476
+ await act(() => tab('models'));
477
+ await page.waitFor(`document.querySelector('#models-status')?.dataset.state === 'ready'`, 'the model list');
478
+ await page.type('#p-sonnet', '');
479
+ await page.key('Escape'); // the list of suggestions covers the boxes below until it closes
480
+ await page.click('label:has(#p-opus-1m)');
481
+ assert.equal(await page.evaluate(`document.getElementById('p-opus-1m').checked`), false);
482
+ await act(() => saveForm());
483
+ const saved = profiles()['intact-claude'];
484
+ assert.equal(saved.defaultModels.sonnet, undefined, 'the emptied slot came back');
485
+ assert.equal(saved.defaultModels.opus, 'up-opus');
486
+ assert.ok(!saved.model1M?.opus, 'the unticked 1M box came back');
487
+ });
488
+
489
+ it('shows the slots of the tool that the profile serves', async () => {
490
+ await act(() => editProfile('intact-claude'));
491
+ await act(() => tab('models'));
492
+ const slots = () => page.evaluate(`[...document.querySelectorAll('#slot-fields input[role=combobox]')].map(i => i.id)`);
493
+ assert.deepEqual(await slots(), ['p-sonnet', 'p-opus', 'p-haiku', 'p-fable']);
494
+ await tab('routing');
495
+ await page.select('#p-tool', 'codex');
496
+ await tab('models');
497
+ assert.deepEqual(await slots(), ['p-main', 'p-review', 'p-subagent']);
498
+ });
499
+ });
500
+
501
+ // ---- testing the connection ----
502
+
503
+ describe('Test Connection', () => {
504
+ const run = async () => {
505
+ await act(() => editProfile('intact-claude'));
506
+ await act(() => page.click('#btn-test-conn'));
507
+ await page.waitFor(`!document.getElementById('btn-test-conn').disabled`, 'the test to finish');
508
+ };
509
+
510
+ it('reports a healthy provider with its latency and a sample', async () => {
511
+ await run();
512
+ assert.match(await page.text('#test-status-badge'), /HTTP 200 OK/);
513
+ assert.match(await page.text('#test-latency'), /Latency: \d+ms/);
514
+ assert.match(await page.text('#test-sample'), /Final answer/);
515
+ assert.equal(await page.text('#btn-test-conn'), 'Test Connection');
516
+ });
517
+
518
+ for (const mode of ['deny', 'down']) {
519
+ it(`reports a failure when the provider answers "${mode}"`, async () => {
520
+ fx.upstream.mode = mode;
521
+ await run();
522
+ assert.match(await page.text('#test-status-badge'), /Failed/);
523
+ assert.match(await page.text('#test-sample'), /^Error:/);
524
+ assert.equal(await page.evaluate(`document.getElementById('btn-test-conn').disabled`), false, 'the button stays disabled after a failure');
525
+ assert.match(await toast(), /connection failed/i);
526
+ });
527
+ }
528
+ });
529
+
530
+ // ---- deleting a profile ----
531
+
532
+ describe('deleting a profile', () => {
533
+ it('keeps the profile when the person says no', async () => {
534
+ page.dialogPolicy = 'dismiss';
535
+ const before = JSON.stringify(fx.config());
536
+ await act(() => editProfile('shared'));
537
+ await act(() => page.click('#btn-delete'));
538
+ assert.match(page.dialogs.at(-1)?.message ?? '', /Delete provider profile "shared"/);
539
+ assert.equal(JSON.stringify(fx.config()), before);
540
+ });
541
+
542
+ it('removes the profile and its route when the person says yes', async () => {
543
+ await act(() => editProfile('intact-claude'));
544
+ await act(() => page.click('#btn-delete'));
545
+ assert.match(await toast(), /deleted/);
546
+ assert.equal(profiles()['intact-claude'], undefined);
547
+ assert.equal(fx.config().activeProfiles.claude, null, 'Claude still routes to a profile that is gone');
548
+ await page.waitFor(`document.querySelectorAll('#profiles-grid .pcard').length === 2`, 'the card to go');
549
+ await expectScreenMatchesDisk('after delete');
550
+ });
551
+ });
552
+
553
+ // ---- moving around ----
554
+
555
+ describe('the pages and the theme', () => {
556
+ const view = () => page.evaluate(`[...document.querySelectorAll('.view-panel')].filter(p => p.style.display !== 'none').map(p => p.id)`);
557
+
558
+ it('opens each page from the sidebar and follows the back button', async () => {
559
+ for (const [nav, id, title] of [['models', 'view-models', 'Dynamic Model Catalog'], ['logs', 'view-logs', 'Request Inspector'], ['doctor', 'view-doctor', 'System Health & Doctor'], ['routes', 'view-routes', 'Routes & Profiles']]) {
560
+ await act(() => page.click(`#sideNav a[data-nav="${nav}"]`));
561
+ assert.deepEqual(await view(), [id], `${nav}: wrong page`);
562
+ assert.equal(await page.text('#page-title'), title);
563
+ assert.equal(await page.evaluate(`document.querySelector('#sideNav [aria-current="page"]')?.dataset.nav`), nav, `${nav}: the sidebar does not mark it`);
564
+ }
565
+ await page.evaluate('history.back()');
566
+ await page.waitFor(`document.getElementById('view-doctor').style.display === 'block'`, 'the back button');
567
+ expectQuiet('navigation');
568
+ });
569
+
570
+ it('shows the routes page for an address it does not know', async () => {
571
+ await open('#/nowhere');
572
+ assert.deepEqual(await view(), ['view-routes']);
573
+ });
574
+
575
+ it('keeps the page a person is on after a reload', async () => {
576
+ await open('#/logs');
577
+ assert.deepEqual(await view(), ['view-logs']);
578
+ await page.reload();
579
+ assert.deepEqual(await view(), ['view-logs']);
580
+ });
581
+
582
+ it('switches the theme and remembers it after a reload', async () => {
583
+ await act(() => page.click('#btn-theme-light'));
584
+ assert.equal(await page.evaluate('document.documentElement.dataset.theme'), 'light');
585
+ assert.equal(await page.evaluate(`document.getElementById('btn-theme-light').getAttribute('aria-pressed')`), 'true');
586
+ await page.reload();
587
+ assert.equal(await page.evaluate('document.documentElement.dataset.theme'), 'light', 'the theme is lost after a reload');
588
+ await act(() => page.click('#btn-theme-dark'));
589
+ assert.equal(await page.evaluate('document.documentElement.dataset.theme'), 'dark');
590
+ });
591
+
592
+ it('shows the gateway port and the interceptor port on the Doctor page', async () => {
593
+ await open('#/doctor');
594
+ assert.equal(await page.text('.stats .gateway-port'), String(fx.port));
595
+ assert.equal(await page.text('#blindfold-port-display'), `Port ${fx.config().blindfold.port}`);
596
+ });
597
+ });
598
+
599
+ describe('the model catalog', () => {
600
+ it('lists the models of both tools from the lists they keep on disk', async () => {
601
+ await open('#/models');
602
+ await page.waitFor(`document.querySelectorAll('#catalog-claude-items .row').length === 3`, 'the Claude models');
603
+ assert.equal(await page.count('#catalog-codex-items .row'), 2);
604
+ assert.match(await page.text('#badge-claude-count'), /^3 models/);
605
+ assert.match(await page.text('#badge-codex-count'), /^2 models/);
606
+ assert.equal(await page.text('#ct-models'), '5');
607
+ });
608
+
609
+ it('filters the list as a person types in the search box', async () => {
610
+ await open('#/models');
611
+ await page.waitFor(`document.querySelectorAll('#catalog-claude-items .row').length === 3`, 'the Claude models');
612
+ await act(() => page.type('#catalog-search', 'sol'));
613
+ const shown = await page.evaluate(`[...document.querySelectorAll('#catalog-claude-items .row, #catalog-codex-items .row')].filter(r => r.style.display !== 'none').map(r => r.querySelector('.mono').textContent.trim())`);
614
+ assert.deepEqual(shown, ['gpt-5.6-sol']);
615
+ await act(() => page.type('#catalog-search', ''));
616
+ assert.equal(await page.evaluate(`[...document.querySelectorAll('#catalog-claude-items .row, #catalog-codex-items .row')].filter(r => r.style.display !== 'none').length`), 5);
617
+ });
618
+
619
+ it('syncs and copies a model name', async () => {
620
+ await open('#/models');
621
+ await page.waitFor(`document.querySelectorAll('#catalog-claude-items .row').length === 3`, 'the Claude models');
622
+ await act(() => page.click('#btn-sync-catalog'));
623
+ await page.waitFor(`!document.getElementById('btn-sync-catalog').disabled`, 'the sync to finish');
624
+ assert.equal(await page.count('#catalog-claude-items .row'), 3);
625
+ await act(() => page.clickText('#catalog-codex-items .row:first-child button', 'Copy'));
626
+ assert.match(await toast(), /Copied gpt-5\.6-sol/);
627
+ expectQuiet('catalog');
628
+ });
629
+ });
630
+
631
+ describe('the request inspector', () => {
632
+ it('lists a request that went through the gateway, and clears the list', async () => {
633
+ const sent = await fx.messages();
634
+ assert.equal(sent.status, 200, sent.text);
635
+ await open('#/logs');
636
+ await page.waitFor(`document.querySelectorAll('#inspector-logs-container .log-item').length === 1`, 'the request row');
637
+ const row = await page.text('#inspector-logs-container .log-item');
638
+ assert.match(row, /\/v1\/messages/);
639
+ assert.match(row, /intact-claude/);
640
+ assert.equal(await page.text('#ct-logs'), '1');
641
+
642
+ await act(() => page.clickText('#view-logs button', 'Clear Logs'));
643
+ await page.waitFor(`document.querySelector('#inspector-logs-container')?.textContent.includes('No requests recorded')`, 'the empty list');
644
+ expectQuiet('inspector');
645
+ });
646
+
647
+ it('shows a new request by itself while auto-refresh is on, and stops when it is off', async () => {
648
+ await open('#/logs');
649
+ await fx.api('/api/logs/clear', {});
650
+ await fx.messages();
651
+ await page.waitFor(`document.querySelectorAll('#inspector-logs-container .log-item').length === 1`, 'the request row with auto-refresh on', 6000);
652
+ await page.click('#chk-auto-refresh-logs');
653
+ await fx.messages();
654
+ await new Promise(r => setTimeout(r, 3600));
655
+ assert.equal(await page.count('#inspector-logs-container .log-item'), 1, 'the list moved while auto-refresh was off');
656
+ });
657
+ });
658
+
659
+ describe('a change made outside the dashboard', () => {
660
+ it('keeps a profile that a terminal added, and shows it after the next action', async () => {
661
+ const cfg = fx.config();
662
+ cfg.profiles.terminal = { ...cfg.profiles.shared, name: 'Added in a terminal' };
663
+ fx.writeConfig(cfg);
664
+ await act(() => page.click(switchOf('intact-claude')));
665
+ assert.ok(fx.config().profiles.terminal, 'the dashboard wrote over the profile a terminal added');
666
+ await page.waitFor(`document.querySelectorAll('#profiles-grid .pcard').length === 4`, 'the new profile card');
667
+ await expectScreenMatchesDisk('after outside edit');
668
+ });
669
+ });
670
+ });