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