mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Review fixes for the read-only recommendation and its card. Recommendation (pure): - `limited` is now a statement about the Mac: no model with 12 billion parameters or more fits. It is judged on everything that fits, before the disk filter and whatever the pick is. A 32 or 36 GB Mac whose pick is a 9B model no longer reads "This Mac can run a small model" above a list of 20B, 27B and 35B models, and knowing the chip no longer lowers the verdict. A small pick under `ok` carries a note that says so. - A model that fits the memory but has no room on the disk is no longer dropped in silence: the best-ranked one is named in a note with the GB that are missing. A full disk cannot turn a 48 GB Mac into a small-model Mac. - "Models fit, but not at a context a harness needs" has its own sentence with the budget and the need, worded as an estimate. The 16 GB Mac mini misses by 1.3 points (10.3 of 10.4 GB) and was told flatly that its memory is too little, the sentence of an 8 GB Mac. - The installed memory is named by the size the Mac is sold as (48, not 51.5): the hardware line and the memory sentence. Every computed size stays decimal GB from the one formatter. An unknown memory size is no longer reported as "0.0 GB". - Slow is graded on the unrounded speed: 9.95 to 9.9996 tokens per second printed as 10 and read ok. - The disk sentence names the 10 GiB that must stay free, so its numbers add up for the reader. Card and Settings group: - The Settings group "Model on this Mac" is hidden on a server that is not a Mac, and until the first answer says it is one. - Only `403 FORBIDDEN` is a refusal. `403 PASSWORD_CHANGE_REQUIRED` (an admin who must change the password) is a failed request and is asked again, so that admin no longer loses the card until a reload. - Each fit and speed label carries what it measures as its tooltip and accessible name, an alternative with a speed estimate prints it, and the count beside "Other models that fit" is gone (it was the number of rows, capped at four, not the number of models that fit). - The line saying nothing can be set up yet sits right under the pick, in the colour of every other line (it was 3.9:1), and says whose step it is. - The dismissal key is named for this stage, so hiding the read-only card does not hide the install offer later. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1020 lines
44 KiB
TypeScript
1020 lines
44 KiB
TypeScript
/**
|
||
* @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 `<img onerror>` 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<unknown> } | null;
|
||
|
||
interface App {
|
||
_api: ReturnType<typeof vi.fn>;
|
||
loadLocalModel(): Promise<void>;
|
||
renderLocalModel(): void;
|
||
dismissLocalModelCard(): void;
|
||
isLocalModelCardDismissed(): boolean;
|
||
}
|
||
|
||
interface I18nApi {
|
||
configure(options: Record<string, string>): 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 = `<!doctype html><html lang="en"><body>
|
||
<div class="welcome-content">${CARD_MARKUP}</div>
|
||
<section class="set-section" id="settings-models">${GROUP_MARKUP}</section>
|
||
</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 }) };
|
||
}
|
||
|
||
/**
|
||
* 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<ApiResponse>)) => {
|
||
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> = {}): 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<Recommendation>, hardware: Partial<HardwareFacts> = {}): 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<string, string>, hardware: Partial<HardwareFacts> = {}) {
|
||
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<string, string>,
|
||
hardware: Partial<HardwareFacts> = {}
|
||
) {
|
||
const answer = none(reason, undefined, hardware);
|
||
answer.recommendation.headline = { text, ...(vars ? { vars } : {}) };
|
||
return answer;
|
||
}
|
||
|
||
const NONE_CASES: Array<{ reason: Exclude<NoneReason, 'not-macos'>; 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('<script defer src="local-model-ui.js"></script>').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<ApiResponse>((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<ApiResponse>> = [
|
||
async () => ok(OK_STATUS),
|
||
() => new Promise<ApiResponse>((done) => (answer = done)),
|
||
];
|
||
const app = newApp(() => (answers.shift() as () => Promise<ApiResponse>)());
|
||
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<HardwareFacts>, 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 = '<img src=x onerror="window.__pwned = 1">';
|
||
|
||
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');
|
||
});
|
||
});
|