Files
Codeman/test/local-model-ui.test.ts
T
Codeman maintainer eb91279a3b feat(local-model): a memory map and real colour in the chooser, the button first
The owner's verdict on the chooser: nice, but not nice enough, too much just
blue. He also wants "Run a local model" on top of the start screen, above
Run Claude Code.

The memory bar is now a map: the model, its context and the brief peaks side
by side on the track of what this Mac can give a model, each with its size in
a legend, and the verdict on the fit beside the figure. recommend() sends what
the need is made of as `Candidate.memory`.

Colour now carries meaning: green for good and tested, amber for tight, orange
for slow, teal for the context. Violet is the feature's own colour, on its
start screen button and its main action. Every row has a size tile and a speed
gauge scaled to the fastest row in the list. The light skins get deeper values
of the same colours, and the window is 40 px taller to hold it without
scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:21:35 +01:00

2372 lines
100 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')!,
/** The body and the foot together: where a screen's controls are. */
frame: document.querySelector('#localModelModal .local-model-window')!,
foot: document.getElementById('localModelWindowFoot')!,
};
}
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 frame the buttons live in: the window's body AND its foot (the buttons are in the foot). */
function frameOf(body: Element): Element {
return body.closest('.local-model-window') ?? body;
}
/** The window's buttons, by their label. */
function buttons(body: Element): string[] {
return texts(frameOf(body), '.lm-buttons button');
}
function press(body: Element, label: string) {
const found = [...frameOf(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 above 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);
// First on the start screen: the owner wants it above Run Claude Code.
expect(order.indexOf('welcomeLocalModelBtn')).toBe(0);
expect(order.indexOf('welcomeLocalModelBtn')).toBeLessThan(order.indexOf('welcomeCliActions'));
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([
'With 8 GB of memory, this Mac cannot run a model that can drive a coding agent.',
]);
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']);
// An estimate, in whole GB: "about 36 of 38", never "36.0 of 37.6".
expect(texts(body, '.lm-figure')).toEqual(['About 36 of 38 GB of memory']);
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']);
// The order can be read off the Download column: the pick, then the others largest first.
expect(rows.map((row) => row.querySelector('.lm-model-size')!.textContent)).toEqual([
'23.1 GB',
'17.7 GB',
'13.8 GB',
'10.4 GB',
'6.6 GB',
]);
// 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(frameOf(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 of 40 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.',
'Codeman has not run this on a Mac like this yet.',
]);
});
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. Codeman installs it 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]> = [
[1, '81', 'About 31 of 38 GB of memory'],
[4, '45', 'About 17 of 38 GB of memory'],
[0, '96', 'About 36 of 38 GB of memory'],
];
for (const [index, percent, figure] 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]);
}
});
it('splits the bar into what the model takes, and names each part with its size', async () => {
const page = mount();
const memory = { modelBytes: 26_105_798_956, contextBytes: 4_849_664_000, peakBytes: 5_000_000_000 };
await opened(
page,
status({
pick: candidate({ memory, fit: 'tight', budgetShare: 0.957, context: 131072 }),
alternatives: [],
budgetBytes: 37_580_963_840,
})
);
const fills = [...page.body.querySelectorAll<HTMLElement>('.lm-bar .lm-bar-fill')];
expect(fills.map((el) => el.dataset.kind)).toEqual(['memory-model', 'memory-context', 'memory-peak']);
expect(fills.map((el) => el.style.width)).toEqual(['69.5%', '12.9%', '13.3%']);
// The meter still says the whole share, for a screen reader.
expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('96');
expect(texts(page.body, '.lm-legend-name')).toEqual([
'The model',
'Context, 131,072 tokens',
'Brief peaks',
'Stays free',
]);
expect(texts(page.body, '.lm-legend-size')).toEqual(['26 GB', '5 GB', '5 GB', '2 GB']);
expect(texts(page.body, '.lm-fit')).toEqual(['Tight fit']);
expect(page.body.querySelector('.lm-fit')!.classList.contains('lm-fit-tight')).toBe(true);
});
it('gives every row a size tile and a speed gauge scaled to the fastest row', async () => {
const page = mount();
await opened(
page,
status({
pick: candidate({ paramsB: 35, tokensPerSecond: 80, speed: 'ok' }),
alternatives: [
candidate({ modelId: 'b:1', modelLabel: 'B', paramsB: 27.8, tokensPerSecond: 8, speed: 'slow' }),
candidate({ modelId: 'c:1', modelLabel: 'C', paramsB: 9, tokensPerSecond: undefined, speed: 'unknown' }),
],
})
);
expect(texts(page.body, '.lm-tile')).toEqual(['35B', '27B', '9B']);
const tiles = [...page.body.querySelectorAll('.lm-tile')];
expect(tiles.map((el) => el.className)).toEqual([
'lm-tile lm-tile-large',
'lm-tile lm-tile-mid',
'lm-tile lm-tile-small',
]);
const gauges = [...page.body.querySelectorAll('.lm-gauge')];
expect(gauges.map((el) => el.className)).toEqual([
'lm-gauge lm-gauge-ok',
'lm-gauge lm-gauge-slow',
'lm-gauge lm-gauge-unknown',
]);
expect(gauges[0].querySelector<HTMLElement>('.lm-gauge-fill')!.style.width).toBe('100%');
expect(gauges[1].querySelector<HTMLElement>('.lm-gauge-fill')!.style.width).toBe('10%');
expect(gauges[2].querySelector('.lm-gauge-fill')).toBeNull();
});
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('moves on a width transition, without a glow, and stands still when motion is not wanted', () => {
const bar = STYLES.match(/\.lm-bar-fill \{[^}]*\}/)![0];
expect(bar).toContain('transition: width');
expect(bar).not.toMatch(/box-shadow|animation/);
expect(STYLES).toMatch(/\.lm-bar \{[^}]*height: 12px;[^}]*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);
// The table is the screen: the chosen row first, the runtime, the missing coding agent
// with its installer's literal command, and the total.
expect(texts(body, '.lm-heading')).toEqual(['What will be downloaded']);
expect(texts(body, '.lm-size-name')).toEqual(['Qwen3.6 35B-A3B', 'Ollama 0.40.15', 'OpenCode', 'Total']);
expect(texts(body, '.lm-size-note')).toEqual([
'At 131,072 tokens of context, with OpenCode',
'The program that runs the model',
'Not on this Mac yet. Its own installer fetches it with this command:',
]);
expect(texts(body, '.lm-size-gb')).toEqual(['23.1 GB', '0.2 GB', '23.3 GB']);
expect(body.querySelector('.lm-size-total .lm-size-gb')!.textContent).toBe('23.3 GB');
expect(texts(body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash']);
// Where it goes, in one line; the developer tools sentence as the one aside.
expect(texts(body, '.lm-path')).toEqual([FOLDER]);
expect(texts(body, '.lm-aside')).toEqual([LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing]);
// The promise sits beside the buttons.
expect(texts(frameOf(body), '.lm-foot-note')).toEqual(['No administrator password.']);
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-name')).toEqual(['Qwen3.6 35B-A3B', 'Total']);
expect(texts(page.body, '.lm-command')).toEqual([]);
expect(texts(page.body, '.lm-path')).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-size-name')[0]).toBe('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-name')).toEqual(['Qwen3.6 35B-A3B', 'Ollama 0.40.15', 'OpenCode']);
expect(page.body.querySelector('.lm-size-total')).toBeNull();
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.frame.querySelectorAll<HTMLButtonElement>('.lm-buttons button')].map((el) => el.disabled)).toEqual([
true,
true,
]);
page.frame.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('the bar is the model download alone: at the start while the runtime is fetched, gone 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 })
);
// Nothing of the model has arrived: the size that is coming, from the row it was chosen on.
expect(texts(page.body, '.lm-figure')).toEqual(['0 of 23.1 GB']);
expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('0');
expect(texts(page.body, '.lm-aside')).toEqual([
'You can close this window. The download continues while Codeman is running.',
]);
// The steps after the download have no bar and no second headline: the list says where it stands.
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(page.body.querySelector('.lm-meter')).toBeNull();
expect(texts(page.body, '.lm-figure')).toEqual([]);
expect(texts(page.body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']);
expect(texts(page.body, '.lm-step-now')).toEqual([label]);
expect(texts(page.body, '.lm-aside')).toEqual([
'You can close this window. The install continues while Codeman is running.',
]);
}
});
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.frame.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.frame.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(['The download waits 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));
// The headline is the FIRST sentence; what follows is read in the body size.
const title = texts(page.body, '.lm-error .lm-title')[0];
const rest = texts(page.body, '.lm-error .lm-lead').join(' ');
const whole = [title, rest].filter(Boolean).join(' ');
expect(whole.length, code).toBeGreaterThan(20);
expect(title, code).toMatch(/[.!?]$/);
expect(title.replace(/[^.!?]/g, '').length, code).toBeLessThanOrEqual(2);
expect(whole, code).not.toMatch(/[{}]/);
expect(whole, 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);
// What it takes now, and what it can take, in the words of the chooser's legend: the
// row's own estimate, so the two figures are never read as a contradiction.
expect(texts(body, '.lm-aside')).toEqual([
'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory now, and up to about 36 GB at its peak, with all of the context in use. All of it is on the GPU.',
]);
expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']);
// One main action.
expect(frameOf(body).querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1);
expect(frameOf(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.frame.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.frame.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);
// The sentence is the headline (not "is ready"), its second sentence in the body size.
expect(texts(page.body, '.lm-title')).toEqual(['The model on this Mac changed since it was installed.']);
expect(texts(page.body, '.lm-error .lm-lead')).toEqual(['Remove it and install it again.']);
// The dot is not green: a session cannot be started. The state line names the model.
expect(page.body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(false);
expect(texts(page.body, '.lm-state-text')).toEqual(['Qwen3.6 35B-A3B, Running']);
// No session can start, so that button is not there. Remove never takes the accent.
expect(buttons(page.body)).toEqual(['Stop', 'Remove']);
expect(page.frame.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(0);
press(page.body, 'Remove');
expect(texts(page.frame, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']);
// In its question the removal is red.
expect(page.frame.querySelector('.lm-remove .wt-btn-danger')!.textContent).toBe('Remove');
});
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.frame.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.frame.querySelector('.lm-remove')).toBeNull();
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
press(page.body, 'Remove');
const box = page.frame.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.frame, '.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.frame, '.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',
'Local coding models need Apple silicon, and this Mac has an Intel processor.',
],
[
'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',
'With 8 GB of memory, this Mac cannot run a model that can drive a coding agent.',
],
[
'disk',
none('disk', { missing: '5.3' }, { freeDiskGb: '12.0' }),
'Apple M5 Pro, 48 GB memory, 12.0 GB free',
'Free up about 5.3 GB of disk space to install the smallest model that fits this Mac.',
],
] 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 { app, serve } = await opened(page, INSTALLING);
expect(page.body.dataset.screen).toBe('progress');
serve(INSTALLED);
await page.tick();
expect(page.body.dataset.screen).toBe('installed');
// The screen that ends an install has one button. Stop and Remove are for the window
// opened later.
expect(buttons(page.body)).toEqual(['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');
// Opened again, the same model has its Stop and Remove.
app.closeLocalModelWindow();
app.openLocalModelWindow();
expect(buttons(page.body)).toEqual(['Remove', 'Stop', '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}`]);
expect(texts(page.body, '.lm-path')).toEqual([`/tmp/${HOSTILE}`]);
expect(texts(page.body, '.lm-size-name')).toContain(`Agent ${HOSTILE}`);
// 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.frame, '.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 GB 内存,共 38 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]]);
});
});
// ---------- After the review: the order of the rows, the words of a tight pick, a refusal's sentence, a start that failed ----------
describe('the rows of the chooser are ordered by a column the reader can see', () => {
it('the pick first, then the other models by download size, largest first, whatever order the server sent', async () => {
const page = mount();
// The server's own order of the alternatives is its ranking, which no column shows.
await opened(page, status({ alternatives: [ALT_SMALL_Q8, ALT_SMALL, ALT_UNKNOWN, ALT_SLOW] }));
const rows = [...page.body.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.querySelector('.lm-model-size')!.textContent)).toEqual([
'23.1 GB',
'17.7 GB',
'13.8 GB',
'10.4 GB',
'6.6 GB',
]);
// The arrow keys walk the drawn order.
const group = page.body.querySelector('[role="radiogroup"]')!;
group.dispatchEvent(new page.dom.window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(page.body.querySelector('[aria-checked="true"] .lm-model-name')!.textContent).toBe('Qwen3.8 27B');
});
it('a pick that only just fits says so in its tag, in the accent like every tag of the pick', async () => {
const page = mount();
const tightPick = candidate({
tested: false,
tag: { kind: 'best-tight', label: LOCAL_MODEL_TAG_LABELS['best-tight'] },
});
await opened(page, status({ pick: tightPick, alternatives: [ALT_SMALL] }));
const tag = page.body.querySelector('[aria-checked="true"] .lm-tag')!;
expect(tag.textContent).toBe('Best fit, tight');
expect(tag.classList.contains('lm-tag-accent')).toBe(true);
expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('96');
});
});
describe('a refusal the server names', () => {
it('is printed from its sentence and slots, not from the English error text', async () => {
const page = mount({ i18n: true });
const { app } = await opened(page, IDLE, () => ({
ok: false,
status: 400,
json: async () => ({
success: false,
error: 'OpenCode is not installed and cannot be installed from here.',
errorCode: 'INVALID_INPUT',
data: { message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'OpenCode' } } },
}),
}));
page.win.CodemanI18n.configure({ language: 'zh-CN' });
await settle();
press(page.body, '安装到此 Mac');
press(page.body, '下载并安装');
await settle();
expect(texts(page.body, '.lm-notice-text')).toEqual(['OpenCode 尚未安装,也无法从这里安装。']);
expect(page.body.textContent).not.toContain('cannot be installed from here');
app.closeLocalModelWindow();
});
});
describe('an installed model whose runtime did not start', () => {
it('says what the runtime said as the headline, keeps the dot grey, and Start tries again', async () => {
const page = mount();
const answer = status(
{},
{
...INSTALLED.install,
running: false,
actions: ['start', 'remove'],
error: {
code: 'runtime-start-failed',
message: { text: LOCAL_MODEL_ERROR_LABELS['runtime-start-failed'] },
// The server adapts the next step to the state: Start, not "try again".
next: 'start',
detail: 'spawn ENOENT',
},
}
);
const { app } = await opened(page, answer, () => ok(INSTALLED.install));
expect(texts(page.body, '.lm-title')).toEqual(['The runtime did not start on this Mac.']);
expect(page.body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(false);
expect(page.body.querySelector('.lm-detail-text')!.textContent).toBe('spawn ENOENT');
expect(buttons(page.body)).toEqual(['Remove', 'Start']);
expect(page.frame.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: 'start' }]]);
});
});