magus-cli 7.2.0 → 7.3.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/package.json +4 -4
- package/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -0
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/plugin-resolution.test.ts +431 -0
- package/src/__tests__/plugin-row-text.test.ts +66 -0
- package/src/__tests__/stale-load-repair.test.ts +1076 -0
- package/src/__tests__/update-apply.test.ts +146 -1
- package/src/__tests__/update-plan.test.ts +338 -0
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/useStaleLoadModal.test.ts +177 -0
- package/src/cli/doctor.ts +128 -2
- package/src/cli/update-view.ts +31 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +4 -1
- package/src/data/npm-package.ts +15 -0
- package/src/services/active-closure.ts +23 -0
- package/src/services/claude-settings.ts +645 -34
- package/src/services/plugin-manager.ts +61 -0
- package/src/services/plugin-resolution.ts +280 -0
- package/src/services/update-engine.ts +104 -2
- package/src/services/update-plan.ts +188 -1
- package/src/services/version-check.ts +20 -10
- package/src/ui/App.tsx +63 -0
- package/src/ui/hooks/useStaleLoadModal.ts +157 -0
- package/src/ui/renderers/pluginRenderers.tsx +29 -0
- package/src/ui/renderers/pluginStatusText.ts +14 -0
- package/src/ui/screens/PluginsScreen.tsx +46 -13
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import {
|
|
7
|
+
isRelevantInstallation,
|
|
8
|
+
pickLoadedEntry,
|
|
9
|
+
repoRootForPath,
|
|
10
|
+
resolveLoadedVersions,
|
|
11
|
+
} from "../services/plugin-resolution.js";
|
|
12
|
+
import type {
|
|
13
|
+
InstalledPluginEntry,
|
|
14
|
+
InstalledPluginsRegistry,
|
|
15
|
+
} from "../types/index.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The loader's own resolution, not magus's.
|
|
19
|
+
*
|
|
20
|
+
* `pickRegistryEntry` (registry-version-resolution.test.ts) answers "what does
|
|
21
|
+
* magus consider installed here". These cases answer the other question — what
|
|
22
|
+
* Claude Code will LOAD here — and the two disagree in ways that are invisible
|
|
23
|
+
* from the settings file: a row belonging to a worktree that no longer exists
|
|
24
|
+
* still decides the answer, and array order beats version order.
|
|
25
|
+
*
|
|
26
|
+
* Exercised against real repositories rather than a hand-written `.git` file,
|
|
27
|
+
* because the point of `repoRootForPath` is agreeing with git about what a
|
|
28
|
+
* linked worktree is. A stub would confirm whatever parse it was written from.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// Isolate from the operator's git config: a global `core.excludesFile`,
|
|
32
|
+
// `init.defaultBranch`, or commit signing would otherwise leak into these repos.
|
|
33
|
+
const GIT_CFG = [
|
|
34
|
+
"-c",
|
|
35
|
+
"user.name=test",
|
|
36
|
+
"-c",
|
|
37
|
+
"user.email=test@example.com",
|
|
38
|
+
"-c",
|
|
39
|
+
"commit.gpgsign=false",
|
|
40
|
+
"-c",
|
|
41
|
+
"init.defaultBranch=main",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
45
|
+
execFileSync("git", [...GIT_CFG, ...args], { cwd, encoding: "utf8" }).trim();
|
|
46
|
+
|
|
47
|
+
let base: string;
|
|
48
|
+
/** The repository under test, standing in for magus-src. */
|
|
49
|
+
let mainTree: string;
|
|
50
|
+
/** A live linked worktree of it. */
|
|
51
|
+
let linked: string;
|
|
52
|
+
/** A worktree that was created, registered, and then REMOVED. */
|
|
53
|
+
let deletedTree: string;
|
|
54
|
+
/** A repository that has nothing to do with the one under test. */
|
|
55
|
+
let otherRepo: string;
|
|
56
|
+
/** Cache directories, as `~/.claude/plugins/cache/magus/madbench/<version>`. */
|
|
57
|
+
let cache030: string;
|
|
58
|
+
let cache050: string;
|
|
59
|
+
|
|
60
|
+
const entry = (
|
|
61
|
+
over: Partial<InstalledPluginEntry> & { version: string },
|
|
62
|
+
): InstalledPluginEntry => ({
|
|
63
|
+
scope: "project",
|
|
64
|
+
installPath: `/cache/${over.version}`,
|
|
65
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
66
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
67
|
+
...over,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
// realpath: on macOS os.tmpdir() is a symlink, and git reports real paths.
|
|
72
|
+
base = await fs.realpath(
|
|
73
|
+
await fs.mkdtemp(path.join(os.tmpdir(), "magus-loader-")),
|
|
74
|
+
);
|
|
75
|
+
mainTree = path.join(base, "magus-src");
|
|
76
|
+
linked = path.join(mainTree, ".claude", "worktrees", "madbench");
|
|
77
|
+
deletedTree = path.join(mainTree, ".claude", "worktrees", "gone");
|
|
78
|
+
otherRepo = path.join(base, "unrelated");
|
|
79
|
+
|
|
80
|
+
await fs.ensureDir(mainTree);
|
|
81
|
+
git(mainTree, "init", "-q");
|
|
82
|
+
await fs.writeFile(path.join(mainTree, "README.md"), "hello\n");
|
|
83
|
+
git(mainTree, "add", "-A");
|
|
84
|
+
git(mainTree, "commit", "-qm", "initial");
|
|
85
|
+
git(mainTree, "worktree", "add", "-q", "-b", "madbench", linked);
|
|
86
|
+
|
|
87
|
+
// Created, then removed — its registry rows are what survive it.
|
|
88
|
+
git(mainTree, "worktree", "add", "-q", "-b", "gone", deletedTree);
|
|
89
|
+
await fs.remove(deletedTree);
|
|
90
|
+
|
|
91
|
+
await fs.ensureDir(otherRepo);
|
|
92
|
+
git(otherRepo, "init", "-q");
|
|
93
|
+
|
|
94
|
+
cache030 = path.join(base, "cache", "magus", "madbench", "0.3.0");
|
|
95
|
+
cache050 = path.join(base, "cache", "magus", "madbench", "0.5.0");
|
|
96
|
+
await fs.ensureDir(cache030);
|
|
97
|
+
await fs.ensureDir(cache050);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterAll(async () => {
|
|
101
|
+
if (base) await fs.remove(base);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("repoRootForPath", () => {
|
|
105
|
+
test("a linked worktree and its main checkout resolve to the SAME root", () => {
|
|
106
|
+
expect(repoRootForPath(linked)).toBe(mainTree);
|
|
107
|
+
expect(repoRootForPath(mainTree)).toBe(mainTree);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a path inside the main checkout resolves to the checkout", () => {
|
|
111
|
+
expect(repoRootForPath(path.join(mainTree, "tools", "magus"))).toBe(
|
|
112
|
+
mainTree,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a DELETED worktree's path still resolves, to the repository that held it", () => {
|
|
117
|
+
// The directory is gone; the walk finds the nearest surviving ancestor's
|
|
118
|
+
// `.git`. This is why the implementation may not shell out to git — every
|
|
119
|
+
// git command fails here, and "no root" is the one answer that would make
|
|
120
|
+
// the entry below stop counting.
|
|
121
|
+
expect(fs.existsSync(deletedTree)).toBe(false);
|
|
122
|
+
expect(repoRootForPath(deletedTree)).toBe(mainTree);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a path that never existed resolves through its surviving ancestors", () => {
|
|
126
|
+
expect(repoRootForPath(path.join(mainTree, "no", "such", "dir"))).toBe(
|
|
127
|
+
mainTree,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a path in no repository is null, and two of them are not equal-by-null", () => {
|
|
132
|
+
expect(repoRootForPath(path.join(base, "loose", "file"))).toBeNull();
|
|
133
|
+
expect(repoRootForPath(otherRepo)).toBe(otherRepo);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("an empty path is null rather than the process's own root", () => {
|
|
137
|
+
expect(repoRootForPath("")).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("isRelevantInstallation", () => {
|
|
142
|
+
const root = () => repoRootForPath(mainTree);
|
|
143
|
+
|
|
144
|
+
test("a user-scope entry is always relevant, wherever it was installed", () => {
|
|
145
|
+
expect(
|
|
146
|
+
isRelevantInstallation(
|
|
147
|
+
entry({ scope: "user", version: "1.0.0", projectPath: undefined }),
|
|
148
|
+
linked,
|
|
149
|
+
repoRootForPath(linked),
|
|
150
|
+
),
|
|
151
|
+
).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("an entry from an UNRELATED repository is never relevant", () => {
|
|
155
|
+
expect(
|
|
156
|
+
isRelevantInstallation(
|
|
157
|
+
entry({ version: "9.9.9", projectPath: otherRepo }),
|
|
158
|
+
mainTree,
|
|
159
|
+
root(),
|
|
160
|
+
),
|
|
161
|
+
).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("an entry pinned to this exact directory is relevant", () => {
|
|
165
|
+
expect(
|
|
166
|
+
isRelevantInstallation(
|
|
167
|
+
entry({ version: "1.0.0", projectPath: linked }),
|
|
168
|
+
linked,
|
|
169
|
+
repoRootForPath(linked),
|
|
170
|
+
),
|
|
171
|
+
).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a sibling worktree of the same repository is relevant", () => {
|
|
175
|
+
expect(
|
|
176
|
+
isRelevantInstallation(
|
|
177
|
+
entry({ version: "1.0.0", projectPath: deletedTree }),
|
|
178
|
+
linked,
|
|
179
|
+
repoRootForPath(linked),
|
|
180
|
+
),
|
|
181
|
+
).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a project-scope entry with no projectPath is not relevant", () => {
|
|
185
|
+
expect(
|
|
186
|
+
isRelevantInstallation(
|
|
187
|
+
entry({ version: "1.0.0", projectPath: undefined }),
|
|
188
|
+
mainTree,
|
|
189
|
+
root(),
|
|
190
|
+
),
|
|
191
|
+
).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("two rootless paths DO match each other, as the original's compare does", () => {
|
|
195
|
+
// `repoRoot(entry.projectPath) === repoRoot(originalCwd)` with both sides
|
|
196
|
+
// null is true in the decompiled filter, so a row from any directory in no
|
|
197
|
+
// repository competes when Claude Code runs from another such directory —
|
|
198
|
+
// a scratch dir, a home directory, a plain non-git project.
|
|
199
|
+
//
|
|
200
|
+
// The port used to answer false here, which made magus name a
|
|
201
|
+
// `loadedVersion` the loader would not pick in any non-git directory. It is
|
|
202
|
+
// the loader's answer this module owes, not the more cautious one; what
|
|
203
|
+
// magus may WRITE is a separate question, pinned in stale-load-repair.test.ts
|
|
204
|
+
// ("a rootless row from another directory is relevant but NOT rewritten").
|
|
205
|
+
const loose = path.join(base, "loose");
|
|
206
|
+
expect(
|
|
207
|
+
isRelevantInstallation(
|
|
208
|
+
entry({
|
|
209
|
+
version: "1.0.0",
|
|
210
|
+
projectPath: path.join(base, "other-loose"),
|
|
211
|
+
}),
|
|
212
|
+
loose,
|
|
213
|
+
repoRootForPath(loose),
|
|
214
|
+
),
|
|
215
|
+
).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a rootless row is still irrelevant to a directory inside a repository", () => {
|
|
219
|
+
// The null-matches-null rule must not leak the other way: `cwdRoot` is a
|
|
220
|
+
// real root here, so a rootless row's null root does not equal it.
|
|
221
|
+
expect(
|
|
222
|
+
isRelevantInstallation(
|
|
223
|
+
entry({ version: "1.0.0", projectPath: path.join(base, "loose") }),
|
|
224
|
+
mainTree,
|
|
225
|
+
root(),
|
|
226
|
+
),
|
|
227
|
+
).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("pickLoadedEntry — madbench 0.3.0", () => {
|
|
232
|
+
test("a DELETED sibling worktree's row beats this project's own newer row", () => {
|
|
233
|
+
// The measured defect. madbench was installed into a worktree that has
|
|
234
|
+
// since been removed; that row is still in installed_plugins.json, still
|
|
235
|
+
// points at a cache directory that still exists, and was written FIRST.
|
|
236
|
+
// This project then installed 0.5.0. magus reported 0.5.0 and every
|
|
237
|
+
// session in this directory loaded 0.3.0.
|
|
238
|
+
const entries = [
|
|
239
|
+
entry({
|
|
240
|
+
version: "0.3.0",
|
|
241
|
+
projectPath: deletedTree,
|
|
242
|
+
installPath: cache030,
|
|
243
|
+
}),
|
|
244
|
+
entry({ version: "0.5.0", projectPath: linked, installPath: cache050 }),
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
expect(pickLoadedEntry(entries, linked)?.version).toBe("0.3.0");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("with the stale row gone, this project's own row loads", () => {
|
|
251
|
+
const entries = [
|
|
252
|
+
entry({ version: "0.5.0", projectPath: linked, installPath: cache050 }),
|
|
253
|
+
];
|
|
254
|
+
expect(pickLoadedEntry(entries, linked)?.version).toBe("0.5.0");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("install ORDER decides, not the version: reversing the rows changes the answer", () => {
|
|
258
|
+
// Stated as its own case because it is the part that reads as a bug and
|
|
259
|
+
// is not one: nothing sorts these, so the same two installs in the other
|
|
260
|
+
// order load the newer plugin.
|
|
261
|
+
const stale = entry({
|
|
262
|
+
version: "0.3.0",
|
|
263
|
+
projectPath: deletedTree,
|
|
264
|
+
installPath: cache030,
|
|
265
|
+
});
|
|
266
|
+
const own = entry({
|
|
267
|
+
version: "0.5.0",
|
|
268
|
+
projectPath: linked,
|
|
269
|
+
installPath: cache050,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(pickLoadedEntry([stale, own], linked)?.version).toBe("0.3.0");
|
|
273
|
+
expect(pickLoadedEntry([own, stale], linked)?.version).toBe("0.5.0");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("a row whose cache directory is gone is skipped for one that exists", () => {
|
|
277
|
+
const entries = [
|
|
278
|
+
entry({
|
|
279
|
+
version: "0.3.0",
|
|
280
|
+
projectPath: deletedTree,
|
|
281
|
+
installPath: path.join(base, "cache", "magus", "madbench", "evicted"),
|
|
282
|
+
}),
|
|
283
|
+
entry({ version: "0.5.0", projectPath: linked, installPath: cache050 }),
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
expect(pickLoadedEntry(entries, linked)?.version).toBe("0.5.0");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("pickLoadedEntry — the selection rules", () => {
|
|
291
|
+
test("no relevant entry at all yields undefined", () => {
|
|
292
|
+
const entries = [entry({ version: "9.9.9", projectPath: otherRepo })];
|
|
293
|
+
expect(pickLoadedEntry(entries, mainTree)).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("empty and missing entry lists are handled", () => {
|
|
297
|
+
expect(pickLoadedEntry([], mainTree)).toBeUndefined();
|
|
298
|
+
expect(pickLoadedEntry(undefined, mainTree)).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("ONE relevant entry loads even though its installPath does not exist", () => {
|
|
302
|
+
// The single-candidate branch carries no existence check. That is the
|
|
303
|
+
// binary's behaviour, not an oversight of the port: one relevant row means
|
|
304
|
+
// that row is what the loader resolves, and it fails at load rather than
|
|
305
|
+
// falling through to anything else.
|
|
306
|
+
const missing = path.join(base, "cache", "magus", "madbench", "not-here");
|
|
307
|
+
expect(fs.existsSync(missing)).toBe(false);
|
|
308
|
+
|
|
309
|
+
const only = entry({
|
|
310
|
+
version: "0.4.0",
|
|
311
|
+
projectPath: linked,
|
|
312
|
+
installPath: missing,
|
|
313
|
+
});
|
|
314
|
+
expect(pickLoadedEntry([only], linked)).toBe(only);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("with no existing installPath anywhere, the FIRST one with a path wins", () => {
|
|
318
|
+
// `firstValid` is captured before the existence test, so the fallback is
|
|
319
|
+
// the first entry that has an installPath at all — not the first existing
|
|
320
|
+
// one, of which there are none here.
|
|
321
|
+
const first = entry({
|
|
322
|
+
version: "0.3.0",
|
|
323
|
+
projectPath: deletedTree,
|
|
324
|
+
installPath: path.join(base, "gone-a"),
|
|
325
|
+
});
|
|
326
|
+
const second = entry({
|
|
327
|
+
version: "0.5.0",
|
|
328
|
+
projectPath: linked,
|
|
329
|
+
installPath: path.join(base, "gone-b"),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(pickLoadedEntry([first, second], linked)).toBe(first);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("an entry with an empty installPath is passed over for the fallback", () => {
|
|
336
|
+
const blank = entry({
|
|
337
|
+
version: "0.3.0",
|
|
338
|
+
projectPath: deletedTree,
|
|
339
|
+
installPath: "",
|
|
340
|
+
});
|
|
341
|
+
const pathed = entry({
|
|
342
|
+
version: "0.5.0",
|
|
343
|
+
projectPath: linked,
|
|
344
|
+
installPath: path.join(base, "gone-b"),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(pickLoadedEntry([blank, pathed], linked)).toBe(pathed);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("every relevant entry lacking an installPath falls back to the first of them", () => {
|
|
351
|
+
const a = entry({
|
|
352
|
+
version: "0.3.0",
|
|
353
|
+
projectPath: deletedTree,
|
|
354
|
+
installPath: "",
|
|
355
|
+
});
|
|
356
|
+
const b = entry({ version: "0.5.0", projectPath: linked, installPath: "" });
|
|
357
|
+
|
|
358
|
+
expect(pickLoadedEntry([a, b], linked)).toBe(a);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("a user-scope row competes with this project's rows on install order", () => {
|
|
362
|
+
const user = entry({
|
|
363
|
+
scope: "user",
|
|
364
|
+
version: "0.3.0",
|
|
365
|
+
projectPath: undefined,
|
|
366
|
+
installPath: cache030,
|
|
367
|
+
});
|
|
368
|
+
const own = entry({
|
|
369
|
+
version: "0.5.0",
|
|
370
|
+
projectPath: linked,
|
|
371
|
+
installPath: cache050,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
expect(pickLoadedEntry([user, own], linked)?.version).toBe("0.3.0");
|
|
375
|
+
expect(pickLoadedEntry([own, user], linked)?.version).toBe("0.5.0");
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe("resolveLoadedVersions", () => {
|
|
380
|
+
const registry = (
|
|
381
|
+
plugins: Record<string, InstalledPluginEntry[]>,
|
|
382
|
+
): InstalledPluginsRegistry => ({ version: 2, plugins });
|
|
383
|
+
|
|
384
|
+
test("maps each plugin id to the version that will actually load", () => {
|
|
385
|
+
const loaded = resolveLoadedVersions(
|
|
386
|
+
registry({
|
|
387
|
+
"madbench@magus": [
|
|
388
|
+
entry({
|
|
389
|
+
version: "0.3.0",
|
|
390
|
+
projectPath: deletedTree,
|
|
391
|
+
installPath: cache030,
|
|
392
|
+
}),
|
|
393
|
+
entry({
|
|
394
|
+
version: "0.5.0",
|
|
395
|
+
projectPath: linked,
|
|
396
|
+
installPath: cache050,
|
|
397
|
+
}),
|
|
398
|
+
],
|
|
399
|
+
"dev@magus": [
|
|
400
|
+
entry({
|
|
401
|
+
scope: "user",
|
|
402
|
+
version: "7.4.0",
|
|
403
|
+
projectPath: undefined,
|
|
404
|
+
installPath: cache050,
|
|
405
|
+
}),
|
|
406
|
+
],
|
|
407
|
+
}),
|
|
408
|
+
linked,
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
expect(loaded).toEqual({ "madbench@magus": "0.3.0", "dev@magus": "7.4.0" });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("a plugin with no relevant entry is absent from the map, not null in it", () => {
|
|
415
|
+
const loaded = resolveLoadedVersions(
|
|
416
|
+
registry({
|
|
417
|
+
"elsewhere@magus": [
|
|
418
|
+
entry({ version: "9.9.9", projectPath: otherRepo }),
|
|
419
|
+
],
|
|
420
|
+
}),
|
|
421
|
+
mainTree,
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
expect(loaded).toEqual({});
|
|
425
|
+
expect(Object.hasOwn(loaded, "elsewhere@magus")).toBe(false);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("an empty registry resolves to an empty map", () => {
|
|
429
|
+
expect(resolveLoadedVersions(registry({}), mainTree)).toEqual({});
|
|
430
|
+
});
|
|
431
|
+
});
|
|
@@ -268,6 +268,7 @@ describe("pluginStatusText — absent is not enabled-not-installed (LOW 6)", ()
|
|
|
268
268
|
pluginId: "x@magus",
|
|
269
269
|
pinned: "latest",
|
|
270
270
|
installed: null,
|
|
271
|
+
loaded: null,
|
|
271
272
|
available: "1.0.0",
|
|
272
273
|
target: "1.0.0",
|
|
273
274
|
action: "install",
|
|
@@ -343,3 +344,68 @@ describe("pluginStatusText — absent is not enabled-not-installed (LOW 6)", ()
|
|
|
343
344
|
).toBe(" stale — reinstall (files changed without a version bump)");
|
|
344
345
|
});
|
|
345
346
|
});
|
|
347
|
+
|
|
348
|
+
describe("pluginRowText — a broken plugin says so, and names the key", () => {
|
|
349
|
+
/**
|
|
350
|
+
* The measured madbench case: 0.5.0 installed in this worktree, 0.3.0 still
|
|
351
|
+
* registered against a sibling worktree that was deleted, and that older row
|
|
352
|
+
* is what Claude Code resolves. Every version magus records says 0.5.0, so
|
|
353
|
+
* without this the row reads as up to date — which was the bug.
|
|
354
|
+
*/
|
|
355
|
+
const MADBENCH = info({
|
|
356
|
+
id: "madbench@magus",
|
|
357
|
+
version: "0.5.0",
|
|
358
|
+
installedVersion: "0.5.0",
|
|
359
|
+
loadedVersion: "0.3.0",
|
|
360
|
+
projectScope: { enabled: true, version: "0.5.0" },
|
|
361
|
+
author: { name: "MadAppGang" },
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("the row states the verdict and the key — NO version archaeology", () => {
|
|
365
|
+
const row = rows([MADBENCH]).get("madbench@magus");
|
|
366
|
+
if (!row) throw new Error("row missing");
|
|
367
|
+
|
|
368
|
+
expect(row.status?.action).toBe("stale-load");
|
|
369
|
+
expect(row.status?.loaded).toBe("0.3.0");
|
|
370
|
+
expect(pluginRowText(row).text).toBe(" v0.5.0 BROKEN — press a to fix");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("the row does NOT carry the classifier's note", () => {
|
|
374
|
+
// It used to print it verbatim — "≠ loads v0.3.0, installed v0.5.0" — which
|
|
375
|
+
// is this codebase's internal vocabulary: it tells a reader neither what is
|
|
376
|
+
// wrong nor what to do, because the loads/installed distinction is one only
|
|
377
|
+
// magus draws. The note itself is unchanged and still reaches the CLI report
|
|
378
|
+
// and the TUI detail panel, which is where the mechanism belongs.
|
|
379
|
+
const row = rows([MADBENCH]).get("madbench@magus");
|
|
380
|
+
if (!row) throw new Error("row missing");
|
|
381
|
+
|
|
382
|
+
const note = cliNote([MADBENCH], "madbench@magus");
|
|
383
|
+
expect(note).toBe("loads v0.3.0, installed v0.5.0");
|
|
384
|
+
expect(pluginRowText(row).text).not.toContain(note ?? "<missing note>");
|
|
385
|
+
expect(pluginRowText(row).text).not.toContain("loads v");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("the row is RED — broken, not the amber of a routine update", () => {
|
|
389
|
+
// Amber is this file's tone for "cannot tell" and for ordinary work. A
|
|
390
|
+
// session running a version nobody installed is broken, and `a` fixes it.
|
|
391
|
+
const row = rows([MADBENCH]).get("madbench@magus");
|
|
392
|
+
if (!row) throw new Error("row missing");
|
|
393
|
+
expect(pluginStatusText(row.status).tone).toBe("danger");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("the same plugin loading its installed version says nothing extra", () => {
|
|
397
|
+
const healthy = info({
|
|
398
|
+
id: "madbench@magus",
|
|
399
|
+
version: "0.5.0",
|
|
400
|
+
installedVersion: "0.5.0",
|
|
401
|
+
loadedVersion: "0.5.0",
|
|
402
|
+
projectScope: { enabled: true, version: "0.5.0" },
|
|
403
|
+
author: { name: "MadAppGang" },
|
|
404
|
+
});
|
|
405
|
+
const row = rows([healthy]).get("madbench@magus");
|
|
406
|
+
if (!row) throw new Error("row missing");
|
|
407
|
+
|
|
408
|
+
expect(row.status?.action).toBe("current");
|
|
409
|
+
expect(pluginRowText(row).text).toBe(" v0.5.0");
|
|
410
|
+
});
|
|
411
|
+
});
|