magus-cli 7.7.0 → 7.8.0

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.
Files changed (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
@@ -0,0 +1,1203 @@
1
+ /**
2
+ * magus 7.8.0 QA, review round 2 — failure reporting and removal follow-ups.
3
+ *
4
+ * Written blind from qa-spec-review2.md (U1–U13) and the exported declarations
5
+ * of ui/adapters/{failureReport,startupReports,pluginsAdapter}.ts,
6
+ * services/{update-engine,update-plan,resolver,plugin-manager}.ts,
7
+ * cli/doctor.ts and ui/components/modals/FailureListModal.tsx, emitted with
8
+ * `tsc --emitDeclarationOnly`. No function body was read.
9
+ *
10
+ * Coverage map (requirement → describe block):
11
+ * U1 ran, changed nothing → "<name> was not updated", no fix → "U1"
12
+ * U2 profile gate problem → subject file/profile, fix by hand → "U2"
13
+ * U3 activation: blocked, not downloaded, skill failed, conflict → "U3"
14
+ * U4 stale repairs: "N plugins were not repaired", failed only → "U4"
15
+ * U5 marketplace refresh failure → subject is the marketplace → "U5"
16
+ * U6 grouping: same reason one row, versions → nouns, exceptions → "U6"
17
+ * U7 claude not on PATH → install fix once, not in the reason → "U7"
18
+ * U8 version did not download → exact reason + `Run magus install`→ "U8"
19
+ * U9 exception text: no `Command failed:`, no claude binary path → "U9"
20
+ * U10 remove → forgetVersion only at scopes actually removed → "U10"
21
+ * U11 doctor: a bare installedPluginVersions claim is not a load → "U11"
22
+ * U12 a deprecated plugin still required is never planned remove → "U12"
23
+ * U13 popup caps rows to the terminal height, ends `+N more` → "U13"
24
+ */
25
+
26
+ import { describe, expect, test } from "bun:test";
27
+ import { testRender } from "@opentui/react/test-utils";
28
+ import { act, createElement } from "react";
29
+ import { findPluginLoadProblems } from "../cli/doctor.js";
30
+ import {
31
+ type PluginScope,
32
+ claudeFailureMessage,
33
+ } from "../services/claude-cli.js";
34
+ import {
35
+ type StaleLoadRepairResult,
36
+ notDownloadedReason,
37
+ } from "../services/claude-settings.js";
38
+ import type { PluginInfo } from "../services/plugin-manager.js";
39
+ import type { ActivationResult } from "../services/profile-activate.js";
40
+ import { requiredByHere } from "../services/resolver.js";
41
+ import {
42
+ type ApplyReporter,
43
+ type PluginApplyDeps,
44
+ applyPlugins,
45
+ describeLoadNotForward,
46
+ } from "../services/update-engine.js";
47
+ import {
48
+ type PluginUpdateAction,
49
+ type PluginUpdateItem,
50
+ planPluginUpdates,
51
+ } from "../services/update-plan.js";
52
+ import {
53
+ type PluginFailure,
54
+ exceptionReason,
55
+ failureReasons,
56
+ pluginFailureReport,
57
+ reasonWithoutVersions,
58
+ } from "../ui/adapters/pluginsAdapter.js";
59
+ import {
60
+ activationFailureReport,
61
+ marketplaceRefreshReport,
62
+ profileProblemRow,
63
+ profileProblemsReport,
64
+ staleRepairReport,
65
+ } from "../ui/adapters/startupReports.js";
66
+ import {
67
+ FailureListModal,
68
+ visibleFailureRows,
69
+ } from "../ui/components/modals/FailureListModal.js";
70
+ import type { FailureReport, FailureRow } from "../ui/state/types.js";
71
+
72
+ const PROJECT = "/tmp/blind-780-review2-project";
73
+
74
+ /** Spec-quoted fix lines. */
75
+ const INSTALL_CLAUDE =
76
+ "Install Claude Code: npm install -g @anthropic-ai/claude-code";
77
+ const RUN_MAGUS_INSTALL = "Run magus install";
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Helpers
81
+ // ---------------------------------------------------------------------------
82
+
83
+ function updateItem(
84
+ pluginId: string,
85
+ over: Partial<PluginUpdateItem> = {},
86
+ ): PluginUpdateItem {
87
+ return {
88
+ pluginId,
89
+ pinned: "latest",
90
+ installed: "1.0.0",
91
+ loaded: "1.0.0",
92
+ available: "2.0.0",
93
+ target: "2.0.0",
94
+ action: "update",
95
+ reason: "behind",
96
+ scopes: ["project"],
97
+ beforeVersions: { project: "1.0.0" },
98
+ ...over,
99
+ };
100
+ }
101
+
102
+ function removeItem(pluginId: string, scopes: PluginScope[]): PluginUpdateItem {
103
+ const before: Partial<Record<PluginScope, string>> = {};
104
+ for (const s of scopes) before[s] = "1.0.0";
105
+ return updateItem(pluginId, {
106
+ available: null,
107
+ target: null,
108
+ action: "remove",
109
+ reason: "deprecated",
110
+ scopes,
111
+ beforeVersions: before,
112
+ });
113
+ }
114
+
115
+ function actionsOf(items: PluginUpdateItem[]) {
116
+ return new Map<string, PluginUpdateAction>(
117
+ items.map((i) => [i.pluginId, i.action]),
118
+ );
119
+ }
120
+
121
+ /** The fix a user reads for a row: its own, or the report's shared one. */
122
+ function fixFor(report: FailureReport, row: FailureRow): string | null {
123
+ return row.fix ?? report.fix;
124
+ }
125
+
126
+ /** Rows whose subject names `word` as a whole word. */
127
+ function rowsNaming(report: FailureReport, word: string): FailureRow[] {
128
+ const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
129
+ const re = new RegExp(`(^|[^\\w-])${esc}($|[^\\w-])`);
130
+ return report.rows.filter((r) => re.test(r.subject));
131
+ }
132
+
133
+ function oneRowNaming(report: FailureReport, word: string): FailureRow {
134
+ const rows = rowsNaming(report, word);
135
+ expect(rows).toHaveLength(1);
136
+ return rows[0] as FailureRow;
137
+ }
138
+
139
+ function allText(report: FailureReport): string {
140
+ return [
141
+ report.headline,
142
+ ...report.rows.flatMap((r) => [r.subject, r.reason, r.fix ?? ""]),
143
+ report.fix ?? "",
144
+ ].join("\n");
145
+ }
146
+
147
+ const SILENT: ApplyReporter = {
148
+ begin: () => {},
149
+ finish: () => {},
150
+ detach: (work) => work(),
151
+ };
152
+
153
+ function emptyActivation(
154
+ over: Partial<ActivationResult> = {},
155
+ ): ActivationResult {
156
+ return {
157
+ profileId: "team",
158
+ displaced: [],
159
+ skills: { skills: {}, installed: [], removed: [], failed: [] },
160
+ conversion: {
161
+ converted: false,
162
+ managed: {},
163
+ restored: [],
164
+ conflicts: [],
165
+ keptAt: null,
166
+ },
167
+ tracked: [],
168
+ keptAt: null,
169
+ dependencies: { derived: [], unreadable: [], blocked: [] },
170
+ ...over,
171
+ };
172
+ }
173
+
174
+ function repairResult(
175
+ pluginId: string,
176
+ ok: boolean,
177
+ error?: string,
178
+ ): StaleLoadRepairResult {
179
+ return {
180
+ pluginId,
181
+ target: "2.0.0",
182
+ rewritten: ok ? 1 : 0,
183
+ backup: null,
184
+ loaded: ok ? "2.0.0" : "1.0.0",
185
+ ok,
186
+ ...(ok ? {} : { error: error ?? "Claude Code still loads v1.0.0" }),
187
+ };
188
+ }
189
+
190
+ async function renderPopup(
191
+ report: FailureReport,
192
+ width: number,
193
+ height: number,
194
+ terminalHeight?: number,
195
+ ): Promise<string[]> {
196
+ const { captureCharFrame, renderOnce, renderer } = await testRender(
197
+ createElement(FailureListModal, {
198
+ report,
199
+ terminalWidth: width,
200
+ ...(terminalHeight === undefined ? {} : { terminalHeight }),
201
+ }),
202
+ { width, height },
203
+ );
204
+ await act(async () => {
205
+ await renderOnce();
206
+ await renderOnce();
207
+ });
208
+ const lines = captureCharFrame().split("\n");
209
+ await act(async () => renderer.destroy());
210
+ return lines;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // U1
215
+ // ---------------------------------------------------------------------------
216
+
217
+ describe("U1 an update that ran and changed nothing", () => {
218
+ /** The installer exits cleanly and the read-back is the version before. */
219
+ function noMoveDeps(): PluginApplyDeps {
220
+ return {
221
+ install: async () => {},
222
+ update: async () => {},
223
+ repair: async () => {},
224
+ readInstalled: async () => "1.0.0",
225
+ saveInstalled: async () => {},
226
+ repairStaleLoad: async (pluginId, target) => ({
227
+ pluginId,
228
+ target,
229
+ rewritten: 0,
230
+ backup: null,
231
+ loaded: target,
232
+ ok: true,
233
+ }),
234
+ };
235
+ }
236
+
237
+ test("U1 reported as '<name> was not updated', reason says it ran but is still v1.0.0, no fix", async () => {
238
+ const items = [updateItem("kanban@magus")];
239
+ const fr = failureReasons();
240
+ const result = await applyPlugins(
241
+ items,
242
+ PROJECT,
243
+ noMoveDeps(),
244
+ fr.reporter,
245
+ );
246
+ // Precondition: the engine classed it as unchanged, not failed.
247
+ expect(result.unchanged.join(" ")).toContain("kanban");
248
+ expect(result.failed).toEqual([]);
249
+
250
+ const report = fr.report(result.failed, actionsOf(items), result.unchanged);
251
+ expect(report).not.toBeNull();
252
+ const r = report as FailureReport;
253
+ expect(r.headline).toBe("kanban was not updated");
254
+ expect(r.rows).toHaveLength(1);
255
+ const row = oneRowNaming(r, "kanban");
256
+ expect(row.reason).toMatch(/\bran\b/i);
257
+ expect(row.reason).toMatch(/still/i);
258
+ expect(row.reason).toContain("v1.0.0");
259
+ expect(row.fix).toBeUndefined();
260
+ expect(r.fix).toBeNull();
261
+ });
262
+
263
+ test("U1 beside a real failure, the unchanged row still carries no fix", async () => {
264
+ const items = [updateItem("kanban@magus"), updateItem("broken@magus")];
265
+ const deps = noMoveDeps();
266
+ deps.update = async (id) => {
267
+ if (id === "broken@magus")
268
+ throw new Error("getaddrinfo ENOTFOUND github.com");
269
+ };
270
+ const fr = failureReasons();
271
+ const result = await applyPlugins(items, PROJECT, deps, fr.reporter);
272
+ const r = fr.report(
273
+ result.failed,
274
+ actionsOf(items),
275
+ result.unchanged,
276
+ ) as FailureReport;
277
+ expect(r).not.toBeNull();
278
+ const row = oneRowNaming(r, "kanban");
279
+ expect(row.reason).toContain("v1.0.0");
280
+ expect(row.fix).toBeUndefined();
281
+ // Rows differ in fix, so no shared fix line may apply to the unchanged row.
282
+ expect(r.fix).toBeNull();
283
+ });
284
+ });
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // U2
288
+ // ---------------------------------------------------------------------------
289
+
290
+ describe("U2 startup profile gate problems", () => {
291
+ const problem =
292
+ ".claude/settings.json cannot be read (Unexpected token } in JSON at position 12) — fix it by hand; magus left it as it is and did not switch";
293
+
294
+ test("U2 the file is the subject and the fix tells the user to fix it by hand", () => {
295
+ const r = profileProblemsReport([problem]);
296
+ expect(r).not.toBeNull();
297
+ const report = r as FailureReport;
298
+ expect(report.rows).toHaveLength(1);
299
+ const row = report.rows[0] as FailureRow;
300
+ expect(row.subject).toBe(".claude/settings.json");
301
+ expect(row.reason).toContain("cannot be read");
302
+ expect(row.reason).not.toMatch(/fix it by hand/i);
303
+ expect(fixFor(report, row) ?? "").toMatch(/fix it by hand/i);
304
+ });
305
+
306
+ test("U2 profileProblemRow alone: the file is the subject", () => {
307
+ const row = profileProblemRow(problem);
308
+ expect(row.subject).toBe(".claude/settings.json");
309
+ expect(`${row.reason} ${row.fix ?? ""}`).toMatch(/fix it by hand/i);
310
+ });
311
+
312
+ test("U2 a line of another shape is about the profile", () => {
313
+ const row = profileProblemRow(
314
+ 'Profile "team" is not defined in profiles.json',
315
+ );
316
+ expect(row.subject).toMatch(/profile/i);
317
+ expect(row.reason.length).toBeGreaterThan(0);
318
+ });
319
+
320
+ test("U2 no problems → no report", () => {
321
+ expect(profileProblemsReport([])).toBeNull();
322
+ });
323
+ });
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // U3
327
+ // ---------------------------------------------------------------------------
328
+
329
+ describe("U3 profile activation failures", () => {
330
+ const full = emptyActivation({
331
+ dependencies: {
332
+ derived: [],
333
+ unreadable: ["imagegen@magus"],
334
+ blocked: [{ plugin: "kanban@magus", dependency: "dev@magus" }],
335
+ },
336
+ skills: {
337
+ skills: {},
338
+ installed: [],
339
+ removed: [],
340
+ failed: [{ skill: "pdfkit", reason: "GitHub answered 404 Not Found" }],
341
+ },
342
+ conversion: {
343
+ converted: true,
344
+ managed: {},
345
+ restored: [],
346
+ conflicts: [
347
+ {
348
+ folder: "notesync",
349
+ kept: ".claude/skills/notesync",
350
+ other: ".claude/_profiles/team/skills/notesync",
351
+ },
352
+ ],
353
+ keptAt: null,
354
+ },
355
+ });
356
+
357
+ test("U3 one report with a row for each of the four", () => {
358
+ const r = activationFailureReport([full]);
359
+ expect(r).not.toBeNull();
360
+ const report = r as FailureReport;
361
+ expect(rowsNaming(report, "kanban").length).toBe(1);
362
+ expect(rowsNaming(report, "imagegen").length).toBe(1);
363
+ expect(rowsNaming(report, "pdfkit").length).toBe(1);
364
+ expect(rowsNaming(report, "notesync").length).toBe(1);
365
+ expect(report.rows.length).toBe(4);
366
+ });
367
+
368
+ test("U3 (a) the blocked plugin's row names the dependency the profile turns off", () => {
369
+ const report = activationFailureReport([full]) as FailureReport;
370
+ const row = oneRowNaming(report, "kanban");
371
+ expect(`${row.reason} ${row.fix ?? ""}`).toContain("dev");
372
+ });
373
+
374
+ test("U3 (b) dependencies not downloaded carry the fix `Run magus install`", () => {
375
+ const report = activationFailureReport([full]) as FailureReport;
376
+ const row = oneRowNaming(report, "imagegen");
377
+ expect(fixFor(report, row)).toBe(RUN_MAGUS_INSTALL);
378
+ });
379
+
380
+ test("U3 (b) alone: the fix is `Run magus install`", () => {
381
+ const report = activationFailureReport([
382
+ emptyActivation({
383
+ dependencies: {
384
+ derived: [],
385
+ unreadable: ["imagegen@magus"],
386
+ blocked: [],
387
+ },
388
+ }),
389
+ ]) as FailureReport;
390
+ expect(report).not.toBeNull();
391
+ const row = oneRowNaming(report, "imagegen");
392
+ expect(fixFor(report, row)).toBe(RUN_MAGUS_INSTALL);
393
+ });
394
+
395
+ test("U3 (c) a failed skill install carries its reason", () => {
396
+ const report = activationFailureReport([full]) as FailureReport;
397
+ const row = oneRowNaming(report, "pdfkit");
398
+ expect(row.reason).toContain("GitHub answered 404 Not Found");
399
+ });
400
+
401
+ test("U3 (d) a skill conflict has a row", () => {
402
+ const report = activationFailureReport([full]) as FailureReport;
403
+ const row = oneRowNaming(report, "notesync");
404
+ expect(row.reason.length).toBeGreaterThan(0);
405
+ });
406
+
407
+ test("U3 only (b) carries `Run magus install` — no shared fix across the four", () => {
408
+ const report = activationFailureReport([full]) as FailureReport;
409
+ expect(report.fix).not.toBe(RUN_MAGUS_INSTALL);
410
+ for (const word of ["kanban", "pdfkit", "notesync"]) {
411
+ const row = oneRowNaming(report, word);
412
+ expect(fixFor(report, row)).not.toBe(RUN_MAGUS_INSTALL);
413
+ }
414
+ });
415
+
416
+ test("U3 an ActivationResult with none of these → no report", () => {
417
+ const r = activationFailureReport([emptyActivation()]);
418
+ expect(r === null || r.rows.length === 0).toBe(true);
419
+ });
420
+
421
+ test("U3 nulls and undefined → no report", () => {
422
+ const r = activationFailureReport([null, undefined]);
423
+ expect(r === null || r.rows.length === 0).toBe(true);
424
+ });
425
+ });
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // U4
429
+ // ---------------------------------------------------------------------------
430
+
431
+ describe("U4 failed stale-load repairs", () => {
432
+ test("U4 two failed and one repaired → '2 plugins were not repaired', repaired one not listed", () => {
433
+ const r = staleRepairReport([
434
+ repairResult("alpha@magus", false),
435
+ repairResult("bravo@magus", true),
436
+ repairResult("charlie@magus", false),
437
+ ]);
438
+ expect(r).not.toBeNull();
439
+ const report = r as FailureReport;
440
+ expect(report.headline).toBe("2 plugins were not repaired");
441
+ expect(rowsNaming(report, "alpha").length).toBe(1);
442
+ expect(rowsNaming(report, "charlie").length).toBe(1);
443
+ expect(allText(report)).not.toContain("bravo");
444
+ });
445
+
446
+ test("U4 only successful repairs → no report", () => {
447
+ const r = staleRepairReport([repairResult("bravo@magus", true)]);
448
+ expect(r === null || r.rows.length === 0).toBe(true);
449
+ });
450
+ });
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // U5
454
+ // ---------------------------------------------------------------------------
455
+
456
+ describe("U5 a failed marketplace refresh", () => {
457
+ test("U5 the row's subject is the marketplace name", () => {
458
+ const r = marketplaceRefreshReport([
459
+ {
460
+ marketplace: "magus-marketing",
461
+ error: new Error("fatal: unable to access remote"),
462
+ },
463
+ ]);
464
+ expect(r).not.toBeNull();
465
+ const report = r as FailureReport;
466
+ expect(report.rows).toHaveLength(1);
467
+ expect((report.rows[0] as FailureRow).subject).toBe("magus-marketing");
468
+ });
469
+
470
+ test("U5 nothing failed → no report", () => {
471
+ const r = marketplaceRefreshReport([]);
472
+ expect(r === null || r.rows.length === 0).toBe(true);
473
+ });
474
+ });
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // U6
478
+ // ---------------------------------------------------------------------------
479
+
480
+ describe("U6 grouping", () => {
481
+ const f = (
482
+ pluginId: string,
483
+ detail: string,
484
+ extra: Partial<PluginFailure> = {},
485
+ ): PluginFailure => ({ pluginId, verb: "updated", detail, ...extra });
486
+
487
+ test("U6 the same reason → one row naming every subject", () => {
488
+ const detail = "the marketplace does not list this plugin any more";
489
+ const report = pluginFailureReport([
490
+ f("alpha@magus", detail),
491
+ f("bravo@magus", detail),
492
+ ]) as FailureReport;
493
+ expect(report.rows).toHaveLength(1);
494
+ const subject = (report.rows[0] as FailureRow).subject;
495
+ expect(subject).toContain("alpha");
496
+ expect(subject).toContain("bravo");
497
+ });
498
+
499
+ test("U6 reasons differing only in versions → one row in nouns, never 'still loads in this folder'", () => {
500
+ const report = pluginFailureReport([
501
+ f(
502
+ "alpha@magus",
503
+ "installed v2.0.6, but Claude Code still loads v2.0.3 in this folder",
504
+ ),
505
+ f(
506
+ "bravo@magus",
507
+ "installed v1.5.0, but Claude Code still loads v1.4.0 in this folder",
508
+ ),
509
+ ]) as FailureReport;
510
+ expect(report.rows).toHaveLength(1);
511
+ const row = report.rows[0] as FailureRow;
512
+ expect(row.subject).toContain("alpha");
513
+ expect(row.subject).toContain("bravo");
514
+ expect(row.reason).toContain("the new version");
515
+ expect(row.reason).toContain("an older version");
516
+ expect(row.reason).not.toContain("still loads in this folder");
517
+ expect(row.reason).not.toMatch(/v\d+\.\d+/);
518
+ });
519
+
520
+ test("U6 'did not download' at different versions → one row, never 'installed, but is not downloaded'", () => {
521
+ const report = pluginFailureReport([
522
+ f("alpha@magus", "installed v1.3.0, but it did not download"),
523
+ f("bravo@magus", "installed v2.2.0, but it did not download"),
524
+ ]) as FailureReport;
525
+ expect(report.rows).toHaveLength(1);
526
+ const row = report.rows[0] as FailureRow;
527
+ expect(row.reason).not.toContain("installed, but is not downloaded");
528
+ expect(row.reason).not.toMatch(/installed,\s*but/);
529
+ expect(row.reason).toContain("the new version");
530
+ });
531
+
532
+ test("U6 reasonWithoutVersions itself uses nouns", () => {
533
+ const s = reasonWithoutVersions(
534
+ "installed v2.0.6, but Claude Code still loads v2.0.3 in this folder",
535
+ );
536
+ expect(s).toContain("the new version");
537
+ expect(s).toContain("an older version");
538
+ expect(s).not.toContain("still loads in this folder");
539
+ });
540
+
541
+ test("U6 recognised network exception with the same message → one shared row", () => {
542
+ const msg = "getaddrinfo ENOTFOUND github.com";
543
+ const report = pluginFailureReport([
544
+ f("alpha@magus", msg, { exception: true }),
545
+ f("bravo@magus", msg, { exception: true }),
546
+ ]) as FailureReport;
547
+ expect(report.rows).toHaveLength(1);
548
+ const subject = (report.rows[0] as FailureRow).subject;
549
+ expect(subject).toContain("alpha");
550
+ expect(subject).toContain("bravo");
551
+ });
552
+
553
+ test("U6 unrecognised exceptions with the same message keep one row each", () => {
554
+ const msg = "Unexpected token } in JSON at position 42";
555
+ const report = pluginFailureReport([
556
+ f("alpha@magus", msg, { exception: true }),
557
+ f("bravo@magus", msg, { exception: true }),
558
+ ]) as FailureReport;
559
+ expect(report.rows).toHaveLength(2);
560
+ expect(rowsNaming(report, "alpha").length).toBe(1);
561
+ expect(rowsNaming(report, "bravo").length).toBe(1);
562
+ });
563
+ });
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // U7
567
+ // ---------------------------------------------------------------------------
568
+
569
+ /** Arguments a failing `claude plugin update` ran with. */
570
+ const ARGS = ["plugin", "update", "alpha@magus", "--scope", "project"];
571
+
572
+ /** An error shaped like the one a child-process call throws. */
573
+ function procError(message: string, extra: Record<string, unknown> = {}) {
574
+ return Object.assign(new Error(message), extra);
575
+ }
576
+
577
+ /**
578
+ * "claude is not on PATH", as the user-facing message the wrapper produces
579
+ * for each way the machine says so.
580
+ */
581
+ const CLAUDE_MISSING: Array<[string, string]> = [
582
+ [
583
+ "node spawn ENOENT",
584
+ claudeFailureMessage(
585
+ procError("spawn claude ENOENT", {
586
+ code: "ENOENT",
587
+ errno: -2,
588
+ syscall: "spawn claude",
589
+ path: "claude",
590
+ spawnargs: ARGS.slice(),
591
+ }),
592
+ ARGS,
593
+ 60_000,
594
+ ),
595
+ ],
596
+ [
597
+ "bun executable not found",
598
+ claudeFailureMessage(
599
+ procError('Executable not found in $PATH: "claude"', { code: "ENOENT" }),
600
+ ARGS,
601
+ 60_000,
602
+ ),
603
+ ],
604
+ [
605
+ "worded, with the install command",
606
+ "Claude CLI not found in PATH. Install Claude Code: npm install -g @anthropic-ai/claude-code",
607
+ ],
608
+ ];
609
+
610
+ describe("U7 claude not on PATH", () => {
611
+ for (const [label, msg] of CLAUDE_MISSING) {
612
+ test(`U7 [${label}] the reason does not repeat the install command; the fix is the install line`, () => {
613
+ const report = pluginFailureReport([
614
+ {
615
+ pluginId: "alpha@magus",
616
+ verb: "updated",
617
+ detail: msg,
618
+ exception: true,
619
+ },
620
+ {
621
+ pluginId: "bravo@magus",
622
+ verb: "installed",
623
+ detail: msg,
624
+ exception: true,
625
+ },
626
+ ]) as FailureReport;
627
+ expect(report).not.toBeNull();
628
+ for (const row of report.rows) {
629
+ expect(row.reason).not.toContain("npm install");
630
+ expect(row.reason).not.toContain("@anthropic-ai/claude-code");
631
+ }
632
+ expect(report.fix).toBe(INSTALL_CLAUDE);
633
+ });
634
+
635
+ test(`U7 [${label}] recognised, sharing one row across plugins (U6)`, () => {
636
+ const report = pluginFailureReport([
637
+ {
638
+ pluginId: "alpha@magus",
639
+ verb: "updated",
640
+ detail: msg,
641
+ exception: true,
642
+ },
643
+ {
644
+ pluginId: "bravo@magus",
645
+ verb: "updated",
646
+ detail: msg,
647
+ exception: true,
648
+ },
649
+ ]) as FailureReport;
650
+ expect(report.rows).toHaveLength(1);
651
+ const e = exceptionReason(msg);
652
+ expect(e.known).toBe(true);
653
+ expect(e.fix).toBe(INSTALL_CLAUDE);
654
+ });
655
+ }
656
+
657
+ test("U7 end to end: install and update throw, the popup's fix line is the install line", async () => {
658
+ const [, worded] = CLAUDE_MISSING[2] as [string, string];
659
+ const deps: PluginApplyDeps = {
660
+ install: async () => {
661
+ throw new Error(worded);
662
+ },
663
+ update: async () => {
664
+ throw new Error(worded);
665
+ },
666
+ repair: async () => {},
667
+ readInstalled: async () => "1.0.0",
668
+ saveInstalled: async () => {},
669
+ repairStaleLoad: async (pluginId, target) => ({
670
+ pluginId,
671
+ target,
672
+ rewritten: 0,
673
+ backup: null,
674
+ loaded: target,
675
+ ok: true,
676
+ }),
677
+ };
678
+ const items = [
679
+ updateItem("alpha@magus"),
680
+ updateItem("bravo@magus", {
681
+ action: "install",
682
+ reason: "absent",
683
+ installed: null,
684
+ loaded: null,
685
+ scopes: [],
686
+ beforeVersions: {},
687
+ }),
688
+ ];
689
+ const fr = failureReasons();
690
+ const result = await applyPlugins(items, PROJECT, deps, fr.reporter);
691
+ expect(result.failed.length).toBe(2);
692
+ const report = fr.report(result.failed, actionsOf(items)) as FailureReport;
693
+ expect(report.fix).toBe(INSTALL_CLAUDE);
694
+ for (const row of report.rows)
695
+ expect(row.reason).not.toContain("npm install");
696
+ });
697
+ });
698
+
699
+ // ---------------------------------------------------------------------------
700
+ // U8
701
+ // ---------------------------------------------------------------------------
702
+
703
+ describe("U8 a version that did not download", () => {
704
+ /** Update lands v1.3.0, loader resolves v1.2.0, repair refuses: no download. */
705
+ function notDownloadedDeps(): PluginApplyDeps {
706
+ return {
707
+ install: async () => {},
708
+ update: async () => {},
709
+ repair: async () => {},
710
+ readInstalled: async () => "1.3.0",
711
+ saveInstalled: async () => {},
712
+ loadedVersion: async () => "1.2.0",
713
+ repairStaleLoad: async (pluginId, target) => ({
714
+ pluginId,
715
+ target,
716
+ rewritten: 0,
717
+ backup: null,
718
+ loaded: null,
719
+ ok: false,
720
+ error: notDownloadedReason(target),
721
+ }),
722
+ };
723
+ }
724
+
725
+ test("U8 describeLoadNotForward: 'installed v1.3.0, but it did not download', never 'v1.3.0 is not downloaded'", () => {
726
+ // The refusal repairStaleLoad makes for a version with no download.
727
+ const detail = describeLoadNotForward({
728
+ pluginId: "alpha@magus",
729
+ target: "1.3.0",
730
+ rewritten: 0,
731
+ backup: null,
732
+ loaded: null,
733
+ ok: false,
734
+ error: notDownloadedReason("1.3.0"),
735
+ });
736
+ expect(detail).toContain("installed v1.3.0, but it did not download");
737
+ expect(detail).not.toContain("v1.3.0 is not downloaded");
738
+ });
739
+
740
+ test("U8 end to end: reason `installed v1.3.0, but it did not download`, fix `Run magus install`", async () => {
741
+ const items = [
742
+ updateItem("alpha@magus", {
743
+ available: "1.3.0",
744
+ target: "1.3.0",
745
+ installed: "1.2.0",
746
+ loaded: "1.2.0",
747
+ beforeVersions: { project: "1.2.0" },
748
+ }),
749
+ ];
750
+ const fr = failureReasons();
751
+ const result = await applyPlugins(
752
+ items,
753
+ PROJECT,
754
+ notDownloadedDeps(),
755
+ fr.reporter,
756
+ );
757
+ expect(result.failed.join(" ")).toContain("alpha");
758
+ const report = fr.report(result.failed, actionsOf(items)) as FailureReport;
759
+ expect(report).not.toBeNull();
760
+ const row = oneRowNaming(report, "alpha");
761
+ expect(row.reason).toBe("installed v1.3.0, but it did not download");
762
+ expect(fixFor(report, row)).toBe(RUN_MAGUS_INSTALL);
763
+ expect(allText(report)).not.toContain("v1.3.0 is not downloaded");
764
+ });
765
+ });
766
+
767
+ // ---------------------------------------------------------------------------
768
+ // U9
769
+ // ---------------------------------------------------------------------------
770
+
771
+ describe("U9 exception text", () => {
772
+ const BIN = "/Users/someone/.local/bin/claude";
773
+ const cmd = `${BIN} ${ARGS.join(" ")}`;
774
+ const errors: Array<[string, unknown]> = [
775
+ [
776
+ "Command failed + a following line, no output",
777
+ procError(`Command failed: ${cmd}\nerror: Plugin alpha not found`, {
778
+ code: 1,
779
+ stderr: "",
780
+ stdout: "",
781
+ }),
782
+ ],
783
+ [
784
+ "Command failed alone, no output",
785
+ procError(`Command failed: ${cmd}`, { code: 1, stderr: "", stdout: "" }),
786
+ ],
787
+ [
788
+ "Command failed with stderr",
789
+ procError(`Command failed: ${cmd}`, {
790
+ code: 1,
791
+ stderr: "Plugin alpha not found in marketplace magus\n",
792
+ stdout: "",
793
+ }),
794
+ ],
795
+ [
796
+ "spawn <abs path> EACCES",
797
+ procError(`spawn ${BIN} EACCES`, {
798
+ code: "EACCES",
799
+ errno: -13,
800
+ syscall: `spawn ${BIN}`,
801
+ path: BIN,
802
+ spawnargs: ARGS.slice(),
803
+ }),
804
+ ],
805
+ [
806
+ "spawn <abs path> ENOENT",
807
+ procError(`spawn ${BIN} ENOENT`, {
808
+ code: "ENOENT",
809
+ errno: -2,
810
+ syscall: `spawn ${BIN}`,
811
+ path: BIN,
812
+ spawnargs: ARGS.slice(),
813
+ }),
814
+ ],
815
+ [
816
+ "killed after the timeout",
817
+ procError(`Command failed: ${cmd}`, {
818
+ killed: true,
819
+ signal: "SIGTERM",
820
+ stderr: "",
821
+ stdout: "",
822
+ }),
823
+ ],
824
+ ];
825
+
826
+ for (const [label, error] of errors) {
827
+ test(`U9 [${label}] the wrapper's message: no 'Command failed:', no claude binary path`, () => {
828
+ const msg = claudeFailureMessage(error, ARGS, 60_000);
829
+ expect(msg).not.toMatch(/^Command failed:/m);
830
+ expect(msg).not.toContain(BIN);
831
+ expect(msg.trim().length).toBeGreaterThan(0);
832
+ });
833
+
834
+ test(`U9 [${label}] the popup built from it is clean too`, () => {
835
+ const msg = claudeFailureMessage(error, ARGS, 60_000);
836
+ const report = pluginFailureReport([
837
+ {
838
+ pluginId: "alpha@magus",
839
+ verb: "updated",
840
+ detail: msg,
841
+ exception: true,
842
+ },
843
+ ]) as FailureReport;
844
+ const text = allText(report);
845
+ expect(text).not.toMatch(/^Command failed:/m);
846
+ expect(text).not.toContain(BIN);
847
+ });
848
+ }
849
+
850
+ test("U9 end to end: an update that throws the wrapper's message", async () => {
851
+ const thrown = claudeFailureMessage(errors[0]?.[1], ARGS, 60_000);
852
+ const deps: PluginApplyDeps = {
853
+ install: async () => {},
854
+ update: async () => {
855
+ throw new Error(thrown);
856
+ },
857
+ repair: async () => {},
858
+ readInstalled: async () => "1.0.0",
859
+ saveInstalled: async () => {},
860
+ repairStaleLoad: async (pluginId, target) => ({
861
+ pluginId,
862
+ target,
863
+ rewritten: 0,
864
+ backup: null,
865
+ loaded: target,
866
+ ok: true,
867
+ }),
868
+ };
869
+ const items = [updateItem("alpha@magus")];
870
+ const fr = failureReasons();
871
+ const result = await applyPlugins(items, PROJECT, deps, fr.reporter);
872
+ const report = fr.report(result.failed, actionsOf(items)) as FailureReport;
873
+ expect(report).not.toBeNull();
874
+ const text = allText(report);
875
+ expect(text).not.toMatch(/^Command failed:/m);
876
+ expect(text).not.toContain(BIN);
877
+ });
878
+ });
879
+
880
+ // ---------------------------------------------------------------------------
881
+ // U10
882
+ // ---------------------------------------------------------------------------
883
+
884
+ describe("U10 removal forgets the version only where removed", () => {
885
+ /** Installed scopes per plugin; uninstall removes unless the scope is stuck. */
886
+ function world(
887
+ installed: Record<string, PluginScope[]>,
888
+ stuck: Partial<Record<PluginScope, "throw" | "stays">> = {},
889
+ ) {
890
+ const state = new Map(
891
+ Object.entries(installed).map(([id, s]) => [id, new Set(s)]),
892
+ );
893
+ const forgotten: Array<{ id: string; scope: PluginScope }> = [];
894
+ const deps: PluginApplyDeps = {
895
+ install: async () => {},
896
+ update: async () => {},
897
+ repair: async () => {},
898
+ readInstalled: async () => null,
899
+ saveInstalled: async () => {},
900
+ repairStaleLoad: async (pluginId, target) => ({
901
+ pluginId,
902
+ target,
903
+ rewritten: 0,
904
+ backup: null,
905
+ loaded: target,
906
+ ok: true,
907
+ }),
908
+ remove: {
909
+ uninstall: async (id, scope) => {
910
+ if (stuck[scope] === "throw") {
911
+ throw new Error("Command failed: claude plugin uninstall");
912
+ }
913
+ if (stuck[scope] === "stays") return;
914
+ state.get(id)?.delete(scope);
915
+ },
916
+ isInstalled: async (id, scope) => state.get(id)?.has(scope) ?? false,
917
+ dropFromProfile: async () => true,
918
+ forgetVersion: async (id, scope) => {
919
+ forgotten.push({ id, scope });
920
+ },
921
+ },
922
+ };
923
+ return { deps, forgotten };
924
+ }
925
+
926
+ test("U10 removed at every scope → forgotten at every scope", async () => {
927
+ const { deps, forgotten } = world({ "old@magus": ["project", "user"] });
928
+ const result = await applyPlugins(
929
+ [removeItem("old@magus", ["project", "user"])],
930
+ PROJECT,
931
+ deps,
932
+ SILENT,
933
+ );
934
+ expect(result.failed).toEqual([]);
935
+ const scopes = forgotten
936
+ .filter((f) => f.id === "old@magus")
937
+ .map((f) => f.scope)
938
+ .sort();
939
+ expect(scopes).toEqual(["project", "user"]);
940
+ });
941
+
942
+ test("U10 uninstall throws at user → forgotten at project only", async () => {
943
+ const { deps, forgotten } = world(
944
+ { "old@magus": ["project", "user"] },
945
+ { user: "throw" },
946
+ );
947
+ await applyPlugins(
948
+ [removeItem("old@magus", ["project", "user"])],
949
+ PROJECT,
950
+ deps,
951
+ SILENT,
952
+ );
953
+ const scopes = forgotten
954
+ .filter((f) => f.id === "old@magus")
955
+ .map((f) => f.scope);
956
+ expect(scopes).toContain("project");
957
+ expect(scopes).not.toContain("user");
958
+ });
959
+
960
+ test("U10 uninstall exits cleanly but user still installed → not forgotten at user", async () => {
961
+ const { deps, forgotten } = world(
962
+ { "old@magus": ["project", "user"] },
963
+ { user: "stays" },
964
+ );
965
+ const result = await applyPlugins(
966
+ [removeItem("old@magus", ["project", "user"])],
967
+ PROJECT,
968
+ deps,
969
+ SILENT,
970
+ );
971
+ const scopes = forgotten
972
+ .filter((f) => f.id === "old@magus")
973
+ .map((f) => f.scope);
974
+ expect(scopes).toContain("project");
975
+ expect(scopes).not.toContain("user");
976
+ expect(result.failed.join(" ")).toContain("old");
977
+ });
978
+
979
+ test("U10 never forgets at a scope the item does not name", async () => {
980
+ const { deps, forgotten } = world({ "old@magus": ["project"] });
981
+ await applyPlugins(
982
+ [removeItem("old@magus", ["project"])],
983
+ PROJECT,
984
+ deps,
985
+ SILENT,
986
+ );
987
+ const scopes = forgotten.map((f) => f.scope);
988
+ expect(scopes).toEqual(["project"]);
989
+ });
990
+ });
991
+
992
+ // ---------------------------------------------------------------------------
993
+ // U11
994
+ // ---------------------------------------------------------------------------
995
+
996
+ describe("U11 doctor: what loads nothing", () => {
997
+ /**
998
+ * findPluginLoadProblems is declared pure over its inputs, so "resolves to
999
+ * no version" is a row for this folder that carries none.
1000
+ */
1001
+ const row = (version: string) => ({
1002
+ scope: "project" as const,
1003
+ projectPath: PROJECT,
1004
+ installPath: `${PROJECT}/.cache/real/${version || "none"}`,
1005
+ version,
1006
+ installedAt: "2026-09-01T00:00:00.000Z",
1007
+ lastUpdated: "2026-09-01T00:00:00.000Z",
1008
+ });
1009
+
1010
+ test("U11 a bare installedPluginVersions claim is not 'loads nothing'", () => {
1011
+ const findings = findPluginLoadProblems(
1012
+ { "ghost@magus": "1.0.0" },
1013
+ {},
1014
+ PROJECT,
1015
+ {},
1016
+ );
1017
+ expect(findings.loadsNothing).not.toContain("ghost@magus");
1018
+ });
1019
+
1020
+ test("U11 claim with an undefined registry and no enabledPlugins is not 'loads nothing'", () => {
1021
+ const findings = findPluginLoadProblems(
1022
+ { "ghost@magus": "1.0.0" },
1023
+ undefined,
1024
+ PROJECT,
1025
+ );
1026
+ expect(findings.loadsNothing).not.toContain("ghost@magus");
1027
+ });
1028
+
1029
+ test("U11 a registry row for this folder that resolves to no version IS 'loads nothing'", () => {
1030
+ const findings = findPluginLoadProblems(
1031
+ { "real@magus": "1.0.0" },
1032
+ { "real@magus": [row("")] },
1033
+ PROJECT,
1034
+ {},
1035
+ );
1036
+ expect(findings.loadsNothing).toContain("real@magus");
1037
+ });
1038
+
1039
+ test("U11 control: a row for this folder at the installed version is not 'loads nothing'", () => {
1040
+ const findings = findPluginLoadProblems(
1041
+ { "real@magus": "1.0.0" },
1042
+ { "real@magus": [row("1.0.0")] },
1043
+ PROJECT,
1044
+ {},
1045
+ );
1046
+ expect(findings.loadsNothing).not.toContain("real@magus");
1047
+ });
1048
+ });
1049
+
1050
+ // ---------------------------------------------------------------------------
1051
+ // U12
1052
+ // ---------------------------------------------------------------------------
1053
+
1054
+ describe("U12 a deprecated plugin another installed plugin requires", () => {
1055
+ const installedAtProject = (v: string) => ({ installed: true, version: v });
1056
+
1057
+ function catalog(): PluginInfo[] {
1058
+ const base = {
1059
+ description: "",
1060
+ marketplace: "magus",
1061
+ marketplaceDisplay: "magus",
1062
+ };
1063
+ return [
1064
+ {
1065
+ ...base,
1066
+ id: "parent@magus",
1067
+ name: "parent",
1068
+ version: "2.0.0",
1069
+ installedVersion: "2.0.0",
1070
+ loadedVersion: "2.0.0",
1071
+ projectScope: installedAtProject("2.0.0"),
1072
+ },
1073
+ {
1074
+ ...base,
1075
+ id: "sibling@magus",
1076
+ name: "sibling",
1077
+ version: "1.0.0",
1078
+ },
1079
+ {
1080
+ ...base,
1081
+ id: "old@magus",
1082
+ name: "old",
1083
+ version: "1.0.0",
1084
+ installedVersion: "1.0.0",
1085
+ loadedVersion: "1.0.0",
1086
+ isOrphaned: true,
1087
+ projectScope: installedAtProject("1.0.0"),
1088
+ },
1089
+ ];
1090
+ }
1091
+
1092
+ const reader = async (id: string): Promise<string[] | null> =>
1093
+ id === "parent@magus" ? ["old@magus"] : [];
1094
+
1095
+ const action = (items: PluginUpdateItem[], id: string) =>
1096
+ items.find((i) => i.pluginId === id)?.action;
1097
+
1098
+ test("U12 control: with nothing requiring it, the deprecated plugin IS planned remove", () => {
1099
+ const items = planPluginUpdates({}, catalog(), {});
1100
+ expect(action(items, "old@magus")).toBe("remove");
1101
+ });
1102
+
1103
+ test("U12 required by an installed plugin (edges from requiredByHere) → not remove", async () => {
1104
+ const requiredBy = await requiredByHere(
1105
+ undefined,
1106
+ ["parent@magus", "old@magus"],
1107
+ PROJECT,
1108
+ reader,
1109
+ );
1110
+ expect(requiredBy["old@magus"]).toBe("parent@magus");
1111
+ const items = planPluginUpdates({}, catalog(), requiredBy);
1112
+ expect(action(items, "old@magus")).toBeDefined();
1113
+ expect(action(items, "old@magus")).not.toBe("remove");
1114
+ });
1115
+
1116
+ test("U12 the profile names it directly and it is still required → not remove", async () => {
1117
+ const requiredBy = await requiredByHere(
1118
+ undefined,
1119
+ ["parent@magus", "old@magus"],
1120
+ PROJECT,
1121
+ reader,
1122
+ );
1123
+ const items = planPluginUpdates(
1124
+ { "old@magus": "latest", "parent@magus": "latest" },
1125
+ catalog(),
1126
+ requiredBy,
1127
+ );
1128
+ expect(action(items, "old@magus")).toBeDefined();
1129
+ expect(action(items, "old@magus")).not.toBe("remove");
1130
+ });
1131
+
1132
+ test("U12 the profile names both and records the edge itself → not remove", () => {
1133
+ const items = planPluginUpdates(
1134
+ { "old@magus": "latest", "parent@magus": "latest" },
1135
+ catalog(),
1136
+ { "old@magus": "parent@magus" },
1137
+ );
1138
+ expect(action(items, "old@magus")).not.toBe("remove");
1139
+ });
1140
+ });
1141
+
1142
+ // ---------------------------------------------------------------------------
1143
+ // U13
1144
+ // ---------------------------------------------------------------------------
1145
+
1146
+ describe("U13 the popup fits the terminal height", () => {
1147
+ function bigReport(n: number): FailureReport {
1148
+ const rows: FailureRow[] = [];
1149
+ for (let i = 0; i < n; i++) {
1150
+ rows.push({
1151
+ subject: `plugin${String(i).padStart(2, "0")}`,
1152
+ reason: `reason number ${i}`,
1153
+ });
1154
+ }
1155
+ return { headline: `${n} plugins were not updated`, rows, fix: null };
1156
+ }
1157
+
1158
+ test("U13 visibleFailureRows: cut rows are counted, at least one shown", () => {
1159
+ const report = bigReport(30);
1160
+ const { rows, hidden } = visibleFailureRows(report, 80, 14);
1161
+ expect(rows.length).toBeGreaterThanOrEqual(1);
1162
+ expect(hidden).toBeGreaterThan(0);
1163
+ expect(rows.length + hidden).toBe(30);
1164
+ expect(rows.length).toBeLessThan(14);
1165
+ });
1166
+
1167
+ test("U13 visibleFailureRows without a height draws every row", () => {
1168
+ const { rows, hidden } = visibleFailureRows(bigReport(30), 80);
1169
+ expect(rows.length).toBe(30);
1170
+ expect(hidden).toBe(0);
1171
+ });
1172
+
1173
+ test("U13 rendered at 16 rows: headline visible, rows capped, ends with '+N more'", async () => {
1174
+ const height = 16;
1175
+ const report = bigReport(30);
1176
+ const lines = await renderPopup(report, 80, height, height);
1177
+ const frame = lines.join("\n");
1178
+ expect(frame).toContain("30 plugins were not updated");
1179
+ const shown = report.rows.filter((r) => frame.includes(r.subject)).length;
1180
+ expect(shown).toBeGreaterThanOrEqual(1);
1181
+ expect(shown).toBeLessThan(height);
1182
+ const more = frame.match(/\+(\d+) more/);
1183
+ expect(more).not.toBeNull();
1184
+ expect(Number(more?.[1])).toBe(30 - shown);
1185
+ // '+N more' comes after the last shown row.
1186
+ const lastShown = report.rows
1187
+ .filter((r) => frame.includes(r.subject))
1188
+ .pop();
1189
+ const idxMore = lines.findIndex((l) => /\+\d+ more/.test(l));
1190
+ const idxLast = lines.findIndex((l) =>
1191
+ l.includes(lastShown?.subject ?? "\0"),
1192
+ );
1193
+ expect(idxMore).toBeGreaterThan(idxLast);
1194
+ });
1195
+
1196
+ test("U13 rows that fit → no '+N more'", async () => {
1197
+ const report = bigReport(3);
1198
+ const lines = await renderPopup(report, 80, 30, 30);
1199
+ const frame = lines.join("\n");
1200
+ for (const r of report.rows) expect(frame).toContain(r.subject);
1201
+ expect(frame).not.toMatch(/\+\d+ more/);
1202
+ });
1203
+ });