/**
* @fileoverview Frontend tests for the Custom Model Endpoint Profiles Run-menu
* picker (docs/custom-model-endpoints-plan.md): the generated entries in
* session-ui.js's `_refreshCustomModelRunOptions()` / `runCustomModelEntry()`.
*
* These are DOM-level facts that need no Playwright and no tmux — `runScripts:
* "dangerously"` is used deliberately (this JSDOM only ever parses markup this
* module itself generated, never live user input) so that a broken inline
* `onclick` attribute shows up as a genuinely uncallable handler, the same way
* it would in a real browser, rather than merely as a string this test parses
* by eye. `test/admin-ui.test.ts` and `test/home-sessions.test.ts` are the
* precedent for driving a real frontend module against a JSDOM window rather
* than a live server.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { describe, expect, it } from 'vitest';
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
function resp(body: unknown, ok = true) {
return { ok, json: async () => body };
}
/**
* Boots a minimal CodemanApp instance with constants.js + session-ui.js
* evaluated against a real JSDOM window, so escapeHtml and the picker's own
* innerHTML-building code run exactly as they do in the browser.
*/
function bootApp(
options: {
customModelClis?: Array<{ id: string; label: string }>;
hosts?: unknown;
cliAvailable?: (id: string) => boolean;
activeCase?: { location?: string } | null;
settingsEnabled?: boolean;
} = {}
) {
const dom = new JSDOM(
`
`,
{ url: 'http://localhost/', runScripts: 'dangerously' }
);
const win = dom.window as unknown as Window &
typeof globalThis & {
CodemanApp: new () => any;
__codemanCustomModelClis?: Array<{ id: string; label: string }>;
};
(win as unknown as { eval: (s: string) => void }).eval('window.CodemanApp = function CodemanApp() {};');
(win as unknown as { eval: (s: string) => void }).eval(CONSTANTS_JS);
(win as unknown as { eval: (s: string) => void }).eval(SESSION_UI_JS);
win.__codemanCustomModelClis = options.customModelClis ?? [{ id: 'claude', label: 'Claude Code' }];
const app = new win.CodemanApp();
app.cases = options.activeCase ? [{ name: 'testcase', ...options.activeCase }] : [{ name: 'testcase' }];
app.loadAppSettingsFromStorage = () => ({ customModelEndpointsEnabled: options.settingsEnabled ?? true });
app.isCliAvailable = options.cliAvailable ?? (() => true);
app.showToast = () => {};
// Real implementation lives in panels-ui.js, not evaluated into this harness (only
// constants.js + session-ui.js are — see below) — a no-op default handle matching its
// real shape, same reasoning as showToast above; tests of the center status itself
// override it.
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
// Default no-op so a button's onclick (selectCustomModelEntry -> possibly
// straight to runCustomModelEntry for a single-model host) never rejects
// with "this.run is not a function"; tests of the launch itself override it.
app.run = async () => {};
// _apiJson unwraps the {success,data} envelope for real against a live
// server; here it stands in for that, driven from a fixed `hosts` fixture
// so these tests exercise the picker's OWN code, not the envelope helper.
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints') return options.hosts ?? [];
return null;
};
return { dom, win, app };
}
describe('Custom Model Endpoint Profiles: Run-menu picker generation', () => {
it('generates a real, clickable button per (capable CLI, endpoint) pair', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }],
});
const menu = win.document.getElementById('runModeMenu')!;
await app._refreshCustomModelRunOptions(menu);
const container = win.document.getElementById('runModeCustomModels')!;
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBe(1);
const btn = buttons[0] as unknown as HTMLButtonElement & { onclick: unknown };
// The real bug: JSON.stringify's own double quotes terminate the
// double-quoted onclick attribute at the first one, so btn.onclick comes
// back null and the parsed attribute is garbage. With escapeHtml wrapping
// each stringified argument, jsdom (which compiles inline handlers under
// runScripts:"dangerously" exactly like a real browser) parses it as a
// real, callable function.
expect(typeof btn.onclick).toBe('function');
win.app = app;
expect(() => btn.onclick!(new (win as any).Event('click'))).not.toThrow();
});
it('escapes a model id containing HTML-significant characters instead of letting it break out of the tag', async () => {
// modelId comes from the endpoint's OWN /v1/models reply, which this box
// does not control — a live-HTML-injection vector if it ever reaches the
// markup unescaped, distinct from (and on top of) the quoting bug above.
const dangerousModel = '">';
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: [dangerousModel] }],
});
const menu = win.document.getElementById('runModeMenu')!;
await app._refreshCustomModelRunOptions(menu);
const container = win.document.getElementById('runModeCustomModels')!;
// The injected markup must never have produced a live element: if it
// did, the attacker-controlled tag closed the button early and escaped
// into sibling markup instead of staying inert string data.
expect(container.querySelector('img')).toBeNull();
expect(container.querySelectorAll('button').length).toBe(1);
});
it('is hidden when the feature setting is off, even with capable CLIs and endpoints present', async () => {
const { win, app } = bootApp({
settingsEnabled: false,
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }],
});
const menu = win.document.getElementById('runModeMenu')!;
await app._refreshCustomModelRunOptions(menu);
expect(win.document.getElementById('runModeCustomModels')!.innerHTML).toBe('');
expect((win.document.getElementById('runModeCustomModelSep') as HTMLElement).style.display).toBe('none');
});
it('is hidden for a remote or Docker active case, since the apply route refuses both', async () => {
for (const location of ['remote', 'docker']) {
const { win, app } = bootApp({
activeCase: { location },
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }],
});
const menu = win.document.getElementById('runModeMenu')!;
await app._refreshCustomModelRunOptions(menu);
expect(win.document.getElementById('runModeCustomModels')!.innerHTML, location).toBe('');
}
});
it('skips an endpoint with no discovered model and no default, rather than generating a dead entry', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'undiscovered', label: 'Not discovered yet', baseUrl: 'http://localhost:8080', models: [] }],
});
const menu = win.document.getElementById('runModeMenu')!;
await app._refreshCustomModelRunOptions(menu);
expect(win.document.getElementById('runModeCustomModels')!.innerHTML).toBe('');
});
it('omits a CLI the host does not have installed, matching the stock entries’ own gating', async () => {
const { win, app } = bootApp({
customModelClis: [
{ id: 'claude', label: 'Claude Code' },
{ id: 'codex', label: 'Codex' },
],
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }],
cliAvailable: (id: string) => id === 'claude',
});
const menu = win.document.getElementById('runModeMenu')!;
await app._refreshCustomModelRunOptions(menu);
const container = win.document.getElementById('runModeCustomModels')!;
expect(container.querySelectorAll('button').length).toBe(1);
expect(container.textContent).toContain('Claude Code');
expect(container.textContent).not.toContain('Codex');
});
});
describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
it('launches straight away for a host with exactly one discovered model, no dialog', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }],
});
let launched: unknown[] | null = null;
app.runCustomModelEntry = async (...args: unknown[]) => {
launched = args;
};
await app.selectCustomModelEntry('claude', 'llama-box');
expect(launched).toEqual(['claude', 'llama-box', 'qwen3']);
expect(win.document.getElementById('customModelPickModal')!.classList.contains('active')).toBe(false);
});
it('opens the picker for a host with more than one discovered model, rather than launching directly', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3', 'llama3'] }],
});
let launched = false;
app.runCustomModelEntry = async () => {
launched = true;
};
await app.selectCustomModelEntry('claude', 'llama-box');
expect(launched).toBe(false);
const modal = win.document.getElementById('customModelPickModal')!;
expect(modal.classList.contains('active')).toBe(true);
const list = win.document.getElementById('customModelPickList')!;
expect(list.querySelectorAll('button').length).toBe(2);
expect(list.textContent).toContain('qwen3');
expect(list.textContent).toContain('llama3');
});
it('always asks with 2+ models, even when a defaultModelId is set — the point is letting this launch differ', async () => {
const { win, app } = bootApp({
hosts: [
{
id: 'llama-box',
label: 'llama.cpp',
baseUrl: 'http://localhost:8080',
models: ['qwen3', 'llama3'],
defaultModelId: 'qwen3',
},
],
});
await app.selectCustomModelEntry('claude', 'llama-box');
const modal = win.document.getElementById('customModelPickModal')!;
expect(modal.classList.contains('active')).toBe(true);
// The default is marked, not auto-chosen.
expect(win.document.getElementById('customModelPickList')!.textContent).toContain('Default');
});
it('picking a row in the modal closes it and launches with that exact model', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3', 'llama3'] }],
});
let launched: unknown[] | null = null;
app.runCustomModelEntry = async (...args: unknown[]) => {
launched = args;
};
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const buttons = win.document.getElementById('customModelPickList')!.querySelectorAll('button');
const llama3Btn = [...buttons].find((b) => b.textContent?.includes('llama3')) as unknown as HTMLButtonElement & {
onclick: (e: unknown) => void;
};
expect(typeof llama3Btn.onclick).toBe('function');
llama3Btn.onclick(new (win as any).Event('click'));
expect(launched).toEqual(['claude', 'llama-box', 'llama3']);
expect(win.document.getElementById('customModelPickModal')!.classList.contains('active')).toBe(false);
});
it('re-fetches the endpoint at click time rather than trusting anything cached from the menu render', async () => {
// The background re-discovery sweep (server-side, every 5 minutes) or a
// settings-panel edit can change the model list between opening the
// dropdown and clicking a row — the picker must reflect what is current.
let fetchCount = 0;
const { win, app } = bootApp({});
app._apiJson = async (path: string) => {
if (path !== '/api/model-endpoints') return null;
fetchCount += 1;
return [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }];
};
await app.selectCustomModelEntry('claude', 'llama-box');
expect(fetchCount).toBe(1);
expect(win.document.getElementById('customModelPickList')!.querySelectorAll('button').length).toBe(3);
});
it('toasts and does nothing when the endpoint has vanished by click time', async () => {
const { app } = bootApp({ hosts: [] });
let toastMessage: string | null = null;
app.showToast = (msg: string) => {
toastMessage = msg;
};
await app.selectCustomModelEntry('claude', 'ghost-endpoint');
expect(toastMessage).toMatch(/no longer exists/i);
});
it('toasts and does nothing when the endpoint has zero discovered models by click time', async () => {
const { app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: [] }],
});
let toastMessage: string | null = null;
app.showToast = (msg: string) => {
toastMessage = msg;
};
await app.selectCustomModelEntry('claude', 'llama-box');
expect(toastMessage).toMatch(/no models discovered/i);
});
});
describe('Custom Model Endpoint Profiles: applying a picked entry', () => {
it('does not apply the endpoint to a session that was already open when the launch fails', async () => {
const { app } = bootApp({});
app.activeSessionId = 'already-open-session';
// Simulate every run*() function's own documented behaviour: a declined or
// failed launch handles its own error and returns normally without ever
// changing activeSessionId — it does NOT throw and does NOT leave it null.
app.run = async () => {};
app._runInFlight = false;
let applyCalled = false;
app._api = async (path: string) => {
if (path.includes('/custom-model')) applyCalled = true;
return { ok: true, json: async () => ({ success: true, data: {} }) };
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(applyCalled).toBe(false);
expect(app.activeSessionId).toBe('already-open-session');
});
it('applies the endpoint once run() actually produces a NEW active session', async () => {
const { app } = bootApp({});
app.activeSessionId = 'old-session';
app.run = async () => {
app.activeSessionId = 'new-session';
};
const calls: Array<{ path: string; body: unknown }> = [];
app._api = async (path: string, opts?: { body?: unknown }) => {
calls.push({ path, body: opts?.body });
return {
ok: true,
json: async () => ({ success: true, data: { customModel: { endpointId: 'llama-box' }, restarted: true } }),
};
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(calls).toHaveLength(1);
expect(calls[0].path).toBe('/api/sessions/new-session/custom-model');
expect(calls[0].body).toEqual({ endpointId: 'llama-box', modelId: 'qwen3' });
});
it('waits for the freshly launched session to go idle before applying, so its own boot activity is never mistaken for a busy turn', async () => {
// Measured live: a just-launched CLI reports 'busy' for its own startup
// (spinner, workspace-trust check) well before the apply call could
// otherwise reach it, and the apply route's isBusy() guard correctly
// refuses to restart a session mid-turn — which a fresh boot looks
// exactly like from the outside. This pins the fix: wait for idle FIRST.
const { app } = bootApp({});
app.activeSessionId = 'old-session';
app.run = async () => {
app.activeSessionId = 'new-session';
};
const calls: string[] = [];
app._apiJson = async (path: string) => {
calls.push(path);
if (path === '/api/model-endpoints') return [];
return null; // the wait call's return value is unused — a timeout is a normal 200
};
app._api = async (path: string) => {
calls.push(path);
return { ok: true, json: async () => ({ success: true, data: {} }) };
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
const waitIndex = calls.findIndex((p) => p.includes('/wait?'));
const applyIndex = calls.findIndex((p) => p.endsWith('/custom-model'));
expect(waitIndex).toBeGreaterThanOrEqual(0);
expect(calls[waitIndex]).toBe('/api/sessions/new-session/wait?until=idle&timeout=20000');
expect(applyIndex).toBeGreaterThan(waitIndex);
});
it('surfaces the real server error in the toast on a failed apply, rather than a generic message', async () => {
const { app } = bootApp({});
app.activeSessionId = 'old-session';
app.run = async () => {
app.activeSessionId = 'new-session';
};
app._api = async () => ({
ok: false,
status: 400,
json: async () => ({
success: false,
error: 'Custom model endpoints are not supported for remote (SSH) or Docker sessions yet',
}),
});
let toastMessage: string | null = null;
let toastType: string | null = null;
app.showToast = (msg: string, type: string) => {
toastMessage = msg;
toastType = type;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(toastMessage).toContain('Custom model endpoints are not supported for remote (SSH) or Docker sessions yet');
expect(toastType).toBe('error');
});
it('shows a status toast for the native-boot-then-restart window, so it never reads as the endpoint failing to apply', async () => {
// Claude still goes through this two-step launch (see runCustomModelEntry's own
// comment for why) — without something saying so, the native boot it starts with
// (which can genuinely talk to the cloud model for a moment) reads as "the
// endpoint didn't apply" rather than "the switch hasn't happened yet".
const { app } = bootApp({});
app.activeSessionId = 'old-session';
app.run = async () => {
app.activeSessionId = 'new-session';
};
app._api = async () => ({
ok: true,
json: async () => ({ success: true, data: { customModel: { endpointId: 'llama-box' }, restarted: true } }),
});
const banners: Array<{ message: string; dismissed: boolean }> = [];
const messageHistory: string[] = [];
app._showCenterStatus = (message: string) => {
const entry = { message, dismissed: false };
banners.push(entry);
messageHistory.push(message);
return {
dismiss: () => {
entry.dismissed = true;
},
setMessage: (next: string) => {
entry.message = next;
messageHistory.push(next);
},
};
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(banners).toHaveLength(1); // updated in place, not stacked with a second banner
expect(messageHistory[0]).toContain('Claude started — switching to llama-box');
expect(messageHistory.at(-1)).toContain('Pointed at llama-box — restarting');
});
it('dismisses the status banner on a failed apply rather than leaving it stuck on "switching"', async () => {
const { app } = bootApp({});
app.activeSessionId = 'old-session';
app.run = async () => {
app.activeSessionId = 'new-session';
};
app._api = async () => ({
ok: false,
status: 500,
json: async () => ({ success: false, error: 'boom' }),
});
let bannerDismissed = false;
app._showCenterStatus = () => ({
dismiss: () => {
bannerDismissed = true;
},
setMessage: () => {},
});
let toastMessage: string | undefined;
app.showToast = (message: string) => {
toastMessage = message;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(bannerDismissed).toBe(true); // the "switching..." banner, cleaned up
expect(toastMessage).toContain('boom'); // the error toast, separate from it
});
it('routes through run() itself, so the Run in-flight lock actually engages', async () => {
// CLAUDE.md, Run launch synchronization: the lock exists so a double click
// cannot create duplicate sessions. A hardcoded dispatch table bypassing
// run() would never set _runInFlight, which is what this pins.
const { app } = bootApp({});
let sawInFlight = false;
app.run = async function (this: typeof app) {
if (this._runInFlight) return;
this._runInFlight = true;
sawInFlight = true;
this._runInFlight = false;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(sawInFlight).toBe(true);
});
it('restores the previous _runMode after a one-off custom-model launch, never persisting it', async () => {
const { app } = bootApp({});
app._runMode = 'opencode';
let modeDuringRun: string | undefined;
app.run = async function (this: typeof app) {
modeDuringRun = this._runMode;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(modeDuringRun).toBe('claude');
expect(app._runMode).toBe('opencode');
});
});
describe('Custom Model Endpoint Profiles: llama-swap model-swap confirmation and loading state', () => {
function launchHarness(applyResponses: Array>) {
const { win, app } = bootApp({});
app.activeSessionId = 'old-session';
app.run = async () => {
app.activeSessionId = 'new-session';
};
const applyBodies: unknown[] = [];
let call = 0;
app._api = async (path: string, opts?: { body?: unknown }) => {
if (path.endsWith('/custom-model')) {
applyBodies.push(opts?.body);
const data = applyResponses[Math.min(call, applyResponses.length - 1)];
call += 1;
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}
throw new Error(`unexpected _api call: ${path}`);
};
return { win, app, applyBodies };
}
it('confirming the in-app swap-confirm modal re-sends the apply with confirmed:true', async () => {
const { app, applyBodies } = launchHarness([
{
requiresConfirmation: true,
currentlyLoadedModel: 'llama3',
affectedSessions: [{ id: 's2', name: 'w2-otherbox' }],
},
{ customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: true },
]);
let confirmMessage: string | undefined;
app._confirmModelSwap = async (message: string) => {
confirmMessage = message;
return true;
};
app._watchLlamaSwapLoading = async () => {}; // not under test here
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(confirmMessage).toContain('w2-otherbox');
expect(confirmMessage).toContain('llama3');
expect(confirmMessage).toContain('qwen3');
expect(applyBodies).toEqual([
{ endpointId: 'llama-box', modelId: 'qwen3' },
{ endpointId: 'llama-box', modelId: 'qwen3', confirmed: true },
]);
});
it('cancelling the in-app swap-confirm modal keeps the native backend and never re-sends the apply', async () => {
const { app, applyBodies } = launchHarness([
{ requiresConfirmation: true, currentlyLoadedModel: 'llama3', affectedSessions: [{ id: 's2', name: 'w2' }] },
]);
app._confirmModelSwap = async () => false;
let toastMessage: string | undefined;
app.showToast = (msg: string) => {
toastMessage = msg;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(applyBodies).toHaveLength(1); // no second (confirmed) call
expect(toastMessage).toMatch(/cancelled/i);
});
it('a successful apply with modelSwapInProgress kicks off the loading watcher', async () => {
const { app } = launchHarness([
{ customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: true },
]);
let watched: unknown[] | null = null;
app._watchLlamaSwapLoading = async (...args: unknown[]) => {
watched = args;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(watched).toEqual(['llama-box', 'qwen3', 'new-session']);
});
it('a successful apply with no swap needed never starts the loading watcher', async () => {
const { app } = launchHarness([
{ customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: false },
]);
let watchCalled = false;
app._watchLlamaSwapLoading = async () => {
watchCalled = true;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(watchCalled).toBe(false);
});
});
describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () => {
// Driven with millisecond intervals (the function's own pollIntervalMs/maxWaitMs
// params — real callers never pass them) rather than fake timers: this code runs
// inside the JSDOM window's own realm (bootApp's `runScripts: "dangerously"` eval),
// whose setTimeout is NOT the one vi.useFakeTimers() patches, so advancing fake
// timers here would advance nothing and either hang or silently no-op.
it('dismisses the loading banner as soon as the target model reports ready', async () => {
const { app } = bootApp({});
const bannerMessages: string[] = [];
const dismissed: string[] = [];
app._showCenterStatus = (message: string) => {
bannerMessages.push(message);
return { dismiss: () => dismissed.push(message), setMessage: () => {} };
};
const toastCalls: string[] = [];
app.showToast = (message: string) => {
toastCalls.push(message);
};
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 200);
expect(bannerMessages[0]).toMatch(/loading qwen3/i);
expect(dismissed).toContain(bannerMessages[0]);
expect(toastCalls.at(-1)).toMatch(/ready/i);
});
it('adds a second line with the real llama.cpp log line once one is available, stripped of the bootlog prefix', async () => {
const { app } = bootApp({});
const bannerMessages: string[] = [];
app._showCenterStatus = (message: string) => {
bannerMessages.push(message);
return { dismiss: () => {}, setMessage: (next: string) => bannerMessages.push(next) };
};
app.showToast = () => {};
let statusCalls = 0;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints') return null; // size lookup — unrelated to this test
statusCalls += 1;
if (statusCalls === 1) {
return {
isLlamaSwap: true,
running: [{ model: 'qwen3', state: 'starting' }],
logLine: '0.31.428.568 I srv llama_server: model loaded',
};
}
return { isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] };
};
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 200);
// First render (before any poll has landed) has no log line at all.
expect(bannerMessages[0]).not.toMatch(/llama\.cpp:/);
// Second render carries the log line, bootlog prefix (timestamp/level/component) stripped.
const withLogLine = bannerMessages.find((m) => m.includes('llama.cpp:'));
expect(withLogLine).toContain('llama.cpp: llama_server: model loaded');
expect(withLogLine).not.toContain('0.31.428.568');
expect(withLogLine).not.toContain(' I srv');
});
it('shows no second line at all when the endpoint has no logLine to offer', async () => {
const { app } = bootApp({});
const bannerMessages: string[] = [];
app._showCenterStatus = (message: string) => {
bannerMessages.push(message);
return { dismiss: () => {}, setMessage: (next: string) => bannerMessages.push(next) };
};
app.showToast = () => {};
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 200);
expect(bannerMessages.some((m) => m.includes('llama.cpp:'))).toBe(false);
});
it('gives up after the bounded wait, turns the banner into a sticky error, and closes the session', async () => {
const { app } = bootApp({});
const banners: Array<{ message: string; opts: unknown }> = [];
app._showCenterStatus = (message: string, opts: unknown) => {
banners.push({ message, opts });
return { dismiss: () => {}, setMessage: () => {} };
};
app.showToast = () => {};
let closedSessionId: string | undefined;
app.closeSession = async (id: string) => {
closedSessionId = id;
};
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'something-else', state: 'ready' }] });
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 30);
const errorBanner = banners.find((b) => (b.opts as { type?: string } | undefined)?.type === 'error');
expect(errorBanner?.message).toMatch(/did not finish loading/i);
expect(errorBanner?.message).toMatch(/llama-swap server logs/i);
expect(closedSessionId).toBe('sess-1');
});
it('never closes anything when no sessionId was given (a caller that has none to close)', async () => {
const { app } = bootApp({});
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
app.showToast = () => {};
let closeCalled = false;
app.closeSession = async () => {
closeCalled = true;
};
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'something-else', state: 'ready' }] });
await app._watchLlamaSwapLoading('llama-box', 'qwen3', undefined, 5, 30);
expect(closeCalled).toBe(false);
});
it('stops polling (without a warning) once the endpoint no longer reads as llama-swap', async () => {
const { app } = bootApp({});
let bannerDismissed = false;
app._showCenterStatus = () => ({
dismiss: () => {
bannerDismissed = true;
},
setMessage: () => {},
});
const toastCalls: string[] = [];
app.showToast = (message: string) => {
toastCalls.push(message);
};
app._apiJson = async () => ({ isLlamaSwap: false, running: [] });
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 200);
expect(bannerDismissed).toBe(true);
expect(toastCalls).toHaveLength(0); // no follow-up warning toast
});
it('keeps waiting through a transient status-fetch failure instead of giving up early', async () => {
const { app } = bootApp({});
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
const toastCalls: string[] = [];
app.showToast = (message: string) => {
toastCalls.push(message);
};
let call = 0;
app._apiJson = async () => {
call += 1;
if (call === 1) return null; // transient failure
return { isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] };
};
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 200);
expect(toastCalls.at(-1)).toMatch(/ready/i);
});
it('checks immediately rather than waiting a full interval before the first check', async () => {
// A model that is already ready by the time this runs (a fast load, or a re-apply
// onto one that was already loaded) shouldn't sit on "Loading..." for a whole
// pollIntervalMs before saying so.
const { app } = bootApp({});
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
let calls = 0;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints') return []; // size lookup — no match, no estimate
calls += 1;
return { isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] };
};
// A huge interval that would time the test out if the function actually waited for
// it before the first check.
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 60000, 300000);
expect(calls).toBe(1);
});
it('a newer call takes over the shared banner — a superseded older call never touches it', async () => {
const { app } = bootApp({});
const dismissCalls: string[] = [];
app._showCenterStatus = (message: string) => ({
dismiss: () => dismissCalls.push(message),
setMessage: () => {},
});
app.showToast = () => {};
// The FIRST call never sees its own target model ready, so left alone it would run all
// the way to its own timeout and (now) turn into an error + close its session — but no
// sessionId is passed, so there is nothing for it to close even if it does get there.
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints') return [];
return { isLlamaSwap: true, running: [] };
};
const firstCall = app._watchLlamaSwapLoading('llama-box', 'model-a', undefined, 5, 30);
// Second call, for a DIFFERENT model that IS ready right away, takes over the banner
// before the first call's own bounded wait has elapsed.
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints') return [];
return { isLlamaSwap: true, running: [{ model: 'model-b', state: 'ready' }] };
};
await app._watchLlamaSwapLoading('llama-box', 'model-b', undefined, 5, 200);
// Let the stale first call run out its own bounded wait and finish.
await firstCall;
// Whatever the first call did or didn't show along the way, its own eventual
// completion (a timeout, in this case) must never touch a banner state that belongs
// to the newer, still-current call — exactly one dismiss, for model-b, is the tell.
expect(dismissCalls).toHaveLength(1);
expect(dismissCalls[0]).toContain('model-b');
});
});
describe('Custom Model Endpoint Profiles: model-size load-time estimate', () => {
it('_estimateModelLoad picks the smallest matching bracket, and returns null for an unknown size', () => {
const { app } = bootApp({});
expect(app._estimateModelLoad(1)).toMatchObject({ label: '~5–15s' });
expect(app._estimateModelLoad(2)).toMatchObject({ label: '~5–15s' }); // inclusive upper bound
expect(app._estimateModelLoad(2.1)).toMatchObject({ label: '~15–45s' });
expect(app._estimateModelLoad(16.35)).toMatchObject({ label: '~1–3 min' }); // just over the 16GB bracket
expect(app._estimateModelLoad(200)).toMatchObject({ label: '~5+ min' });
expect(app._estimateModelLoad(undefined)).toBeNull();
expect(app._estimateModelLoad(0)).toBeNull();
expect(app._estimateModelLoad(-5)).toBeNull();
expect(app._estimateModelLoad(NaN)).toBeNull();
});
it('_lookupModelSizeGB reads the size off the matching endpoint/model, ignoring one with no parseable size', async () => {
const { app } = bootApp({});
app._apiJson = async (path: string) => {
expect(path).toBe('/api/model-endpoints');
return [
{ id: 'llama-box', modelSizesGB: { 'qwen3.8-27b-ud-q4_k_xl': 16.35, big: undefined } },
{ id: 'other-box', modelSizesGB: { 'qwen3.8-27b-ud-q4_k_xl': 999 } }, // must not match wrong endpoint
];
};
expect(await app._lookupModelSizeGB('llama-box', 'qwen3.8-27b-ud-q4_k_xl')).toBe(16.35);
expect(await app._lookupModelSizeGB('llama-box', 'big')).toBeUndefined(); // no parseable size
expect(await app._lookupModelSizeGB('llama-box', 'unknown-model')).toBeUndefined();
expect(await app._lookupModelSizeGB('ghost-endpoint', 'qwen3')).toBeUndefined();
});
it('_lookupModelSizeGB is best-effort: an unreachable/malformed response yields undefined, never a throw', async () => {
const { app } = bootApp({});
app._apiJson = async () => {
throw new Error('network down');
};
await expect(app._lookupModelSizeGB('llama-box', 'qwen3')).resolves.toBeUndefined();
app._apiJson = async () => null; // e.g. a failed request _apiJson already swallowed
await expect(app._lookupModelSizeGB('llama-box', 'qwen3')).resolves.toBeUndefined();
});
it('the loading banner includes the size and estimate when the size is known', async () => {
const { app } = bootApp({});
const bannerMessages: string[] = [];
app._showCenterStatus = (message: string) => {
bannerMessages.push(message);
return { dismiss: () => {}, setMessage: () => {} };
};
app.showToast = () => {};
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints') {
return [{ id: 'llama-box', modelSizesGB: { 'qwen3.8-27b-ud-q4_k_xl': 16.35 } }];
}
return { isLlamaSwap: true, running: [{ model: 'qwen3.8-27b-ud-q4_k_xl', state: 'ready' }] };
};
await app._watchLlamaSwapLoading('llama-box', 'qwen3.8-27b-ud-q4_k_xl', undefined, 5);
expect(bannerMessages[0]).toMatch(
/^Loading qwen3\.8-27b-ud-q4_k_xl \(16\.4 GB, typically ~1–3 min\) on llama-box — .+ remaining$/
);
});
it('the loading banner omits the size/estimate entirely when the size is unknown', async () => {
const { app } = bootApp({});
const bannerMessages: string[] = [];
app._showCenterStatus = (message: string) => {
bannerMessages.push(message);
return { dismiss: () => {}, setMessage: () => {} };
};
app.showToast = () => {};
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints') return [{ id: 'llama-box', modelSizesGB: {} }];
return { isLlamaSwap: true, running: [{ model: 'big', state: 'ready' }] };
};
await app._watchLlamaSwapLoading('llama-box', 'big', undefined, 5);
expect(bannerMessages[0]).toMatch(/^Loading big on llama-box — .+ remaining$/);
});
it('uses the size-scaled estimate as the default timeout when maxWaitMs is not passed', async () => {
// A 200GB model estimates to the top "~5+ min" bracket (900000ms); a huge poll interval
// would time the TEST out if the function only waited the flat, smaller previous
// default (300000ms) instead of the size-scaled one.
const { app } = bootApp({});
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
app.showToast = () => {};
let calls = 0;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints') return [{ id: 'llama-box', modelSizesGB: { huge: 200 } }];
calls += 1;
if (calls < 3) return { isLlamaSwap: true, running: [] }; // not ready on the first couple of checks
return { isLlamaSwap: true, running: [{ model: 'huge', state: 'ready' }] };
};
// pollIntervalMs only — maxWaitMs omitted, so it must fall back to the size estimate.
await app._watchLlamaSwapLoading('llama-box', 'huge', undefined, 5);
expect(calls).toBe(3);
});
});
describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own overhead can exceed a small model's real context)", () => {
function launchHarness(applyResponses: Array>) {
const { win, app } = bootApp({});
app.activeSessionId = 'old-session';
app.run = async () => {
app.activeSessionId = 'new-session';
};
const applyBodies: unknown[] = [];
let call = 0;
app._api = async (path: string, opts?: { body?: unknown }) => {
if (path.endsWith('/custom-model')) {
applyBodies.push(opts?.body);
const data = applyResponses[Math.min(call, applyResponses.length - 1)];
call += 1;
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}
throw new Error(`unexpected _api call: ${path}`);
};
return { win, app, applyBodies };
}
it('confirming the in-app context-warning modal re-sends the apply with confirmed:true', async () => {
const { app, applyBodies } = launchHarness([
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
{ customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: false },
]);
let confirmArgs: unknown[] | undefined;
app._confirmContextWarning = async (...args: unknown[]) => {
confirmArgs = args;
return true;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(confirmArgs).toEqual(['qwen3', 16384, 40000]);
expect(applyBodies).toEqual([
{ endpointId: 'llama-box', modelId: 'qwen3' },
{ endpointId: 'llama-box', modelId: 'qwen3', confirmed: true },
]);
});
it('declining the in-app context-warning modal keeps the native backend and never re-sends the apply', async () => {
const { app, applyBodies } = launchHarness([
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
]);
app._confirmContextWarning = async () => false;
let toastMessage: string | undefined;
app.showToast = (msg: string) => {
toastMessage = msg;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(applyBodies).toHaveLength(1); // no second (confirmed) call
expect(toastMessage).toMatch(/context window too small/i);
});
});
describe('Custom Model Endpoint Profiles: _confirmModelSwap (in-app modal, replaces a native confirm() popup)', () => {
it('shows the message, activates the modal, and resolves true when "Switch anyway" is clicked', async () => {
const { win, app } = bootApp({});
const promise = app._confirmModelSwap('w2 is using llama3. Switch anyway?');
const modal = win.document.getElementById('customModelSwapConfirmModal')!;
expect(modal.classList.contains('active')).toBe(true);
expect(win.document.getElementById('customModelSwapConfirmMessage')!.textContent).toBe(
'w2 is using llama3. Switch anyway?'
);
app._resolveModelSwapConfirm(true);
expect(await promise).toBe(true);
expect(modal.classList.contains('active')).toBe(false);
});
it('resolves false when Cancel (or the backdrop) is clicked, without ever showing a browser confirm() popup', async () => {
const { win, app } = bootApp({});
const promise = app._confirmModelSwap('w2 is using llama3. Switch anyway?');
app._resolveModelSwapConfirm(false);
expect(await promise).toBe(false);
expect(win.document.getElementById('customModelSwapConfirmModal')!.classList.contains('active')).toBe(false);
});
});
describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal, native backend never restarted while it is up)', () => {
it('shows a message naming the model, the discovered context and the safe floor, activates the modal, and resolves true on "Launch anyway"', async () => {
const { win, app } = bootApp({});
const promise = app._confirmContextWarning('qwen3.8-27b-ud-q4_k_xl', 16384, 40000);
const modal = win.document.getElementById('customModelContextWarningModal')!;
expect(modal.classList.contains('active')).toBe(true);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain('qwen3.8-27b-ud-q4_k_xl');
expect(message).toContain('16,384');
expect(message).toContain('40,000');
expect(message).toMatch(/llama-swap/i);
expect(message).toMatch(/fit-ctx/i);
app._resolveContextWarningConfirm(true);
expect(await promise).toBe(true);
expect(modal.classList.contains('active')).toBe(false);
});
it('resolves false when Cancel is clicked', async () => {
const { win, app } = bootApp({});
const promise = app._confirmContextWarning('qwen3', 16384, 40000);
app._resolveContextWarningConfirm(false);
expect(await promise).toBe(false);
expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(false);
});
it('describes an unknown context length without printing a bogus number', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', undefined, 40000);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).not.toMatch(/undefined/);
expect(message).toMatch(/unknown/i);
app._resolveContextWarningConfirm(false);
});
});