Files
Codeman/test/local-model-ui.test.ts
T
Codeman maintainer 84d5c691ff feat(local-model): one button and a chooser window replace the welcome card
The read-only card is gone. The welcome screen has one button, "Run a local
model" ("Local model" once one is installed), and App Settings > Models has
one row; both open the same window.

The window follows `install.state`: choose a model (a radio group, with a
memory bar filled to what the selected model needs), confirm the sizes and
the folder, watch the download and the steps, and end on "Start a session".
Paused, failed, installed (running or stopped), Remove and "no model fits"
are screens of the same window. It polls the GET once a second while open.

Every dynamic string goes in through textContent, labels and sentences come
from the payload, no CLI id is compared, and every new string has a zh-CN
entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 22:16:38 +01:00

2170 lines
91 KiB
TypeScript

/**
* @fileoverview "Run a model on this Mac": the welcome button, the chooser window and the App
* Settings row (src/web/public/local-model-ui.js), driven in JSDOM over the REAL markup of
* index.html with the real module and a mocked `_api`.
*
* What is pinned:
* - the markup: one button in the welcome launchers (no card), one modal after App Settings,
* one row with a button in Settings > Models;
* - when anything shows: hidden while the first request is in flight, for a refusal (403
* FORBIDDEN, and only that 403), for a failed request (Settings alone says so) and for a
* server that is not a Mac;
* - every screen of the window: the chooser with five models, a tight fit, confirm, progress,
* paused, a failure with each helpful action, installed (running and stopped), the Remove
* question, and no recommendation;
* - the radio group: roles, one tab stop, arrow keys, Home and End, the focus following;
* - the memory bar's value following the selection;
* - the consent: the install request carries the server's own total for the row that was on
* screen, and a 409 `size-changed` shows the new total and sends that one next;
* - the poll: once a second while the window is open, never after it closes, and a tick that
* moved only the download leaves the buttons in place;
* - every payload string lands as text: an `<img onerror>` in a model, agent or chip name, an
* error's detail, a session name, a folder or a command creates no element;
* - a button exists only for an action the server lists.
*
* The payloads are fixtures typed with the contract's types and built from its label
* constants, not the output of the route: what the server computes is pinned by
* test/local-model-recommend.test.ts and test/routes/local-model-routes.test.ts.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import {
LOCAL_MODEL_ERROR_LABELS,
LOCAL_MODEL_ERROR_NEXT,
LOCAL_MODEL_INSTALL_LABELS,
LOCAL_MODEL_LABELS,
LOCAL_MODEL_NONE_LABELS,
LOCAL_MODEL_TAG_LABELS,
} from '../src/types.js';
import type {
Candidate,
CandidateHarness,
HardwareFacts,
LocalModelConflict,
LocalModelErrorCode,
LocalModelInstallView,
LocalModelMessage,
LocalModelStatus,
NoneReason,
Recommendation,
} from '../src/types.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const MODULE = readFileSync(resolve(PUBLIC, 'local-model-ui.js'), 'utf8');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const MOBILE = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
type ApiResponse = { ok: boolean; status: number; json(): Promise<unknown> } | null;
type ApiOptions = { method?: string; body?: unknown };
type ApiHandler = (path: string, options: ApiOptions) => ApiResponse | Promise<ApiResponse>;
interface App {
_api: ReturnType<typeof vi.fn>;
loadLocalModel(options?: { force?: boolean }): Promise<void>;
renderLocalModel(): void;
openLocalModelWindow(): void;
closeLocalModelWindow(): void;
isLocalModelWindowOpen(): boolean;
startLocalModelSession(): Promise<void>;
loadAppSettingsFromServer?: () => Promise<void>;
loadCliListForSettings?: () => Promise<void>;
selectCustomModelEntry?: (cli: string, endpointId: string) => Promise<void>;
closeAppSettings?: () => void;
}
interface I18nApi {
configure(options: Record<string, string>): void;
start(): void;
}
/**
* The three pieces of markup exactly as index.html ships them, lifted out of the real page
* once. Each test then runs in a small page made of that markup: parsing the whole of
* index.html for every case would cost the gate minutes and prove nothing more.
*/
const REAL = new JSDOM(INDEX);
const REAL_DOCUMENT = REAL.window.document;
const BUTTON_MARKUP = REAL_DOCUMENT.getElementById('welcomeLocalModelBtn')?.outerHTML ?? '';
const GROUP_MARKUP = REAL_DOCUMENT.getElementById('localModelGroup')?.outerHTML ?? '';
const MODAL_MARKUP = REAL_DOCUMENT.getElementById('localModelModal')?.outerHTML ?? '';
const PAGE = `<!doctype html><html lang="en"><body>
<div class="welcome-actions"><div id="welcomeCliActions"></div>${BUTTON_MARKUP}</div>
<div class="modal" id="appSettingsModal"><section class="set-section" id="settings-models">${GROUP_MARKUP}</section></div>
${MODAL_MARKUP}
</body></html>`;
const open: JSDOM[] = [];
afterEach(() => {
for (const dom of open.splice(0)) dom.window.close();
});
afterAll(() => {
REAL.window.close();
});
/** A response as `_api` hands it over, the server's envelope included. */
function ok(data: unknown): ApiResponse {
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}
function refusal(status: number, errorCode: string): ApiResponse {
return { ok: false, status, json: async () => ({ success: false, error: 'no', errorCode }) };
}
/** A 409 of an install verb: the error envelope plus the conflict as `data`. */
function conflict(data: LocalModelConflict): ApiResponse {
return {
ok: false,
status: 409,
json: async () => ({ success: false, error: data.message.text, errorCode: 'CONFLICT', data }),
};
}
/**
* The real markup with the real module in it. `CodemanApp` is the only stand-in: app.js defines
* it as a classic-script global, and the module mixes into its prototype. Timers are a queue the
* test runs by hand (`tick`), so the once-a-second poll is counted, never waited for.
*/
function mount(options: { i18n?: boolean; visible?: boolean } = {}) {
const dom = new JSDOM(PAGE, {
runScripts: 'outside-only',
url: 'http://localhost/',
pretendToBeVisual: options.visible !== false,
});
open.push(dom);
const context = dom.getInternalVMContext();
const timers: Array<{ id: number; fn: () => void; ms: number }> = [];
let nextTimer = 1;
const win = dom.window as unknown as {
setTimeout: (fn: () => void, ms: number) => number;
clearTimeout: (id: number) => void;
CodemanI18n: I18nApi;
__codemanUser?: { multiUser: boolean; role: string };
};
win.setTimeout = (fn, ms) => {
timers.push({ id: nextTimer, fn, ms });
return nextTimer++;
};
win.clearTimeout = (id) => {
const at = timers.findIndex((timer) => timer.id === id);
if (at >= 0) timers.splice(at, 1);
};
if (options.i18n) vm.runInContext(I18N, context, { filename: 'i18n.js' });
vm.runInContext('function CodemanApp() {}', context);
vm.runInContext(MODULE, context, { filename: 'local-model-ui.js' });
const document = dom.window.document;
const newApp = (handler?: ApiResponse | ApiHandler) => {
const app = vm.runInContext('new CodemanApp()', context) as App;
app._api = vi.fn(async (path: string, opts: ApiOptions = {}) =>
typeof handler === 'function' ? handler(path, opts) : (handler ?? null)
);
return app;
};
return {
dom,
win,
document,
newApp,
timers,
/** Run every timer that is due now (the poll re-arms itself, so one call is one tick). */
tick: async () => {
for (const timer of timers.splice(0)) timer.fn();
await settle();
},
button: document.getElementById('welcomeLocalModelBtn')!,
row: document.getElementById('localModelSettings')!,
rowButton: document.getElementById('localModelSettingsOpen')!,
group: document.getElementById('localModelGroup')!,
modal: document.getElementById('localModelModal')!,
body: document.getElementById('localModelWindowBody')!,
};
}
type Mounted = ReturnType<typeof mount>;
/** Text of every element matching a selector under a root, in document order. */
function texts(root: ParentNode, selector: string): string[] {
return [...root.querySelectorAll(selector)].map((el) => el.textContent ?? '');
}
/** The window's buttons, by their label. */
function buttons(body: Element): string[] {
return texts(body, '.lm-buttons button');
}
function press(body: Element, label: string) {
const found = [...body.querySelectorAll<HTMLButtonElement>('.lm-buttons button')].find(
(el) => el.textContent === label
);
expect(found, `no button "${label}" among ${JSON.stringify(buttons(body))}`).toBeTruthy();
found!.click();
}
/** Lets queued promise callbacks and MutationObserver callbacks run. */
const settle = () => new Promise((done) => setTimeout(done, 0));
/** The calls that were not the GET, as `[method, path, body]`. */
function verbs(app: App): Array<[string, string, unknown]> {
return app._api.mock.calls
.filter(([, opts]) => opts && (opts as ApiOptions).method && (opts as ApiOptions).method !== 'GET')
.map(([path, opts]) => [(opts as ApiOptions).method as string, path as string, (opts as ApiOptions).body]);
}
const gets = (app: App) => app._api.mock.calls.filter(([, opts]) => !(opts as ApiOptions | undefined)?.method).length;
// ---------- Fixtures (the contract's types) ----------
/** The MacBook of the measurements: 48 GB as sold. */
const HARDWARE: HardwareFacts = {
platform: 'darwin',
arch: 'arm64',
arm64: true,
translated: false,
memBytes: 51539607552,
memGb: '51.5',
memClassGb: 48,
chip: 'Apple M5 Pro',
model: 'Mac17,13',
macosVersion: '27.2',
macosMajor: 27,
wiredLimitMb: 0,
freeDiskBytes: 897000000000,
freeDiskGb: '897',
source: 'sysctl',
};
const RUNTIME_BYTES = 185971671;
const OPENCODE: CandidateHarness = {
cli: 'opencode',
cliLabel: 'OpenCode',
installed: false,
installCommand: 'curl -fsSL https://opencode.ai/install | bash',
};
const CLAUDE: CandidateHarness = { cli: 'claude', cliLabel: 'Claude Code', installed: true, slowFirstTurn: true };
function candidate(over: Partial<Candidate> = {}): Candidate {
const row: Candidate = {
modelId: 'qwen3.6:35b-a3b',
modelLabel: 'Qwen3.6 35B-A3B',
license: 'Apache-2.0',
paramsB: 35,
context: 131072,
cli: 'opencode',
cliLabel: 'OpenCode',
cliInstalled: false,
needBytes: 35955462956,
needGb: '36.0',
downloadBytes: 23105798956,
downloadGb: '23.1',
budgetShare: 0.957,
fit: 'tight',
fitLabel: LOCAL_MODEL_LABELS.fitTight,
speed: 'ok',
tokensPerSecond: 67.7,
tested: true,
needSource: 'measured',
tag: { kind: 'tested', label: LOCAL_MODEL_TAG_LABELS.tested },
harnesses: [OPENCODE, CLAUDE],
installBytes: 23105798956 + RUNTIME_BYTES,
installGb: '23.3',
...over,
};
return row;
}
const PICK = candidate();
const ALT_SLOW = candidate({
modelId: 'qwen3.8:27b',
modelLabel: 'Qwen3.8 27B',
paramsB: 27.8,
needGb: '30.6',
downloadBytes: 17741871939,
downloadGb: '17.7',
budgetShare: 0.814,
fit: 'good',
fitLabel: LOCAL_MODEL_LABELS.fitGood,
speed: 'slow',
speedLabel: LOCAL_MODEL_LABELS.speedSlow,
tokensPerSecond: 9.1,
tested: false,
needSource: 'estimated',
tag: { kind: 'slow', label: LOCAL_MODEL_TAG_LABELS.slow },
harnesses: [OPENCODE],
installBytes: 17741871939 + RUNTIME_BYTES,
installGb: '17.9',
});
const ALT_UNKNOWN = candidate({
modelId: 'gpt-oss:20b',
modelLabel: 'GPT-OSS 20B',
paramsB: 20,
needGb: '26.6',
downloadBytes: 13793441427,
downloadGb: '13.8',
budgetShare: 0.709,
fit: 'good',
fitLabel: LOCAL_MODEL_LABELS.fitGood,
speed: 'unknown',
speedLabel: LOCAL_MODEL_LABELS.speedUnknown,
tokensPerSecond: undefined,
tested: false,
needSource: 'estimated',
tag: undefined,
installBytes: 13793441427 + RUNTIME_BYTES,
installGb: '14.0',
});
const ALT_SMALL_Q8 = candidate({
modelId: 'qwen3.5:9b-q8_0',
modelLabel: 'Qwen3.5 9B (Q8)',
paramsB: 9,
context: 65536,
needGb: '20.9',
downloadBytes: 10449217949,
downloadGb: '10.4',
budgetShare: 0.555,
fit: 'good',
fitLabel: LOCAL_MODEL_LABELS.fitGood,
tokensPerSecond: 17.8,
tested: false,
needSource: 'estimated',
tag: { kind: 'small', label: LOCAL_MODEL_TAG_LABELS.small },
installBytes: 10449217949 + RUNTIME_BYTES,
installGb: '10.6',
});
const ALT_SMALL = candidate({
modelId: 'qwen3.5:9b',
modelLabel: 'Qwen3.5 9B',
paramsB: 9,
context: 65536,
needGb: '17.0',
downloadBytes: 6550825373,
downloadGb: '6.6',
budgetShare: 0.452,
fit: 'good',
fitLabel: LOCAL_MODEL_LABELS.fitGood,
tokensPerSecond: 28.6,
tested: false,
needSource: 'estimated',
tag: { kind: 'small', label: LOCAL_MODEL_TAG_LABELS.small },
installBytes: 6550825373 + RUNTIME_BYTES,
installGb: '6.7',
});
const RUNTIME_MISSING = {
version: '0.40.15',
installed: false,
downloadBytes: RUNTIME_BYTES,
downloadGb: '0.2',
unpackedGb: '0.6',
};
const RUNTIME_THERE = { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' };
const FOLDER = '/Users/sam/.codeman/local-model';
function install(over: Partial<LocalModelInstallView> = {}): LocalModelInstallView {
return {
state: 'idle',
running: false,
developerTools: 'present',
runtime: RUNTIME_MISSING,
folder: FOLDER,
actions: ['install'],
...over,
};
}
/** The members a job carries from `installing` on. */
const JOB: Partial<LocalModelInstallView> = {
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
cli: 'opencode',
cliLabel: 'OpenCode',
context: 131072,
port: 11435,
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
runtime: RUNTIME_THERE,
};
function status(
recommendation: Partial<Recommendation> = {},
installOver: Partial<LocalModelInstallView> = {},
hardware: Partial<HardwareFacts> = {}
): LocalModelStatus {
return {
hardware: { ...HARDWARE, ...hardware },
recommendation: {
verdict: 'ok',
headline: { text: LOCAL_MODEL_LABELS.headlineTested },
notes: [],
pick: PICK,
alternatives: [ALT_SLOW, ALT_UNKNOWN, ALT_SMALL_Q8, ALT_SMALL],
budgetBytes: 37580963840,
budgetGb: '37.6',
budgetSource: 'estimated',
...recommendation,
},
catalogUpdated: '2026-11-06',
install: install(installOver),
};
}
const IDLE = status();
const PROGRESS = {
completedBytes: 6238565718,
totalBytes: 23105798956,
completedGb: '6.2',
totalGb: '23.1',
bytesPerSecond: 58000000,
mbPerSecond: '58',
secondsLeft: 291,
};
const INSTALLING = status({}, { ...JOB, state: 'installing', step: 'model', actions: ['cancel'], progress: PROGRESS });
const PAUSED = status(
{},
{
...JOB,
state: 'paused',
step: 'model',
actions: ['resume', 'install', 'remove'],
progress: { completedBytes: 6238565718, totalBytes: 23105798956, completedGb: '6.2', totalGb: '23.1' },
}
);
const INSTALLED = status(
{ budgetBytes: 40157944217, budgetGb: '40.2', budgetSource: 'measured' },
{
...JOB,
state: 'installed',
running: true,
endpointId: 'local-ollama',
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
actions: ['start-session', 'stop', 'remove'],
}
);
const STOPPED = status(
{},
{
...JOB,
state: 'installed',
running: false,
endpointId: 'local-ollama',
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
actions: ['start', 'remove'],
}
);
/** A stopped job with one error code, its sentence and its helpful action from the contract's tables. */
function failed(code: LocalModelErrorCode, vars: Record<string, string> = {}, extra: Record<string, unknown> = {}) {
return status(
{},
{
...JOB,
state: 'failed',
step: 'verify',
actions: ['resume', 'install', 'remove'],
error: {
code,
message: { text: LOCAL_MODEL_ERROR_LABELS[code], ...(Object.keys(vars).length ? { vars } : {}) },
next: LOCAL_MODEL_ERROR_NEXT[code],
...extra,
},
}
);
}
/** A `none` answer: no pick, the reason's sentence as headline, its slots in `vars`. */
function none(reason: NoneReason, vars?: Record<string, string>, hardware: Partial<HardwareFacts> = {}) {
const headline: LocalModelMessage = { text: LOCAL_MODEL_NONE_LABELS[reason], ...(vars ? { vars } : {}) };
return status(
{ verdict: 'none', reason, headline, pick: undefined, alternatives: [], budgetBytes: 0, budgetGb: '0.0' },
{ actions: [] },
hardware
);
}
/**
* Load an answer and open the window on it. The GET answers with the current status, and a verb
* that succeeds moves that status the way the server does: its answer is the `install` object
* every later GET carries.
*/
async function opened(page: Mounted, first: LocalModelStatus, verb?: ApiHandler) {
let current = first;
const app = page.newApp(async (path, opts) => {
if (!opts.method || opts.method === 'GET') return ok(current);
const answer = verb ? await verb(path, opts) : null;
if (answer?.ok) {
const body = (await answer.json()) as { data?: LocalModelInstallView };
if (body.data && typeof body.data.state === 'string') current = { ...current, install: body.data };
}
return answer;
});
await app.loadLocalModel();
app.openLocalModelWindow();
await settle();
return {
app,
/** What the server answers from now on. */
serve: (next: LocalModelStatus) => {
current = next;
},
};
}
// ---------- The page ----------
describe('the markup the module fills', () => {
it('ships one hidden button among the welcome launchers, and no card', () => {
const button = REAL_DOCUMENT.getElementById('welcomeLocalModelBtn')!;
expect(button.hidden).toBe(true);
expect(button.tagName).toBe('BUTTON');
expect(button.getAttribute('type')).toBe('button');
// The chip family of the launchers, and never inside the slot renderWelcomeCliActions empties.
expect(button.classList.contains('welcome-chip')).toBe(true);
expect(button.parentElement?.classList.contains('welcome-actions')).toBe(true);
expect(button.closest('#welcomeCliActions')).toBeNull();
const order = [...button.parentElement!.children].map((el) => el.id);
expect(order.indexOf('welcomeLocalModelBtn')).toBeGreaterThan(order.indexOf('welcomeCliActions'));
expect(order.indexOf('welcomeLocalModelBtn')).toBeLessThan(order.indexOf('welcomeTunnelBtn'));
expect(button.getAttribute('onclick')).toBe('app.openLocalModelWindow()');
expect(button.hasAttribute('data-i18n-skip')).toBe(true);
// The read-only card of the first version is gone, with its styles.
expect(REAL_DOCUMENT.getElementById('localModelCard')).toBeNull();
for (const css of [STYLES, MOBILE]) expect(css).not.toContain('.local-model-card');
// The phone overview is a different screen and does not carry the button.
expect(button.closest('#mobileOverview')).toBeNull();
// `.welcome-chip` is inline-flex: without this rule `hidden` would not hide it.
expect(STYLES).toMatch(/\.welcome-local-model\[hidden\]\s*\{\s*display:\s*none;/);
});
it('ships the window as a dialog in App Settings chrome, after App Settings', () => {
const modal = REAL_DOCUMENT.getElementById('localModelModal')!;
expect(modal.classList.contains('modal')).toBe(true);
expect(modal.classList.contains('active')).toBe(false);
expect(modal.getAttribute('role')).toBe('dialog');
expect(modal.getAttribute('aria-modal')).toBe('true');
expect(modal.getAttribute('aria-labelledby')).toBe('localModelModalTitle');
expect(REAL_DOCUMENT.getElementById('localModelModalTitle')!.textContent).toBe('Run a model on this Mac');
expect(modal.querySelector('.modal-backdrop')!.getAttribute('onclick')).toBe('app.closeLocalModelWindow()');
expect(modal.querySelector('.modal-header .modal-close')!.getAttribute('onclick')).toBe(
'app.closeLocalModelWindow()'
);
const body = REAL_DOCUMENT.getElementById('localModelWindowBody')!;
expect(body.hasAttribute('data-i18n-skip')).toBe(true);
expect(body.textContent).toBe('');
// Later in the document than App Settings, so it stacks above it when opened from there.
const settings = REAL_DOCUMENT.getElementById('appSettingsModal')!;
expect(settings.compareDocumentPosition(modal) & REAL.window.Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('ships one row with a button in Settings > Models', () => {
const row = REAL_DOCUMENT.getElementById('localModelSettings')!;
const group = REAL_DOCUMENT.getElementById('localModelGroup')!;
expect(group.closest('#appSettingsModal #settings-models')).not.toBeNull();
// Its own group: the endpoints toggle hides #customModelEndpointsBody, and this does not depend on it.
expect(row.closest('#customModelEndpointsBody')).toBeNull();
expect(row.closest('.set-row')).not.toBeNull();
expect(row.hasAttribute('data-i18n-skip')).toBe(true);
const controls = [...group.querySelectorAll('input, select, button, textarea')];
expect(controls.map((el) => el.id)).toEqual(['localModelSettingsOpen']);
expect(controls[0].getAttribute('onclick')).toBe('app.openLocalModelWindow()');
expect(group.querySelector('.set-group-hint')!.textContent).not.toMatch(/read-only/i);
expect(INDEX.split('<script defer src="local-model-ui.js"></script>').length - 1).toBe(1);
});
it('is what every other test here runs on', () => {
const { button, group, modal } = mount();
expect(button.outerHTML).toBe(BUTTON_MARKUP);
expect(group.outerHTML).toBe(GROUP_MARKUP);
expect(modal.outerHTML).toBe(MODAL_MARKUP);
});
it('is wired into the places that show it, and Escape closes the window alone', () => {
const read = (file: string) => readFileSync(resolve(PUBLIC, file), 'utf8');
// The welcome screen (not the phone overview branch above it) and App Settings.
expect(read('terminal-ui.js').split('this.loadLocalModel?.();').length - 1).toBe(1);
expect(read('settings-ui.js').split('this.loadLocalModel?.();').length - 1).toBe(1);
// The window can sit on App Settings: its Escape is claimed before every panel is closed.
const app = read('app.js');
const claim = app.indexOf('if (this.isLocalModelWindowOpen?.()) {');
expect(claim).toBeGreaterThan(-1);
expect(app.slice(claim, claim + 120)).toContain('this.closeLocalModelWindow();\n return;');
expect(claim).toBeLessThan(app.indexOf('this.closeAllPanels();', claim));
});
});
describe('before there is an answer', () => {
it('keeps the button and the Settings group hidden while the first request is in flight', async () => {
const { newApp, button, row, group } = mount();
let answer!: (response: ApiResponse) => void;
const app = newApp(() => new Promise<ApiResponse>((done) => (answer = done)));
const loading = app.loadLocalModel();
expect(button.hidden).toBe(true);
// Nothing says yet that the server is a Mac, so no group titled "Model on this Mac".
expect(group.style.display).toBe('none');
expect(row.textContent).toBe('');
answer(ok(IDLE));
await loading;
expect(button.hidden).toBe(false);
expect(group.style.display).toBe('');
expect(texts(row, '.set-row-label')).toEqual(['No model installed']);
});
it.each([
['the route answers 501', refusal(501, 'OPERATION_FAILED')],
['the request never reached the server', null],
['the body is not the envelope', { ok: true, status: 200, json: async () => ({ hello: 'world' }) }],
['the body is not JSON', { ok: true, status: 200, json: async () => Promise.reject(new Error('bad')) }],
['the data carries no recommendation', ok({ hardware: HARDWARE })],
] as Array<[string, ApiResponse]>)('shows a failed request only in Settings: %s', async (_name, response) => {
const { newApp, button, row, rowButton, group } = mount();
const app = newApp(response);
await app.loadLocalModel();
// Nothing is known about the machine, so the welcome screen gets no button, and the row no way in.
expect(button.hidden).toBe(true);
expect(group.style.display).toBe('');
expect(row.textContent).toBe('Could not reach the server.');
expect(rowButton.hidden).toBe(true);
app.openLocalModelWindow();
expect(app.isLocalModelWindowOpen()).toBe(true);
});
it('asks again the next time it is shown after a failure, and keeps an answer while it is refreshed', async () => {
const { win, newApp, button, row } = mount();
let now = 1_000_000;
(win as unknown as { Date: DateConstructor }).Date.now = () => now;
const answers: ApiResponse[] = [null, ok(IDLE), null];
const app = newApp(async () => answers.shift() ?? null);
await app.loadLocalModel();
expect(row.textContent).toBe('Could not reach the server.');
await app.loadLocalModel();
expect(app._api).toHaveBeenCalledTimes(2);
expect(button.hidden).toBe(false);
// Fresh for a minute: no request. Then a failed refresh keeps the answer on screen.
await app.loadLocalModel();
expect(app._api).toHaveBeenCalledTimes(2);
now += 61_000;
await app.loadLocalModel();
expect(app._api).toHaveBeenCalledTimes(3);
expect(button.hidden).toBe(false);
expect(texts(row, '.set-row-label')).toEqual(['No model installed']);
});
});
describe('a refused request (a non-admin in multi-user mode)', () => {
it('shows neither the button nor the Settings group, opens no window, and stops asking', async () => {
const { newApp, button, row, group, modal } = mount();
const app = newApp(refusal(403, 'FORBIDDEN'));
await app.loadLocalModel();
expect(button.hidden).toBe(true);
expect(group.style.display).toBe('none');
expect(row.textContent).toBe('');
await app.loadLocalModel();
expect(app._api).toHaveBeenCalledTimes(1);
app.openLocalModelWindow();
expect(modal.classList.contains('active')).toBe(false);
});
it('does not ask at all when the page already knows the user is not an admin', async () => {
const { win, newApp, button, group } = mount();
win.__codemanUser = { multiUser: true, role: 'user' };
const app = newApp(ok(IDLE));
await app.loadLocalModel();
expect(app._api).not.toHaveBeenCalled();
expect(button.hidden).toBe(true);
expect(group.style.display).toBe('none');
});
it('does not take another 403 for a refusal: an admin who must change the password is asked again', async () => {
// The auth layer answers 403 PASSWORD_CHANGE_REQUIRED for every /api route until the
// password is changed, and the dialog that changes it closes without a reload.
const { dom, win, document, newApp, button, row, group } = mount();
win.__codemanUser = { multiUser: true, role: 'admin' };
const answers: ApiResponse[] = [refusal(403, 'PASSWORD_CHANGE_REQUIRED'), ok(IDLE)];
const app = newApp(async () => answers.shift() ?? null);
await app.loadLocalModel();
expect(button.hidden).toBe(true);
// A failed request, not a refusal: the group stays, with the failure sentence.
expect(group.style.display).toBe('');
expect(row.textContent).toBe('Could not reach the server.');
// The password was changed: admin-ui.js announces the user again.
document.dispatchEvent(new dom.window.CustomEvent('codeman:me'));
await vi.waitFor(() => expect(button.hidden).toBe(false));
expect(app._api).toHaveBeenCalledTimes(2);
});
it('closes an open window when the routes start refusing', async () => {
const page = mount();
const { app } = await opened(page, IDLE);
expect(page.modal.classList.contains('active')).toBe(true);
app._api.mockImplementation(async () => refusal(403, 'FORBIDDEN'));
await app.loadLocalModel({ force: true });
expect(page.modal.classList.contains('active')).toBe(false);
expect(page.button.hidden).toBe(true);
});
});
describe('a server that is not a Mac', () => {
it('shows no button and no Settings group', async () => {
const { newApp, button, row, group, modal } = mount();
const app = newApp(ok(none('not-macos', undefined, { platform: 'linux', arch: 'x64', chip: 'AMD EPYC' })));
await app.loadLocalModel();
expect(button.hidden).toBe(true);
expect(group.style.display).toBe('none');
expect(row.textContent).toBe('');
expect(modal.classList.contains('active')).toBe(false);
});
});
describe('the welcome button', () => {
it('offers to run a local model until one is installed, then names it', async () => {
const { newApp, button, document } = mount();
let current = IDLE;
const app = newApp(() => ok(current));
await app.loadLocalModel();
expect(button.hidden).toBe(false);
expect(document.getElementById('welcomeLocalModelLabel')!.textContent).toBe('Run a local model');
// The icon of the markup stays: only the label is written.
expect(button.querySelector('svg')).not.toBeNull();
for (const busy of [INSTALLING, PAUSED, failed('load-failed', { model: 'Qwen3.6 35B-A3B' })]) {
current = busy;
await app.loadLocalModel({ force: true });
expect(button.textContent!.trim()).toBe('Run a local model');
}
current = INSTALLED;
await app.loadLocalModel({ force: true });
expect(button.textContent!.trim()).toBe('Local model');
current = STOPPED;
await app.loadLocalModel({ force: true });
expect(button.textContent!.trim()).toBe('Local model');
});
it('shows on a Mac that can run no model too: the window says why', async () => {
const { newApp, button } = mount();
const app = newApp(ok(none('memory', { memory: '8' }, { memClassGb: 8, memGb: '8.6', chip: 'Apple M2' })));
await app.loadLocalModel();
expect(button.hidden).toBe(false);
expect(button.textContent!.trim()).toBe('Run a local model');
});
it('opens the window, and the window gives the focus back when it closes', async () => {
const page = mount();
const app = page.newApp(ok(IDLE));
await app.loadLocalModel();
page.button.focus();
app.openLocalModelWindow();
expect(page.modal.classList.contains('active')).toBe(true);
expect(app.isLocalModelWindowOpen()).toBe(true);
// The focus lands on the selected model, inside the dialog.
expect(page.document.activeElement?.getAttribute('role')).toBe('radio');
app.closeLocalModelWindow();
expect(page.modal.classList.contains('active')).toBe(false);
expect(page.document.activeElement).toBe(page.button);
// Escape and the backdrop call it whether or not the window is open.
app.closeLocalModelWindow();
expect(app.isLocalModelWindowOpen()).toBe(false);
});
});
describe('the App Settings row', () => {
it.each([
['nothing installed', IDLE, 'No model installed', 'Apple M5 Pro, 48 GB memory, 897 GB free', 'Choose a model'],
['an install running', INSTALLING, 'Installing Qwen3.6 35B-A3B', 'Apple M5 Pro, 48 GB memory, 897 GB free', 'Open'],
['a paused install', PAUSED, 'The install of Qwen3.6 35B-A3B is paused', undefined, 'Open'],
[
'a stopped install',
failed('load-failed', { model: 'Qwen3.6 35B-A3B' }),
'The install of Qwen3.6 35B-A3B stopped',
undefined,
'Open',
],
['a running model', INSTALLED, 'Qwen3.6 35B-A3B is running', undefined, 'Open'],
['a stopped model', STOPPED, 'Qwen3.6 35B-A3B is installed and stopped', undefined, 'Open'],
] as Array<[string, LocalModelStatus, string, string | undefined, string]>)(
'says where things stand in a few words: %s',
async (_name, answer, label, detail, action) => {
const { newApp, row, rowButton, group } = mount();
const app = newApp(ok(answer));
await app.loadLocalModel();
expect(group.style.display).toBe('');
expect(texts(row, '.set-row-label')).toEqual([label]);
if (detail) expect(texts(row, '.set-row-desc')).toEqual([detail]);
expect(rowButton.hidden).toBe(false);
expect(rowButton.textContent).toBe(action);
}
);
it('gives the reason when no model fits, and still opens the window', async () => {
const page = mount();
const app = page.newApp(ok(none('memory', { memory: '8' }, { memClassGb: 8, memGb: '8.6', chip: 'Apple M2' })));
await app.loadLocalModel();
expect(texts(page.row, '.set-row-label')).toEqual(['No model installed']);
expect(texts(page.row, '.set-row-desc')).toEqual([
'This Mac has 8 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.',
]);
expect(page.rowButton.textContent).toBe('Open');
app.openLocalModelWindow();
expect(page.body.dataset.screen).toBe('none');
});
});
// ---------- The chooser ----------
describe('the chooser', () => {
it('opens with the hardware line, the memory bar and the five models, the pick selected', async () => {
const page = mount();
await opened(page, IDLE);
const { body } = page;
expect(body.dataset.screen).toBe('choose');
expect(texts(body, '.lm-hw')).toEqual(['Apple M5 Pro, 48 GB memory, 897 GB free']);
expect(texts(body, '.lm-figure')).toEqual(['About 36.0 of 37.6 GB of memory']);
expect(texts(body, '.lm-legend span')).toEqual(['The model at 131,072 tokens of context', 'What stays free']);
const group = body.querySelector('[role="radiogroup"]')!;
const label = page.document.getElementById(group.getAttribute('aria-labelledby')!)!;
// The server's word for a pick it has run on a Mac like this one labels the list.
expect(label.textContent).toBe('Recommended for this Mac');
const rows = [...group.querySelectorAll('[role="radio"]')];
expect(rows.map((row) => row.querySelector('.lm-model-name')!.textContent)).toEqual([
'Qwen3.6 35B-A3B',
'Qwen3.8 27B',
'GPT-OSS 20B',
'Qwen3.5 9B (Q8)',
'Qwen3.5 9B',
]);
expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual(['true', 'false', 'false', 'false', 'false']);
// One tab stop: the selected row.
expect(rows.map((row) => row.getAttribute('tabindex'))).toEqual(['0', '-1', '-1', '-1', '-1']);
// One line a row: name, one tag, speed, download size.
expect(rows.map((row) => row.querySelector('.lm-model-tag')!.textContent)).toEqual([
'Tested on a Mac like this',
'Slow',
'',
'Small model',
'Small model',
]);
// A measured speed is stated, an estimate says "about", and a missing one says so.
expect(texts(group, '.lm-model-speed')).toEqual([
'68 tokens/s',
'about 9.1 tokens/s',
'speed not known',
'about 18 tokens/s',
'about 29 tokens/s',
]);
expect(texts(group, '.lm-model-size')).toEqual(['23.1 GB', '17.7 GB', '13.8 GB', '10.4 GB', '6.6 GB']);
expect(texts(body, '.lm-caption span')).toEqual(['Recommended for this Mac', 'Speed', 'Download']);
expect(texts(body, '.lm-foot-note')).toEqual(['No account and no API key. The model runs only on this Mac.']);
expect(buttons(body)).toEqual(['Install on this Mac']);
});
it('gives the accent to the pick and to a tested row only', async () => {
const page = mount();
const best = candidate({ tested: false, tag: { kind: 'best', label: LOCAL_MODEL_TAG_LABELS.best } });
const tight = candidate({
modelId: 'gpt-oss:20b',
modelLabel: 'GPT-OSS 20B',
tested: false,
tag: { kind: 'tight', label: LOCAL_MODEL_TAG_LABELS.tight },
});
await opened(
page,
status({
headline: { text: LOCAL_MODEL_LABELS.headlineOk },
pick: best,
alternatives: [tight, ALT_SLOW, ALT_SMALL],
})
);
const tags = [...page.body.querySelectorAll('.lm-tag')];
expect(tags.map((tag) => tag.textContent)).toEqual(['Best fit', 'Tight', 'Slow', 'Small model']);
expect(tags.map((tag) => tag.classList.contains('lm-tag-accent'))).toEqual([true, false, false, false]);
// "Best fit for this Mac" would only repeat the tag: the list is labelled by what it holds.
expect(texts(page.body, '.lm-caption-label')).toEqual(['Models that fit this Mac']);
expect(page.body.textContent).not.toContain('Recommended');
expect(page.body.textContent).not.toContain('Best fit for this Mac');
});
it('says "estimated" once while the budget is a formula, and not once it is measured', async () => {
const page = mount();
const { app, serve } = await opened(page, IDLE);
expect(texts(page.body, '.lm-figure-aside')).toEqual(['estimated']);
for (const budgetSource of ['measured', 'wired-limit'] as const) {
serve(status({ budgetBytes: 40157944217, budgetGb: '40.2', budgetSource }));
await app.loadLocalModel({ force: true });
expect(texts(page.body, '.lm-figure')).toEqual(['About 36.0 of 40.2 GB of memory']);
expect(texts(page.body, '.lm-figure-aside')).toEqual([]);
expect(page.body.textContent).not.toContain('estimated');
}
});
it('puts the small-model caveat above the list and the notes under it', async () => {
const page = mount();
await opened(
page,
status({
verdict: 'limited',
headline: { text: LOCAL_MODEL_LABELS.headlineLimited },
notes: [
{ text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.4' } },
{ text: LOCAL_MODEL_LABELS.noteUntested },
],
pick: ALT_SMALL,
alternatives: [],
})
);
expect(texts(page.body, '.lm-lead')).toEqual([LOCAL_MODEL_LABELS.headlineLimited]);
expect(texts(page.body, '.lm-caption-label')).toEqual(['Models that fit this Mac']);
expect(texts(page.body, '.lm-notes .lm-aside')).toEqual([
'Qwen3.6 35B-A3B fits the memory of this Mac too, but its download needs about 0.4 GB more free disk space.',
'Not yet tested by us on a Mac like this.',
]);
});
it('offers only the coding agents the server lists for the selected model', async () => {
const page = mount();
await opened(page, IDLE);
const { body } = page;
const select = () => body.querySelector<HTMLSelectElement>('select.lm-select')!;
expect(texts(body, '.lm-runs-label')).toEqual(['Runs with']);
expect(body.querySelector('.lm-runs-label')!.getAttribute('for')).toBe(select().id);
expect([...select().options].map((option) => option.textContent)).toEqual(['OpenCode', 'Claude Code']);
expect(select().selectedOptions[0].textContent).toBe('OpenCode');
expect(texts(body, '.lm-runs-hint')).toEqual(['Not on this Mac yet. It is installed for you.']);
// No value in the page is a CLI id: the choice is a position in the server's list.
expect([...select().options].map((option) => option.value)).toEqual(['0', '1']);
select().value = '1';
select().dispatchEvent(new page.dom.window.Event('change'));
expect(select().selectedOptions[0].textContent).toBe('Claude Code');
expect(texts(body, '.lm-runs-hint')).toEqual(['The first answer can take minutes.']);
// The agent stays when the next row offers it too, and falls back to that row's first when not.
(body.querySelectorAll<HTMLElement>('[role="radio"]')[2] as HTMLElement).click();
expect(select().selectedOptions[0].textContent).toBe('Claude Code');
(body.querySelectorAll<HTMLElement>('[role="radio"]')[1] as HTMLElement).click();
expect([...select().options].map((option) => option.textContent)).toEqual(['OpenCode']);
});
it('reads a payload without the agent list as the one agent of the flat fields', async () => {
const page = mount();
const flat = { ...PICK, harnesses: undefined, cliInstalled: true } as unknown as Candidate;
await opened(page, status({ pick: flat, alternatives: [] }));
const select = page.body.querySelector<HTMLSelectElement>('select.lm-select')!;
expect([...select.options].map((option) => option.textContent)).toEqual(['OpenCode']);
expect(texts(page.body, '.lm-runs-hint')).toEqual([]);
});
it('has a button only when the server accepts an install', async () => {
const page = mount();
const { app, serve } = await opened(page, status({}, { actions: [] }));
expect(page.body.dataset.screen).toBe('choose');
expect(buttons(page.body)).toEqual([]);
// A server that sends no `install` object yet: the chooser, and nothing to press.
const older = { ...IDLE } as Partial<LocalModelStatus>;
delete older.install;
serve(older as LocalModelStatus);
await app.loadLocalModel({ force: true });
expect(page.body.querySelectorAll('[role="radio"]')).toHaveLength(5);
expect(buttons(page.body)).toEqual([]);
});
});
describe('the radio group at the keyboard', () => {
const key = (page: Mounted, name: string) => {
const event = new page.dom.window.KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true });
page.document.activeElement!.dispatchEvent(event);
return event;
};
const checked = (page: Mounted) =>
page.body.querySelector('[role="radio"][aria-checked="true"] .lm-model-name')!.textContent;
it('moves the selection with the arrow keys, Home and End, and the focus follows', async () => {
const page = mount();
await opened(page, IDLE);
expect(checked(page)).toBe('Qwen3.6 35B-A3B');
const walk: Array<[string, string]> = [
['ArrowDown', 'Qwen3.8 27B'],
['ArrowRight', 'GPT-OSS 20B'],
['ArrowUp', 'Qwen3.8 27B'],
['ArrowLeft', 'Qwen3.6 35B-A3B'],
// The ends wrap.
['ArrowUp', 'Qwen3.5 9B'],
['ArrowDown', 'Qwen3.6 35B-A3B'],
['End', 'Qwen3.5 9B'],
['Home', 'Qwen3.6 35B-A3B'],
];
for (const [name, model] of walk) {
const event = key(page, name);
expect(event.defaultPrevented, name).toBe(true);
expect(checked(page), name).toBe(model);
// The focus is on the selected row, which is the group's one tab stop.
const active = page.document.activeElement!;
expect(active.getAttribute('aria-checked'), name).toBe('true');
expect(active.getAttribute('tabindex'), name).toBe('0');
expect(page.body.querySelectorAll('[role="radio"][tabindex="0"]'), name).toHaveLength(1);
}
});
it('leaves every other key to the page', async () => {
const page = mount();
await opened(page, IDLE);
for (const name of ['Tab', 'a', 'Escape', 'PageDown']) {
expect(key(page, name).defaultPrevented, name).toBe(false);
expect(checked(page)).toBe('Qwen3.6 35B-A3B');
}
});
it('selects a row on a click, and keeps the selection across the poll', async () => {
const page = mount();
const { app } = await opened(page, IDLE);
page.body.querySelectorAll<HTMLElement>('[role="radio"]')[3].click();
expect(checked(page)).toBe('Qwen3.5 9B (Q8)');
const row = page.body.querySelector('[role="radio"][aria-checked="true"]');
await page.tick();
await app.loadLocalModel({ force: true });
expect(checked(page)).toBe('Qwen3.5 9B (Q8)');
// The same answer again repaints nothing: the very same element is still there.
expect(page.body.querySelector('[role="radio"][aria-checked="true"]')).toBe(row);
});
});
describe('the memory bar', () => {
const meter = (page: Mounted) => page.body.querySelector('.lm-bar')!;
const fill = (page: Mounted) => page.body.querySelector<HTMLElement>('.lm-bar-fill')!;
it('is filled to what the selected model needs, and follows the selection', async () => {
const page = mount();
await opened(page, IDLE);
expect(meter(page).getAttribute('role')).toBe('meter');
expect(meter(page).getAttribute('aria-valuemin')).toBe('0');
expect(meter(page).getAttribute('aria-valuemax')).toBe('100');
// The pick is a tight fit: the bar is nearly full.
expect(meter(page).getAttribute('aria-valuenow')).toBe('96');
expect(fill(page).style.width).toBe('96%');
expect(meter(page).getAttribute('aria-label')).toBe(
'The selected model needs 96% of the memory this Mac can give a model'
);
const expected: Array<[number, string, string, string]> = [
[1, '81', 'About 30.6 of 37.6 GB of memory', 'The model at 131,072 tokens of context'],
[4, '45', 'About 17.0 of 37.6 GB of memory', 'The model at 65,536 tokens of context'],
[0, '96', 'About 36.0 of 37.6 GB of memory', 'The model at 131,072 tokens of context'],
];
for (const [index, percent, figure, legend] of expected) {
page.body.querySelectorAll<HTMLElement>('[role="radio"]')[index].click();
expect(meter(page).getAttribute('aria-valuenow')).toBe(percent);
expect(fill(page).style.width).toBe(`${percent}%`);
expect(texts(page.body, '.lm-figure')).toEqual([figure]);
expect(texts(page.body, '.lm-legend span')[0]).toBe(legend);
}
});
it('never runs past its track', async () => {
const page = mount();
await opened(page, status({ pick: candidate({ budgetShare: 1.2 }), alternatives: [] }));
expect(meter(page).getAttribute('aria-valuenow')).toBe('100');
expect(fill(page).style.width).toBe('100%');
});
it('is one flat fill on a track, and stands still when motion is not wanted', () => {
const bar = STYLES.match(/\.lm-bar-fill \{[^}]*\}/)![0];
expect(bar).toContain('background: var(--accent);');
expect(bar).not.toMatch(/gradient|box-shadow|animation/);
expect(STYLES).toMatch(/\.lm-bar \{[^}]*height: 10px;[^}]*border-radius: 999px;/);
expect(STYLES).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.lm-bar-fill,[^}]*transition: none;/);
});
});
// ---------- Confirm and the consent ----------
describe('the confirm screen', () => {
it('collapses the list to the chosen row and says what is downloaded, and where it goes', async () => {
const page = mount();
await opened(page, status({}, { developerTools: 'missing' }));
press(page.body, 'Install on this Mac');
const { body } = page;
expect(body.dataset.screen).toBe('confirm');
// Nothing was sent: this is the question.
expect(body.querySelectorAll('[role="radio"]')).toHaveLength(0);
expect(texts(body, '.lm-model .lm-model-name')).toEqual(['Qwen3.6 35B-A3B']);
expect(texts(body, '.lm-size-row').map((text) => text.replace(/\s+/g, ' '))).toEqual([
'Qwen3.6 35B-A3B23.1 GB',
'Ollama 0.40.15, the program that runs the model0.2 GB',
'Total23.3 GB',
]);
expect(body.querySelector('.lm-size-total .lm-size-gb')!.textContent).toBe('23.3 GB');
// The coding agent is missing: the literal command its installer runs, and the folder.
expect(texts(body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash', FOLDER]);
const asides = texts(body, '.lm-aside');
expect(asides).toContain('Runs with OpenCode, at 131,072 tokens of context.');
expect(asides).toContain('OpenCode is not on this Mac yet. Its own installer is run with this command:');
expect(asides).toContain('No administrator password.');
expect(asides).toContain(LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing);
expect(buttons(body)).toEqual(['Back', 'Download and install']);
});
it('leaves out what does not apply: an installed runtime, an installed agent, a Mac with developer tools', async () => {
const page = mount();
const pick = candidate({ installBytes: 23105798956, installGb: '23.1', harnesses: [CLAUDE] });
await opened(page, status({ pick, alternatives: [] }, { runtime: RUNTIME_THERE, developerTools: 'present' }));
press(page.body, 'Install on this Mac');
expect(texts(page.body, '.lm-size-what')).toEqual(['Qwen3.6 35B-A3B', 'Total']);
expect(texts(page.body, '.lm-command')).toEqual([FOLDER]);
expect(page.body.textContent).not.toContain('developer tools');
expect(page.body.textContent).not.toContain('installer');
});
it.each(['unknown', 'present'] as const)('says nothing about developer tools when they are %s', async (tools) => {
const page = mount();
await opened(page, status({}, { developerTools: tools }));
press(page.body, 'Install on this Mac');
expect(page.body.textContent).not.toContain('git');
});
it('goes back to the chooser with the selection kept', async () => {
const page = mount();
await opened(page, IDLE);
page.body.querySelectorAll<HTMLElement>('[role="radio"]')[1].click();
press(page.body, 'Install on this Mac');
expect(texts(page.body, '.lm-model .lm-model-name')).toEqual(['Qwen3.8 27B']);
press(page.body, 'Back');
expect(page.body.dataset.screen).toBe('choose');
expect(page.body.querySelector('[aria-checked="true"] .lm-model-name')!.textContent).toBe('Qwen3.8 27B');
});
});
describe('the consent', () => {
it('sends the row that was on screen with the total that was on screen', async () => {
const page = mount();
const { app } = await opened(page, IDLE, () => ok(INSTALLING.install));
press(page.body, 'Install on this Mac');
expect(verbs(app)).toEqual([]);
press(page.body, 'Download and install');
await settle();
expect(verbs(app)).toEqual([
[
'POST',
'/api/local-model/install',
{
modelId: 'qwen3.6:35b-a3b',
cli: 'opencode',
context: 131072,
acknowledgedBytes: 23105798956 + RUNTIME_BYTES,
},
],
]);
// The answer is the install after the verb: the window shows the download at once.
expect(page.body.dataset.screen).toBe('progress');
});
it('sends the other row and the other agent when those were chosen', async () => {
const page = mount();
const { app } = await opened(page, IDLE, () => ok(INSTALLING.install));
page.body.querySelectorAll<HTMLElement>('[role="radio"]')[3].click();
const select = page.body.querySelector<HTMLSelectElement>('select.lm-select')!;
select.value = '1';
select.dispatchEvent(new page.dom.window.Event('change'));
press(page.body, 'Install on this Mac');
expect(texts(page.body, '.lm-size-total .lm-size-gb')).toEqual(['10.6 GB']);
press(page.body, 'Download and install');
await settle();
expect(verbs(app)[0][2]).toEqual({
modelId: 'qwen3.5:9b-q8_0',
cli: 'claude',
context: 65536,
acknowledgedBytes: 10449217949 + RUNTIME_BYTES,
});
});
it('reads a row without its own total as the model plus the runtime download', async () => {
const page = mount();
const bare = { ...PICK, installBytes: undefined, installGb: undefined } as unknown as Candidate;
const { app } = await opened(page, status({ pick: bare, alternatives: [] }), () => ok(INSTALLING.install));
press(page.body, 'Install on this Mac');
// No total the server formatted, so none is printed: bytes are never turned into a size here.
expect(texts(page.body, '.lm-size-what')).toEqual([
'Qwen3.6 35B-A3B',
'Ollama 0.40.15, the program that runs the model',
]);
press(page.body, 'Download and install');
await settle();
expect((verbs(app)[0][2] as { acknowledgedBytes: number }).acknowledgedBytes).toBe(23105798956 + RUNTIME_BYTES);
});
it('shows the new total after a 409 size-changed, and sends that one next', async () => {
const page = mount();
const answers: ApiResponse[] = [
conflict({
reason: 'size-changed',
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.9' } },
expectedBytes: 23900000000,
expectedGb: '23.9',
}),
ok(INSTALLING.install),
];
const { app } = await opened(page, IDLE, () => answers.shift() ?? null);
press(page.body, 'Install on this Mac');
press(page.body, 'Download and install');
await settle();
// Still the question, now with the server's sentence and its total.
expect(page.body.dataset.screen).toBe('confirm');
const notice = page.body.querySelector('.lm-notice')!;
expect(notice.getAttribute('role')).toBe('alert');
expect(notice.textContent).toBe('The download is 23.9 GB now. Check the sizes and confirm again.');
expect(texts(page.body, '.lm-size-total .lm-size-gb')).toEqual(['23.9 GB']);
press(page.body, 'Download and install');
await settle();
expect(verbs(app).map((verb) => (verb[2] as { acknowledgedBytes: number }).acknowledgedBytes)).toEqual([
23105798956 + RUNTIME_BYTES,
23900000000,
]);
expect(page.body.dataset.screen).toBe('progress');
});
it('forgets a refused total when the person goes back and picks again', async () => {
const page = mount();
const { app } = await opened(page, IDLE, () =>
conflict({
reason: 'size-changed',
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.9' } },
expectedBytes: 23900000000,
expectedGb: '23.9',
})
);
press(page.body, 'Install on this Mac');
press(page.body, 'Download and install');
await settle();
press(page.body, 'Back');
expect(page.body.querySelector('.lm-notice')).toBeNull();
page.body.querySelectorAll<HTMLElement>('[role="radio"]')[4].click();
press(page.body, 'Install on this Mac');
expect(texts(page.body, '.lm-size-total .lm-size-gb')).toEqual(['6.7 GB']);
press(page.body, 'Download and install');
await settle();
expect((verbs(app)[1][2] as { acknowledgedBytes: number }).acknowledgedBytes).toBe(6550825373 + RUNTIME_BYTES);
});
it('follows the server when a job is already running', async () => {
const page = mount();
const { serve } = await opened(page, IDLE, () =>
conflict({ reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } })
);
press(page.body, 'Install on this Mac');
serve(INSTALLING);
press(page.body, 'Download and install');
await settle();
await settle();
expect(page.body.dataset.screen).toBe('progress');
expect(texts(page.body, '.lm-notice-text')).toEqual(['An install is already running.']);
});
it('says so when the request did not reach the server, and when the server did not take it', async () => {
const page = mount();
const answers: ApiResponse[] = [
null,
{
ok: false,
status: 400,
json: async () => ({ success: false, error: 'not a model <b>offered</b>', errorCode: 'INVALID_INPUT' }),
},
];
await opened(page, IDLE, () => answers.shift() ?? null);
press(page.body, 'Install on this Mac');
press(page.body, 'Download and install');
await settle();
expect(page.body.dataset.screen).toBe('confirm');
expect(texts(page.body, '.lm-notice-text')).toEqual(['Could not reach the server.']);
press(page.body, 'Download and install');
await settle();
expect(texts(page.body, '.lm-notice-text')).toEqual(['The server did not accept that.']);
// The server's own words, as text.
expect(texts(page.body, '.lm-notice-detail')).toEqual(['not a model <b>offered</b>']);
expect(page.body.querySelector('.lm-notice b')).toBeNull();
});
it('does not let a read that was already on its way undo what a verb answered', async () => {
const page = mount();
let stale!: (response: ApiResponse) => void;
const { app } = await opened(page, IDLE, () => ok(INSTALLING.install));
press(page.body, 'Install on this Mac');
// A poll is in flight with the old answer when the install is sent.
app._api.mockImplementationOnce(() => new Promise<ApiResponse>((done) => (stale = done)));
await page.tick();
press(page.body, 'Download and install');
await settle();
expect(page.body.dataset.screen).toBe('progress');
// Every later read hangs, so what the old answer does to the screen can be seen.
const before = gets(app);
app._api.mockImplementation(() => new Promise<ApiResponse>(() => {}));
stale(ok(IDLE));
await settle();
await settle();
// The older answer is dropped, and a new read was sent after it.
expect(page.body.dataset.screen).toBe('progress');
expect(gets(app)).toBe(before + 1);
});
it('sends one request for two quick clicks', async () => {
const page = mount();
let answer!: (response: ApiResponse) => void;
const { app } = await opened(page, IDLE, () => new Promise<ApiResponse>((done) => (answer = done)));
press(page.body, 'Install on this Mac');
press(page.body, 'Download and install');
// While the request is in flight every button waits.
expect([...page.body.querySelectorAll<HTMLButtonElement>('.lm-buttons button')].map((el) => el.disabled)).toEqual([
true,
true,
]);
page.body.querySelectorAll<HTMLButtonElement>('.lm-buttons button')[1].click();
answer(ok(INSTALLING.install));
await settle();
expect(verbs(app)).toHaveLength(1);
});
});
// ---------- Progress ----------
describe('the progress screen', () => {
it('replaces the memory bar with the download bar and lists the steps, the current one marked', async () => {
const page = mount();
await opened(page, INSTALLING);
const { body } = page;
expect(body.dataset.screen).toBe('progress');
expect(texts(body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']);
expect(texts(body, '.lm-figure')).toEqual(['6.2 of 23.1 GB']);
expect(texts(body, '.lm-figure-aside')).toEqual(['58 MB per second, about 5 minutes left']);
const bar = body.querySelector('.lm-bar')!;
expect(bar.getAttribute('role')).toBe('progressbar');
expect(bar.getAttribute('aria-valuenow')).toBe('27');
expect(body.querySelector<HTMLElement>('.lm-bar-fill')!.style.width).toBe('27%');
expect(body.querySelector('[role="meter"]')).toBeNull();
const steps = [...body.querySelectorAll('ol.lm-steps > li')];
expect(steps.map((step) => step.textContent)).toEqual([
'Getting the runtime',
'Downloading the model',
'Loading and checking',
'Installing OpenCode',
'Connecting',
]);
expect(steps.map((step) => step.className.replace('lm-step', '').trim())).toEqual([
'lm-step-done',
'lm-step-now',
'',
'',
'',
]);
expect(steps.map((step) => step.getAttribute('aria-current'))).toEqual([null, 'step', null, null, null]);
expect(texts(body, '.lm-aside')).toEqual([
'You can close this window. The download continues while Codeman is running.',
]);
expect(buttons(body)).toEqual(['Cancel']);
});
it('leaves the agent step out of a job whose coding agent is installed', async () => {
const page = mount();
await opened(
page,
status(
{},
{
...JOB,
state: 'installing',
step: 'runtime',
steps: ['runtime', 'model', 'verify', 'connect'],
actions: ['cancel'],
}
)
);
expect(texts(page.body, 'ol.lm-steps > li')).toEqual([
'Getting the runtime',
'Downloading the model',
'Loading and checking',
'Connecting',
]);
});
it.each([
[undefined, '58 MB per second'],
[0, '58 MB per second, less than a minute left'],
[59, '58 MB per second, less than a minute left'],
[60, '58 MB per second, about 1 minute left'],
[149, '58 MB per second, about 2 minutes left'],
[3599, '58 MB per second, about 60 minutes left'],
[3600, '58 MB per second, about 1 hour left'],
[9000, '58 MB per second, about 3 hours left'],
] as Array<[number | undefined, string]>)('words the time left: %s seconds', async (secondsLeft, aside) => {
const page = mount();
await opened(page, status({}, { ...INSTALLING.install, progress: { ...PROGRESS, secondsLeft } }));
expect(texts(page.body, '.lm-figure-aside')).toEqual([aside]);
});
it('names the step when nothing is being downloaded, with the bar full once the model is in', async () => {
const page = mount();
const { app, serve } = await opened(
page,
status({}, { ...JOB, state: 'installing', step: 'runtime', actions: ['cancel'], runtime: RUNTIME_MISSING })
);
expect(texts(page.body, '.lm-figure')).toEqual(['Getting the runtime']);
expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('0');
for (const [step, label] of [
['verify', 'Loading and checking'],
['agent', 'Installing OpenCode'],
['connect', 'Connecting'],
] as const) {
serve(status({}, { ...JOB, state: 'installing', step, actions: ['cancel'] }));
await app.loadLocalModel({ force: true });
expect(texts(page.body, '.lm-figure')).toEqual([label]);
expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('100');
expect(texts(page.body, '.lm-figure-aside')).toEqual([]);
}
});
it('cancels with one request', async () => {
const page = mount();
const { app } = await opened(page, INSTALLING, () => ok(PAUSED.install));
press(page.body, 'Cancel');
await settle();
expect(verbs(app)).toEqual([['POST', '/api/local-model/cancel', undefined]]);
expect(page.body.dataset.screen).toBe('paused');
});
it('moves only the bar when a tick moved only the download: the button is not swapped under a click', async () => {
const page = mount();
const { app, serve } = await opened(page, INSTALLING);
const cancel = page.body.querySelector('.lm-buttons button');
const steps = page.body.querySelector('ol.lm-steps');
serve(
status(
{},
{
...INSTALLING.install,
progress: { ...PROGRESS, completedBytes: 11552899478, completedGb: '11.6', secondsLeft: 199 },
}
)
);
await app.loadLocalModel({ force: true });
expect(texts(page.body, '.lm-figure')).toEqual(['11.6 of 23.1 GB']);
expect(texts(page.body, '.lm-figure-aside')).toEqual(['58 MB per second, about 3 minutes left']);
expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('50');
expect(page.body.querySelector<HTMLElement>('.lm-bar-fill')!.style.width).toBe('50%');
expect(page.body.querySelectorAll('.lm-meter')).toHaveLength(1);
expect(page.body.querySelector('.lm-buttons button')).toBe(cancel);
expect(page.body.querySelector('ol.lm-steps')).toBe(steps);
// A new step is a new picture.
serve(status({}, { ...JOB, state: 'installing', step: 'verify', actions: ['cancel'] }));
await app.loadLocalModel({ force: true });
expect(page.body.querySelector('ol.lm-steps')).not.toBe(steps);
expect(texts(page.body, '.lm-step-now')).toEqual(['Loading and checking']);
});
});
describe('a paused install', () => {
it('shows where it stopped and continues only on a click', async () => {
const page = mount();
const { app } = await opened(page, PAUSED, () => ok(INSTALLING.install));
const { body } = page;
expect(body.dataset.screen).toBe('paused');
expect(texts(body, '.lm-title')).toEqual(['The install of Qwen3.6 35B-A3B is paused']);
expect(texts(body, '.lm-figure')).toEqual(['6.2 of 23.1 GB']);
// No speed and no time left on a download that is not running.
expect(texts(body, '.lm-figure-aside')).toEqual([]);
expect(texts(body, '.lm-step-stopped')).toEqual(['Downloading the model']);
expect(texts(body, '.lm-aside')).toEqual(['Nothing is downloaded until you continue.']);
expect(buttons(body)).toEqual(['Remove', 'Choose another model', 'Continue']);
expect(verbs(app)).toEqual([]);
press(body, 'Continue');
await settle();
expect(verbs(app)).toEqual([['POST', '/api/local-model/resume', {}]]);
expect(body.dataset.screen).toBe('progress');
});
it('lets the person pick another model, and come back', async () => {
const page = mount();
const { app } = await opened(page, PAUSED, () => ok(INSTALLING.install));
press(page.body, 'Choose another model');
expect(page.body.dataset.screen).toBe('choose');
expect(buttons(page.body)).toEqual(['Back', 'Install on this Mac']);
press(page.body, 'Back');
expect(page.body.dataset.screen).toBe('paused');
press(page.body, 'Choose another model');
page.body.querySelectorAll<HTMLElement>('[role="radio"]')[4].click();
press(page.body, 'Install on this Mac');
press(page.body, 'Download and install');
await settle();
// A new install replaces the paused job.
expect(verbs(app)[0].slice(0, 2)).toEqual(['POST', '/api/local-model/install']);
expect((verbs(app)[0][2] as { modelId: string }).modelId).toBe('qwen3.5:9b');
});
});
// ---------- Failures ----------
describe('a failure', () => {
it("says what happened in the server's sentence, and offers the one action that helps", async () => {
const page = mount();
const { app } = await opened(
page,
failed('load-failed', { model: 'Qwen3.6 35B-A3B' }, { detail: 'runner exited with status 2' }),
() => ok(INSTALLING.install)
);
const { body } = page;
expect(body.dataset.screen).toBe('failed');
expect(texts(body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B did not load on this Mac.']);
// The raw detail is folded away, and is text.
const details = body.querySelector('details.lm-details')!;
expect((details as HTMLDetailsElement).open).toBe(false);
expect(details.querySelector('summary')!.textContent).toBe('Details');
expect(details.querySelector('pre')!.textContent).toBe('runner exited with status 2');
expect(texts(body, '.lm-step-stopped')).toEqual(['Loading and checking']);
expect(buttons(body)).toEqual(['Remove', 'Try again']);
press(body, 'Try again');
await settle();
expect(verbs(app)).toEqual([['POST', '/api/local-model/resume', {}]]);
});
it('never shows a bare word in place of the sentence', async () => {
for (const code of Object.keys(LOCAL_MODEL_ERROR_LABELS) as LocalModelErrorCode[]) {
const page = mount();
const vars = { model: 'M', port: '11435', actual: '4096', wanted: '131072', cli: 'OpenCode', id: 'local-ollama' };
await opened(page, failed(code, vars));
const title = texts(page.body, '.lm-title')[0];
expect(title.length, code).toBeGreaterThan(20);
expect(title, code).not.toMatch(/[{}]/);
expect(title, code).not.toMatch(/sorry|apolog/i);
page.dom.window.close();
}
});
it('answers a port that is taken with another port', async () => {
const page = mount();
const { app } = await opened(page, failed('port-in-use', { port: '11435' }), () => ok(INSTALLING.install));
expect(texts(page.body, '.lm-title')).toEqual(['Port 11435 is in use by another program.']);
expect(buttons(page.body)).toEqual(['Remove', 'Use another port']);
press(page.body, 'Use another port');
await settle();
expect(verbs(app)).toEqual([['POST', '/api/local-model/resume', { newPort: true }]]);
});
it('offers the next model by name, and opens the chooser on it', async () => {
const page = mount();
const { app } = await opened(
page,
failed(
'no-tool-call',
{ model: 'Qwen3.6 35B-A3B' },
{ nextModel: { modelId: 'qwen3.8:27b', modelLabel: 'Qwen3.8 27B' } }
)
);
expect(texts(page.body, '.lm-title')).toEqual([
'Qwen3.6 35B-A3B loaded but did not call a tool, so a coding agent cannot work with it.',
]);
expect(buttons(page.body)).toEqual(['Remove', 'Try Qwen3.8 27B']);
press(page.body, 'Try Qwen3.8 27B');
expect(page.body.dataset.screen).toBe('choose');
expect(page.body.querySelector('[aria-checked="true"] .lm-model-name')!.textContent).toBe('Qwen3.8 27B');
// The person still confirms its sizes: nothing was sent.
expect(verbs(app)).toEqual([]);
});
it('offers the chooser when the server names no next model, and nothing when none is left', async () => {
const page = mount();
const { app, serve } = await opened(page, failed('does-not-fit', { model: 'Qwen3.6 35B-A3B' }));
expect(buttons(page.body)).toEqual(['Remove', 'Choose another model']);
// A model the list no longer holds is not offered by name.
serve(
failed('does-not-fit', { model: 'Qwen3.6 35B-A3B' }, { nextModel: { modelId: 'gone:1b', modelLabel: 'Gone 1B' } })
);
await app.loadLocalModel({ force: true });
expect(buttons(page.body)).toEqual(['Remove', 'Choose another model']);
const empty = failed('does-not-fit', { model: 'Qwen3.6 35B-A3B' });
empty.recommendation = none('memory', { memory: '48' }).recommendation;
serve(empty);
await app.loadLocalModel({ force: true });
expect(buttons(page.body)).toEqual(['Remove']);
});
it('shows a button only for an action the server accepts right now', async () => {
const page = mount();
const answer = failed('load-failed', { model: 'Qwen3.6 35B-A3B' });
answer.install.actions = [];
await opened(page, answer);
expect(texts(page.body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B did not load on this Mac.']);
expect(buttons(page.body)).toEqual([]);
});
});
// ---------- Installed ----------
describe('an installed model', () => {
it('says it is ready, with the measured facts in one line', async () => {
const page = mount();
await opened(page, INSTALLED);
const { body } = page;
expect(body.dataset.screen).toBe('installed');
expect(texts(body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is ready on this Mac.']);
expect(texts(body, '.lm-state-text')).toEqual(['Running']);
expect(body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(true);
expect(texts(body, '.lm-aside')).toEqual([
'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory when loaded, all on the GPU.',
]);
expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']);
// One main action.
expect(body.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1);
expect(body.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start a session');
});
it('says the one honest sentence about git and Python only on a Mac without developer tools', async () => {
const page = mount();
const { app, serve } = await opened(page, INSTALLED);
expect(page.body.textContent).not.toContain('developer tools');
serve(status({}, { ...INSTALLED.install, developerTools: 'missing' }));
await app.loadLocalModel({ force: true });
expect(texts(page.body, '.lm-aside')).toContain(
'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.'
);
});
it('stops and starts the runtime, and Start is the main action of a stopped model', async () => {
const page = mount();
const answers = [ok(STOPPED.install), ok(INSTALLED.install)];
const { app } = await opened(page, INSTALLED, () => answers.shift() ?? null);
press(page.body, 'Stop');
await settle();
expect(texts(page.body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is installed on this Mac.']);
expect(texts(page.body, '.lm-state-text')).toEqual(['Stopped']);
expect(page.body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(false);
expect(buttons(page.body)).toEqual(['Remove', 'Start']);
expect(page.body.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start');
press(page.body, 'Start');
await settle();
expect(verbs(app)).toEqual([
['POST', '/api/local-model/runtime', { action: 'stop' }],
['POST', '/api/local-model/runtime', { action: 'start' }],
]);
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
});
it.each([
[
'runtime-stopped' as const,
{},
['start', 'remove'] as const,
'The runtime keeps stopping, so Codeman no longer restarts it.',
['Remove', 'Start'],
{ action: 'start' },
],
[
'port-in-use' as const,
{ port: '11435' },
['start', 'remove'] as const,
'Port 11435 is in use by another program.',
['Remove', 'Use another port'],
{ action: 'start', newPort: true },
],
])('shows what blocks a session and its one action: %s', async (code, vars, actions, sentence, labels, body) => {
const page = mount();
const answer = status(
{},
{
...STOPPED.install,
actions: [...actions],
error: {
code,
message: { text: LOCAL_MODEL_ERROR_LABELS[code], ...(Object.keys(vars).length ? { vars } : {}) },
next: LOCAL_MODEL_ERROR_NEXT[code],
},
}
);
const { app } = await opened(page, answer, () => ok(INSTALLED.install));
expect(texts(page.body, '.lm-error .lm-title')).toEqual([sentence]);
expect(buttons(page.body)).toEqual(labels);
expect(page.body.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1);
press(page.body, labels[1]);
await settle();
expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', body]]);
});
it('offers only the removal when the model changed since it was installed', async () => {
const page = mount();
const answer = status(
{},
{
...INSTALLED.install,
actions: ['stop', 'remove'],
error: {
code: 'model-changed',
message: { text: LOCAL_MODEL_ERROR_LABELS['model-changed'] },
next: LOCAL_MODEL_ERROR_NEXT['model-changed'],
},
}
);
await opened(page, answer);
expect(texts(page.body, '.lm-error .lm-title')).toEqual([
'The model on this Mac changed since it was installed. Remove it and install it again.',
]);
// No session can start, so that button is not there.
expect(buttons(page.body)).toEqual(['Stop', 'Remove']);
press(page.body, 'Remove');
expect(texts(page.body, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']);
});
it('starts a session on the saved connection with the agent of the install, after refreshing the page', async () => {
const page = mount();
const { app } = await opened(page, INSTALLED);
const order: string[] = [];
app.loadAppSettingsFromServer = vi.fn(async () => void order.push('settings'));
app.loadCliListForSettings = vi.fn(async () => void order.push('clis'));
app.selectCustomModelEntry = vi.fn(async () => void order.push('launch'));
app.closeAppSettings = vi.fn();
press(page.body, 'Start a session');
await settle();
// The settings flag and the agent's availability are read again before the launcher runs.
expect(order).toEqual(['settings', 'clis', 'launch']);
// Both values are the payload's.
expect(app.selectCustomModelEntry).toHaveBeenCalledWith('opencode', 'local-ollama');
expect(app.isLocalModelWindowOpen()).toBe(false);
expect(page.modal.classList.contains('active')).toBe(false);
// App Settings was not open: it is left alone.
expect(app.closeAppSettings).not.toHaveBeenCalled();
});
it('closes App Settings too when the window was opened from there', async () => {
const page = mount();
const { app } = await opened(page, INSTALLED);
page.document.getElementById('appSettingsModal')!.classList.add('active');
app.selectCustomModelEntry = vi.fn(async () => undefined);
app.closeAppSettings = vi.fn();
press(page.body, 'Start a session');
await settle();
expect(app.closeAppSettings).toHaveBeenCalledTimes(1);
expect(app.selectCustomModelEntry).toHaveBeenCalledWith('opencode', 'local-ollama');
});
});
describe('Remove', () => {
it('asks first, and deletes the downloaded model too unless the person keeps it', async () => {
const page = mount();
const { app } = await opened(page, INSTALLED, () => ok(install()));
press(page.body, 'Remove');
// The question, in place of the buttons. Nothing was sent.
expect(verbs(app)).toEqual([]);
const question = page.body.querySelector('.lm-remove')!;
expect(texts(question, '.lm-lead')).toEqual(['Remove the local model from this Mac?']);
expect(texts(question, '.lm-check span')).toEqual(['Also delete the downloaded model']);
expect(texts(question, '.lm-aside')).toEqual(['The coding agent stays installed.']);
expect(question.querySelector<HTMLInputElement>('input[type="checkbox"]')!.checked).toBe(true);
expect(buttons(page.body)).toEqual(['Cancel', 'Remove']);
press(page.body, 'Remove');
await settle();
expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]);
});
it('keeps the model when the box is cleared, and Cancel sends nothing', async () => {
const page = mount();
const { app } = await opened(page, INSTALLED, () => ok(install()));
press(page.body, 'Remove');
press(page.body, 'Cancel');
expect(page.body.querySelector('.lm-remove')).toBeNull();
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
press(page.body, 'Remove');
const box = page.body.querySelector<HTMLInputElement>('.lm-remove input[type="checkbox"]')!;
box.checked = false;
box.dispatchEvent(new page.dom.window.Event('change'));
press(page.body, 'Remove');
await settle();
expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: false }]]);
});
it('goes back to the chooser once the model is gone', async () => {
const page = mount();
const { serve } = await opened(page, INSTALLED, () => ok(install()));
serve(IDLE);
press(page.body, 'Remove');
press(page.body, 'Remove');
await settle();
await settle();
expect(page.body.dataset.screen).toBe('choose');
expect(page.button.textContent!.trim()).toBe('Run a local model');
});
it("names the sessions that still use the model, in the server's sentence", async () => {
const page = mount();
await opened(page, INSTALLED, () =>
conflict({
reason: 'sessions-open',
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-site' } },
sessions: ['w1-app', 'w2-site'],
})
);
press(page.body, 'Remove');
press(page.body, 'Remove');
await settle();
expect(page.body.dataset.screen).toBe('installed');
expect(texts(page.body, '.lm-notice-text')).toEqual([
'Sessions are still using this model: w1-app, w2-site. Close them first.',
]);
// Still the question: the person can close the sessions and press Remove again.
expect(buttons(page.body)).toEqual(['Cancel', 'Remove']);
});
it('removes a paused or stopped install the same way', async () => {
for (const answer of [PAUSED, failed('load-failed', { model: 'Qwen3.6 35B-A3B' })]) {
const page = mount();
const { app } = await opened(page, answer, () => ok(install()));
press(page.body, 'Remove');
expect(texts(page.body, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']);
press(page.body, 'Remove');
await settle();
expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]);
page.dom.window.close();
}
});
});
// ---------- No recommendation ----------
describe('no recommendation', () => {
it.each([
[
'intel',
none('intel', undefined, { arch: 'x64', arm64: false, chip: 'Intel(R) Core(TM) i7-9750H', memClassGb: 16 }),
'Intel(R) Core(TM) i7-9750H, 16 GB memory, 897 GB free',
'This Mac has an Intel processor. Local coding models need Apple silicon.',
],
[
'memory',
none('memory', { memory: '8' }, { memGb: '8.6', memClassGb: 8, chip: 'Apple M2', freeDiskGb: '41.3' }),
'Apple M2, 8 GB memory, 41.3 GB free',
'This Mac has 8 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.',
],
[
'disk',
none('disk', { download: '6.6', reserve: '10.7', free: '12.0', missing: '5.3' }, { freeDiskGb: '12.0' }),
'Apple M5 Pro, 48 GB memory, 12.0 GB free',
'Not enough free disk space. The smallest model that fits this Mac is a 6.6 GB download, and 10.7 GB must stay free after it. 12.0 GB is free now. Free up about 5.3 GB.',
],
] as Array<[string, LocalModelStatus, string, string]>)(
'opens with the hardware line, the reason and no button except Close: %s',
async (_reason, answer, hardware, sentence) => {
const page = mount();
const { app } = await opened(page, answer);
expect(page.body.dataset.screen).toBe('none');
expect(texts(page.body, '.lm-hw')).toEqual([hardware]);
expect(texts(page.body, '.lm-reason')).toEqual([sentence]);
expect(page.body.querySelector('[role="radiogroup"]')).toBeNull();
expect(page.body.querySelector('.lm-bar')).toBeNull();
expect(buttons(page.body)).toEqual(['Close']);
press(page.body, 'Close');
expect(app.isLocalModelWindowOpen()).toBe(false);
}
);
it('leaves an unknown memory size out of the hardware line', async () => {
const page = mount();
const answer = none('memory', undefined, { memBytes: 0, memGb: '0.0', memClassGb: 0 });
answer.recommendation.headline = { text: LOCAL_MODEL_LABELS.noneMemoryUnknown };
await opened(page, answer);
expect(texts(page.body, '.lm-hw')).toEqual(['Apple M5 Pro']);
expect(page.body.textContent).not.toMatch(/\b0(\.0)? GB/);
});
});
// ---------- The poll ----------
describe('the poll', () => {
it('asks once a second while the window is open, and stops when it closes', async () => {
const page = mount();
const app = page.newApp(() => ok(INSTALLING));
await app.loadLocalModel();
expect(gets(app)).toBe(1);
// Closed: no timer at all, however long the welcome screen stays up.
expect(page.timers).toHaveLength(0);
app.openLocalModelWindow();
await settle();
// Opening asks at once (the minute-old answer is not good enough for a download).
expect(gets(app)).toBe(2);
expect(page.timers.map((timer) => timer.ms)).toEqual([1000]);
for (let second = 1; second <= 3; second++) {
await page.tick();
expect(gets(app)).toBe(2 + second);
expect(page.timers.map((timer) => timer.ms)).toEqual([1000]);
}
app.closeLocalModelWindow();
expect(page.timers).toHaveLength(0);
await page.tick();
await page.tick();
expect(gets(app)).toBe(5);
// Reopened: it asks again.
app.openLocalModelWindow();
await settle();
await page.tick();
expect(gets(app)).toBe(7);
app.closeLocalModelWindow();
});
it('does not re-arm a tick that was already running when the window closed', async () => {
const page = mount();
let answer: ((response: ApiResponse) => void) | null = null;
const app = page.newApp(() => ok(IDLE));
await app.loadLocalModel();
app.openLocalModelWindow();
await settle();
app._api.mockImplementation(() => new Promise<ApiResponse>((done) => (answer = done)));
const due = page.timers.splice(0);
for (const timer of due) timer.fn();
app.closeLocalModelWindow();
answer!(ok(IDLE));
await settle();
expect(page.timers).toHaveLength(0);
});
it('keeps its place and asks nothing while the tab is in the background', async () => {
const page = mount({ visible: false });
expect(page.document.hidden).toBe(true);
const app = page.newApp(() => ok(INSTALLING));
await app.loadLocalModel();
app.openLocalModelWindow();
await settle();
const before = gets(app);
await page.tick();
await page.tick();
expect(gets(app)).toBe(before);
expect(page.timers).toHaveLength(1);
app.closeLocalModelWindow();
});
it('follows the install from the download to the end without a click', async () => {
const page = mount();
const { serve } = await opened(page, INSTALLING);
expect(page.body.dataset.screen).toBe('progress');
serve(INSTALLED);
await page.tick();
expect(page.body.dataset.screen).toBe('installed');
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
// The focus goes to the main action of the new screen, inside the dialog.
expect(page.document.activeElement?.textContent).toBe('Start a session');
});
});
// ---------- Text only ----------
describe('what the server sends is shown as text', () => {
const HOSTILE = '<img src=x onerror="window.__pwned=1">';
const clean = (page: Mounted) => {
expect(page.document.querySelectorAll('img, script, iframe')).toHaveLength(0);
expect((page.win as unknown as { __pwned?: number }).__pwned).toBeUndefined();
};
it('in the chooser and on the confirm screen', async () => {
const page = mount();
const hostile = candidate({
modelId: 'evil:1b',
modelLabel: `Model ${HOSTILE}`,
harnesses: [{ cli: 'x', cliLabel: `Agent ${HOSTILE}`, installed: false, installCommand: `run ${HOSTILE}` }],
});
await opened(
page,
status(
{
pick: hostile,
alternatives: [],
notes: [{ text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: HOSTILE, missing: '1' } }],
},
{ folder: `/tmp/${HOSTILE}` },
{ chip: `Chip ${HOSTILE}` }
)
);
clean(page);
expect(texts(page.body, '.lm-hw')[0]).toContain(HOSTILE);
expect(texts(page.body, '.lm-model-name')).toEqual([`Model ${HOSTILE}`]);
expect(page.body.querySelector('option')!.textContent).toBe(`Agent ${HOSTILE}`);
expect(texts(page.body, '.lm-notes .lm-aside')[0]).toContain(HOSTILE);
press(page.body, 'Install on this Mac');
clean(page);
expect(texts(page.body, '.lm-command')).toEqual([`run ${HOSTILE}`, `/tmp/${HOSTILE}`]);
expect(page.body.textContent).toContain(`Agent ${HOSTILE} is not on this Mac yet.`);
// The row and the button on the welcome screen too.
expect(page.row.textContent).toContain(HOSTILE);
clean(page);
});
it('in a job, an error and its detail, a refusal and the installed screen', async () => {
const page = mount();
const job = { ...JOB, model: { id: 'evil:1b', label: `Model ${HOSTILE}` }, cliLabel: `Agent ${HOSTILE}` };
const { app, serve } = await opened(
page,
status({}, { ...job, state: 'installing', step: 'agent', actions: ['cancel'] }),
() =>
conflict({
reason: 'sessions-open',
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: HOSTILE } },
sessions: [HOSTILE],
})
);
expect(texts(page.body, '.lm-title')).toEqual([`Installing Model ${HOSTILE}`]);
expect(texts(page.body, '.lm-step-now')).toEqual([`Installing Agent ${HOSTILE}`]);
clean(page);
serve(
status(
{},
{
...job,
state: 'failed',
step: 'agent',
actions: ['resume', 'remove'],
error: {
code: 'harness-failed',
message: { text: LOCAL_MODEL_ERROR_LABELS['harness-failed'], vars: { cli: HOSTILE } },
next: 'retry',
detail: `installer said ${HOSTILE}`,
},
}
)
);
await app.loadLocalModel({ force: true });
expect(texts(page.body, '.lm-title')).toEqual([`${HOSTILE} could not be installed.`]);
expect(page.body.querySelector('.lm-detail-text')!.textContent).toBe(`installer said ${HOSTILE}`);
clean(page);
serve(status({}, { ...INSTALLED.install, ...job }));
await app.loadLocalModel({ force: true });
expect(texts(page.body, '.lm-title')).toEqual([`Model ${HOSTILE} is ready on this Mac.`]);
press(page.body, 'Remove');
press(page.body, 'Remove');
await settle();
expect(texts(page.body, '.lm-notice-text')[0]).toContain(HOSTILE);
expect(page.row.textContent).toContain(HOSTILE);
clean(page);
});
it('because the module writes nothing as markup', () => {
const code = MODULE.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
expect(code).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/);
});
});
// ---------- Languages, rules ----------
describe('in another language', () => {
it('repaints the button, the row and the open window when the language changes', async () => {
const page = mount({ i18n: true });
page.win.CodemanI18n.start();
await opened(page, IDLE);
expect(buttons(page.body)).toEqual(['Install on this Mac']);
page.win.CodemanI18n.configure({ language: 'zh-CN' });
await settle();
expect(buttons(page.body)).toEqual(['安装到此 Mac']);
expect(page.button.textContent!.trim()).toBe('运行本地模型');
expect(texts(page.row, '.set-row-label')).toEqual(['尚未安装模型']);
expect(texts(page.body, '.lm-figure')).toEqual(['约需 36.0 GB 内存,共 37.6 GB 可用']);
// Names are someone else's text and stay as sent; the server's tag is translated.
expect(texts(page.body, '.lm-model-name')[0]).toBe('Qwen3.6 35B-A3B');
expect(texts(page.body, '.lm-tag')[0]).toBe('已在同类 Mac 上测试');
// The static title is the page translator's.
expect(page.document.getElementById('localModelModalTitle')!.textContent).toBe('在此 Mac 上运行模型');
page.win.CodemanI18n.configure({ language: 'en' });
await settle();
expect(buttons(page.body)).toEqual(['Install on this Mac']);
});
});
describe('a repaint of everything', () => {
it('reaches a progress screen whole when the language changes, not its bar alone', async () => {
const page = mount({ i18n: true });
page.win.CodemanI18n.start();
await opened(page, INSTALLING);
expect(texts(page.body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']);
page.win.CodemanI18n.configure({ language: 'zh-CN' });
await settle();
// Title, figure, steps, note and button: every word of the screen, not the bar's line only.
expect(texts(page.body, '.lm-title')).toEqual(['正在安装 Qwen3.6 35B-A3B']);
expect(texts(page.body, '.lm-figure')).toEqual(['已下载 6.2 GB,共 23.1 GB']);
expect(texts(page.body, '.lm-figure-aside')).toEqual(['每秒 58 MB,剩余约 5 分钟']);
expect(texts(page.body, 'ol.lm-steps > li')).toEqual([
'获取运行时',
'下载模型',
'加载并检查',
'安装 OpenCode',
'正在连接',
]);
expect(buttons(page.body)).toEqual(['取消']);
expect(page.body.textContent).not.toMatch(/Installing|Cancel|You can close/);
page.win.CodemanI18n.configure({ language: 'en' });
await settle();
expect(buttons(page.body)).toEqual(['Cancel']);
expect(texts(page.body, 'ol.lm-steps > li')[0]).toBe('Getting the runtime');
});
it('follows a display name that changed while a download is on screen', async () => {
const page = mount({ i18n: true });
page.win.CodemanI18n.start();
await opened(page, INSTALLING);
page.win.CodemanI18n.configure({ language: 'en', displayName: 'Acme' });
await settle();
expect(texts(page.body, '.lm-aside')).toEqual([
'You can close this window. The download continues while Acme is running.',
]);
});
it('paints the whole screen again when the window is reopened on the same download', async () => {
const page = mount();
const { app } = await opened(page, INSTALLING);
app.closeLocalModelWindow();
// Whatever the closed window still holds is not trusted: here, a title something else wrote.
page.body.querySelector('.lm-title')!.textContent = 'stale';
app.openLocalModelWindow();
await settle();
expect(texts(page.body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']);
expect(buttons(page.body)).toEqual(['Cancel']);
});
});
describe('the module keeps to its rules', () => {
/** U+2192, U+00B7, U+2013 and U+2014, built from their code points so this file holds none of them. */
const ORNAMENT = new RegExp(`[${String.fromCharCode(0x2192, 0x00b7, 0x2013, 0x2014)}]`);
const code = MODULE.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
it('never says "Recommended" by itself: that headline is the server\'s to send', () => {
expect(code).not.toMatch(/recommended/i);
});
it("prints the developer tools sentence in the contract's exact words", () => {
expect(code).toContain(`'${LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing}'`);
});
it('uses the words of the reader: no VRAM, no endpoint, no harness, no context window', () => {
const strings = [...MODULE.matchAll(/localModelT\(\s*'([^']+)'/g)].map((match) => match[1]);
expect(strings.length).toBeGreaterThan(60);
for (const text of strings) {
expect(text, text).not.toMatch(/VRAM|endpoint|harness|context window/i);
// No arrow on a button, no middle dot between facts, no long dash.
expect(text, text).not.toMatch(ORNAMENT);
}
});
it('asks the routes of the contract, under the base path', async () => {
const page = mount();
const context = page.dom.getInternalVMContext();
vm.runInContext('var CodemanBase = { url: (path) => "/codeman" + path };', context);
const { app } = await opened(page, INSTALLING, () => ok(PAUSED.install));
press(page.body, 'Cancel');
await settle();
expect(app._api.mock.calls[0][0]).toBe('/codeman/api/local-model');
expect(verbs(app)).toEqual([['POST', '/codeman/api/local-model/cancel', undefined]]);
});
});