claudeup 6.7.1 → 6.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/claudeup.js +116 -12
- package/package.json +4 -4
- package/scripts/build-binaries.ts +26 -1
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/launcher-signal.test.ts +314 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/model-visuals.test.tsx +1698 -25
- package/src/__tests__/models-adapter.test.ts +21 -6
- package/src/__tests__/models-cli.test.ts +100 -0
- package/src/__tests__/models-core.test.ts +273 -111
- package/src/__tests__/models-manager.test.ts +15 -12
- package/src/__tests__/models-presets-marketplace.test.ts +29 -2
- package/src/__tests__/models-screen-state.test.ts +57 -1
- package/src/cli/doctor.ts +8 -13
- package/src/cli/models.ts +97 -13
- package/src/cli/upgrade.ts +7 -1
- package/src/data/models-presets.ts +62 -2
- package/src/services/binary-signing.ts +78 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/models-core.ts +371 -30
- package/src/ui/adapters/modelsAdapter.ts +58 -15
- package/src/ui/components/layout/ScreenLayout.tsx +6 -1
- package/src/ui/renderers/modelRenderers.tsx +413 -108
- package/src/ui/renderers/modelVisuals.tsx +694 -145
- package/src/ui/screens/ModelsScreen.tsx +74 -10
- package/src/ui/state/reducer.ts +20 -0
- package/src/ui/state/types.ts +31 -0
- package/src/ui/theme-mode.ts +128 -14
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the mate slots are drawn, and what "installed" is allowed to mean.
|
|
3
|
+
*
|
|
4
|
+
* The question is asked of the plugin registry through `plugin-manager`, never of
|
|
5
|
+
* `installed_plugins.json` / `enabledPlugins` / `installedPluginVersions` directly — those
|
|
6
|
+
* are Claude Code's to own, and the last of them is maintained by nothing but claudeup, so it
|
|
7
|
+
* goes stale silently. What is tested here is the DECISION on top of that data, which is the
|
|
8
|
+
* part a real registry cannot exercise on demand.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
MATE_FORCE_ENV,
|
|
13
|
+
MATE_PLUGIN_ID,
|
|
14
|
+
areMatesAvailable,
|
|
15
|
+
matesForced,
|
|
16
|
+
} from "../services/mate-availability.js";
|
|
17
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
18
|
+
|
|
19
|
+
const plugin = (overrides: Partial<PluginInfo> = {}): PluginInfo => ({
|
|
20
|
+
id: MATE_PLUGIN_ID,
|
|
21
|
+
name: "multimodel",
|
|
22
|
+
version: "1.0.0",
|
|
23
|
+
description: "",
|
|
24
|
+
marketplace: "magus",
|
|
25
|
+
marketplaceDisplay: "Magus",
|
|
26
|
+
enabled: true,
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const lister = (plugins: PluginInfo[]) => async () => plugins;
|
|
31
|
+
|
|
32
|
+
describe("areMatesAvailable", () => {
|
|
33
|
+
test("false when the plugin is nowhere in the list", async () => {
|
|
34
|
+
expect(await areMatesAvailable(undefined, lister([]))).toBe(false);
|
|
35
|
+
expect(
|
|
36
|
+
await areMatesAvailable(
|
|
37
|
+
undefined,
|
|
38
|
+
lister([plugin({ id: "dev@magus", name: "dev" })]),
|
|
39
|
+
),
|
|
40
|
+
).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("true when it is installed at ANY scope", async () => {
|
|
44
|
+
// Any scope, because a mate is served by whatever Claude Code has loaded when the
|
|
45
|
+
// agent spawns — the union of the three. A user-scope install serves a project that
|
|
46
|
+
// enables nothing of its own.
|
|
47
|
+
for (const scope of ["userScope", "projectScope", "localScope"] as const) {
|
|
48
|
+
const one = plugin({ [scope]: { enabled: true, version: "1.0.0" } });
|
|
49
|
+
expect({
|
|
50
|
+
scope,
|
|
51
|
+
available: await areMatesAvailable(undefined, lister([one])),
|
|
52
|
+
}).toEqual({ scope, available: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The enabled-but-not-installed state is NOT installed.
|
|
58
|
+
*
|
|
59
|
+
* `enabledPlugins` is a flag only claudeup and the user maintain; the version comes from
|
|
60
|
+
* the registry, which is what Claude Code actually loaded. Flag set with no version is the
|
|
61
|
+
* broken state the plugin list already renders as "not installed" — and a mate row drawn
|
|
62
|
+
* on the strength of it would promise routing through a plugin that never loaded.
|
|
63
|
+
*/
|
|
64
|
+
test("false when it is enabled but never actually installed", async () => {
|
|
65
|
+
const flagged = plugin({ userScope: { enabled: true } });
|
|
66
|
+
expect(await areMatesAvailable(undefined, lister([flagged]))).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("false when it is installed but disabled", async () => {
|
|
70
|
+
const off = plugin({
|
|
71
|
+
userScope: { enabled: false, version: "1.0.0" },
|
|
72
|
+
});
|
|
73
|
+
expect(await areMatesAvailable(undefined, lister([off]))).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a version of "0.0.0" still counts — that is how some plugins record themselves', async () => {
|
|
77
|
+
const zero = plugin({ userScope: { enabled: true, version: "0.0.0" } });
|
|
78
|
+
expect(await areMatesAvailable(undefined, lister([zero]))).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* FAILS CLOSED, and that is the load-bearing half.
|
|
83
|
+
*
|
|
84
|
+
* This reaches marketplace resolution, which does network work with its own timeouts. A
|
|
85
|
+
* failure means "we do not know", and the honest rendering of not knowing is the screen
|
|
86
|
+
* exactly as it was before mates existed — not three rows put on screen by an error.
|
|
87
|
+
*/
|
|
88
|
+
test("false when the lookup throws", async () => {
|
|
89
|
+
const throwing = async () => {
|
|
90
|
+
throw new Error("marketplace unreachable");
|
|
91
|
+
};
|
|
92
|
+
expect(await areMatesAvailable(undefined, throwing)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `CLAUDEUP_MATES=1` — draw the slots on a machine that cannot serve them.
|
|
98
|
+
*
|
|
99
|
+
* It exists because the three rows ARE the feature: without the plugin installed there is no
|
|
100
|
+
* way to put them on screen, which leaves `kangaroo`'s eight cells — the widest thing this
|
|
101
|
+
* layout ever has to fit — as the one part nobody can check by eye.
|
|
102
|
+
*/
|
|
103
|
+
describe("the mate-slot override", () => {
|
|
104
|
+
test("accepts the affirmative spellings, and nothing else", () => {
|
|
105
|
+
for (const on of ["1", "true", "on", "yes", "TRUE", " On "]) {
|
|
106
|
+
expect(matesForced({ [MATE_FORCE_ENV]: on })).toBe(true);
|
|
107
|
+
}
|
|
108
|
+
// "0" and "false" are not merely unrecognised, they are the common way to try to turn
|
|
109
|
+
// a flag OFF. Reading either as true would be the worst possible misparse.
|
|
110
|
+
for (const off of ["0", "false", "off", "no", "", "maybe"]) {
|
|
111
|
+
expect(matesForced({ [MATE_FORCE_ENV]: off })).toBe(false);
|
|
112
|
+
}
|
|
113
|
+
expect(matesForced({})).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The flag SHORT-CIRCUITS, and a throwing lister is how that is proved.
|
|
118
|
+
*
|
|
119
|
+
* A lister that throws returns false through every other path in this file, so if the
|
|
120
|
+
* answer here is true, the registry was genuinely never consulted — which is the point:
|
|
121
|
+
* the network work behind it is the slow part, and its result would be discarded anyway.
|
|
122
|
+
*/
|
|
123
|
+
test("returns true without consulting the registry at all", async () => {
|
|
124
|
+
const throwing = async () => {
|
|
125
|
+
throw new Error("the registry must not be reached");
|
|
126
|
+
};
|
|
127
|
+
const original = process.env[MATE_FORCE_ENV];
|
|
128
|
+
process.env[MATE_FORCE_ENV] = "1";
|
|
129
|
+
try {
|
|
130
|
+
expect(await areMatesAvailable(undefined, throwing)).toBe(true);
|
|
131
|
+
} finally {
|
|
132
|
+
if (original === undefined) delete process.env[MATE_FORCE_ENV];
|
|
133
|
+
else process.env[MATE_FORCE_ENV] = original;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Negative control for the test above.
|
|
139
|
+
*
|
|
140
|
+
* Same lister, same call, flag absent — false. Without this pair, a bug that made
|
|
141
|
+
* `areMatesAvailable` return true unconditionally would pass the short-circuit test and
|
|
142
|
+
* look like proof of the flag.
|
|
143
|
+
*/
|
|
144
|
+
test("the same call is false once the flag is gone", async () => {
|
|
145
|
+
const throwing = async () => {
|
|
146
|
+
throw new Error("the registry must not be reached");
|
|
147
|
+
};
|
|
148
|
+
const original = process.env[MATE_FORCE_ENV];
|
|
149
|
+
delete process.env[MATE_FORCE_ENV];
|
|
150
|
+
try {
|
|
151
|
+
expect(await areMatesAvailable(undefined, throwing)).toBe(false);
|
|
152
|
+
} finally {
|
|
153
|
+
if (original !== undefined) process.env[MATE_FORCE_ENV] = original;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading claudish's live model catalogue, and the one rule that matters most about it.
|
|
3
|
+
*
|
|
4
|
+
* This module's whole job is to be ADVISORY and to FAIL SILENT. So the tests that earn their
|
|
5
|
+
* place are the negative ones: what happens when claudish is missing, when it exits non-zero,
|
|
6
|
+
* when it prints a table this build has never seen. Every one of those has to end in "the
|
|
7
|
+
* catalogue is unknown" and nothing else — an advisory that fires hardest when it knows least
|
|
8
|
+
* is worse than no advisory at all.
|
|
9
|
+
*
|
|
10
|
+
* The fixture below is REAL output, captured from `claudish --models` (v9.3.0) rather than
|
|
11
|
+
* written from memory of the format. A parser tested against its author's idea of the format
|
|
12
|
+
* passes forever and tells you nothing about the format.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import {
|
|
16
|
+
type CatalogModel,
|
|
17
|
+
catalogIds,
|
|
18
|
+
clearMateCatalogCache,
|
|
19
|
+
loadMateCatalog,
|
|
20
|
+
parseModelsTable,
|
|
21
|
+
stalenessNote,
|
|
22
|
+
unknownBindings,
|
|
23
|
+
} from "../services/mate-catalog.js";
|
|
24
|
+
|
|
25
|
+
/** Verbatim `claudish --models`, trimmed in the middle. Header, rule, rows, legend, footer. */
|
|
26
|
+
const REAL_OUTPUT = `
|
|
27
|
+
Top 100 models from Firebase (pool: 384 eligible)
|
|
28
|
+
|
|
29
|
+
# Model Provider Pricing Context Caps Released
|
|
30
|
+
──────────────────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
4 deepseek-v4.1-flash deepseek $0.75/1M 1M TRV 2026-09-10
|
|
32
|
+
1 gpt-6-astra openai $30.00/1M 1M TRV 2026-09-03
|
|
33
|
+
3 gemini-3.8-flash google $2.25/1M 1M TRV 2026-09-02
|
|
34
|
+
57 grok-4.6 x-ai $4.00/1M 500K TRV 2026-08-12
|
|
35
|
+
15 kimi-k3 moonshotai $9.00/1M 1M TRV 2026-07-16
|
|
36
|
+
8 glm-5.3-fp8 z-ai FREE 1M TR 2026-08-14
|
|
37
|
+
55 inkling-small thinking-machines $0.85/1M 1M TRV 2026-07-30
|
|
38
|
+
68 mimo-v2.5 xiaomi FREE 1M TRV —
|
|
39
|
+
99 codex-mini-latest openai $3.75/1M 200K TRV —
|
|
40
|
+
|
|
41
|
+
Caps: T = tools R = reasoning V = vision
|
|
42
|
+
|
|
43
|
+
Local providers
|
|
44
|
+
──────────────────────────────────────────────────────────────────────
|
|
45
|
+
Ollama: not running
|
|
46
|
+
LiteLLM: not configured (set LITELLM_BASE_URL + LITELLM_API_KEY)
|
|
47
|
+
|
|
48
|
+
Filter by provider: claudish --models --provider <slug>
|
|
49
|
+
All providers: claudish --providers
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const idsOf = (models: CatalogModel[]) => models.map((m) => m.id);
|
|
53
|
+
|
|
54
|
+
describe("parseModelsTable", () => {
|
|
55
|
+
test("reads every model row out of real claudish output", () => {
|
|
56
|
+
expect(idsOf(parseModelsTable(REAL_OUTPUT))).toEqual([
|
|
57
|
+
"deepseek-v4.1-flash",
|
|
58
|
+
"gpt-6-astra",
|
|
59
|
+
"gemini-3.8-flash",
|
|
60
|
+
"grok-4.6",
|
|
61
|
+
"kimi-k3",
|
|
62
|
+
"glm-5.3-fp8",
|
|
63
|
+
"inkling-small",
|
|
64
|
+
"mimo-v2.5",
|
|
65
|
+
"codex-mini-latest",
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("keeps the fields a binding and a picker need", () => {
|
|
70
|
+
const grok = parseModelsTable(REAL_OUTPUT).find((m) => m.id === "grok-4.6");
|
|
71
|
+
expect(grok).toEqual({
|
|
72
|
+
id: "grok-4.6",
|
|
73
|
+
provider: "x-ai",
|
|
74
|
+
pricing: "$4.00/1M",
|
|
75
|
+
context: "500K",
|
|
76
|
+
caps: "TRV",
|
|
77
|
+
released: "2026-08-12",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("`FREE` is a pricing string like any other, not a missing field", () => {
|
|
82
|
+
const free = parseModelsTable(REAL_OUTPUT).find(
|
|
83
|
+
(m) => m.id === "glm-5.3-fp8",
|
|
84
|
+
);
|
|
85
|
+
expect(free?.pricing).toBe("FREE");
|
|
86
|
+
expect(free?.caps).toBe("TR");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("an em-dash release date becomes empty, not a dash in a date field", () => {
|
|
90
|
+
// Carrying `—` through would put a glyph in a field every reader treats as a date,
|
|
91
|
+
// and anything that formats or sorts it would have to special-case the glyph.
|
|
92
|
+
const undated = parseModelsTable(REAL_OUTPUT).find(
|
|
93
|
+
(m) => m.id === "mimo-v2.5",
|
|
94
|
+
);
|
|
95
|
+
expect(undated?.released).toBe("");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("the legend, the local-provider block and the footer are not models", () => {
|
|
99
|
+
const ids = idsOf(parseModelsTable(REAL_OUTPUT));
|
|
100
|
+
for (const notAModel of [
|
|
101
|
+
"Caps:",
|
|
102
|
+
"Ollama:",
|
|
103
|
+
"LiteLLM:",
|
|
104
|
+
"Local",
|
|
105
|
+
"Filter",
|
|
106
|
+
"Top",
|
|
107
|
+
]) {
|
|
108
|
+
expect(ids).not.toContain(notAModel);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The defensive half of the seven-token rule.
|
|
114
|
+
*
|
|
115
|
+
* If claudish adds a column, every row stops matching and the catalogue comes back empty
|
|
116
|
+
* — which means "unknown", which suppresses the advisory and changes nothing on screen.
|
|
117
|
+
* The alternative (take the first seven of >= 7) would keep matching while reading the
|
|
118
|
+
* wrong field into `id`, and would then report live models as retired. Silence beats
|
|
119
|
+
* confident nonsense.
|
|
120
|
+
*/
|
|
121
|
+
test("an extra column makes the table unreadable rather than misread", () => {
|
|
122
|
+
const eightColumns =
|
|
123
|
+
" 4 deepseek-v4.1-flash deepseek $0.75/1M 1M TRV 2026-09-10 extra";
|
|
124
|
+
expect(parseModelsTable(eightColumns)).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("never throws on junk, however shaped", () => {
|
|
128
|
+
for (const junk of ["", "\n\n\n", "error: command not found", "{}", "—"]) {
|
|
129
|
+
expect(() => parseModelsTable(junk)).not.toThrow();
|
|
130
|
+
expect(parseModelsTable(junk)).toEqual([]);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("strips SGR sequences, so colour cannot ride into an id", () => {
|
|
135
|
+
// Measured: a piped `claudish --models` emits none today. This is about the day it
|
|
136
|
+
// starts colouring a pipe — an ESC left in the string would make a live model look
|
|
137
|
+
// retired, which is the one false positive this module must never produce.
|
|
138
|
+
const coloured =
|
|
139
|
+
" 57 [32mgrok-4.6[0m x-ai $4.00/1M 500K TRV 2026-08-12";
|
|
140
|
+
expect(idsOf(parseModelsTable(coloured))).toEqual(["grok-4.6"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a repeated id is listed once", () => {
|
|
144
|
+
const twice = `
|
|
145
|
+
57 grok-4.6 x-ai $4.00/1M 500K TRV 2026-08-12
|
|
146
|
+
58 grok-4.6 x-ai $4.00/1M 500K TRV 2026-08-12
|
|
147
|
+
`;
|
|
148
|
+
expect(idsOf(parseModelsTable(twice))).toEqual(["grok-4.6"]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("unknownBindings", () => {
|
|
153
|
+
const catalog = parseModelsTable(REAL_OUTPUT);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* THE load-bearing test in this file.
|
|
157
|
+
*
|
|
158
|
+
* An unread catalogue lists nothing, so a naive "not in the set" check would mark every
|
|
159
|
+
* binding on the machine as retired the moment claudish was missing. The advisory has to
|
|
160
|
+
* go quiet exactly when it knows least.
|
|
161
|
+
*/
|
|
162
|
+
test("says nothing at all when the catalogue could not be read", () => {
|
|
163
|
+
expect(unknownBindings(["grok-4.6", "totally-made-up"], [])).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("names an id the catalogue does not list", () => {
|
|
167
|
+
expect(unknownBindings(["grok-4.6", "gpt-4-turbo"], catalog)).toEqual([
|
|
168
|
+
"gpt-4-turbo",
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("says nothing when every binding is live", () => {
|
|
173
|
+
expect(unknownBindings(["grok-4.6", "kimi-k3"], catalog)).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("exact match only — a neighbouring version is not a stand-in", () => {
|
|
177
|
+
// `grok-4.5` resolving a retired `grok-4.6` is the exact failure this exists to
|
|
178
|
+
// catch, wearing a helpful face. Same rule the image-model registry holds to.
|
|
179
|
+
expect(unknownBindings(["grok-4.5"], catalog)).toEqual(["grok-4.5"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("reports a repeated unknown id once", () => {
|
|
183
|
+
expect(unknownBindings(["nope", "nope"], catalog)).toEqual(["nope"]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("catalogIds", () => {
|
|
188
|
+
test("is every id, as a set", () => {
|
|
189
|
+
const ids = catalogIds(parseModelsTable(REAL_OUTPUT));
|
|
190
|
+
expect(ids.has("grok-4.6")).toBe(true);
|
|
191
|
+
expect(ids.has("gpt-4-turbo")).toBe(false);
|
|
192
|
+
expect(ids.size).toBe(9);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("stalenessNote", () => {
|
|
197
|
+
test("is null when there is nothing to say", () => {
|
|
198
|
+
expect(stalenessNote([])).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("hedges, because absence from a TOP-N list is weak evidence", () => {
|
|
202
|
+
// `--models` prints the top 100 of a 384-model pool, so a missing id may simply sit
|
|
203
|
+
// below the cut. The note must not claim the model is gone.
|
|
204
|
+
const note = stalenessNote(["gpt-4-turbo"]);
|
|
205
|
+
expect(note).toContain("gpt-4-turbo");
|
|
206
|
+
expect(note).toContain("may");
|
|
207
|
+
expect(note).not.toContain("deleted");
|
|
208
|
+
expect(note).not.toContain("retired");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("agrees with itself about number", () => {
|
|
212
|
+
expect(stalenessNote(["a"])).toContain(" is ");
|
|
213
|
+
expect(stalenessNote(["a", "b"])).toContain(" are ");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("loadMateCatalog", () => {
|
|
218
|
+
test("returns [] when claudish is not there to answer", async () => {
|
|
219
|
+
clearMateCatalogCache();
|
|
220
|
+
expect(await loadMateCatalog(async () => null)).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("returns [] when the fetcher throws, rather than propagating", async () => {
|
|
224
|
+
// Nothing above this module is allowed to see an exception from a catalogue read.
|
|
225
|
+
clearMateCatalogCache();
|
|
226
|
+
expect(
|
|
227
|
+
await loadMateCatalog(async () => {
|
|
228
|
+
throw new Error("claudish exploded");
|
|
229
|
+
}),
|
|
230
|
+
).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("parses what it is handed", async () => {
|
|
234
|
+
clearMateCatalogCache();
|
|
235
|
+
const models = await loadMateCatalog(async () => REAL_OUTPUT);
|
|
236
|
+
expect(idsOf(models)).toContain("grok-4.6");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("caches, so one screen's lookup answers for the session", async () => {
|
|
240
|
+
clearMateCatalogCache();
|
|
241
|
+
let calls = 0;
|
|
242
|
+
const counting = async () => {
|
|
243
|
+
calls += 1;
|
|
244
|
+
return REAL_OUTPUT;
|
|
245
|
+
};
|
|
246
|
+
await loadMateCatalog(counting);
|
|
247
|
+
await loadMateCatalog(counting);
|
|
248
|
+
expect(calls).toBe(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A FAILURE is cached too, and that is deliberate.
|
|
253
|
+
*
|
|
254
|
+
* The catalogue cannot change while claudeup is open, so a machine with no claudish would
|
|
255
|
+
* otherwise pay the spawn — and its full timeout — once per screen that asks.
|
|
256
|
+
*/
|
|
257
|
+
test("caches the failure as well, so a missing claudish is paid for once", async () => {
|
|
258
|
+
clearMateCatalogCache();
|
|
259
|
+
let calls = 0;
|
|
260
|
+
const failing = async () => {
|
|
261
|
+
calls += 1;
|
|
262
|
+
return null;
|
|
263
|
+
};
|
|
264
|
+
await loadMateCatalog(failing);
|
|
265
|
+
await loadMateCatalog(failing);
|
|
266
|
+
expect(calls).toBe(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("concurrent callers share one lookup", async () => {
|
|
270
|
+
clearMateCatalogCache();
|
|
271
|
+
let calls = 0;
|
|
272
|
+
const slow = async () => {
|
|
273
|
+
calls += 1;
|
|
274
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
275
|
+
return REAL_OUTPUT;
|
|
276
|
+
};
|
|
277
|
+
await Promise.all([loadMateCatalog(slow), loadMateCatalog(slow)]);
|
|
278
|
+
expect(calls).toBe(1);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("clearing the cache means the next call asks again", async () => {
|
|
282
|
+
// The negative control for all four cache tests above: without it, a `loadMateCatalog`
|
|
283
|
+
// that never called its fetcher at all would pass every one of them.
|
|
284
|
+
clearMateCatalogCache();
|
|
285
|
+
let calls = 0;
|
|
286
|
+
const counting = async () => {
|
|
287
|
+
calls += 1;
|
|
288
|
+
return REAL_OUTPUT;
|
|
289
|
+
};
|
|
290
|
+
await loadMateCatalog(counting);
|
|
291
|
+
clearMateCatalogCache();
|
|
292
|
+
await loadMateCatalog(counting);
|
|
293
|
+
expect(calls).toBe(2);
|
|
294
|
+
});
|
|
295
|
+
});
|