mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
Merge branch 'feat/local-model-recommendation' into test/desktop-macbook
This commit is contained in:
commit
bce9fde8df
32 files changed
+7427
-4
No files matched your search
@@ -167,7 +167,6 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
||||
),
|
||||
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
|
||||
|
||||
|
||||
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
|
||||
"web/routes/session-routes.ts::mode !== 'omp'": allow(
|
||||
2,
|
||||
@@ -212,8 +211,10 @@ const ID_ALT = IDS.join('|');
|
||||
*/
|
||||
const BRANCH_PATTERN = new RegExp(
|
||||
[
|
||||
// mode === 'codex' / mode !== 'codex'
|
||||
`\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
||||
// mode === 'codex' / mode !== 'codex'. `cli` is the name the local model catalogue gives
|
||||
// the same thing (`LocalHarnessEntry.cli`, `Candidate.cli`), so `harness.cli === 'claude'`
|
||||
// is the same banned branch under another field name.
|
||||
`\\b(?:mode|id|agentType|cli)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
||||
// case 'codex':
|
||||
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
|
||||
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
|
||||
@@ -267,7 +268,7 @@ function scan(): { findings: Finding[]; filesScanned: number } {
|
||||
lines.forEach((line, i) => {
|
||||
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
|
||||
for (const match of line.matchAll(BRANCH_PATTERN)) {
|
||||
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode');
|
||||
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType|cli)/, 'mode');
|
||||
const posix = rel.split(sep).join('/');
|
||||
findings.push({ file: posix, expression, line: i + 1, key: `${posix}::${expression}` });
|
||||
}
|
||||
@@ -337,6 +338,8 @@ describe('no CLI-id branching outside the stock catalog', () => {
|
||||
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
|
||||
"switch (mode) { case 'gemini': return 1; }",
|
||||
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
|
||||
"if (harness.cli === 'claude') { doSomething(); }",
|
||||
"const others = candidates.filter((c) => c.cli !== 'opencode');",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
BRANCH_PATTERN.lastIndex = 0;
|
||||
|
||||
@@ -38,6 +38,9 @@ const SCANNED_FILES = [
|
||||
'panels-ui.js',
|
||||
// A row's mode comes from the same catalog, never from a list of ids here.
|
||||
'foreign-sessions.js',
|
||||
// The harness a local model is paired with arrives as a label in the payload
|
||||
// (`Candidate.cliLabel`); the card names no CLI and compares no id.
|
||||
'local-model-ui.js',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @fileoverview Tests for src/config/local-model-catalog.ts: the compiled catalogue of models,
|
||||
* harnesses and chips, its schema, and the chip lookup.
|
||||
*
|
||||
* Three things are pinned:
|
||||
*
|
||||
* 1. The stock constant PARSES. `getLocalModelCatalog()` validates it on first use and throws on
|
||||
* a bad row, so this test is what keeps a bad row from ever reaching the route.
|
||||
* 2. The schema refuses what must never get in: a model id that could name another registry
|
||||
* (a slash, a host), an unknown key (a `runtime` block with a download URL), and rows that
|
||||
* break a rule across rows (a repeated id, contexts in the wrong order, a chip row that
|
||||
* hides a more specific one).
|
||||
* 3. The chip lookup never guesses: a chip that no row names has no bandwidth, including one
|
||||
* whose name merely contains a known name ('Apple M10', 'Apple M6 Pro').
|
||||
*
|
||||
* Port: N/A (pure data and functions).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
chipBandwidthGbps,
|
||||
chipNameMatches,
|
||||
getLocalModelCatalog,
|
||||
LOCAL_MODEL_ID_PATTERN,
|
||||
LocalModelCatalogSchema,
|
||||
STOCK_LOCAL_MODEL_CATALOG,
|
||||
} from '../src/config/local-model-catalog.js';
|
||||
import type { LocalModelCatalog, LocalModelEntry } from '../src/types.js';
|
||||
|
||||
/** A deep copy of the stock catalogue that a test may break. */
|
||||
function copy(): LocalModelCatalog {
|
||||
return structuredClone(STOCK_LOCAL_MODEL_CATALOG);
|
||||
}
|
||||
|
||||
/** The message of every issue the schema raises for `catalog`, or an empty list when it parses. */
|
||||
function issuesOf(catalog: unknown): string[] {
|
||||
const result = LocalModelCatalogSchema.safeParse(catalog);
|
||||
return result.success ? [] : result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`);
|
||||
}
|
||||
|
||||
/** The stock catalogue with its first model changed. */
|
||||
/** `url` is no field of a model row: one test passes it to prove the schema refuses an unknown key. */
|
||||
function withFirstModel(change: Partial<Record<keyof LocalModelEntry | 'url', unknown>>): unknown {
|
||||
const catalog = copy();
|
||||
return { ...catalog, models: [{ ...catalog.models[0], ...change }, ...catalog.models.slice(1)] };
|
||||
}
|
||||
|
||||
describe('the stock catalogue', () => {
|
||||
it('passes its own schema', () => {
|
||||
expect(issuesOf(STOCK_LOCAL_MODEL_CATALOG)).toEqual([]);
|
||||
});
|
||||
|
||||
it('is what the getter returns, validated once', () => {
|
||||
const first = getLocalModelCatalog();
|
||||
expect(first).toEqual(STOCK_LOCAL_MODEL_CATALOG);
|
||||
// Parsed on first use, then reused: the same object, not a new parse per request.
|
||||
expect(getLocalModelCatalog()).toBe(first);
|
||||
});
|
||||
|
||||
it('lists the seven models of the design, without flint:40b', () => {
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b',
|
||||
'qwen3.5:9b-q8_0',
|
||||
'gpt-oss:20b',
|
||||
'qwen3.6:35b-a3b',
|
||||
'qwen3.8:27b',
|
||||
'qwen3.8:27b-q8_0',
|
||||
'qwen3.5:122b-a10b',
|
||||
]);
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.models.some((m) => m.id.startsWith('flint'))).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the byte sizes of the registry manifests', () => {
|
||||
// The sum of the layer sizes of each tag's manifest, as fetched on 2026-11-06.
|
||||
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.bytes]))).toEqual({
|
||||
'qwen3.5:9b': 6_550_825_373,
|
||||
'qwen3.5:9b-q8_0': 10_449_217_949,
|
||||
'gpt-oss:20b': 13_793_441_427,
|
||||
'qwen3.6:35b-a3b': 22_621_314_161,
|
||||
'qwen3.8:27b': 17_741_871_939,
|
||||
'qwen3.8:27b-q8_0': 29_978_241_837,
|
||||
'qwen3.5:122b-a10b': 81_370_035_877,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the KV cost per token of each model', () => {
|
||||
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.kvBytesPerToken]))).toEqual({
|
||||
'qwen3.5:9b': 32_768,
|
||||
'qwen3.5:9b-q8_0': 32_768,
|
||||
'gpt-oss:20b': 24_576,
|
||||
'qwen3.6:35b-a3b': 20_480,
|
||||
'qwen3.8:27b': 65_536,
|
||||
'qwen3.8:27b-q8_0': 65_536,
|
||||
'qwen3.5:122b-a10b': 24_576,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives every model a parameter count and an empty tested list', () => {
|
||||
for (const model of STOCK_LOCAL_MODEL_CATALOG.models) {
|
||||
expect(model.paramsB, model.id).toBeGreaterThan(0);
|
||||
// Nobody has run any of these with a harness on a Mac yet.
|
||||
expect(model.tested, model.id).toEqual([]);
|
||||
expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN);
|
||||
expect(model.license).toBe('Apache-2.0');
|
||||
}
|
||||
// The two 9B rows are the ones under the 12 billion line of the `limited` verdict.
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b',
|
||||
'qwen3.5:9b-q8_0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has a speed figure only where one was measured or scaled from a measurement', () => {
|
||||
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.speedPer100Gbps ?? null]))).toEqual({
|
||||
'qwen3.5:9b': 9.3,
|
||||
'qwen3.5:9b-q8_0': 5.8,
|
||||
'gpt-oss:20b': null,
|
||||
'qwen3.6:35b-a3b': 13.6,
|
||||
'qwen3.8:27b': 2.95,
|
||||
'qwen3.8:27b-q8_0': 1.7,
|
||||
'qwen3.5:122b-a10b': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('lists its harnesses as data rows, one of them gated', () => {
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.harnesses).toEqual([
|
||||
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
||||
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
||||
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts the prompt cache as 512 MiB below 32 GB and 2 GiB from 32 GB', () => {
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.promptCacheTiers).toEqual([
|
||||
{ minMemGb: 0, bytes: 512 * 1024 ** 2 },
|
||||
{ minMemGb: 32, bytes: 2 * 1024 ** 3 },
|
||||
]);
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.minMacosMajor).toBe(14);
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.schema).toBe(1);
|
||||
});
|
||||
|
||||
it('holds no download data: no URL, no digest, no runtime block', () => {
|
||||
const text = JSON.stringify(STOCK_LOCAL_MODEL_CATALOG);
|
||||
expect(text).not.toMatch(/https?:/);
|
||||
expect(text).not.toMatch(/sha256/i);
|
||||
expect(Object.keys(STOCK_LOCAL_MODEL_CATALOG).sort()).toEqual(
|
||||
['chips', 'harnesses', 'minMacosMajor', 'models', 'promptCacheTiers', 'schema', 'updated'].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the model id rule', () => {
|
||||
it('accepts name:tag in lowercase', () => {
|
||||
for (const id of ['qwen3.5:9b', 'gpt-oss:20b', 'qwen3.8:27b-q8_0', 'a:b', 'x_y.z-1:2.3_4']) {
|
||||
expect(LOCAL_MODEL_ID_PATTERN.test(id), id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses anything that could name another registry, namespace or no tag at all', () => {
|
||||
const bad = [
|
||||
'evil.example/x/y:z',
|
||||
'library/qwen3.5:9b',
|
||||
'registry.ollama.ai/library/qwen3.5:9b',
|
||||
'Qwen3.5:9b',
|
||||
'qwen3.5:9B',
|
||||
'qwen3.5',
|
||||
'qwen3.5:',
|
||||
':9b',
|
||||
'qwen3.5:9b:extra',
|
||||
'qwen 3.5:9b',
|
||||
'-qwen:9b',
|
||||
'qwen:-9b',
|
||||
'qwen3.5:9b\n',
|
||||
'',
|
||||
];
|
||||
for (const id of bad) expect(LOCAL_MODEL_ID_PATTERN.test(id), JSON.stringify(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('makes the schema reject a catalogue with such an id', () => {
|
||||
for (const id of ['evil.example/x/y:z', 'Qwen3.5:9b', 'qwen3.5', 'qwen3.5:9b ']) {
|
||||
const issues = issuesOf(withFirstModel({ id }));
|
||||
expect(issues, id).toHaveLength(1);
|
||||
expect(issues[0]).toContain('models.0.id');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the schema, field by field', () => {
|
||||
it('refuses a key it does not know, at the top and inside a row', () => {
|
||||
// The whole point of having no `runtime` block: a data row can never carry a download URL.
|
||||
expect(issuesOf({ ...copy(), runtime: { url: 'https://example.com/x.tgz', sha256: 'abc' } })).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ url: 'https://example.com/model' }))).not.toEqual([]);
|
||||
const catalog = copy();
|
||||
expect(issuesOf({ ...catalog, harnesses: [{ ...catalog.harnesses[0], dialect: 'openai' }] })).not.toEqual([]);
|
||||
expect(issuesOf({ ...catalog, chips: [{ match: 'M4', gbps: 120, cores: 10 }] })).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('requires paramsB and tested on every model row', () => {
|
||||
for (const field of ['paramsB', 'tested'] as const) {
|
||||
const row: Record<string, unknown> = { ...copy().models[0] };
|
||||
delete row[field];
|
||||
const issues = issuesOf({ ...copy(), models: [row] });
|
||||
expect(issues, field).toHaveLength(1);
|
||||
expect(issues[0]).toContain(`models.0.${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses sizes and counts that are not positive whole numbers', () => {
|
||||
expect(issuesOf(withFirstModel({ bytes: 0 }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ bytes: 1.5 }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ kvBytesPerToken: -1 }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ contexts: [] }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ paramsB: 0 }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ speedPer100Gbps: 0 }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a tested entry and checks its shape', () => {
|
||||
const good = { chip: 'Apple M4', memGb: 16, cli: 'opencode', date: '2026-11-06' };
|
||||
expect(issuesOf(withFirstModel({ tested: [good] }))).toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ tested: [{ ...good, date: '6 November 2026' }] }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ tested: [{ ...good, memGb: 0 }] }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ tested: [{ ...good, result: 'pass' }] }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses another schema version and a date that is not ISO', () => {
|
||||
expect(issuesOf({ ...copy(), schema: 2 })).not.toEqual([]);
|
||||
expect(issuesOf({ ...copy(), updated: 'today' })).not.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the schema, across rows', () => {
|
||||
it('refuses a repeated model id', () => {
|
||||
const catalog = copy();
|
||||
catalog.models[1] = { ...catalog.models[1], id: catalog.models[0].id };
|
||||
expect(issuesOf(catalog)).toEqual(['models.1.id: model ids must be unique']);
|
||||
});
|
||||
|
||||
it('refuses a repeated model rank, which would leave the order of two models to chance', () => {
|
||||
const catalog = copy();
|
||||
catalog.models[1] = { ...catalog.models[1], rank: catalog.models[0].rank };
|
||||
expect(issuesOf(catalog)).toEqual(['models.1.rank: model ranks must be unique']);
|
||||
});
|
||||
|
||||
it('refuses a harness listed twice and a repeated harness rank', () => {
|
||||
const twice = copy();
|
||||
twice.harnesses.push({ cli: twice.harnesses[0].cli, minContext: 32768, rank: 9 });
|
||||
expect(issuesOf(twice)).toEqual(['harnesses.3.cli: a harness may be listed only once']);
|
||||
|
||||
const sameRank = copy();
|
||||
sameRank.harnesses[1] = { ...sameRank.harnesses[1], rank: sameRank.harnesses[0].rank };
|
||||
expect(issuesOf(sameRank)).toEqual(['harnesses.1.rank: harness ranks must be unique']);
|
||||
});
|
||||
|
||||
it('refuses contexts that are not listed largest first, or that repeat', () => {
|
||||
expect(issuesOf(withFirstModel({ contexts: [32768, 65536] }))).toEqual([
|
||||
'models.0.contexts.1: contexts must be listed largest first, without repeats',
|
||||
]);
|
||||
expect(issuesOf(withFirstModel({ contexts: [65536, 65536] }))).toEqual([
|
||||
'models.0.contexts.1: contexts must be listed largest first, without repeats',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a tested entry for a harness the catalogue does not list', () => {
|
||||
const entry = { chip: 'Apple M4', memGb: 16, cli: 'not-a-harness', date: '2026-11-06' };
|
||||
expect(issuesOf(withFirstModel({ tested: [entry] }))).toEqual([
|
||||
'models.0.tested.0.cli: a tested entry must name a harness of this catalogue',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses chips that are not listed most specific first', () => {
|
||||
const catalog = copy();
|
||||
catalog.chips = [
|
||||
{ match: 'M4', gbps: 120 },
|
||||
{ match: 'M4 Max', gbps: 410 },
|
||||
];
|
||||
expect(issuesOf(catalog)).toEqual([
|
||||
"chips.1.match: chips must be listed most specific first: 'M4' comes before 'M4 Max'",
|
||||
]);
|
||||
|
||||
const repeated = copy();
|
||||
repeated.chips = [
|
||||
{ match: 'M4', gbps: 120 },
|
||||
{ match: 'm4', gbps: 130 },
|
||||
];
|
||||
expect(issuesOf(repeated)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses prompt cache tiers that do not start at 0 or do not ascend', () => {
|
||||
const late = copy();
|
||||
late.promptCacheTiers = [{ minMemGb: 16, bytes: 1 }];
|
||||
expect(issuesOf(late)).toEqual(['promptCacheTiers.0.minMemGb: the first prompt cache tier must start at 0']);
|
||||
|
||||
const unordered = copy();
|
||||
unordered.promptCacheTiers = [
|
||||
{ minMemGb: 0, bytes: 1 },
|
||||
{ minMemGb: 32, bytes: 2 },
|
||||
{ minMemGb: 24, bytes: 3 },
|
||||
];
|
||||
expect(issuesOf(unordered)).toEqual(['promptCacheTiers.2.minMemGb: prompt cache tiers must ascend by minMemGb']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the chips table', () => {
|
||||
const chips = STOCK_LOCAL_MODEL_CATALOG.chips;
|
||||
|
||||
it('holds every chip of the source table that shipped', () => {
|
||||
// Bandwidth column of https://github.com/ggml-org/llama.cpp/discussions/4167, read on
|
||||
// 2026-11-06. A name that covers two bandwidths has the lower one.
|
||||
expect(Object.fromEntries(chips.map((row) => [row.match, row.gbps]))).toEqual({
|
||||
'M5 Ultra': 1228,
|
||||
'M3 Ultra': 800,
|
||||
'M2 Ultra': 800,
|
||||
'M1 Ultra': 800,
|
||||
'M5 Max': 460,
|
||||
'M4 Max': 410,
|
||||
'M3 Max': 300,
|
||||
'M2 Max': 400,
|
||||
'M1 Max': 400,
|
||||
'M5 Pro': 307,
|
||||
'M4 Pro': 273,
|
||||
'M3 Pro': 150,
|
||||
'M2 Pro': 200,
|
||||
'M1 Pro': 200,
|
||||
M5: 154,
|
||||
M4: 120,
|
||||
M3: 100,
|
||||
M2: 100,
|
||||
M1: 68,
|
||||
});
|
||||
});
|
||||
|
||||
it('finds each row under the name macOS reports for that chip', () => {
|
||||
// No row is hidden by an earlier one: 'Apple M4 Max' is 410, not the 120 of 'M4'.
|
||||
for (const row of chips) {
|
||||
expect(chipBandwidthGbps(chips, `Apple ${row.match}`), row.match).toBe(row.gbps);
|
||||
}
|
||||
});
|
||||
|
||||
it('knows the chips of the two test Macs', () => {
|
||||
expect(chipBandwidthGbps(chips, 'Apple M4')).toBe(120);
|
||||
expect(chipBandwidthGbps(chips, 'Apple M5 Pro')).toBe(307);
|
||||
});
|
||||
|
||||
it('never guesses: a chip that no row names has no bandwidth', () => {
|
||||
const unknown = [
|
||||
// A base chip that is not in the table.
|
||||
'Apple M6',
|
||||
// A variant the table has never heard of must not borrow the figure of its base chip.
|
||||
'Apple M6 Pro',
|
||||
'Apple M4 Ultra',
|
||||
'Apple M5 Extreme',
|
||||
// A name that merely contains a known name.
|
||||
'Apple M10',
|
||||
'Apple M12 Pro',
|
||||
'Apple M1 Max (Virtual)',
|
||||
'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz',
|
||||
'VirtualApple @ 2.50GHz processor',
|
||||
'Max',
|
||||
'',
|
||||
];
|
||||
for (const chip of unknown) expect(chipBandwidthGbps(chips, chip), chip).toBeUndefined();
|
||||
expect(chipBandwidthGbps(chips, undefined)).toBeUndefined();
|
||||
expect(chipBandwidthGbps([], 'Apple M4')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chipNameMatches', () => {
|
||||
it('matches the whole chip name at the end of the string, ignoring case and padding', () => {
|
||||
expect(chipNameMatches('Apple M4 Pro', 'M4 Pro')).toBe(true);
|
||||
expect(chipNameMatches('Apple M4', 'M4')).toBe(true);
|
||||
expect(chipNameMatches('M4', 'M4')).toBe(true);
|
||||
expect(chipNameMatches(' apple m4 pro ', 'M4 PRO')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match a part of a longer name', () => {
|
||||
expect(chipNameMatches('Apple M4 Pro', 'M4')).toBe(false);
|
||||
expect(chipNameMatches('Apple M14', 'M4')).toBe(false);
|
||||
expect(chipNameMatches('Apple M10', 'M1')).toBe(false);
|
||||
expect(chipNameMatches('Apple XM4', 'M4')).toBe(false);
|
||||
expect(chipNameMatches('Apple M4', '')).toBe(false);
|
||||
expect(chipNameMatches('', 'M4')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @fileoverview Tests for src/local-model/format.ts: the ONE place a byte count becomes a size a
|
||||
* person reads, and the memory class a Mac is sold as.
|
||||
*
|
||||
* What is pinned here is a contract with the browser: it prints the text it was sent and adds
|
||||
* ' GB' itself, so the text must be decimal GB, carry no unit, and round the same way everywhere.
|
||||
*
|
||||
* Port: N/A (pure functions).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatGb, GB, GIB, memClassGb, MIB } from '../src/local-model/format.js';
|
||||
|
||||
describe('byte units', () => {
|
||||
it('keeps decimal GB (what a person reads) apart from the binary units of the budget math', () => {
|
||||
expect(GB).toBe(1_000_000_000);
|
||||
expect(GIB).toBe(1_073_741_824);
|
||||
expect(MIB).toBe(1_048_576);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGb', () => {
|
||||
it('prints decimal GB, so a Mac sold as 16 GB reads 17.2 and one sold as 48 GB reads 51.5', () => {
|
||||
expect(formatGb(17_179_869_184)).toBe('17.2');
|
||||
expect(formatGb(51_539_607_552)).toBe('51.5');
|
||||
});
|
||||
|
||||
it('prints the registry sizes of the catalogue models as the registry shows them', () => {
|
||||
expect(formatGb(6_550_825_373)).toBe('6.6');
|
||||
expect(formatGb(13_793_441_427)).toBe('13.8');
|
||||
expect(formatGb(17_741_871_939)).toBe('17.7');
|
||||
expect(formatGb(22_621_314_161)).toBe('22.6');
|
||||
expect(formatGb(81_370_035_877)).toBe('81.4');
|
||||
});
|
||||
|
||||
it('keeps one decimal below 100 and none from 100', () => {
|
||||
expect(formatGb(1 * GB)).toBe('1.0');
|
||||
expect(formatGb(99.94 * GB)).toBe('99.9');
|
||||
// Rounds to 100.0, which is already "from 100".
|
||||
expect(formatGb(99.96 * GB)).toBe('100');
|
||||
expect(formatGb(100 * GB)).toBe('100');
|
||||
// The free disk of the two test Macs, from their `df -k` lines.
|
||||
expect(formatGb(890_926_084 * 1024)).toBe('912');
|
||||
expect(formatGb(41_752_848 * 1024)).toBe('42.8');
|
||||
});
|
||||
|
||||
it('never prints a unit, a thousands separator or an exponent', () => {
|
||||
for (const bytes of [1, 5 * MIB, 3 * GIB, 250 * GB, 4000 * GB, 2.5e15]) {
|
||||
expect(formatGb(bytes)).toMatch(/^\d+(\.\d)?$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads '0.0' for anything that is not a positive size", () => {
|
||||
for (const bytes of [0, -1, -5 * GB, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
|
||||
expect(formatGb(bytes)).toBe('0.0');
|
||||
}
|
||||
});
|
||||
|
||||
it('shows a size too small for one decimal as 0.0, never as an empty text', () => {
|
||||
expect(formatGb(10 * MIB)).toBe('0.0');
|
||||
expect(formatGb(512 * MIB)).toBe('0.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memClassGb', () => {
|
||||
it('names the size a Mac is sold as: whole binary gigabytes', () => {
|
||||
for (const sold of [8, 16, 18, 24, 32, 36, 48, 64, 96, 128, 192]) {
|
||||
expect(memClassGb(sold * GIB)).toBe(sold);
|
||||
}
|
||||
// The exact `hw.memsize` of the two test Macs.
|
||||
expect(memClassGb(17_179_869_184)).toBe(16);
|
||||
expect(memClassGb(51_539_607_552)).toBe(48);
|
||||
});
|
||||
|
||||
it('rounds a size that is not a whole number of gigabytes', () => {
|
||||
// `hw.memsize_usable` of the 16 GB test Mac is a little below 16 GiB.
|
||||
expect(memClassGb(16_631_808_000)).toBe(15);
|
||||
expect(memClassGb(16 * GIB - 100 * MIB)).toBe(16);
|
||||
});
|
||||
|
||||
it('reads 0 for anything that is not a positive size', () => {
|
||||
for (const bytes of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(memClassGb(bytes)).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* @fileoverview Tests for src/local-model/hardware.ts: the probe that tells the recommendation
|
||||
* what this Mac is.
|
||||
*
|
||||
* Two halves, mirroring the module:
|
||||
*
|
||||
* 1. `parseSysctl()`, pure. Fed the REAL output of the probe command on the two test Macs (a
|
||||
* MacBook Pro with an M5 Pro and 48 GB, a Mac mini with an M4 and 16 GB), copied here
|
||||
* character for character with the extra lines the capture holds, plus an Intel-shaped
|
||||
* output (three of the seven keys do not exist there) and outputs cut off mid-line.
|
||||
* 2. `readHardware()`, with every piece of IO injected: nothing is spawned and no real disk is
|
||||
* read. What is pinned: a failed spawn is an ANSWER (the `os-fallback` shape), nothing is
|
||||
* spawned off macOS, the result is cached for 60 s and concurrent callers share one probe.
|
||||
*
|
||||
* The IO itself is tested apart, against things this suite controls:
|
||||
*
|
||||
* 3. `spawnSysctl()` runs a small shell script of the test in place of sysctl. That proves what
|
||||
* a fake cannot: the arguments are the seven keys and nothing else, the output of a program
|
||||
* that exits 1 is still read (an Intel Mac lacks three keys, so sysctl exits 1 there), a
|
||||
* program that hangs is given up on, and the server's environment is not handed over.
|
||||
* 4. `dataDirFreeBytes()` with injected probes: a folder that does not exist yet, a mount that
|
||||
* does not answer, and a `statfs` that never comes back, which must not be started twice.
|
||||
*
|
||||
* One test at the end runs the real defaults once (the real spawn and the real `statfs`), to
|
||||
* prove they cannot reject on whatever machine runs the suite. On Linux `sysctl` knows none of
|
||||
* the keys, so that run ends in the fallback; on a Mac it ends in a real answer. Both are fine.
|
||||
*
|
||||
* Port: N/A (no server).
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
dataDirFreeBytes,
|
||||
FREE_DISK_TIMEOUT_MS,
|
||||
freeDiskBytesOf,
|
||||
HARDWARE_CACHE_TTL_MS,
|
||||
parseSysctl,
|
||||
readHardware,
|
||||
resetHardwareCache,
|
||||
spawnSysctl,
|
||||
SYSCTL_KEYS,
|
||||
SYSCTL_PATH,
|
||||
SYSCTL_TIMEOUT_MS,
|
||||
type HardwareProbeDeps,
|
||||
} from '../src/local-model/hardware.js';
|
||||
|
||||
/**
|
||||
* The capture of the MacBook Pro (Mac17,13), 2026-11-06. The last four lines are not part of
|
||||
* the probe: two extra keys the capture asked for, the shell's exit status and a `df -k` line.
|
||||
*/
|
||||
const MACBOOK_SYSCTL = `hw.memsize: 51539607552
|
||||
machdep.cpu.brand_string: Apple M5 Pro
|
||||
hw.optional.arm64: 1
|
||||
sysctl.proc_translated: 0
|
||||
iogpu.wired_limit_mb: 0
|
||||
kern.osproductversion: 27.2
|
||||
hw.model: Mac17,13
|
||||
hw.perflevel0.physicalcpu: 6
|
||||
hw.perflevel1.physicalcpu: 12
|
||||
exit=0
|
||||
/dev/disk3s5 971094180 54383564 890926084 6% 542146 8909260840 0% /System/Volumes/Data
|
||||
`;
|
||||
|
||||
/** The capture of the Mac mini (Mac16,10), 2026-11-06, with the same four extra lines. */
|
||||
const MACMINI_SYSCTL = `hw.memsize: 17179869184
|
||||
machdep.cpu.brand_string: Apple M4
|
||||
hw.optional.arm64: 1
|
||||
sysctl.proc_translated: 0
|
||||
iogpu.wired_limit_mb: 0
|
||||
kern.osproductversion: 27.2
|
||||
hw.model: Mac16,10
|
||||
hw.perflevel0.physicalcpu: 4
|
||||
hw.perflevel1.physicalcpu: 6
|
||||
exit=0
|
||||
/dev/disk3s5 239362496 172954124 41752848 81% 2353838 417528480 1% /System/Volumes/Data
|
||||
`;
|
||||
|
||||
/** Free disk of the two Macs: the "available" column of their `df -k` lines, in bytes. */
|
||||
const MACBOOK_FREE_DISK = 890_926_084 * 1024;
|
||||
const MACMINI_FREE_DISK = 41_752_848 * 1024;
|
||||
|
||||
/**
|
||||
* What an Intel Mac prints: `hw.optional.arm64`, `sysctl.proc_translated` and
|
||||
* `iogpu.wired_limit_mb` do not exist there, so sysctl leaves their lines out (and exits 1).
|
||||
* NOT a capture: no Intel Mac was available, the shape is recalled.
|
||||
*/
|
||||
const INTEL_SYSCTL = `hw.memsize: 17179869184
|
||||
machdep.cpu.brand_string: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
|
||||
kern.osproductversion: 13.6.7
|
||||
hw.model: MacBookPro15,1
|
||||
`;
|
||||
|
||||
/** Deps that spawn nothing and read no disk. A test overrides what it is about. */
|
||||
function deps(overrides: HardwareProbeDeps = {}): HardwareProbeDeps {
|
||||
return {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
runSysctl: async () => MACMINI_SYSCTL,
|
||||
freeDiskBytes: async () => MACMINI_FREE_DISK,
|
||||
totalMemBytes: () => 8 * 1024 ** 3,
|
||||
cpuModel: () => 'Fallback CPU',
|
||||
now: () => 1_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetHardwareCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetHardwareCache();
|
||||
});
|
||||
|
||||
describe('the probe command', () => {
|
||||
it('is an absolute path and asks for exactly the seven keys, never with -n', () => {
|
||||
expect(isAbsolute(SYSCTL_PATH)).toBe(true);
|
||||
expect(SYSCTL_PATH).toBe('/usr/sbin/sysctl');
|
||||
expect([...SYSCTL_KEYS]).toEqual([
|
||||
'hw.memsize',
|
||||
'machdep.cpu.brand_string',
|
||||
'hw.optional.arm64',
|
||||
'sysctl.proc_translated',
|
||||
'iogpu.wired_limit_mb',
|
||||
'kern.osproductversion',
|
||||
'hw.model',
|
||||
]);
|
||||
// Every argument is a key name. `-n` would print bare values, and a key missing on a
|
||||
// machine would then shift every value after it onto the wrong key.
|
||||
for (const key of SYSCTL_KEYS) expect(key).toMatch(/^[a-z][a-z0-9_.]*$/);
|
||||
});
|
||||
|
||||
it('is bounded, and its result is reused for 60 seconds', () => {
|
||||
expect(SYSCTL_TIMEOUT_MS).toBe(2000);
|
||||
expect(HARDWARE_CACHE_TTL_MS).toBe(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSysctl on the two real Macs', () => {
|
||||
it('reads the MacBook Pro: M5 Pro, 48 GB, macOS 27.2, no wired limit set', () => {
|
||||
expect(parseSysctl(MACBOOK_SYSCTL)).toEqual({
|
||||
memBytes: 51_539_607_552,
|
||||
chip: 'Apple M5 Pro',
|
||||
arm64: true,
|
||||
translated: false,
|
||||
wiredLimitMb: 0,
|
||||
macosVersion: '27.2',
|
||||
macosMajor: 27,
|
||||
model: 'Mac17,13',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the Mac mini: M4, 16 GB, macOS 27.2, no wired limit set', () => {
|
||||
expect(parseSysctl(MACMINI_SYSCTL)).toEqual({
|
||||
memBytes: 17_179_869_184,
|
||||
chip: 'Apple M4',
|
||||
arm64: true,
|
||||
translated: false,
|
||||
wiredLimitMb: 0,
|
||||
macosVersion: '27.2',
|
||||
macosMajor: 27,
|
||||
model: 'Mac16,10',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the lines it does not know: other keys, the exit status, the df line', () => {
|
||||
const facts = parseSysctl(MACMINI_SYSCTL);
|
||||
expect(Object.keys(facts).sort()).toEqual(
|
||||
['arm64', 'chip', 'macosMajor', 'macosVersion', 'memBytes', 'model', 'translated', 'wiredLimitMb'].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a wired limit of 0 as 0 (not set), and a set one as its number', () => {
|
||||
expect(parseSysctl(MACMINI_SYSCTL).wiredLimitMb).toBe(0);
|
||||
expect(parseSysctl('iogpu.wired_limit_mb: 14000\n').wiredLimitMb).toBe(14000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSysctl on other shapes', () => {
|
||||
it('reads an Intel Mac, whose output lacks three of the keys, without shifting a value', () => {
|
||||
expect(parseSysctl(INTEL_SYSCTL)).toEqual({
|
||||
memBytes: 17_179_869_184,
|
||||
chip: 'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz',
|
||||
macosVersion: '13.6.7',
|
||||
macosMajor: 13,
|
||||
model: 'MacBookPro15,1',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads an Intel Mac that does print the arm64 flag, as 0', () => {
|
||||
const facts = parseSysctl(`hw.optional.arm64: 0\n${INTEL_SYSCTL}`);
|
||||
expect(facts.arm64).toBe(false);
|
||||
expect(facts.translated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads a process under Rosetta: the machine is arm64 and the process is translated', () => {
|
||||
const facts = parseSysctl(MACMINI_SYSCTL.replace('sysctl.proc_translated: 0', 'sysctl.proc_translated: 1'));
|
||||
expect(facts.arm64).toBe(true);
|
||||
expect(facts.translated).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a line that was cut off: a truncated output never yields half a value', () => {
|
||||
// Cut in the middle of the chip name. The memory line before it is whole.
|
||||
const cutInChip = 'hw.memsize: 17179869184\nmachdep.cpu.brand_string: Apple M';
|
||||
expect(parseSysctl(cutInChip)).toEqual({ memBytes: 17_179_869_184 });
|
||||
|
||||
// Cut in the middle of the memory figure: '1717' would be a valid, wrong number.
|
||||
expect(parseSysctl('hw.memsize: 1717')).toEqual({});
|
||||
|
||||
// Cut right after a whole line: everything up to there counts.
|
||||
const cutAfterLine = MACMINI_SYSCTL.slice(0, MACMINI_SYSCTL.indexOf('hw.optional.arm64'));
|
||||
expect(parseSysctl(cutAfterLine)).toEqual({ memBytes: 17_179_869_184, chip: 'Apple M4' });
|
||||
});
|
||||
|
||||
it('reads nothing from an empty output or from error text', () => {
|
||||
expect(parseSysctl('')).toEqual({});
|
||||
expect(parseSysctl('\n\n')).toEqual({});
|
||||
expect(
|
||||
parseSysctl("sysctl: unknown oid 'hw.memsize'\nsysctl: cannot stat /proc/sys/hw/memsize: No such file\n")
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('leaves out a value that does not parse instead of guessing', () => {
|
||||
const facts = parseSysctl(
|
||||
[
|
||||
'hw.memsize: lots',
|
||||
'machdep.cpu.brand_string: ',
|
||||
'hw.optional.arm64: yes',
|
||||
'sysctl.proc_translated: 2',
|
||||
'iogpu.wired_limit_mb: -1',
|
||||
'kern.osproductversion: Sequoia',
|
||||
`hw.model: ${'x'.repeat(200)}`,
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
expect(facts).toEqual({});
|
||||
});
|
||||
|
||||
it('refuses a memory size of 0 or one too large to be a number of bytes', () => {
|
||||
expect(parseSysctl('hw.memsize: 0\n').memBytes).toBeUndefined();
|
||||
expect(parseSysctl('hw.memsize: 99999999999999999999999\n').memBytes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts Windows line ends and padding, and the first line of a key wins', () => {
|
||||
const facts = parseSysctl('hw.memsize: 17179869184 \r\nhw.model: Mac16,10\r\nhw.model: Other9,9\r\n');
|
||||
expect(facts).toEqual({ memBytes: 17_179_869_184, model: 'Mac16,10' });
|
||||
});
|
||||
|
||||
it('reads the macOS major version from one, two or three numbers', () => {
|
||||
expect(parseSysctl('kern.osproductversion: 14\n')).toMatchObject({ macosVersion: '14', macosMajor: 14 });
|
||||
expect(parseSysctl('kern.osproductversion: 15.7\n')).toMatchObject({ macosVersion: '15.7', macosMajor: 15 });
|
||||
expect(parseSysctl('kern.osproductversion: 13.6.7\n')).toMatchObject({ macosVersion: '13.6.7', macosMajor: 13 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('readHardware on the two real Macs', () => {
|
||||
it('describes the MacBook Pro', async () => {
|
||||
const hw = await readHardware(
|
||||
deps({ runSysctl: async () => MACBOOK_SYSCTL, freeDiskBytes: async () => MACBOOK_FREE_DISK })
|
||||
);
|
||||
expect(hw).toEqual({
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
arm64: true,
|
||||
translated: false,
|
||||
memBytes: 51_539_607_552,
|
||||
memGb: '51.5',
|
||||
memClassGb: 48,
|
||||
chip: 'Apple M5 Pro',
|
||||
model: 'Mac17,13',
|
||||
macosVersion: '27.2',
|
||||
macosMajor: 27,
|
||||
wiredLimitMb: 0,
|
||||
freeDiskBytes: MACBOOK_FREE_DISK,
|
||||
freeDiskGb: '912',
|
||||
source: 'sysctl',
|
||||
});
|
||||
});
|
||||
|
||||
it('describes the Mac mini', async () => {
|
||||
const hw = await readHardware(deps());
|
||||
expect(hw).toEqual({
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
arm64: true,
|
||||
translated: false,
|
||||
memBytes: 17_179_869_184,
|
||||
memGb: '17.2',
|
||||
memClassGb: 16,
|
||||
chip: 'Apple M4',
|
||||
model: 'Mac16,10',
|
||||
macosVersion: '27.2',
|
||||
macosMajor: 27,
|
||||
wiredLimitMb: 0,
|
||||
freeDiskBytes: MACMINI_FREE_DISK,
|
||||
freeDiskGb: '42.8',
|
||||
source: 'sysctl',
|
||||
});
|
||||
});
|
||||
|
||||
it('is plain JSON: it survives a round trip unchanged', async () => {
|
||||
const hw = await readHardware(deps());
|
||||
expect(JSON.parse(JSON.stringify(hw))).toEqual(hw);
|
||||
});
|
||||
|
||||
it('describes an Intel Mac from its shorter output: no arm64 flag, an x64 process', async () => {
|
||||
const hw = await readHardware(deps({ arch: 'x64', runSysctl: async () => INTEL_SYSCTL }));
|
||||
expect(hw.source).toBe('sysctl');
|
||||
expect(hw.arch).toBe('x64');
|
||||
expect(hw.chip).toBe('Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz');
|
||||
expect(hw.macosMajor).toBe(13);
|
||||
expect(hw).not.toHaveProperty('arm64');
|
||||
expect(hw).not.toHaveProperty('translated');
|
||||
expect(hw).not.toHaveProperty('wiredLimitMb');
|
||||
});
|
||||
|
||||
it('takes the chip name from node:os when sysctl printed everything but that', async () => {
|
||||
const hw = await readHardware(
|
||||
deps({ runSysctl: async () => MACMINI_SYSCTL.replace('machdep.cpu.brand_string: Apple M4\n', '') })
|
||||
);
|
||||
expect(hw.source).toBe('sysctl');
|
||||
expect(hw.chip).toBe('Fallback CPU');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readHardware when the probe fails', () => {
|
||||
it('answers from node:os when the spawn could not run or timed out', async () => {
|
||||
const hw = await readHardware(
|
||||
deps({
|
||||
runSysctl: async () => undefined,
|
||||
totalMemBytes: () => 34_359_738_368,
|
||||
cpuModel: () => 'Apple M2 Max',
|
||||
})
|
||||
);
|
||||
expect(hw).toEqual({
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
memBytes: 34_359_738_368,
|
||||
memGb: '34.4',
|
||||
memClassGb: 32,
|
||||
chip: 'Apple M2 Max',
|
||||
freeDiskBytes: MACMINI_FREE_DISK,
|
||||
freeDiskGb: '42.8',
|
||||
source: 'os-fallback',
|
||||
});
|
||||
});
|
||||
|
||||
it('never rejects when a dependency throws or rejects', async () => {
|
||||
const hw = await readHardware(
|
||||
deps({
|
||||
runSysctl: () => Promise.reject(new Error('spawn EACCES')),
|
||||
freeDiskBytes: () => {
|
||||
throw new Error('statfs failed');
|
||||
},
|
||||
cpuModel: () => {
|
||||
throw new Error('no cpus');
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(hw.source).toBe('os-fallback');
|
||||
expect(hw.memBytes).toBe(8 * 1024 ** 3);
|
||||
expect(hw).not.toHaveProperty('chip');
|
||||
expect(hw).not.toHaveProperty('freeDiskBytes');
|
||||
expect(hw).not.toHaveProperty('freeDiskGb');
|
||||
});
|
||||
|
||||
it('still answers when even the memory size cannot be read', async () => {
|
||||
const hw = await readHardware(
|
||||
deps({
|
||||
runSysctl: async () => undefined,
|
||||
totalMemBytes: () => {
|
||||
throw new Error('no memory figure');
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(hw).toMatchObject({ source: 'os-fallback', memBytes: 0, memGb: '0.0', memClassGb: 0 });
|
||||
});
|
||||
|
||||
it('trusts nothing of an output that has no memory size', async () => {
|
||||
// A truncated or foreign output: the chip and version lines are whole, hw.memsize is not there.
|
||||
const withoutMemory = MACMINI_SYSCTL.replace('hw.memsize: 17179869184\n', '');
|
||||
const hw = await readHardware(deps({ runSysctl: async () => withoutMemory }));
|
||||
expect(hw.source).toBe('os-fallback');
|
||||
expect(hw.memBytes).toBe(8 * 1024 ** 3);
|
||||
expect(hw.chip).toBe('Fallback CPU');
|
||||
expect(hw).not.toHaveProperty('macosVersion');
|
||||
expect(hw).not.toHaveProperty('macosMajor');
|
||||
expect(hw).not.toHaveProperty('model');
|
||||
expect(hw).not.toHaveProperty('wiredLimitMb');
|
||||
expect(hw).not.toHaveProperty('arm64');
|
||||
});
|
||||
|
||||
it('falls back when the output was cut off inside the memory figure', async () => {
|
||||
const hw = await readHardware(deps({ runSysctl: async () => 'hw.memsize: 1717' }));
|
||||
expect(hw.source).toBe('os-fallback');
|
||||
expect(hw.memBytes).toBe(8 * 1024 ** 3);
|
||||
});
|
||||
|
||||
it('leaves the free disk out when it is unknown, and keeps the rest', async () => {
|
||||
const hw = await readHardware(deps({ freeDiskBytes: async () => undefined }));
|
||||
expect(hw.source).toBe('sysctl');
|
||||
expect(hw).not.toHaveProperty('freeDiskBytes');
|
||||
expect(hw).not.toHaveProperty('freeDiskGb');
|
||||
});
|
||||
|
||||
it('reports a full disk as 0 bytes free, which is a figure and not "unknown"', async () => {
|
||||
const hw = await readHardware(deps({ freeDiskBytes: async () => 0 }));
|
||||
expect(hw.freeDiskBytes).toBe(0);
|
||||
expect(hw.freeDiskGb).toBe('0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readHardware off macOS', () => {
|
||||
it('spawns nothing and reads no disk', async () => {
|
||||
let spawned = 0;
|
||||
let diskRead = 0;
|
||||
const hw = await readHardware(
|
||||
deps({
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
runSysctl: async () => {
|
||||
spawned++;
|
||||
return MACMINI_SYSCTL;
|
||||
},
|
||||
freeDiskBytes: async () => {
|
||||
diskRead++;
|
||||
return 1;
|
||||
},
|
||||
totalMemBytes: () => 67_430_023_168,
|
||||
})
|
||||
);
|
||||
expect(spawned).toBe(0);
|
||||
expect(diskRead).toBe(0);
|
||||
expect(hw).toEqual({
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
memBytes: 67_430_023_168,
|
||||
memGb: '67.4',
|
||||
memClassGb: 63,
|
||||
source: 'not-probed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the cache of readHardware', () => {
|
||||
it('reuses one result for 60 seconds, then probes again', async () => {
|
||||
let clock = 5_000_000;
|
||||
let spawned = 0;
|
||||
const counting = deps({
|
||||
now: () => clock,
|
||||
runSysctl: async () => {
|
||||
spawned++;
|
||||
return spawned === 1 ? MACMINI_SYSCTL : MACBOOK_SYSCTL;
|
||||
},
|
||||
});
|
||||
|
||||
const first = await readHardware(counting);
|
||||
clock += HARDWARE_CACHE_TTL_MS - 1;
|
||||
const second = await readHardware(counting);
|
||||
expect(spawned).toBe(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
clock += 1;
|
||||
const third = await readHardware(counting);
|
||||
expect(spawned).toBe(2);
|
||||
expect(third.chip).toBe('Apple M5 Pro');
|
||||
});
|
||||
|
||||
it('caches a failed probe too: a broken sysctl is not spawned on every request', async () => {
|
||||
let spawned = 0;
|
||||
const failing = deps({
|
||||
runSysctl: async () => {
|
||||
spawned++;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
expect((await readHardware(failing)).source).toBe('os-fallback');
|
||||
expect((await readHardware(failing)).source).toBe('os-fallback');
|
||||
expect(spawned).toBe(1);
|
||||
});
|
||||
|
||||
it('lets concurrent callers share one probe', async () => {
|
||||
let spawned = 0;
|
||||
let release: (text: string) => void = () => {};
|
||||
const slow = deps({
|
||||
runSysctl: () => {
|
||||
spawned++;
|
||||
return new Promise<string>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const callers = [readHardware(slow), readHardware(slow), readHardware(slow)];
|
||||
// Let the three calls reach the spawn before it answers.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
release(MACMINI_SYSCTL);
|
||||
const results = await Promise.all(callers);
|
||||
expect(spawned).toBe(1);
|
||||
expect(results[1]).toBe(results[0]);
|
||||
expect(results[2]).toBe(results[0]);
|
||||
});
|
||||
|
||||
it('probes again after a reset, and does not trust a clock that ran backwards', async () => {
|
||||
let clock = 9_000_000;
|
||||
let spawned = 0;
|
||||
const counting = deps({
|
||||
now: () => clock,
|
||||
runSysctl: async () => {
|
||||
spawned++;
|
||||
return MACMINI_SYSCTL;
|
||||
},
|
||||
});
|
||||
await readHardware(counting);
|
||||
resetHardwareCache();
|
||||
await readHardware(counting);
|
||||
expect(spawned).toBe(2);
|
||||
|
||||
clock -= 10_000;
|
||||
await readHardware(counting);
|
||||
expect(spawned).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// The scripts below are POSIX shell. Codeman's server does not run on Windows.
|
||||
describe.skipIf(process.platform === 'win32')('spawnSysctl, against a script of the test', () => {
|
||||
let folder: string;
|
||||
|
||||
beforeEach(() => {
|
||||
folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-spawn-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** An executable shell script standing in for sysctl. */
|
||||
function script(body: string, mode = 0o755): string {
|
||||
const path = join(folder, 'fake-sysctl');
|
||||
writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode });
|
||||
return path;
|
||||
}
|
||||
|
||||
it('passes the seven keys as arguments and nothing else: no -n, no shell', async () => {
|
||||
// printf, not echo: echo would swallow a `-n` argument, the very thing this test looks for.
|
||||
const output = await spawnSysctl(script('printf \'%s\\n\' "$@"'));
|
||||
expect(output?.trimEnd().split('\n')).toEqual([...SYSCTL_KEYS]);
|
||||
});
|
||||
|
||||
it('reads the output of a program that exits 1, as sysctl does on an Intel Mac', async () => {
|
||||
// Three keys are unknown there: their errors go to stderr, the other four lines to stdout.
|
||||
const output = await spawnSysctl(
|
||||
script(
|
||||
[
|
||||
"echo 'hw.memsize: 17179869184'",
|
||||
"echo 'machdep.cpu.brand_string: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz'",
|
||||
`echo "sysctl: unknown oid 'hw.optional.arm64'" >&2`,
|
||||
`echo "sysctl: unknown oid 'sysctl.proc_translated'" >&2`,
|
||||
`echo "sysctl: unknown oid 'iogpu.wired_limit_mb'" >&2`,
|
||||
"echo 'kern.osproductversion: 13.6.7'",
|
||||
"echo 'hw.model: MacBookPro15,1'",
|
||||
'exit 1',
|
||||
].join('\n')
|
||||
)
|
||||
);
|
||||
expect(output).toBeDefined();
|
||||
expect(output).not.toContain('unknown oid');
|
||||
expect(parseSysctl(output as string)).toEqual({
|
||||
memBytes: 17_179_869_184,
|
||||
chip: 'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz',
|
||||
macosVersion: '13.6.7',
|
||||
macosMajor: 13,
|
||||
model: 'MacBookPro15,1',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers undefined for a program that does not exist or may not be run', async () => {
|
||||
expect(await spawnSysctl(join(folder, 'no-such-program'))).toBeUndefined();
|
||||
expect(await spawnSysctl(script('echo "hw.memsize: 1"', 0o644))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives up on a program that hangs, and does not hand over half an output', async () => {
|
||||
const started = Date.now();
|
||||
// `exec` so the signal reaches the sleeper itself and nothing is left behind.
|
||||
const output = await spawnSysctl(script("echo 'hw.memsize: 17179869184'\nexec sleep 20"), 200);
|
||||
expect(output).toBeUndefined();
|
||||
expect(Date.now() - started).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('answers undefined for an output far larger than seven lines', async () => {
|
||||
expect(await spawnSysctl(script("head -c 200000 /dev/zero | tr '\\0' 'x'"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not pass the server's environment to the program", async () => {
|
||||
const name = 'CODEMAN_LOCAL_MODEL_TEST_SECRET';
|
||||
process.env[name] = 'hunter2';
|
||||
try {
|
||||
const output = await spawnSysctl(script(`echo "secret=\${${name}:-unset}"`));
|
||||
expect(output?.trim()).toBe('secret=unset');
|
||||
} finally {
|
||||
delete process.env[name];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataDirFreeBytes', () => {
|
||||
let folder: string;
|
||||
|
||||
beforeEach(() => {
|
||||
folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-disk-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads the free bytes of the data dir with the real probe and the real statfs', async () => {
|
||||
const free = await dataDirFreeBytes({ dataDir: () => folder });
|
||||
expect(typeof free).toBe('number');
|
||||
expect(free).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('uses the nearest existing parent when the data dir does not exist yet', async () => {
|
||||
const asked: string[] = [];
|
||||
const free = await dataDirFreeBytes({
|
||||
dataDir: () => join(folder, 'not', 'yet', 'there'),
|
||||
freeBytesOf: async (path) => {
|
||||
asked.push(path);
|
||||
return 123;
|
||||
},
|
||||
});
|
||||
expect(free).toBe(123);
|
||||
expect(asked).toEqual([folder]);
|
||||
});
|
||||
|
||||
it('does not touch a folder whose mount does not answer, or a path that is a file', async () => {
|
||||
for (const kind of ['unknown', 'file'] as const) {
|
||||
let touched = 0;
|
||||
const free = await dataDirFreeBytes({
|
||||
dataDir: () => folder,
|
||||
probeKind: async () => kind,
|
||||
freeBytesOf: async () => {
|
||||
touched++;
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
expect(free, kind).toBeUndefined();
|
||||
expect(touched, kind).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('stops walking up after a few parents', async () => {
|
||||
let probes = 0;
|
||||
const free = await dataDirFreeBytes({
|
||||
dataDir: () => '/a/b/c/d/e/f/g/h/i/j/k/l',
|
||||
probeKind: async () => {
|
||||
probes++;
|
||||
return 'absent';
|
||||
},
|
||||
freeBytesOf: async () => 1,
|
||||
});
|
||||
expect(free).toBeUndefined();
|
||||
expect(probes).toBe(7);
|
||||
});
|
||||
|
||||
it('gives up on a statfs that does not come back, and never starts a second one meanwhile', async () => {
|
||||
let started = 0;
|
||||
let finish: (bytes: number) => void = () => {};
|
||||
const hanging = {
|
||||
dataDir: () => folder,
|
||||
freeBytesOf: () => {
|
||||
started++;
|
||||
return new Promise<number>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
},
|
||||
timeoutMs: 20,
|
||||
};
|
||||
|
||||
// The first read waits its timeout and answers "unknown".
|
||||
expect(await dataDirFreeBytes(hanging)).toBeUndefined();
|
||||
expect(started).toBe(1);
|
||||
|
||||
// While that statfs is still out, later reads answer at once and start nothing.
|
||||
const before = Date.now();
|
||||
expect(await dataDirFreeBytes(hanging)).toBeUndefined();
|
||||
expect(await dataDirFreeBytes({ ...hanging, timeoutMs: 60_000 })).toBeUndefined();
|
||||
expect(Date.now() - before).toBeLessThan(1000);
|
||||
expect(started).toBe(1);
|
||||
|
||||
// Once it finally answers, the next read works again.
|
||||
finish(5);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(await dataDirFreeBytes({ ...hanging, freeBytesOf: async () => 42 })).toBe(42);
|
||||
});
|
||||
|
||||
it('never rejects when one of its reads does', async () => {
|
||||
const failing = async (): Promise<never> => {
|
||||
throw new Error('EIO');
|
||||
};
|
||||
expect(await dataDirFreeBytes({ dataDir: () => folder, freeBytesOf: failing })).toBeUndefined();
|
||||
expect(await dataDirFreeBytes({ dataDir: () => folder, probeKind: failing })).toBeUndefined();
|
||||
expect(
|
||||
await dataDirFreeBytes({
|
||||
dataDir: () => {
|
||||
throw new Error('no home');
|
||||
},
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is bounded by default', () => {
|
||||
expect(FREE_DISK_TIMEOUT_MS).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the real defaults', () => {
|
||||
let folder: string;
|
||||
|
||||
beforeEach(() => {
|
||||
folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-hw-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads the free bytes of a real folder, and undefined for one that does not exist', async () => {
|
||||
const free = await freeDiskBytesOf(folder);
|
||||
expect(typeof free).toBe('number');
|
||||
expect(free).toBeGreaterThanOrEqual(0);
|
||||
expect(await freeDiskBytesOf(join(folder, 'missing', 'deeper'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never rejects with the real spawn and the real disk read, whatever machine this is', async () => {
|
||||
// Pretend to be a Mac so the spawn and the disk read really run. On Linux /usr/sbin/sysctl
|
||||
// either does not exist or knows none of the keys: the answer is then the fallback.
|
||||
const hw = await readHardware({ platform: 'darwin' });
|
||||
expect(['sysctl', 'os-fallback']).toContain(hw.source);
|
||||
expect(hw.memBytes).toBeGreaterThan(0);
|
||||
expect(hw.memGb).toMatch(/^\d+(\.\d)?$/);
|
||||
if (hw.freeDiskBytes !== undefined) {
|
||||
expect(hw.freeDiskBytes).toBeGreaterThanOrEqual(0);
|
||||
expect(hw.freeDiskGb).toMatch(/^\d+(\.\d)?$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @fileoverview `buildHarnessFacts()`: what the server knows about each catalogue harness on
|
||||
* this machine (src/local-model/harness-facts.ts), read from the CLI registry at call time.
|
||||
*
|
||||
* The catalogue here is the test's own: the stock one is filled by another module and its rows
|
||||
* are not what this file is about. The registry is the REAL stock registry unless a test says
|
||||
* otherwise, so the model id anchors below are regression anchors on shipped data.
|
||||
*
|
||||
* Two things a reader should know before trusting a green run:
|
||||
* - Under vitest the real availability probe is inert (it finds no CLI). A test that cares
|
||||
* about `installed` therefore injects `probeInstalled`.
|
||||
* - The registry's install command resolver reads `process.platform` and falls back to the
|
||||
* Linux command, so on a Linux machine it never returns a macOS command. The macOS cases
|
||||
* inject `installCommandFor` and read the entry's own `darwin` command.
|
||||
*
|
||||
* Port: none (no server; the on-disk case uses the per-file temp HOME from test/setup.ts).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import {
|
||||
buildHarnessFacts,
|
||||
cliAcceptsCustomModelId,
|
||||
installCommandNeedsAdmin,
|
||||
} from '../src/local-model/harness-facts.js';
|
||||
import { getCli, registryFilePath, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
import type { LocalModelCatalog } from '../src/types.js';
|
||||
|
||||
/** A catalogue whose harness rows are `clis`, in that order. Nothing else in it matters here. */
|
||||
function catalogWith(clis: string[]): LocalModelCatalog {
|
||||
return {
|
||||
schema: 1,
|
||||
updated: '2026-11-06',
|
||||
minMacosMajor: 14,
|
||||
promptCacheTiers: [{ minMemGb: 0, bytes: 512 * 1024 * 1024 }],
|
||||
models: [],
|
||||
harnesses: clis.map((cli, index) => ({ cli, minContext: 32768, rank: clis.length - index })),
|
||||
chips: [],
|
||||
};
|
||||
}
|
||||
|
||||
function stockEntry(id: string): CliEntry {
|
||||
const entry = getCli(id);
|
||||
if (!entry) throw new Error(`${id} is missing from the CLI registry`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The command the registry holds for macOS, whatever machine the test runs on. */
|
||||
const darwinCommand = (entry: CliEntry): string | undefined => entry.discovery.install.command.darwin;
|
||||
|
||||
/** A probe that reports exactly these CLIs as installed. */
|
||||
const installedOnly =
|
||||
(...ids: string[]) =>
|
||||
async () =>
|
||||
(entry: CliEntry) =>
|
||||
ids.includes(entry.id as string);
|
||||
|
||||
afterEach(() => {
|
||||
const path = registryFilePath();
|
||||
if (existsSync(path)) rmSync(path);
|
||||
reloadCliRegistry();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('installCommandNeedsAdmin', () => {
|
||||
it.each([
|
||||
['brew install can1357/tap/omp', true],
|
||||
[' brew install something', true],
|
||||
['sudo apt install something', true],
|
||||
['curl -fsSL https://example.com/install.sh | sudo bash', true],
|
||||
['npm install -g thing && sudo ln -s a b', true],
|
||||
['echo first; brew install thing', true],
|
||||
['echo first\nsudo make install', true],
|
||||
['(brew install thing)', true],
|
||||
['curl -fsSL https://opencode.ai/install | bash', false],
|
||||
['curl -fsSL https://claude.ai/install.sh | bash', false],
|
||||
['npm install -g --ignore-scripts @earendil-works/pi-coding-agent', false],
|
||||
['npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)', false],
|
||||
// The words inside a URL, a package name or an argument are not a command.
|
||||
['curl -fsSL https://brew.sh/install.sh | bash', false],
|
||||
['npm install -g homebrew-sudo-helper', false],
|
||||
['echo sudo', false],
|
||||
])('%s -> %s', (command, expected) => {
|
||||
expect(installCommandNeedsAdmin(command)).toBe(expected);
|
||||
});
|
||||
|
||||
it('agrees with the registry on every stock macOS command: only a Homebrew one needs an administrator', () => {
|
||||
expect(installCommandNeedsAdmin(darwinCommand(stockEntry('omp')) ?? '')).toBe(true);
|
||||
for (const id of ['claude', 'opencode', 'codex', 'pi']) {
|
||||
expect(installCommandNeedsAdmin(darwinCommand(stockEntry(id)) ?? '')).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cliAcceptsCustomModelId (the launch path’s model id test, on the stock registry)', () => {
|
||||
it('accepts a registry style `name:tag` id for the CLIs that can carry a colon', () => {
|
||||
// opencode and claude select the model in their config or environment: no launch id at all.
|
||||
// pi and codex force the id onto `--model`, and both of their patterns allow a colon.
|
||||
for (const id of ['opencode', 'claude', 'pi', 'codex']) {
|
||||
expect(cliAcceptsCustomModelId(stockEntry(id), 'qwen3.5:9b')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a colon for a CLI whose `--model` pattern has none, and accepts the same CLI without it', () => {
|
||||
const omp = stockEntry('omp');
|
||||
expect(cliAcceptsCustomModelId(omp, 'qwen3.5:9b')).toBe(false);
|
||||
expect(cliAcceptsCustomModelId(omp, 'qwen3-coder')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses the ids the launch routes’ own tests refuse', () => {
|
||||
// test/routes/quick-start-custom-model.test.ts pins both pairs against the real route.
|
||||
expect(cliAcceptsCustomModelId(stockEntry('pi'), 'qwen 3 with spaces')).toBe(false);
|
||||
expect(cliAcceptsCustomModelId(stockEntry('codex'), 'org/model@q4_k_m')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a CLI with no custom model mechanism', () => {
|
||||
expect(cliAcceptsCustomModelId(stockEntry('shell'), 'qwen3.5:9b')).toBe(false);
|
||||
expect(cliAcceptsCustomModelId(stockEntry('antigravity'), 'qwen3.5:9b')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the entry it is given, so a changed pattern changes the answer (no id is looked at)', () => {
|
||||
const omp = stockEntry('omp');
|
||||
const widened: CliEntry = {
|
||||
...omp,
|
||||
launch: { ...omp.launch, params: { ...omp.launch.params, model: { type: 'token', pattern: 'model-pi' } } },
|
||||
};
|
||||
expect(cliAcceptsCustomModelId(widened, 'qwen3.5:9b')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHarnessFacts', () => {
|
||||
it('answers one fact per catalogue harness, in catalogue order, with the registry’s label', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['pi', 'opencode', 'claude']), {
|
||||
probeInstalled: installedOnly('opencode'),
|
||||
installCommandFor: darwinCommand,
|
||||
});
|
||||
expect(facts.map((f) => f.cli)).toEqual(['pi', 'opencode', 'claude']);
|
||||
expect(facts.map((f) => f.label)).toEqual(['pi', 'opencode', 'claude'].map((id) => stockEntry(id).label));
|
||||
expect(facts.map((f) => f.installed)).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it('leaves out a CLI the registry does not know, one with no custom model mechanism, and a disabled one', async () => {
|
||||
const disabled = (id: string): CliEntry | undefined => {
|
||||
const entry = getCli(id);
|
||||
return entry && id === 'claude' ? { ...entry, enabled: false } : entry;
|
||||
};
|
||||
const facts = await buildHarnessFacts(catalogWith(['no-such-cli', 'shell', 'antigravity', 'claude', 'opencode']), {
|
||||
getCli: disabled,
|
||||
probeInstalled: installedOnly(),
|
||||
});
|
||||
expect(facts.map((f) => f.cli)).toEqual(['opencode']);
|
||||
});
|
||||
|
||||
it('keeps one fact for a CLI the catalogue lists twice', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'opencode']), { probeInstalled: installedOnly() });
|
||||
expect(facts.map((f) => f.cli)).toEqual(['opencode']);
|
||||
});
|
||||
|
||||
it('installable: not installed, a stock CLI, and a macOS command that needs no administrator', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), {
|
||||
probeInstalled: installedOnly('claude'),
|
||||
installCommandFor: darwinCommand,
|
||||
});
|
||||
const byCli = new Map(facts.map((f) => [f.cli, f]));
|
||||
// curl installer into the home directory.
|
||||
expect(byCli.get('opencode')).toMatchObject({ installed: false, installable: true });
|
||||
// Already there: nothing to install.
|
||||
expect(byCli.get('claude')).toMatchObject({ installed: true, installable: false });
|
||||
// npm: the install runner points it at a prefix the server user can write.
|
||||
expect(byCli.get('pi')).toMatchObject({ installed: false, installable: true });
|
||||
// Homebrew on macOS.
|
||||
expect(byCli.get('omp')).toMatchObject({ installed: false, installable: false });
|
||||
});
|
||||
|
||||
it('installable is false without an install command, for a sudo command, and for a custom entry', async () => {
|
||||
const command = vi.fn<(entry: CliEntry) => string | undefined>();
|
||||
command.mockReturnValueOnce(undefined);
|
||||
expect(
|
||||
(
|
||||
await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
probeInstalled: installedOnly(),
|
||||
installCommandFor: command,
|
||||
})
|
||||
)[0].installable
|
||||
).toBe(false);
|
||||
command.mockReturnValueOnce(' ');
|
||||
expect(
|
||||
(
|
||||
await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
probeInstalled: installedOnly(),
|
||||
installCommandFor: command,
|
||||
})
|
||||
)[0].installable
|
||||
).toBe(false);
|
||||
command.mockReturnValueOnce('sudo installer -pkg agent.pkg -target /');
|
||||
expect(
|
||||
(
|
||||
await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
probeInstalled: installedOnly(),
|
||||
installCommandFor: command,
|
||||
})
|
||||
)[0].installable
|
||||
).toBe(false);
|
||||
|
||||
// A custom entry's install command is never run by the install runner, so it is never
|
||||
// offered, however harmless the text looks.
|
||||
const custom = (id: string): CliEntry | undefined => {
|
||||
const entry = getCli(id);
|
||||
return entry ? { ...entry, stock: false } : entry;
|
||||
};
|
||||
const harmless = vi.fn(() => 'curl -fsSL https://example.com/install | bash');
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
getCli: custom,
|
||||
probeInstalled: installedOnly(),
|
||||
installCommandFor: harmless,
|
||||
});
|
||||
expect(facts[0].installable).toBe(false);
|
||||
expect(harmless).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never reads an install command for a CLI that is already installed', async () => {
|
||||
const command = vi.fn(() => 'curl -fsSL https://example.com/install | bash');
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
probeInstalled: installedOnly('opencode'),
|
||||
installCommandFor: command,
|
||||
});
|
||||
expect(facts[0]).toMatchObject({ installed: true, installable: false });
|
||||
expect(command).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('acceptsModelId is the launch path’s test for that harness', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), {
|
||||
probeInstalled: installedOnly(),
|
||||
});
|
||||
const accepts = (cli: string, modelId: string) => facts.find((f) => f.cli === cli)?.acceptsModelId(modelId);
|
||||
expect(accepts('opencode', 'qwen3.5:9b')).toBe(true);
|
||||
expect(accepts('claude', 'qwen3.8:27b-q8_0')).toBe(true);
|
||||
expect(accepts('pi', 'gpt-oss:20b')).toBe(true);
|
||||
expect(accepts('omp', 'gpt-oss:20b')).toBe(false);
|
||||
});
|
||||
|
||||
it('asks the availability probe once per build, not once per harness', async () => {
|
||||
const probe = vi.fn(installedOnly('opencode'));
|
||||
await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi']), { probeInstalled: probe });
|
||||
expect(probe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a probe that fails is an answer: every harness reads as not installed, and install data still counts', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'omp']), {
|
||||
probeInstalled: async () => {
|
||||
throw new Error('login shell did not answer');
|
||||
},
|
||||
installCommandFor: darwinCommand,
|
||||
});
|
||||
expect(facts.map((f) => [f.cli, f.installed, f.installable])).toEqual([
|
||||
['opencode', false, true],
|
||||
['omp', false, false],
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0][0])).toContain('login shell did not answer');
|
||||
});
|
||||
|
||||
it('a probe that throws for one harness does not take the others down', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude']), {
|
||||
probeInstalled: async () => (entry) => {
|
||||
if (entry.label === stockEntry('opencode').label) throw new Error('boom');
|
||||
return true;
|
||||
},
|
||||
});
|
||||
expect(facts.map((f) => [f.cli, f.installed])).toEqual([
|
||||
['opencode', false],
|
||||
['claude', true],
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads the registry when it is called, not when the module loaded', async () => {
|
||||
const before = await buildHarnessFacts(catalogWith(['opencode', 'claude']));
|
||||
expect(before.map((f) => f.cli)).toEqual(['opencode', 'claude']);
|
||||
|
||||
// The user disables a CLI in ~/.codeman/clis.json after the module was imported.
|
||||
const path = registryFilePath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify({ schemaVersion: 1, clis: { opencode: { enabled: false } } }), { mode: 0o600 });
|
||||
reloadCliRegistry();
|
||||
|
||||
const after = await buildHarnessFacts(catalogWith(['opencode', 'claude']));
|
||||
expect(after.map((f) => f.cli)).toEqual(['claude']);
|
||||
});
|
||||
|
||||
it('with its default dependencies it spawns nothing under vitest and finds no CLI installed', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi']));
|
||||
expect(facts.map((f) => f.cli)).toEqual(['opencode', 'claude', 'pi']);
|
||||
expect(facts.every((f) => f.installed === false)).toBe(true);
|
||||
// opencode's install command is a curl installer on every platform the registry lists.
|
||||
expect(facts[0].installable).toBe(true);
|
||||
});
|
||||
|
||||
it('an empty harness list is an empty answer', async () => {
|
||||
expect(await buildHarnessFacts(catalogWith([]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @fileoverview The "Model on this Mac" card and its App Settings group in Chinese (zh-CN).
|
||||
*
|
||||
* Three sources of text, all checked against the real dictionary in i18n.js:
|
||||
* - the module's own strings, HARVESTED from the real local-model-ui.js (every literal it
|
||||
* passes to `localModelT`), so a new string without an entry fails here instead of showing
|
||||
* up English on one surface;
|
||||
* - every sentence and label the SERVER may send (`LOCAL_MODEL_LABELS`, imported from the
|
||||
* contract, so a label added there fails here until it has an entry);
|
||||
* - the static heading and hint of the Settings group, read from the real index.html and run
|
||||
* through the real DOM translator.
|
||||
*
|
||||
* Both containers carry data-i18n-skip (model, harness and chip names are someone else's
|
||||
* text), so nothing in them is reached by the DOM translator: these strings translate only
|
||||
* through the module's own call, which is why they are checked through `t()` here.
|
||||
*
|
||||
* Beyond "it is Chinese now", each entry must keep its `{placeholder}` slots: a translation
|
||||
* that drops one prints a sentence without its number.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { LOCAL_MODEL_LABELS, LOCAL_MODEL_NONE_LABELS } from '../src/types.js';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
|
||||
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const MODULE = readFileSync(resolve(PUBLIC, 'local-model-ui.js'), 'utf8');
|
||||
|
||||
type Api = {
|
||||
configure(o: Record<string, string>): void;
|
||||
start(): void;
|
||||
t(source: string, vars?: Record<string, string>): string;
|
||||
};
|
||||
|
||||
/** Every literal the module hands to its translator call (Prettier may break after the paren). */
|
||||
const FROM_MODULE = [...new Set([...MODULE.matchAll(/localModelT\(\s*'([^']+)'/g)].map((m) => m[1]))];
|
||||
/** Every text the server may send. */
|
||||
const FROM_SERVER: string[] = Object.values(LOCAL_MODEL_LABELS);
|
||||
/** Already in the dictionary for the outside-sessions block; reused here for a failed request. */
|
||||
const SHARED = ['Could not reach the server.'];
|
||||
const OWN = FROM_MODULE.filter((source) => !SHARED.includes(source));
|
||||
|
||||
/** A page with only the translator in it: enough for `t()`, and cheap to re-configure. */
|
||||
const small = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(I18N, small.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (small.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
api.configure({ language: 'zh-CN' });
|
||||
|
||||
/** The real page, translated once, for the strings that live in index.html. */
|
||||
const page = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
vm.runInContext(I18N, page.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const pageApi = (page.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
pageApi.start();
|
||||
pageApi.configure({ language: 'zh-CN' });
|
||||
|
||||
afterAll(() => {
|
||||
small.window.close();
|
||||
page.window.close();
|
||||
});
|
||||
|
||||
/** The Settings group's static strings, as index.html ships them (English). */
|
||||
const GROUP_MARKUP = INDEX.match(
|
||||
/<div class="set-group" id="localModelGroup">\s*<div class="set-group-head"><h4>([^<]+)<\/h4><\/div>\s*<p class="set-group-hint">([^<]+)<\/p>/
|
||||
);
|
||||
const STATIC = GROUP_MARKUP ? [GROUP_MARKUP[1].trim(), GROUP_MARKUP[2].trim()] : [];
|
||||
|
||||
const ALL = [...new Set([...OWN, ...FROM_SERVER, ...STATIC])];
|
||||
|
||||
/** The `{slot}` names of a source text. */
|
||||
const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m) => m[1]);
|
||||
|
||||
/** U+2013, U+2014 and U+2015, built from their code points so this file holds none of them. */
|
||||
const LONG_DASH = new RegExp(`[${String.fromCharCode(0x2013, 0x2014, 0x2015)}]`);
|
||||
|
||||
/** What may stay Latin: the product, the platform and its vendors, the unit, and Token (the dictionary's own word). */
|
||||
const leftover = (text: string) =>
|
||||
text.replace(/\b(Codeman|macOS|Mac|GB|Apple|Intel|Token)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
describe('the local model strings in zh-CN', () => {
|
||||
it('harvests the module (the check is not vacuous)', () => {
|
||||
expect(FROM_MODULE).toContain('Model on this Mac');
|
||||
expect(FROM_MODULE).toContain('{model} ({id}) with {harness}');
|
||||
expect(FROM_MODULE).toContain('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).');
|
||||
expect(FROM_MODULE).toContain(
|
||||
'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'
|
||||
);
|
||||
// What the fit and speed labels measure, and the speed of an alternative.
|
||||
expect(FROM_MODULE).toContain('Memory fit: needs {share}% of what this Mac can give a model');
|
||||
expect(FROM_MODULE).toContain('No speed estimate for this model on this chip');
|
||||
expect(FROM_MODULE).toContain('about {speed} tokens per second');
|
||||
expect(FROM_MODULE.length).toBeGreaterThanOrEqual(20);
|
||||
// The only other way the module calls its translator is with payload text (a server
|
||||
// sentence or label), so no string of its own can reach the page unharvested.
|
||||
const code = MODULE.split('\n')
|
||||
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
|
||||
.join('\n');
|
||||
const calls = code.split('localModelT(').length - 1;
|
||||
const literalCalls = [...code.matchAll(/localModelT\(\s*'/g)].length;
|
||||
const payloadCalls = [...code.matchAll(/localModelT\((message\.text, message\.vars|label)\)/g)].length;
|
||||
// One more: the function's own declaration.
|
||||
expect(calls).toBe(literalCalls + payloadCalls + 1);
|
||||
expect(payloadCalls).toBe(2);
|
||||
});
|
||||
|
||||
it('lists every server label, the none reasons included', () => {
|
||||
// A label added to the contract later fails below by its text, not here by a count.
|
||||
expect(FROM_SERVER.length).toBeGreaterThanOrEqual(18);
|
||||
for (const text of Object.values(LOCAL_MODEL_NONE_LABELS)) expect(FROM_SERVER).toContain(text);
|
||||
expect(FROM_SERVER).toContain('Best fit for this Mac');
|
||||
expect(FROM_SERVER).toContain('Speed unknown');
|
||||
// The two other sentences of the `memory` reason and the two notes are not in the
|
||||
// reason map, so they are named here.
|
||||
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryContext);
|
||||
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryUnknown);
|
||||
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteSmallPick);
|
||||
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteDiskDropped);
|
||||
});
|
||||
|
||||
it('never uses the translator slot {name} for a value of its own', () => {
|
||||
for (const text of ALL) expect(slots(text), text).not.toContain('name');
|
||||
});
|
||||
|
||||
it.each(ALL)('translates %s, with no English left', (text) => {
|
||||
const chinese = api.t(text);
|
||||
expect(chinese).not.toBe(text);
|
||||
expect(leftover(chinese)).toEqual([]);
|
||||
// No long dash in any form: commas, colons and parentheses only.
|
||||
expect(chinese).not.toMatch(LONG_DASH);
|
||||
});
|
||||
|
||||
it.each(ALL.filter((text) => slots(text).length > 0))('keeps every slot of %s', (text) => {
|
||||
// Digits only, so a marker is neither translated nor counted as leftover English.
|
||||
const vars = Object.fromEntries(slots(text).map((slot, i) => [slot, `90${i}1`]));
|
||||
const chinese = api.t(text, vars);
|
||||
for (const value of Object.values(vars)) expect(chinese, `${text} lost a slot`).toContain(value);
|
||||
expect(chinese).not.toMatch(/[{}]/);
|
||||
});
|
||||
|
||||
it('keeps the product name a slot, so a custom display name reaches the Chinese text', () => {
|
||||
api.configure({ language: 'zh-CN', displayName: 'Acme' });
|
||||
try {
|
||||
for (const text of ALL.filter((candidate) => candidate.includes('Codeman'))) {
|
||||
const chinese = api.t(text);
|
||||
expect(chinese, text).toContain('Acme');
|
||||
expect(chinese, text).not.toContain('Codeman');
|
||||
}
|
||||
} finally {
|
||||
api.configure({ language: 'zh-CN', displayName: 'Codeman' });
|
||||
}
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
// A repeated key in the object literal silently replaces the first. Prettier leaves a key
|
||||
// that is one word unquoted.
|
||||
for (const text of ALL) {
|
||||
const bare = /^\w+$/.test(text) ? I18N.split(`\n ${text}: `).length - 1 : 0;
|
||||
expect(I18N.split(`'${text}':`).length - 1 + bare, text).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('translates the Settings group heading and hint in the page, and leaves both containers alone', () => {
|
||||
expect(STATIC).toHaveLength(2);
|
||||
expect(STATIC[0]).toBe('Model on this Mac');
|
||||
const translated = page.window.document.getElementById('localModelGroup')!;
|
||||
const heading = translated.querySelector('.set-group-head h4')!.textContent!.trim();
|
||||
const hint = translated.querySelector('.set-group-hint')!.textContent!.trim();
|
||||
expect(heading).toBe('此 Mac 上的模型');
|
||||
expect(hint).not.toBe(STATIC[1]);
|
||||
expect(hint).toBe(api.t(STATIC[1]));
|
||||
expect(leftover(hint)).toEqual([]);
|
||||
// Written only by the module, through its own translator call.
|
||||
expect(INDEX).toMatch(/id="localModelCard"[^>]*data-i18n-skip/);
|
||||
expect(INDEX).toMatch(/id="localModelSettings"[^>]*data-i18n-skip/);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* @fileoverview GET /api/local-model: what this Mac is and which local model fits it.
|
||||
*
|
||||
* The route only composes four injected functions (the hardware probe, the catalogue getter,
|
||||
* the harness facts and the pure `recommend()`), so every test passes its own and no test
|
||||
* depends on what those modules compute. What is pinned here is the route's own contract:
|
||||
* the envelope, the admin gate in multi-user mode, that an unsupported platform and a failed
|
||||
* probe are answers (200) and never a 500, what does answer 500, and that a request writes
|
||||
* nothing.
|
||||
*
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { arch, homedir, totalmem } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import {
|
||||
defaultLocalModelRouteDeps,
|
||||
hardwareFromOs,
|
||||
registerLocalModelRoutes,
|
||||
type LocalModelRouteDeps,
|
||||
} from '../../src/web/routes/local-model-routes.js';
|
||||
import { buildHarnessFacts } from '../../src/local-model/harness-facts.js';
|
||||
import { formatGb, memClassGb } from '../../src/local-model/format.js';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import {
|
||||
LOCAL_MODEL_LABELS,
|
||||
type HardwareFacts,
|
||||
type HarnessFact,
|
||||
type LocalModelCatalog,
|
||||
type Recommendation,
|
||||
} from '../../src/types.js';
|
||||
|
||||
const CATALOG: LocalModelCatalog = {
|
||||
schema: 1,
|
||||
updated: '2026-11-06',
|
||||
minMacosMajor: 14,
|
||||
promptCacheTiers: [
|
||||
{ minMemGb: 0, bytes: 512 * 1024 * 1024 },
|
||||
{ minMemGb: 32, bytes: 2 * 1024 ** 3 },
|
||||
],
|
||||
models: [],
|
||||
harnesses: [
|
||||
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
||||
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
||||
],
|
||||
chips: [],
|
||||
};
|
||||
|
||||
/** A 48 GB Apple silicon Mac whose chip is in no chips row, as the probe would report it. */
|
||||
const MAC: 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',
|
||||
};
|
||||
|
||||
/** The same Mac after a `sysctl` spawn that failed: `node:os` supplied what little is known. */
|
||||
const MAC_FALLBACK: HardwareFacts = {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
memBytes: 51539607552,
|
||||
memGb: '51.5',
|
||||
memClassGb: 48,
|
||||
chip: 'Apple M5 Pro',
|
||||
source: 'os-fallback',
|
||||
};
|
||||
|
||||
const LINUX: HardwareFacts = {
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
memBytes: 67430404096,
|
||||
memGb: '67.4',
|
||||
memClassGb: 63,
|
||||
source: 'not-probed',
|
||||
};
|
||||
|
||||
const OK: Recommendation = {
|
||||
verdict: 'ok',
|
||||
headline: { text: LOCAL_MODEL_LABELS.headlineOk },
|
||||
notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }],
|
||||
pick: {
|
||||
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,
|
||||
},
|
||||
alternatives: [],
|
||||
budgetBytes: 37580963840,
|
||||
budgetGb: '37.6',
|
||||
budgetSource: 'estimated',
|
||||
};
|
||||
|
||||
const NOT_MACOS: Recommendation = {
|
||||
verdict: 'none',
|
||||
reason: 'not-macos',
|
||||
headline: { text: LOCAL_MODEL_LABELS.noneNotMacos },
|
||||
notes: [],
|
||||
alternatives: [],
|
||||
budgetBytes: 0,
|
||||
budgetGb: '0.0',
|
||||
budgetSource: 'estimated',
|
||||
};
|
||||
|
||||
const FACTS: HarnessFact[] = [
|
||||
{ cli: 'opencode', label: 'OpenCode', installed: true, installable: false, acceptsModelId: () => true },
|
||||
];
|
||||
|
||||
interface Fakes {
|
||||
deps: LocalModelRouteDeps;
|
||||
readHardware: Mock<LocalModelRouteDeps['readHardware']>;
|
||||
getCatalog: Mock<LocalModelRouteDeps['getCatalog']>;
|
||||
buildHarnessFacts: Mock<LocalModelRouteDeps['buildHarnessFacts']>;
|
||||
recommend: Mock<LocalModelRouteDeps['recommend']>;
|
||||
}
|
||||
|
||||
function fakes(hardware: HardwareFacts = MAC, recommendation: Recommendation = OK): Fakes {
|
||||
const readHardware = vi.fn<LocalModelRouteDeps['readHardware']>(async () => hardware);
|
||||
const getCatalog = vi.fn<LocalModelRouteDeps['getCatalog']>(() => CATALOG);
|
||||
const buildFacts = vi.fn<LocalModelRouteDeps['buildHarnessFacts']>(async () => FACTS);
|
||||
const recommend = vi.fn<LocalModelRouteDeps['recommend']>(() => recommendation);
|
||||
return {
|
||||
deps: { readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend },
|
||||
readHardware,
|
||||
getCatalog,
|
||||
buildHarnessFacts: buildFacts,
|
||||
recommend,
|
||||
};
|
||||
}
|
||||
|
||||
const harness = (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) =>
|
||||
createRouteTestHarness((app) => registerLocalModelRoutes(app, deps), authUser ? { authUser } : undefined);
|
||||
|
||||
const get = async (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) => {
|
||||
const { app } = await harness(deps, authUser);
|
||||
return app.inject({ method: 'GET', url: '/api/local-model' });
|
||||
};
|
||||
|
||||
/** Every file and folder under `root`, with its size and modification time. */
|
||||
function snapshot(root: string): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
const rows: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const path = join(dir, name);
|
||||
const stat = lstatSync(path);
|
||||
rows.push(`${path} ${stat.isDirectory() ? 'dir' : stat.size} ${stat.mtimeMs}`);
|
||||
if (stat.isDirectory()) walk(path);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return rows;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/local-model', () => {
|
||||
it('answers the success envelope with the hardware, the recommendation and the catalogue date', async () => {
|
||||
const f = fakes();
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({
|
||||
success: true,
|
||||
data: { hardware: MAC, recommendation: OK, catalogUpdated: '2026-11-06' },
|
||||
});
|
||||
});
|
||||
|
||||
it('hands recommend() exactly what the probe, the catalogue and the harness facts answered', async () => {
|
||||
const f = fakes();
|
||||
await get(f.deps);
|
||||
expect(f.readHardware).toHaveBeenCalledTimes(1);
|
||||
expect(f.buildHarnessFacts).toHaveBeenCalledTimes(1);
|
||||
expect(f.buildHarnessFacts).toHaveBeenCalledWith(CATALOG);
|
||||
expect(f.recommend).toHaveBeenCalledTimes(1);
|
||||
const [hw, catalog, facts] = f.recommend.mock.calls[0];
|
||||
expect(hw).toBe(MAC);
|
||||
expect(catalog).toBe(CATALOG);
|
||||
expect(facts).toBe(FACTS);
|
||||
});
|
||||
|
||||
it('sends the payload as the modules built it: no size is reformatted and no function leaks', async () => {
|
||||
const res = await get(fakes().deps);
|
||||
const { data } = res.json();
|
||||
expect(Object.keys(data).sort()).toEqual(['catalogUpdated', 'hardware', 'recommendation']);
|
||||
expect(data.hardware.memGb).toBe('51.5');
|
||||
expect(data.hardware.freeDiskGb).toBe('912');
|
||||
expect(data.recommendation.pick.needGb).toBe('29.6');
|
||||
expect(data.recommendation.budgetGb).toBe('37.6');
|
||||
// Harness facts hold a function and stay on the server.
|
||||
expect(res.body).not.toContain('acceptsModelId');
|
||||
expect(res.body).not.toContain('installable');
|
||||
});
|
||||
|
||||
it('registers a GET and nothing else: this stage has no route that changes anything', async () => {
|
||||
const { app } = await harness(fakes().deps);
|
||||
expect((await app.inject({ method: 'POST', url: '/api/local-model', payload: {} })).statusCode).toBe(404);
|
||||
expect((await app.inject({ method: 'DELETE', url: '/api/local-model' })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
describe('multi-user mode', () => {
|
||||
it('refuses a non-admin with 403 before anything is probed', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const f = fakes();
|
||||
const res = await get(f.deps, { username: 'bob', role: 'user' });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json()).toMatchObject({ success: false, errorCode: 'FORBIDDEN' });
|
||||
expect(res.body).not.toContain('Apple M5 Pro');
|
||||
expect(f.readHardware).not.toHaveBeenCalled();
|
||||
expect(f.getCatalog).not.toHaveBeenCalled();
|
||||
expect(f.buildHarnessFacts).not.toHaveBeenCalled();
|
||||
expect(f.recommend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers an admin', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const res = await get(fakes().deps, { username: 'root', role: 'admin' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.hardware.chip).toBe('Apple M5 Pro');
|
||||
});
|
||||
|
||||
it('is not gated while multi-user mode is off, whoever asks', async () => {
|
||||
const res = await get(fakes().deps, { username: 'bob', role: 'user' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an unsupported platform is an answer', () => {
|
||||
it('answers 200 with verdict none and reason not-macos on a server that is not a Mac', async () => {
|
||||
const f = fakes(LINUX, NOT_MACOS);
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { success, data } = res.json();
|
||||
expect(success).toBe(true);
|
||||
expect(data.hardware).toEqual(LINUX);
|
||||
expect(data.hardware.source).toBe('not-probed');
|
||||
expect(data.recommendation.verdict).toBe('none');
|
||||
expect(data.recommendation.reason).toBe('not-macos');
|
||||
expect(data.recommendation.pick).toBeUndefined();
|
||||
expect(data.recommendation.alternatives).toEqual([]);
|
||||
expect(data.recommendation.headline.text).toBe(LOCAL_MODEL_LABELS.noneNotMacos);
|
||||
expect(data.catalogUpdated).toBe('2026-11-06');
|
||||
expect(f.recommend.mock.calls[0][0]).toBe(LINUX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a failed probe is an answer', () => {
|
||||
it('answers 200 when the probe reports that sysctl failed (source os-fallback)', async () => {
|
||||
const f = fakes(MAC_FALLBACK, OK);
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.hardware).toEqual(MAC_FALLBACK);
|
||||
expect(res.json().data.recommendation.verdict).toBe('ok');
|
||||
});
|
||||
|
||||
it('answers 200 from node:os when the probe itself rejects, and still recommends', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const f = fakes();
|
||||
f.readHardware.mockRejectedValue(new Error('spawn EAGAIN'));
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { success, data } = res.json();
|
||||
expect(success).toBe(true);
|
||||
expect(data.hardware).toEqual(hardwareFromOs());
|
||||
expect(data.hardware.memBytes).toBe(totalmem());
|
||||
expect(data.recommendation).toEqual(OK);
|
||||
expect(f.recommend).toHaveBeenCalledTimes(1);
|
||||
expect(f.recommend.mock.calls[0][0]).toEqual(hardwareFromOs());
|
||||
expect(String(warn.mock.calls[0][0])).toContain('spawn EAGAIN');
|
||||
});
|
||||
|
||||
it('hardwareFromOs() is a complete answer of its own, sized by the one formatter', () => {
|
||||
const hw = hardwareFromOs();
|
||||
expect(hw.platform).toBe(process.platform);
|
||||
expect(hw.arch).toBe(arch());
|
||||
expect(hw.memBytes).toBe(totalmem());
|
||||
expect(hw.memGb).toBe(formatGb(totalmem()));
|
||||
expect(hw.memClassGb).toBe(memClassGb(totalmem()));
|
||||
expect(hw.source).toBe(process.platform === 'darwin' ? 'os-fallback' : 'not-probed');
|
||||
// Nothing that only sysctl or statfs can know is invented.
|
||||
expect(Object.keys(hw).sort()).toEqual(['arch', 'memBytes', 'memClassGb', 'memGb', 'platform', 'source']);
|
||||
});
|
||||
|
||||
it('answers 200 when no harness fact could be built (the facts are just empty)', async () => {
|
||||
const none: Recommendation = {
|
||||
...NOT_MACOS,
|
||||
reason: 'no-harness',
|
||||
headline: { text: LOCAL_MODEL_LABELS.noneNoHarness },
|
||||
};
|
||||
const f = fakes(MAC, none);
|
||||
f.buildHarnessFacts.mockResolvedValue([]);
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.recommendation.reason).toBe('no-harness');
|
||||
expect(f.recommend.mock.calls[0][2]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a fault that is not a probe is a 500 in the envelope, never a made-up answer', () => {
|
||||
it.each([
|
||||
[
|
||||
'recommend() throws',
|
||||
(f: Fakes) =>
|
||||
f.recommend.mockImplementation(() => {
|
||||
throw new Error('rank table is empty');
|
||||
}),
|
||||
'rank table is empty',
|
||||
],
|
||||
[
|
||||
'the catalogue getter throws',
|
||||
(f: Fakes) =>
|
||||
f.getCatalog.mockImplementation(() => {
|
||||
throw new Error('catalogue did not parse');
|
||||
}),
|
||||
'catalogue did not parse',
|
||||
],
|
||||
[
|
||||
'the harness facts reject',
|
||||
(f: Fakes) => f.buildHarnessFacts.mockRejectedValue(new Error('registry unreadable')),
|
||||
'registry unreadable',
|
||||
],
|
||||
])('%s', async (_label, breakIt, message) => {
|
||||
const f = fakes();
|
||||
breakIt(f);
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(500);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INTERNAL_ERROR');
|
||||
expect(body.error).toContain(message);
|
||||
expect(body.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-only', () => {
|
||||
it('writes nothing: the home directory and the data dir are byte for byte what they were', async () => {
|
||||
// The real harness facts (they read the CLI registry and run the availability probe),
|
||||
// the route's own composition, and a hardware answer and a recommendation of the test's.
|
||||
const f = fakes();
|
||||
const { app } = await harness({ ...f.deps, buildHarnessFacts: (catalog) => buildHarnessFacts(catalog) });
|
||||
|
||||
const home = homedir();
|
||||
const before = snapshot(home);
|
||||
const dataDirExisted = existsSync(getDataDir());
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/local-model' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
// A refused request and a failing one write nothing either.
|
||||
f.recommend.mockImplementationOnce(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect((await app.inject({ method: 'GET', url: '/api/local-model' })).statusCode).toBe(500);
|
||||
|
||||
expect(snapshot(home)).toEqual(before);
|
||||
expect(existsSync(getDataDir())).toBe(dataDirExisted);
|
||||
expect(existsSync(join(getDataDir(), 'settings.json'))).toBe(false);
|
||||
expect(existsSync(join(getDataDir(), 'local-model.json'))).toBe(false);
|
||||
expect(existsSync(join(getDataDir(), 'local-models.json'))).toBe(false);
|
||||
// The facts really were built from the registry for the catalogue's two harnesses.
|
||||
expect(f.recommend.mock.calls[0][2].map((fact) => fact.cli)).toEqual(['opencode', 'claude']);
|
||||
|
||||
// The comparison above is not vacuous: one new file in the home directory shows up.
|
||||
const probe = join(home, 'local-model-route-test-probe');
|
||||
writeFileSync(probe, 'x');
|
||||
try {
|
||||
expect(snapshot(home)).not.toEqual(before);
|
||||
} finally {
|
||||
rmSync(probe);
|
||||
}
|
||||
});
|
||||
|
||||
it('is registered on the real server through the routes barrel', () => {
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8');
|
||||
const barrel = readFileSync(join(root, 'src/web/routes/index.ts'), 'utf8');
|
||||
expect(barrel).toContain("export { registerLocalModelRoutes } from './local-model-routes.js';");
|
||||
// No second argument: the server runs the real probe, catalogue, facts and recommend().
|
||||
expect(server).toContain('registerLocalModelRoutes(this.app);');
|
||||
});
|
||||
|
||||
it('the default dependencies are the real modules, read at call time', () => {
|
||||
expect(Object.keys(defaultLocalModelRouteDeps).sort()).toEqual([
|
||||
'buildHarnessFacts',
|
||||
'getCatalog',
|
||||
'readHardware',
|
||||
'recommend',
|
||||
]);
|
||||
for (const dep of Object.values(defaultLocalModelRouteDeps)) expect(typeof dep).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user