/** * @fileoverview The "Model on this Mac" card of the welcome screen and its App Settings row * (src/web/public/local-model-ui.js), driven in JSDOM over the REAL index.html with the real * module and a mocked `_api`. * * What is pinned: * - every state the route can produce: loading, refused (403 FORBIDDEN), a 403 that is not a * refusal, failed, not macOS, each `none` sentence, `limited`, `ok`; * - the card stays hidden until the server answered for a Mac, and so does the Settings group * (never "Model on this Mac" on a server that is not one); the group carries the failure * sentence; * - the card and the row show the SAME pick (one builder), the card folds the alternatives; * - the installed memory is the size the Mac is sold as, every other size is printed as sent; * - each fit and speed label says what it measures, and the line about the missing setup * sits right under the pick; * - every payload string lands as text: an `` in a model, harness or chip name, * or in a slot value of a server sentence, creates no element and runs nothing; * - sizes are printed as sent (decimal GB text), and the word "Recommended" is the server's * to send, never this module's; * - the dismissal is a per-device localStorage key and survives a new app instance; * - one request a minute at most, shared while in flight, and a failed refresh keeps the answer. * * The payloads here are fixtures typed with `LocalModelStatus` and built from * `LOCAL_MODEL_LABELS`, not the output of the route: what the server computes for a given Mac 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_LABELS, LOCAL_MODEL_NONE_LABELS } from '../src/types.js'; import type { Candidate, HardwareFacts, 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'); /** Named for the read-only stage, so the stage that can set a model up takes a key of its own. */ const DISMISS_KEY = 'codeman:local-model-dismissed:readonly'; const NEXT_STEP = 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'; type ApiResponse = { ok: boolean; status: number; json(): Promise } | null; interface App { _api: ReturnType; loadLocalModel(): Promise; renderLocalModel(): void; dismissLocalModelCard(): void; isLocalModelCardDismissed(): boolean; } interface I18nApi { configure(options: Record): void; start(): void; } /** * The two containers 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 half a minute and prove nothing more. */ const REAL = new JSDOM(INDEX); const REAL_DOCUMENT = REAL.window.document; const CARD_MARKUP = REAL_DOCUMENT.getElementById('localModelCard')?.outerHTML ?? ''; const GROUP_MARKUP = REAL_DOCUMENT.getElementById('localModelGroup')?.outerHTML ?? ''; const PAGE = `
${CARD_MARKUP}
${GROUP_MARKUP}
`; 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 }) }; } /** * 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. */ function mount(options: { i18n?: boolean; before?: string } = {}) { const dom = new JSDOM(PAGE, { runScripts: 'outside-only', url: 'http://localhost/' }); open.push(dom); const context = dom.getInternalVMContext(); if (options.i18n) vm.runInContext(I18N, context, { filename: 'i18n.js' }); if (options.before) vm.runInContext(options.before, context); vm.runInContext('function CodemanApp() {}', context); vm.runInContext(MODULE, context, { filename: 'local-model-ui.js' }); const document = dom.window.document; const newApp = (response?: ApiResponse | (() => Promise)) => { const app = vm.runInContext('new CodemanApp()', context) as App; app._api = vi.fn(async () => (typeof response === 'function' ? response() : (response ?? null))); return app; }; return { dom, document, newApp, card: document.getElementById('localModelCard')!, row: document.getElementById('localModelSettings')!, group: document.getElementById('localModelGroup')!, i18n: (dom.window as unknown as { CodemanI18n: I18nApi }).CodemanI18n, }; } /** Text of every element with that class under a root, in document order. */ function texts(root: Element, selector: string): string[] { return [...root.querySelectorAll(selector)].map((el) => el.textContent ?? ''); } /** Lets a queued MutationObserver callback (a microtask in JSDOM) run. */ const settle = () => new Promise((done) => setTimeout(done, 0)); // ---------- Fixtures (the contract's types) ---------- /** The MacBook of the probe: 48 GB as sold. The candidates below give it an unknown speed, as for a chip in no chips row. */ 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: 912308310016, freeDiskGb: '912', source: 'sysctl', }; function candidate(over: Partial = {}): Candidate { return { modelId: 'qwen3.8:27b', modelLabel: 'Qwen3.8 27B', license: 'Apache-2.0', paramsB: 27.8, context: 131072, cli: 'opencode', cliLabel: 'OpenCode', cliInstalled: true, needBytes: 29551160064, needGb: '29.6', downloadBytes: 17740000000, downloadGb: '17.7', budgetShare: 0.79, fit: 'good', fitLabel: LOCAL_MODEL_LABELS.fitGood, speed: 'unknown', speedLabel: LOCAL_MODEL_LABELS.speedUnknown, tested: false, ...over, }; } const ALT_SLOW = candidate({ modelId: 'qwen3.8:27b-q8_0', modelLabel: 'Qwen3.8 27B Q8', context: 65536, needGb: '36.4', downloadGb: '30.0', budgetShare: 0.97, fit: 'tight', fitLabel: LOCAL_MODEL_LABELS.fitTight, speed: 'slow', speedLabel: LOCAL_MODEL_LABELS.speedSlow, tokensPerSecond: 5.2, }); const ALT_FAST = candidate({ modelId: 'qwen3.6:35b-a3b', modelLabel: 'Qwen3.6 35B-A3B', needGb: '26.4', downloadGb: '22.6', budgetShare: 0.7, speed: 'ok', speedLabel: undefined, tokensPerSecond: 41.7, }); function status(recommendation: Partial, hardware: Partial = {}): LocalModelStatus { return { hardware: { ...HARDWARE, ...hardware }, recommendation: { verdict: 'ok', headline: { text: LOCAL_MODEL_LABELS.headlineOk }, notes: [], alternatives: [], budgetBytes: 37580963840, budgetGb: '37.6', budgetSource: 'estimated', ...recommendation, }, catalogUpdated: '2026-11-06', }; } const OK_STATUS = status({ notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }], pick: candidate(), alternatives: [ALT_FAST, ALT_SLOW], }); const LIMITED_STATUS = status( { verdict: 'limited', headline: { text: LOCAL_MODEL_LABELS.headlineLimited }, notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }], pick: candidate({ modelId: 'qwen3.5:9b', modelLabel: 'Qwen3.5 9B', paramsB: 9.7, context: 65536, needGb: '10.9', downloadGb: '6.6', budgetShare: 0.87, fit: 'tight', fitLabel: LOCAL_MODEL_LABELS.fitTight, speed: 'ok', speedLabel: undefined, tokensPerSecond: 11.16, }), budgetGb: '12.5', }, { memGb: '19.3', memClassGb: 18, chip: 'Apple M4' } ); /** A `none` answer: no pick, the reason's sentence as headline, its slots in `vars`. */ function none(reason: NoneReason, vars?: Record, hardware: Partial = {}) { const headline: LocalModelMessage = { text: LOCAL_MODEL_NONE_LABELS[reason], ...(vars ? { vars } : {}) }; return status({ verdict: 'none', reason, headline, budgetBytes: 0, budgetGb: '0.0' }, hardware); } /** A `none` answer whose headline is one of the other sentences of its reason. */ function noneWith( reason: NoneReason, text: LocalModelMessage['text'], vars?: Record, hardware: Partial = {} ) { const answer = none(reason, undefined, hardware); answer.recommendation.headline = { text, ...(vars ? { vars } : {}) }; return answer; } const NONE_CASES: Array<{ reason: Exclude; answer: LocalModelStatus; sentence: string }> = [ { reason: 'intel', answer: none('intel', undefined, { arch: 'x64', arm64: false, chip: 'Intel(R) Core(TM) i7-9750H' }), sentence: 'This Mac has an Intel processor. Local coding models need Apple silicon.', }, { reason: 'memory', answer: none('memory', { memory: '8' }, { memGb: '8.6', memClassGb: 8 }), sentence: '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.', }, { reason: 'disk', answer: none('disk', { download: '17.7', reserve: '10.7', free: '12.0', missing: '16.4' }, { freeDiskGb: '12.0' }), sentence: 'Not enough free disk space. The smallest model that fits this Mac is a 17.7 GB download, and 10.7 GB must stay free after it. 12.0 GB is free now. Free up about 16.4 GB.', }, { reason: 'macos', answer: none('macos', { version: '13.6', minimum: '14' }, { macosVersion: '13.6', macosMajor: 13 }), sentence: 'This Mac runs macOS 13.6. Local models need macOS 14 or newer.', }, { reason: 'no-harness', answer: none('no-harness'), sentence: 'No coding agent that Codeman can connect to a local model is available on this Mac.', }, ]; // ---------- The page ---------- describe('the markup the module fills', () => { it('ships the card hidden on the welcome screen and one read-only group in Settings > Models', () => { const card = REAL_DOCUMENT.getElementById('localModelCard')!; const row = REAL_DOCUMENT.getElementById('localModelSettings')!; const group = REAL_DOCUMENT.getElementById('localModelGroup')!; expect(card.hidden).toBe(true); expect(card.closest('#welcomeOverlay .welcome-content')).not.toBeNull(); expect(card.hasAttribute('data-i18n-skip')).toBe(true); // A direct child of the column (no wrapper that would hold a margin open while the card is // hidden), under the Resume list and above the sessions opened outside Codeman. const order = [...REAL_DOCUMENT.querySelector('.welcome-content')!.children].map((el) => el.id || el.className); expect(order.indexOf('localModelCard')).toBeGreaterThan(order.indexOf('historySessions')); expect(order.indexOf('localModelCard')).toBeLessThan(order.indexOf('welcome-foreign')); // The phone overview is a different screen and does not carry the card. expect(card.closest('#mobileOverview')).toBeNull(); expect(group.closest('#appSettingsModal #settings-models')).not.toBeNull(); // Its own group: the endpoints toggle hides #customModelEndpointsBody, and this answer does not depend on it. expect(row.closest('#customModelEndpointsBody')).toBeNull(); expect(row.closest('#customModelEndpointsGroup')).toBeNull(); expect(row.closest('.set-row')).not.toBeNull(); expect(row.hasAttribute('data-i18n-skip')).toBe(true); // Read-only: no control of any kind in the group. expect(group.querySelectorAll('input, select, button, textarea').length).toBe(0); expect(INDEX.split('').length - 1).toBe(1); }); it('is what every other test here runs on', () => { const { card, row, group } = mount(); expect(card.outerHTML).toBe(CARD_MARKUP); expect(group.outerHTML).toBe(GROUP_MARKUP); expect(row.textContent).toBe(''); }); it('is wired into the two places that show it', () => { 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); }); }); describe('before there is an answer', () => { it('keeps the card and the Settings group hidden while the first request is in flight', async () => { const { newApp, card, row, group } = mount(); let answer!: (response: ApiResponse) => void; const app = newApp(() => new Promise((done) => (answer = done))); const loading = app.loadLocalModel(); expect(card.hidden).toBe(true); expect(card.textContent).toBe(''); // Nothing says yet that the server is a Mac, so no group titled "Model on this Mac" and no // sentence about "this Mac" either: on most servers both would be false. expect(group.style.display).toBe('none'); expect(row.textContent).toBe(''); answer(ok(OK_STATUS)); await loading; expect(card.hidden).toBe(false); expect(group.style.display).toBe(''); expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); }); it('keeps an answer on screen, group included, while it is refreshed', async () => { const { dom, newApp, row, group } = mount(); let now = 1_000_000; (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; let answer!: (response: ApiResponse) => void; const answers: Array<() => Promise> = [ async () => ok(OK_STATUS), () => new Promise((done) => (answer = done)), ]; const app = newApp(() => (answers.shift() as () => Promise)()); await app.loadLocalModel(); now += 61_000; const refreshing = app.loadLocalModel(); expect(group.style.display).toBe(''); expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); answer(ok(OK_STATUS)); await refreshing; }); 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, card, row, group } = mount(); const app = newApp(response); await app.loadLocalModel(); // Nothing is known about the machine, so the welcome screen gets no card at all. expect(card.hidden).toBe(true); expect(group.style.display).toBe(''); expect(row.textContent).toBe('Could not reach the server.'); }); it('asks again the next time it is shown after a failure', async () => { const { newApp, card, row } = mount(); const answers: ApiResponse[] = [null, ok(OK_STATUS)]; 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(card.hidden).toBe(false); expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); }); }); describe('a refused request (a non-admin in multi-user mode)', () => { it('shows neither the card nor the Settings group, and stops asking', async () => { const { newApp, card, row, group } = mount(); const app = newApp(refusal(403, 'FORBIDDEN')); await app.loadLocalModel(); expect(card.hidden).toBe(true); expect(group.style.display).toBe('none'); expect(row.textContent).toBe(''); await app.loadLocalModel(); expect(app._api).toHaveBeenCalledTimes(1); }); 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, document, newApp, card, row, group } = mount(); const win = dom.window as unknown as { __codemanUser?: { multiUser: boolean; role: string } }; win.__codemanUser = { multiUser: true, role: 'admin' }; const answers: ApiResponse[] = [refusal(403, 'PASSWORD_CHANGE_REQUIRED'), ok(OK_STATUS)]; const app = newApp(async () => answers.shift() ?? null); await app.loadLocalModel(); expect(app._api).toHaveBeenCalledTimes(1); expect(card.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(card.hidden).toBe(false)); expect(app._api).toHaveBeenCalledTimes(2); expect(group.style.display).toBe(''); expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); }); it.each([ ['a 403 without a body', { ok: false, status: 403, json: async () => Promise.reject(new Error('no body')) }], ['a 403 with another error code', refusal(403, 'SOMETHING_ELSE')], ] as Array<[string, ApiResponse]>)('keeps asking after %s', async (_name, response) => { const { newApp, group } = mount(); const app = newApp(response); await app.loadLocalModel(); await app.loadLocalModel(); expect(app._api).toHaveBeenCalledTimes(2); expect(group.style.display).toBe(''); }); it('does not ask at all once the page knows the role, and asks when the role turns out to be admin', async () => { const { dom, document, newApp, card, group } = mount(); const win = dom.window as unknown as { __codemanUser?: { multiUser: boolean; role: string } }; win.__codemanUser = { multiUser: true, role: 'user' }; const app = newApp(ok(OK_STATUS)); await app.loadLocalModel(); expect(app._api).not.toHaveBeenCalled(); expect(group.style.display).toBe('none'); expect(card.hidden).toBe(true); win.__codemanUser = { multiUser: true, role: 'admin' }; document.dispatchEvent(new dom.window.CustomEvent('codeman:me')); await vi.waitFor(() => expect(card.hidden).toBe(false)); expect(app._api).toHaveBeenCalledTimes(1); expect(group.style.display).toBe(''); }); }); describe('a server that is not a Mac', () => { const LINUX = none('not-macos', undefined, { platform: 'linux', arch: 'x64', arm64: undefined, chip: 'AMD Ryzen 9 7950X', source: 'not-probed', }); it('shows neither the card nor the Settings group: nothing there is about this server', async () => { const { newApp, card, row, group } = mount(); await newApp(ok(LINUX)).loadLocalModel(); expect(card.hidden).toBe(true); expect(card.textContent).toBe(''); // A group titled "Model on this Mac", about "the Mac that Codeman runs on", on a Linux // server: hidden, the same lever as a refused request. expect(group.style.display).toBe('none'); expect(row.textContent).toBe(''); }); it('never shows the group at any point of the load, and keeps it away on a refresh', async () => { const { dom, newApp, group, row } = mount(); let now = 1_000_000; (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; const seen: string[] = []; const app = newApp(async () => { seen.push(group.style.display); return ok(LINUX); }); const first = app.loadLocalModel(); seen.push(group.style.display); await first; now += 61_000; const second = app.loadLocalModel(); seen.push(group.style.display); await second; seen.push(group.style.display); expect(app._api).toHaveBeenCalledTimes(2); expect(seen).toEqual(['none', 'none', 'none', 'none', 'none']); expect(row.textContent).toBe(''); }); }); describe('verdict none on a Mac', () => { it.each(NONE_CASES)('says why for $reason, with the numbers the server sent', async ({ answer, sentence }) => { const { newApp, card, row } = mount(); await newApp(ok(answer)).loadLocalModel(); expect(card.hidden).toBe(false); for (const root of [card, row]) { expect(texts(root, '.local-model-headline')).toEqual([sentence]); expect(texts(root, '.local-model-hw')).toHaveLength(1); expect(root.querySelector('.local-model-pick')).toBeNull(); expect(root.querySelector('.local-model-alts')).toBeNull(); // Nothing to set up, so no line about setting it up. expect(root.textContent).not.toContain(NEXT_STEP); expect(root.textContent).not.toContain('Recommended'); } }); it('covers every reason the contract knows', () => { const covered = [...NONE_CASES.map((c) => c.reason), 'not-macos'].sort(); expect(covered).toEqual(Object.keys(LOCAL_MODEL_NONE_LABELS).sort()); }); it('says both numbers when the Mac misses by a hair (the 16 GB Mac mini)', async () => { const { newApp, card, row } = mount(); const answer = noneWith( 'memory', LOCAL_MODEL_LABELS.noneMemoryContext, { budget: '10.4', need: '10.3' }, { memGb: '17.2', memClassGb: 16, chip: 'Apple M4', freeDiskGb: '42.8' } ); await newApp(ok(answer)).loadLocalModel(); for (const root of [card, row]) { expect(texts(root, '.local-model-hw')).toEqual(['Apple M4, 16 GB memory, 42.8 GB free']); expect(texts(root, '.local-model-headline')).toEqual([ 'Codeman estimates that this Mac can give a model about 10.4 GB of memory. The smallest model a coding agent can work with needs about 10.3 GB. That leaves no safe margin, so Codeman recommends none here.', ]); } }); it('states no memory size when the server could not read one', async () => { const { newApp, card, row } = mount(); const answer = noneWith('memory', LOCAL_MODEL_LABELS.noneMemoryUnknown, undefined, { memBytes: 0, memGb: '0.0', memClassGb: 0, }); await newApp(ok(answer)).loadLocalModel(); for (const root of [card, row]) { // The chip and nothing else: no "0 GB memory" in the hardware line, no size in the sentence. expect(texts(root, '.local-model-hw')).toEqual(['Apple M5 Pro']); expect(texts(root, '.local-model-headline')).toEqual([ 'Codeman could not read how much memory this Mac has, so it recommends no model here.', ]); expect(root.textContent).not.toMatch(/\b0(\.0)? GB/); } }); }); describe('verdict ok', () => { it('shows the hardware, the headline, the pick with its numbers, the note and the next step', async () => { const { newApp, card } = mount(); await newApp(ok(OK_STATUS)).loadLocalModel(); expect(card.hidden).toBe(false); expect(texts(card, '.local-model-title')).toEqual(['Model on this Mac']); // The page can be open on another device: the title says whose Mac is meant. expect((card.querySelector('.local-model-title') as HTMLElement).title).toBe( 'The Mac that Codeman runs on, which is not always the device you are reading on.' ); // 48, the size this Mac is sold as and what About This Mac shows: not 51.5 (its bytes in decimal GB). expect(texts(card, '.local-model-hw')).toEqual(['Apple M5 Pro, 48 GB memory, 912 GB free']); expect(texts(card, '.local-model-headline')).toEqual(['Best fit for this Mac']); expect(texts(card, '.local-model-note')).toEqual(['Not yet tested by us on a Mac like this.']); const pick = card.querySelector('.local-model-pick')!; expect(texts(pick, '.local-model-name')).toEqual(['Qwen3.8 27B (qwen3.8:27b) with OpenCode']); expect(texts(pick, '.local-model-pill')).toEqual(['Good', 'Speed unknown']); expect(texts(pick, '.local-model-line')).toEqual([ 'Context 131,072 tokens · 17.7 GB download · Apache-2.0', 'Needs about 29.6 GB of the 37.6 GB this Mac can give a model (estimated).', ]); expect(texts(card, '.local-model-next')).toEqual([NEXT_STEP]); }); it('never says "Recommended" for a pick nobody tested: that word is the server\'s to send', async () => { const { newApp, card, row } = mount(); await newApp(ok(OK_STATUS)).loadLocalModel(); expect(card.textContent).not.toMatch(/recommend/i); expect(row.textContent).not.toMatch(/recommend/i); // The module holds no such string of its own (comments aside). const code = MODULE.split('\n') .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) .join('\n'); expect(code).not.toMatch(/Recommended/); }); it('shows the tested headline only because the server sent it', async () => { const { newApp, card } = mount(); const tested = status({ headline: { text: LOCAL_MODEL_LABELS.headlineTested }, pick: candidate({ tested: true }) }); await newApp(ok(tested)).loadLocalModel(); expect(texts(card, '.local-model-headline')).toEqual(['Recommended for this Mac']); }); it('lists the other models that fit with Good, Tight, Slow or Speed unknown', async () => { const { newApp, card } = mount(); await newApp(ok(OK_STATUS)).loadLocalModel(); const alts = card.querySelector('.local-model-alts')!; // No count beside the title: the server lists four at most, so a number here would be // the number of rows, not of models that fit. expect(texts(alts, '.local-model-alts-title')).toEqual(['Other models that fit']); expect(alts.querySelector('.local-model-count')).toBeNull(); const rows = [...alts.querySelectorAll('.local-model-alt')]; expect(rows.map((row) => texts(row, '.local-model-name')[0])).toEqual([ 'Qwen3.6 35B-A3B (qwen3.6:35b-a3b) with OpenCode', 'Qwen3.8 27B Q8 (qwen3.8:27b-q8_0) with OpenCode', ]); // A candidate whose speed is fine carries a fit label only. expect(rows.map((row) => texts(row, '.local-model-pill'))).toEqual([['Good'], ['Tight', 'Slow']]); // An alternative with an estimate prints it: "Slow" alone does not say how slow. expect(rows.map((row) => texts(row, '.local-model-line')[0])).toEqual([ 'Context 131,072 tokens · 26.4 GB of memory · 22.6 GB download · about 42 tokens per second', 'Context 65,536 tokens · 36.4 GB of memory · 30.0 GB download · about 5.2 tokens per second', ]); }); it('says what each label measures, as its tooltip and its accessible name', async () => { const { newApp, card, row } = mount(); await newApp(ok(OK_STATUS)).loadLocalModel(); for (const root of [card, row]) { const pills = [...root.querySelectorAll('.local-model-pill')] as HTMLElement[]; expect(pills.map((pill) => [pill.textContent, pill.title])).toEqual([ // The pick: 0.79 of the budget, a chip in no chips row. ['Good', 'Memory fit: needs 79% of what this Mac can give a model'], ['Speed unknown', 'No speed estimate for this model on this chip'], ['Good', 'Memory fit: needs 70% of what this Mac can give a model'], ['Tight', 'Memory fit: needs 97% of what this Mac can give a model'], ['Slow', 'About 5.2 tokens per second (estimated).'], ]); for (const pill of pills) { expect(pill.getAttribute('role')).toBe('note'); expect(pill.getAttribute('aria-label')).toBe(`${pill.textContent}. ${pill.title}`); } } }); it('leaves a fit label without a share bare instead of inventing a number', async () => { const { newApp, card } = mount(); const bare = candidate({ budgetShare: undefined as unknown as number, speedLabel: undefined, speed: 'ok' }); await newApp(ok(status({ pick: bare }))).loadLocalModel(); const pill = card.querySelector('.local-model-pill') as HTMLElement; expect(pill.textContent).toBe('Good'); expect(pill.title).toBe(''); expect(pill.hasAttribute('aria-label')).toBe(false); }); it('puts the line about the missing setup right under the pick and its notes, above the list', async () => { const { newApp, card, row } = mount(); const withNotes = status({ notes: [ { text: LOCAL_MODEL_LABELS.noteSmallPick }, { text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.4' } }, { text: LOCAL_MODEL_LABELS.noteUntested }, ], pick: candidate(), alternatives: [ALT_FAST, ALT_SLOW], }); await newApp(ok(withNotes)).loadLocalModel(); for (const root of [card, row]) { const body = root.querySelector('.local-model-body')!; expect([...body.children].map((el) => el.className)).toEqual([ 'local-model-hw', 'local-model-headline', 'local-model-pick', 'local-model-note', 'local-model-note', 'local-model-note', 'local-model-next', 'local-model-alts', ]); // A note with slots is filled like a headline. expect(texts(root, '.local-model-note')).toEqual([ 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', '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.', ]); expect(texts(root, '.local-model-next')).toEqual([NEXT_STEP]); } }); it('prints every size exactly as sent, in one unit', async () => { const { newApp, card, row } = mount(); await newApp(ok(OK_STATUS)).loadLocalModel(); for (const root of [card, row]) { const text = root.textContent ?? ''; expect(text).not.toMatch(/GiB|MiB|\bMB\b|bytes/); // Every number in front of the unit is one the payload carries: as text for the computed // sizes, and `memClassGb` (48) for the installed memory. const printed = [...text.matchAll(/([\d.,]+) GB/g)].map((m) => m[1]); const sent = ['48', '912', '37.6', '29.6', '17.7', '26.4', '22.6', '36.4', '30.0']; expect(printed.length).toBeGreaterThanOrEqual(sent.length); expect(printed.filter((value) => !sent.includes(value))).toEqual([]); // The decimal figure of the installed memory (`memGb`) is on neither surface. expect(text).not.toContain('51.5'); } }); it('calls a budget that is the wired limit of this Mac a fact, not an estimate', async () => { const { newApp, card } = mount(); const wired = status({ pick: candidate(), budgetSource: 'wired-limit', budgetGb: '40.0' }); await newApp(ok(wired)).loadLocalModel(); expect(texts(card, '.local-model-pick .local-model-line')[1]).toBe( 'Needs about 29.6 GB of the 40.0 GB this Mac can give a model.' ); }); it('adds the speed, a missing harness and a slow first answer only when the payload says so', async () => { const { newApp, card } = mount(); const pick = candidate({ speed: 'ok', speedLabel: undefined, tokensPerSecond: 12.3, cliInstalled: false, slowFirstTurn: true, cliLabel: 'Some Harness', }); await newApp(ok(status({ pick }))).loadLocalModel(); expect(texts(card, '.local-model-pick .local-model-pill')).toEqual(['Good']); expect(texts(card, '.local-model-pick .local-model-line').slice(2)).toEqual([ 'About 12 tokens per second (estimated).', 'Some Harness is not installed on this Mac yet.', 'With Some Harness, the first answer can take minutes.', ]); }); it('describes the hardware with whatever parts the probe got', async () => { const { newApp, card } = mount(); const cases: Array<[Partial, string]> = [ [{ freeDiskBytes: undefined, freeDiskGb: undefined }, 'Apple M5 Pro, 48 GB memory'], [{ chip: undefined }, 'Mac17,13, 48 GB memory, 912 GB free'], [{ chip: undefined, model: undefined }, '48 GB memory, 912 GB free'], [{ chip: undefined, model: undefined, freeDiskBytes: undefined, freeDiskGb: undefined }, '48 GB memory'], // The sizes Macs are sold in, as sold. [{ memGb: '17.2', memClassGb: 16 }, 'Apple M5 Pro, 16 GB memory, 912 GB free'], [{ memGb: '34.4', memClassGb: 32 }, 'Apple M5 Pro, 32 GB memory, 912 GB free'], [{ memGb: '137', memClassGb: 128 }, 'Apple M5 Pro, 128 GB memory, 912 GB free'], // An unknown size is left out, whatever else the payload carries for it. [{ memGb: '0.0', memClassGb: 0 }, 'Apple M5 Pro'], [{ memClassGb: undefined as unknown as number }, 'Apple M5 Pro'], [{ memClassGb: '48' as unknown as number }, 'Apple M5 Pro'], ]; for (const [hardware, line] of cases) { await newApp(ok(status({ pick: candidate() }, hardware))).loadLocalModel(); expect(texts(card, '.local-model-hw')).toEqual([line]); } }); }); describe('verdict limited', () => { it('says what a small model is good for, shows the pick, and never recommends', async () => { const { newApp, card, row } = mount(); await newApp(ok(LIMITED_STATUS)).loadLocalModel(); expect(card.hidden).toBe(false); for (const root of [card, row]) { expect(texts(root, '.local-model-headline')).toEqual([ 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', ]); expect(texts(root, '.local-model-hw')).toEqual(['Apple M4, 18 GB memory, 912 GB free']); expect(texts(root, '.local-model-name')).toEqual(['Qwen3.5 9B (qwen3.5:9b) with OpenCode']); expect(texts(root, '.local-model-pill')).toEqual(['Tight']); expect(texts(root, '.local-model-pick .local-model-line')).toEqual([ 'Context 65,536 tokens · 6.6 GB download · Apache-2.0', 'Needs about 10.9 GB of the 12.5 GB this Mac can give a model (estimated).', 'About 11 tokens per second (estimated).', ]); expect(texts(root, '.local-model-note')).toEqual(['Not yet tested by us on a Mac like this.']); expect(texts(root, '.local-model-next')).toEqual([NEXT_STEP]); expect(root.textContent).not.toMatch(/recommend/i); expect(root.textContent).not.toContain('Best fit'); } }); }); describe('the Settings row', () => { it('shows the same answer as the card, with the alternatives open', async () => { const { newApp, card, row } = mount(); await newApp(ok(OK_STATUS)).loadLocalModel(); for (const part of ['.local-model-hw', '.local-model-headline', '.local-model-pick', '.local-model-note']) { expect(texts(row, part)).toEqual(texts(card, part)); expect(texts(row, part).length).toBeGreaterThan(0); } expect(texts(row, '.local-model-alt')).toEqual(texts(card, '.local-model-alt')); expect(texts(row, '.local-model-next')).toEqual([NEXT_STEP]); // The card folds the list behind a native disclosure; Settings scrolls, so it lists them. expect(card.querySelector('details.local-model-alts')).not.toBeNull(); expect((card.querySelector('details') as HTMLDetailsElement).open).toBe(false); expect(row.querySelector('details')).toBeNull(); // The row has no chrome of the card: no title, no Hide. expect(row.querySelector('button')).toBeNull(); }); it('keeps the alternatives unfolded across a repaint of the card', async () => { const { newApp, card } = mount(); const app = newApp(ok(OK_STATUS)); await app.loadLocalModel(); (card.querySelector('details') as HTMLDetailsElement).open = true; app.renderLocalModel(); expect((card.querySelector('details') as HTMLDetailsElement).open).toBe(true); }); }); describe('payload text is text', () => { const EVIL = ''; it('renders markup in a name, a license or a sentence slot as characters, and runs nothing', async () => { const { dom, newApp, card, row } = mount(); const evil = candidate({ modelLabel: EVIL, modelId: `m:${EVIL}`, cliLabel: EVIL, license: EVIL, needGb: EVIL }); const answer = status( { // A slot value is server text too. headline: { text: LOCAL_MODEL_LABELS.noneMacos, vars: { version: EVIL, minimum: EVIL } }, notes: [{ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: EVIL } }], pick: evil, alternatives: [evil], budgetGb: EVIL, }, { chip: EVIL, memGb: EVIL, freeDiskGb: EVIL } ); await newApp(ok(answer)).loadLocalModel(); expect(card.hidden).toBe(false); for (const root of [card, row]) { expect(root.querySelector('img')).toBeNull(); expect(root.querySelector('script, iframe, a, [onerror]')).toBeNull(); // The memory is a number or nothing: text in its place (`memGb` here) is never printed. expect(texts(root, '.local-model-hw')[0]).toBe(`${EVIL}, 48 GB memory, ${EVIL} GB free`); expect(texts(root, '.local-model-headline')[0]).toBe( `This Mac runs macOS ${EVIL}. Local models need macOS ${EVIL} or newer.` ); expect(texts(root, '.local-model-name')[0]).toBe(`${EVIL} (m:${EVIL}) with ${EVIL}`); expect(texts(root, '.local-model-pick .local-model-line')[0]).toContain(` · ${EVIL}`); expect(texts(root, '.local-model-note')[0]).toContain(`This Mac has ${EVIL} GB of memory.`); } expect((dom.window as unknown as { __pwned?: number }).__pwned).toBeUndefined(); }); it('never assigns markup: the module has no innerHTML, outerHTML or insertAdjacentHTML', () => { expect(MODULE).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/); }); it('shows a label the contract does not know as it came, still as text', async () => { const { newApp, card } = mount(); const odd = candidate({ fitLabel: EVIL as Candidate['fitLabel'], speedLabel: undefined }); await newApp(ok(status({ pick: odd }))).loadLocalModel(); expect(texts(card, '.local-model-pill')).toEqual([EVIL]); expect(card.querySelector('img')).toBeNull(); }); }); describe('hiding the card', () => { it('hides it on this device for good, and leaves the Settings row as the way back', async () => { const { dom, newApp, card, row } = mount(); const app = newApp(ok(OK_STATUS)); await app.loadLocalModel(); const hide = card.querySelector('button.local-model-hide') as HTMLButtonElement; expect(hide.textContent).toBe('Hide'); expect(hide.type).toBe('button'); expect(hide.title).toBe('Hide this card on this device. It stays in App Settings, under Models.'); hide.click(); expect(card.hidden).toBe(true); expect(card.textContent).toBe(''); expect(dom.window.localStorage.getItem(DISMISS_KEY)).toBe('1'); expect(texts(row, '.local-model-name')[0]).toBe('Qwen3.8 27B (qwen3.8:27b) with OpenCode'); // A reload is a new app instance over the same storage. const reloaded = newApp(ok(OK_STATUS)); expect(reloaded.isLocalModelCardDismissed()).toBe(true); await reloaded.loadLocalModel(); expect(card.hidden).toBe(true); expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); }); it('shows the card on a device that never hid it', async () => { const { dom, newApp, card } = mount(); expect(dom.window.localStorage.getItem(DISMISS_KEY)).toBeNull(); await newApp(ok(OK_STATUS)).loadLocalModel(); expect(card.hidden).toBe(false); }); it('stays hidden for the page load when storage is blocked', async () => { const { dom, newApp, card } = mount(); const storage = dom.window.Storage.prototype; vi.spyOn(storage, 'getItem').mockImplementation(() => { throw new Error('blocked'); }); vi.spyOn(storage, 'setItem').mockImplementation(() => { throw new Error('blocked'); }); const app = newApp(ok(OK_STATUS)); await app.loadLocalModel(); expect(card.hidden).toBe(false); app.dismissLocalModelCard(); expect(card.hidden).toBe(true); app.renderLocalModel(); expect(card.hidden).toBe(true); vi.restoreAllMocks(); }); it('writes nothing to the server: the only request the module ever makes is the GET', async () => { const { newApp, card } = mount(); const app = newApp(ok(OK_STATUS)); await app.loadLocalModel(); (card.querySelector('button.local-model-hide') as HTMLButtonElement).click(); expect(app._api.mock.calls).toEqual([['/api/local-model']]); }); }); describe('the request', () => { it('goes through the base path when Codeman is mounted under one', async () => { const before = "const CodemanBase = { base: '/cm', url: (path) => '/cm' + path };"; const { newApp } = mount({ before }); const app = newApp(ok(OK_STATUS)); await app.loadLocalModel(); expect(app._api.mock.calls).toEqual([['/cm/api/local-model']]); }); it('is sent once a minute at most, and shared while in flight', async () => { const { dom, newApp, card } = mount(); let now = 1_000_000; (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; const app = newApp(ok(OK_STATUS)); const first = app.loadLocalModel(); const second = app.loadLocalModel(); await Promise.all([first, second]); expect(app._api).toHaveBeenCalledTimes(1); now += 59_000; await app.loadLocalModel(); expect(app._api).toHaveBeenCalledTimes(1); now += 2_000; await app.loadLocalModel(); expect(app._api).toHaveBeenCalledTimes(2); expect(card.hidden).toBe(false); }); it('keeps the answer on screen when a later refresh fails', async () => { const { dom, newApp, card, row } = mount(); let now = 1_000_000; (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; const answers: ApiResponse[] = [ok(OK_STATUS), null]; const app = newApp(async () => answers.shift() ?? null); await app.loadLocalModel(); now += 61_000; await app.loadLocalModel(); expect(app._api).toHaveBeenCalledTimes(2); expect(card.hidden).toBe(false); expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); expect(row.textContent).not.toContain('Could not reach the server.'); }); }); describe('with the real translator', () => { it('renders in Chinese, and follows a language or display-name change without a reload', async () => { const { newApp, card, row, i18n } = mount({ i18n: true }); i18n.start(); i18n.configure({ language: 'zh-CN' }); const app = newApp(ok(OK_STATUS)); await app.loadLocalModel(); expect(texts(card, '.local-model-headline')).toEqual(['最适合此 Mac 的选择']); expect(texts(card, '.local-model-pill')).toEqual(['良好', '速度未知', '良好', '偏紧', '较慢']); expect((card.querySelector('.local-model-pill') as HTMLElement).title).toBe( '内存占用:需要此 Mac 可分配给模型的内存的 79%' ); // Names and numbers ride the slots untouched. expect(texts(card, '.local-model-name')[0]).toBe('Qwen3.8 27B(qwen3.8:27b),搭配 OpenCode'); expect(texts(row, '.local-model-hw')).toEqual(['Apple M5 Pro,内存 48 GB,可用磁盘空间 912 GB']); // Both containers are data-i18n-skip, so the module itself must repaint. i18n.configure({ language: 'en' }); await settle(); expect(texts(card, '.local-model-headline')).toEqual(['Best fit for this Mac']); expect(texts(row, '.local-model-next')).toEqual([NEXT_STEP]); i18n.configure({ displayName: 'Acme' }); await settle(); expect(texts(card, '.local-model-next')).toEqual([ 'Acme cannot download or install this for you yet. That is the next step, and it is not built yet.', ]); }); it('leaves a slot-shaped piece of a model name alone', async () => { const { newApp, card, i18n } = mount({ i18n: true }); i18n.start(); const odd = candidate({ modelLabel: 'Model {harness} {name}' }); await newApp(ok(status({ pick: odd }))).loadLocalModel(); await settle(); // The DOM translator would empty `{harness}` and brand `{name}`; the skip attribute keeps it out. expect(texts(card, '.local-model-name')[0]).toBe('Model {harness} {name} (qwen3.8:27b) with OpenCode'); }); });