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,1004 @@
1
+ /**
2
+ * magus 7.8.0 QA — failure reasons (F1, F2) and the failure popup (P1–P8).
3
+ *
4
+ * Written blind: from qa-spec-7.8.0.md §F1/F2, qa-spec-failures-popup.md, and
5
+ * the exported declarations of ui/adapters/pluginsAdapter.ts,
6
+ * ui/components/modals/FailureListModal.tsx, ui/state/types.ts,
7
+ * services/update-engine.ts, services/update-plan.ts and cli/update-view.ts
8
+ * (emitted with `tsc --emitDeclarationOnly`). No function body was read.
9
+ *
10
+ * Coverage map (requirement → describe block):
11
+ * F1 install lands, older version still loads → exact detail → "F1"
12
+ * F2 engine failure details carry no internal word → "F2"
13
+ * P1 headline counts, verb matches the action, "or" when mixed → "P1"
14
+ * P2 one entry per plugin or per same-reason group; all named → "P2"
15
+ * P3 an exception: its own entry, first line of its message → "P3"
16
+ * P4 one shared fix at the bottom, otherwise none → "P4"
17
+ * P5 plugins that succeeded are not listed → "P5"
18
+ * P6 entry reason is contained in the CLI ✗ row for that item → "P6"
19
+ * P7 no entry/headline carries an internal word → "P7"
20
+ * P8 at 50 columns names are not split, long reasons wrap → "P8"
21
+ *
22
+ * Failures are produced end to end: `applyPlugins` with injected
23
+ * `PluginApplyDeps`, its reporter fanned out to `failureReasons()` (the popup)
24
+ * and to `applyRow` (the CLI's ✗ row), exactly as the two front ends consume it.
25
+ */
26
+
27
+ import { describe, expect, test } from "bun:test";
28
+ import { testRender } from "@opentui/react/test-utils";
29
+ import { act, createElement } from "react";
30
+ import { applyRow } from "../cli/update-view.js";
31
+ import type { PluginScope } from "../services/claude-cli.js";
32
+ import type { StaleLoadRepairResult } from "../services/claude-settings.js";
33
+ import {
34
+ type ApplyReporter,
35
+ type FinishCause,
36
+ type PluginApplyDeps,
37
+ applyPlugins,
38
+ describeLoadNotForward,
39
+ pluginName,
40
+ } from "../services/update-engine.js";
41
+ import type {
42
+ PluginUpdateAction,
43
+ PluginUpdateItem,
44
+ } from "../services/update-plan.js";
45
+ import {
46
+ type PluginFailure,
47
+ failureReasons,
48
+ pluginFailureReport,
49
+ } from "../ui/adapters/pluginsAdapter.js";
50
+ import { FailureListModal } from "../ui/components/modals/FailureListModal.js";
51
+ import type { FailureReport } from "../ui/state/types.js";
52
+
53
+ const PROJECT = "/tmp/blind-780-project";
54
+
55
+ /** Words user-facing failure text must never contain (F2, P7). */
56
+ const FORBIDDEN: RegExp[] = [
57
+ /loader/i,
58
+ /registry/i,
59
+ /\brows?\b/i,
60
+ /\brecords?\b/i,
61
+ /installed_plugins/i,
62
+ /settings\.json/i,
63
+ /cache directory/i,
64
+ ];
65
+
66
+ function leaks(text: string): string[] {
67
+ return FORBIDDEN.filter((re) => re.test(text)).map((re) => re.source);
68
+ }
69
+
70
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
71
+ function plain(text: string): string {
72
+ return text.replace(ANSI, "");
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Plan items
77
+ // ---------------------------------------------------------------------------
78
+
79
+ function updateItem(
80
+ pluginId: string,
81
+ over: Partial<PluginUpdateItem> = {},
82
+ ): PluginUpdateItem {
83
+ return {
84
+ pluginId,
85
+ pinned: "latest",
86
+ installed: "1.0.0",
87
+ loaded: "1.0.0",
88
+ available: "2.0.0",
89
+ target: "2.0.0",
90
+ action: "update",
91
+ reason: "behind",
92
+ scopes: ["project"],
93
+ beforeVersions: { project: "1.0.0" },
94
+ ...over,
95
+ };
96
+ }
97
+
98
+ function installItem(
99
+ pluginId: string,
100
+ over: Partial<PluginUpdateItem> = {},
101
+ ): PluginUpdateItem {
102
+ return updateItem(pluginId, {
103
+ installed: null,
104
+ loaded: null,
105
+ action: "install",
106
+ reason: "absent",
107
+ scopes: [],
108
+ beforeVersions: {},
109
+ ...over,
110
+ });
111
+ }
112
+
113
+ function removeItem(
114
+ pluginId: string,
115
+ over: Partial<PluginUpdateItem> = {},
116
+ ): PluginUpdateItem {
117
+ return updateItem(pluginId, {
118
+ available: null,
119
+ target: null,
120
+ action: "remove",
121
+ reason: "deprecated",
122
+ scopes: ["project"],
123
+ beforeVersions: { project: "1.0.0" },
124
+ ...over,
125
+ });
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // A world of injected Claude Code calls, chosen per plugin
130
+ // ---------------------------------------------------------------------------
131
+
132
+ interface PluginWorld {
133
+ /** Version read back after the install/update lands. */
134
+ lands: string | null;
135
+ /** Version Claude Code loads here after the install, before any repair. */
136
+ loads: string | null;
137
+ /** What the stale-load repair answers. */
138
+ repair?: (target: string) => StaleLoadRepairResult;
139
+ /** Throw from install/update with this message. */
140
+ throws?: string;
141
+ /** remove: the plugin is still installed after the uninstall. */
142
+ stillInstalled?: boolean;
143
+ }
144
+
145
+ function depsFor(world: Record<string, PluginWorld>): PluginApplyDeps {
146
+ const w = (id: string): PluginWorld => {
147
+ const found = world[id];
148
+ if (!found) throw new Error(`no world for ${id}`);
149
+ return found;
150
+ };
151
+ const land = async (id: string, _scope: PluginScope) => {
152
+ const t = w(id).throws;
153
+ if (t) throw new Error(t);
154
+ };
155
+ return {
156
+ install: land,
157
+ update: land,
158
+ repair: land,
159
+ readInstalled: async (id) => w(id).lands,
160
+ saveInstalled: async () => {},
161
+ loadedVersion: async (id) => w(id).loads,
162
+ repairStaleLoad: async (id, target) =>
163
+ w(id).repair?.(target) ?? {
164
+ pluginId: id,
165
+ target,
166
+ rewritten: 0,
167
+ backup: null,
168
+ loaded: w(id).loads,
169
+ ok: w(id).loads === target,
170
+ ...(w(id).loads === target
171
+ ? {}
172
+ : { error: "rows in installed_plugins.json still behind" }),
173
+ },
174
+ remove: {
175
+ uninstall: async () => {},
176
+ isInstalled: async (id) => w(id).stillInstalled === true,
177
+ dropFromProfile: async () => true,
178
+ },
179
+ };
180
+ }
181
+
182
+ function refused(pluginId: string, error: string) {
183
+ return (target: string): StaleLoadRepairResult => ({
184
+ pluginId,
185
+ target,
186
+ rewritten: 0,
187
+ backup: null,
188
+ loaded: null,
189
+ ok: false,
190
+ error,
191
+ });
192
+ }
193
+
194
+ function recorder() {
195
+ const finished: Array<{
196
+ ok: boolean;
197
+ name: string;
198
+ detail: string;
199
+ cause?: FinishCause;
200
+ }> = [];
201
+ const reporter: ApplyReporter = {
202
+ begin: () => {},
203
+ finish: (ok, name, detail, _ms, cause) =>
204
+ finished.push({ ok, name, detail, cause }),
205
+ detach: (work) => work(),
206
+ };
207
+ return { reporter, finished };
208
+ }
209
+
210
+ function actionsOf(items: PluginUpdateItem[]) {
211
+ return new Map<string, PluginUpdateAction>(
212
+ items.map((i) => [i.pluginId, i.action]),
213
+ );
214
+ }
215
+
216
+ /**
217
+ * One apply, seen by both front ends: the popup (`failureReasons`) and the
218
+ * CLI's permanent `✗` row (`applyRow`), fed from the same reporter calls.
219
+ */
220
+ async function runBoth(
221
+ items: PluginUpdateItem[],
222
+ world: Record<string, PluginWorld>,
223
+ ) {
224
+ const fr = failureReasons();
225
+ const cliRows: Array<{ ok: boolean; name: string; row: string }> = [];
226
+ const reporter: ApplyReporter = {
227
+ begin: (name) => fr.reporter.begin(name),
228
+ finish: (ok, name, detail, ms, cause) => {
229
+ fr.reporter.finish(ok, name, detail, ms, cause);
230
+ cliRows.push({
231
+ ok,
232
+ name,
233
+ row: plain(applyRow(ok, name, 24, detail, ms)),
234
+ });
235
+ },
236
+ detach: (work) => work(),
237
+ };
238
+ const result = await applyPlugins(items, PROJECT, depsFor(world), reporter);
239
+ const report = fr.report(result.failed, actionsOf(items));
240
+ /** The CLI's ✗ row for a plugin id, whichever form of name the engine used. */
241
+ const cliRowFor = (id: string) =>
242
+ cliRows.find((r) => !r.ok && (r.name === id || r.name === pluginName(id)))
243
+ ?.row;
244
+ return { result, report, cliRows, cliRowFor };
245
+ }
246
+
247
+ /** Everything a report says, as the strings a user reads. */
248
+ function reportTexts(report: FailureReport): string[] {
249
+ return [
250
+ report.headline,
251
+ ...report.rows.flatMap((r) => [r.subject, r.reason]),
252
+ ...(report.fix ? [report.fix] : []),
253
+ ];
254
+ }
255
+
256
+ /** The row whose subject names `name` as a whole word. */
257
+ function rowNaming(report: FailureReport, name: string) {
258
+ const re = new RegExp(`(^|[^\\w-])${name}($|[^\\w-])`);
259
+ return report.rows.filter((r) => re.test(r.subject));
260
+ }
261
+
262
+ // Failure kinds the engine can produce, each with its own injected world.
263
+ type Kind = (id: string) => { item: PluginUpdateItem; world: PluginWorld };
264
+
265
+ const KINDS: Record<string, Kind> = {
266
+ "update lands, older version still loads": (id) => ({
267
+ item: updateItem(id),
268
+ world: { lands: "2.0.0", loads: "1.0.0" },
269
+ }),
270
+ "install lands, older version still loads": (id) => ({
271
+ item: installItem(id),
272
+ world: { lands: "2.0.0", loads: "1.4.0" },
273
+ }),
274
+ "load repair refused before writing": (id) => ({
275
+ item: updateItem(id),
276
+ world: {
277
+ lands: "2.0.0",
278
+ loads: "1.0.0",
279
+ repair: refused(id, "the folder could not be written"),
280
+ },
281
+ }),
282
+ "update threw": (id) => ({
283
+ item: updateItem(id),
284
+ world: {
285
+ lands: "2.0.0",
286
+ loads: "2.0.0",
287
+ throws: "network unreachable (ECONNRESET)",
288
+ },
289
+ }),
290
+ "version could not be read back": (id) => ({
291
+ item: updateItem(id),
292
+ world: { lands: null, loads: null },
293
+ }),
294
+ "deprecated plugin still installed after removal": (id) => ({
295
+ item: removeItem(id),
296
+ world: { lands: null, loads: null, stillInstalled: true },
297
+ }),
298
+ };
299
+
300
+ async function runKinds(spec: Array<[string, string]>) {
301
+ const items: PluginUpdateItem[] = [];
302
+ const world: Record<string, PluginWorld> = {};
303
+ for (const [kind, id] of spec) {
304
+ const make = KINDS[kind];
305
+ if (!make) throw new Error(`no kind ${kind}`);
306
+ const k = make(id);
307
+ items.push(k.item);
308
+ world[id] = k.world;
309
+ }
310
+ return runBoth(items, world);
311
+ }
312
+
313
+ async function renderPopup(
314
+ report: FailureReport,
315
+ width: number,
316
+ ): Promise<string[]> {
317
+ const { captureCharFrame, renderOnce, renderer } = await testRender(
318
+ createElement(FailureListModal, { report, terminalWidth: width }),
319
+ { width, height: 40 },
320
+ );
321
+ await act(async () => {
322
+ await renderOnce();
323
+ await renderOnce();
324
+ });
325
+ const lines = captureCharFrame().split("\n");
326
+ await act(async () => renderer.destroy());
327
+ return lines;
328
+ }
329
+
330
+ /** A frame's text with borders and all whitespace taken out. */
331
+ function flatten(lines: string[]): string {
332
+ return lines.join("").replace(/[\s│┃║╭╮╰╯─━┌┐└┘╔╗╚╝═]/g, "");
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // F1
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe("F1 an install that lands but does not load", () => {
340
+ test("F1 update: the failure detail is exactly the installed and the still-loading versions", async () => {
341
+ const deps = depsFor({ "dev@magus": { lands: "2.0.0", loads: "1.0.0" } });
342
+ const r = recorder();
343
+
344
+ const result = await applyPlugins(
345
+ [updateItem("dev@magus")],
346
+ PROJECT,
347
+ deps,
348
+ r.reporter,
349
+ );
350
+
351
+ expect(result.failed).toHaveLength(1);
352
+ expect(r.finished.filter((f) => !f.ok).map((f) => f.detail)).toEqual([
353
+ "installed v2.0.0, but Claude Code still loads v1.0.0 in this folder",
354
+ ]);
355
+ });
356
+
357
+ test("F1 install: the same exact detail", async () => {
358
+ const deps = depsFor({ "kit@magus": { lands: "3.1.0", loads: "2.9.4" } });
359
+ const r = recorder();
360
+
361
+ await applyPlugins(
362
+ [installItem("kit@magus", { target: "3.1.0", available: "3.1.0" })],
363
+ PROJECT,
364
+ deps,
365
+ r.reporter,
366
+ );
367
+
368
+ expect(r.finished.filter((f) => !f.ok).map((f) => f.detail)).toEqual([
369
+ "installed v3.1.0, but Claude Code still loads v2.9.4 in this folder",
370
+ ]);
371
+ });
372
+
373
+ test("F1 describeLoadNotForward names both versions exactly when the repair saw the old one still loading", () => {
374
+ const text = describeLoadNotForward({
375
+ pluginId: "dev@magus",
376
+ target: "2.0.0",
377
+ rewritten: 3,
378
+ backup: "/somewhere/backup",
379
+ loaded: "1.0.0",
380
+ ok: false,
381
+ error: "rows still behind in the registry",
382
+ });
383
+
384
+ expect(text).toBe(
385
+ "installed v2.0.0, but Claude Code still loads v1.0.0 in this folder",
386
+ );
387
+ });
388
+
389
+ test("F1 control: when the new version loads, the item is not a failure", async () => {
390
+ const deps = depsFor({ "dev@magus": { lands: "2.0.0", loads: "2.0.0" } });
391
+ const r = recorder();
392
+
393
+ const result = await applyPlugins(
394
+ [updateItem("dev@magus")],
395
+ PROJECT,
396
+ deps,
397
+ r.reporter,
398
+ );
399
+
400
+ expect(result.failed).toEqual([]);
401
+ expect(r.finished.every((f) => f.ok)).toBe(true);
402
+ });
403
+ });
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // F2
407
+ // ---------------------------------------------------------------------------
408
+
409
+ describe("F2 engine failure details are in the user's terms", () => {
410
+ test("F2 no reason-caused failure detail carries an internal word, across failure kinds", async () => {
411
+ const kinds = Object.keys(KINDS).filter((k) => k !== "update threw");
412
+ const items: PluginUpdateItem[] = [];
413
+ const world: Record<string, PluginWorld> = {};
414
+ kinds.forEach((kind, i) => {
415
+ const k = (KINDS[kind] as Kind)(`kind${i}@magus`);
416
+ items.push(k.item);
417
+ world[k.item.pluginId] = k.world;
418
+ });
419
+ const r = recorder();
420
+
421
+ await applyPlugins(items, PROJECT, depsFor(world), r.reporter);
422
+
423
+ const reasonFailures = r.finished.filter(
424
+ (f) => !f.ok && f.cause !== "exception",
425
+ );
426
+ expect(reasonFailures.length).toBeGreaterThanOrEqual(3);
427
+ for (const f of reasonFailures) {
428
+ expect({ detail: f.detail, leaks: leaks(f.detail) }).toEqual({
429
+ detail: f.detail,
430
+ leaks: [],
431
+ });
432
+ }
433
+ });
434
+
435
+ test("F2 a remove applied without the calls it needs fails in the user's terms", async () => {
436
+ const deps = depsFor({ "oldie@magus": { lands: null, loads: null } });
437
+ const { remove: _dropped, ...withoutRemove } = deps;
438
+ const r = recorder();
439
+
440
+ const result = await applyPlugins(
441
+ [removeItem("oldie@magus")],
442
+ PROJECT,
443
+ withoutRemove,
444
+ r.reporter,
445
+ );
446
+
447
+ expect(result.failed).toHaveLength(1);
448
+ const detail = r.finished.find((f) => !f.ok)?.detail ?? "";
449
+ expect(detail.length).toBeGreaterThan(0);
450
+ expect(leaks(detail)).toEqual([]);
451
+ });
452
+ });
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // P1 headline
456
+ // ---------------------------------------------------------------------------
457
+
458
+ describe("P1 the headline counts what did not happen", () => {
459
+ test("P1 one failed update → '<name> was not updated'", async () => {
460
+ const { report } = await runKinds([
461
+ ["update lands, older version still loads", "alpha@magus"],
462
+ ]);
463
+ expect(report?.headline).toBe("alpha was not updated");
464
+ });
465
+
466
+ test("P1 one failed install → '<name> was not installed'", async () => {
467
+ const { report } = await runKinds([
468
+ ["install lands, older version still loads", "kit@magus"],
469
+ ]);
470
+ expect(report?.headline).toBe("kit was not installed");
471
+ });
472
+
473
+ test("P1 one failed removal → '<name> was not removed'", async () => {
474
+ const { report } = await runKinds([
475
+ ["deprecated plugin still installed after removal", "oldie@magus"],
476
+ ]);
477
+ expect(report?.headline).toBe("oldie was not removed");
478
+ });
479
+
480
+ test("P1 three failed updates → '3 plugins were not updated'", async () => {
481
+ const { report } = await runKinds([
482
+ ["update lands, older version still loads", "alpha@magus"],
483
+ ["load repair refused before writing", "bravo@magus"],
484
+ ["update threw", "charlie@magus"],
485
+ ]);
486
+ expect(report?.headline).toBe("3 plugins were not updated");
487
+ });
488
+
489
+ test("P1 an install and an update failing → the two verbs joined with 'or'", async () => {
490
+ const { report } = await runKinds([
491
+ ["install lands, older version still loads", "kit@magus"],
492
+ ["update lands, older version still loads", "alpha@magus"],
493
+ ]);
494
+ expect(report?.headline).toMatch(
495
+ /^2 plugins were not (installed or updated|updated or installed)$/,
496
+ );
497
+ });
498
+
499
+ test("P1 install, update and remove failing → every verb named, joined with 'or'", async () => {
500
+ const { report } = await runKinds([
501
+ ["install lands, older version still loads", "kit@magus"],
502
+ ["update lands, older version still loads", "alpha@magus"],
503
+ ["deprecated plugin still installed after removal", "oldie@magus"],
504
+ ]);
505
+ const headline = report?.headline ?? "";
506
+ expect(headline).toStartWith("3 plugins were not ");
507
+ expect(headline).toContain("installed");
508
+ expect(headline).toContain("updated");
509
+ expect(headline).toContain("removed");
510
+ expect(headline).toContain(" or ");
511
+ });
512
+
513
+ test("P1 pure report: one failure's verb comes from that failure", () => {
514
+ const report = pluginFailureReport([
515
+ {
516
+ pluginId: "claudish@magus",
517
+ verb: "removed",
518
+ detail: "it is still installed for this project",
519
+ },
520
+ ]);
521
+ expect(report?.headline).toBe("claudish was not removed");
522
+ });
523
+ });
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // P2 entries
527
+ // ---------------------------------------------------------------------------
528
+
529
+ describe("P2 one entry per plugin or per same-reason group", () => {
530
+ test("P2 the same reason at different versions is one entry naming every plugin", async () => {
531
+ const { report, result } = await runBoth(
532
+ [
533
+ updateItem("alpha@magus", { target: "2.0.0" }),
534
+ updateItem("bravo@magus", { target: "5.4.0", available: "5.4.0" }),
535
+ updateItem("charlie@magus", { target: "0.3.1", available: "0.3.1" }),
536
+ ],
537
+ {
538
+ "alpha@magus": { lands: "2.0.0", loads: "1.0.0" },
539
+ "bravo@magus": { lands: "5.4.0", loads: "5.3.9" },
540
+ "charlie@magus": { lands: "0.3.1", loads: "0.2.0" },
541
+ },
542
+ );
543
+
544
+ expect(result.failed).toHaveLength(3);
545
+ expect(report?.rows).toHaveLength(1);
546
+ for (const name of ["alpha", "bravo", "charlie"]) {
547
+ expect(rowNaming(report as FailureReport, name)).toHaveLength(1);
548
+ }
549
+ expect(report?.rows[0]?.reason).toContain("Claude Code still loads");
550
+ });
551
+
552
+ test("P2 different reasons are different entries", async () => {
553
+ const { report } = await runKinds([
554
+ ["update lands, older version still loads", "alpha@magus"],
555
+ ["update lands, older version still loads", "bravo@magus"],
556
+ ["load repair refused before writing", "delta@magus"],
557
+ ]);
558
+
559
+ const r = report as FailureReport;
560
+ expect(r.rows).toHaveLength(2);
561
+ const grouped = rowNaming(r, "alpha");
562
+ const alone = rowNaming(r, "delta");
563
+ expect(grouped).toHaveLength(1);
564
+ expect(alone).toHaveLength(1);
565
+ expect(grouped[0]).not.toBe(alone[0]);
566
+ expect(rowNaming(r, "bravo")[0]).toBe(grouped[0]);
567
+ expect(grouped[0]?.subject).not.toContain("delta");
568
+ expect(alone[0]?.subject).not.toContain("alpha");
569
+ });
570
+
571
+ test("P2 every failed plugin name appears in the popup, across all failure kinds", async () => {
572
+ const spec = Object.keys(KINDS).map(
573
+ (kind, i) => [kind, `plug${i}@magus`] as [string, string],
574
+ );
575
+ const { report, result } = await runKinds(spec);
576
+
577
+ expect(result.failed.length).toBeGreaterThanOrEqual(5);
578
+ for (const id of result.failed) {
579
+ const name = pluginName(id);
580
+ expect({
581
+ name,
582
+ rows: rowNaming(report as FailureReport, name).length,
583
+ }).toEqual({ name, rows: 1 });
584
+ }
585
+ });
586
+
587
+ test("P2 pure report: same reason ignoring versions shares an entry; a different reason does not", () => {
588
+ const failures: PluginFailure[] = [
589
+ {
590
+ pluginId: "alpha@magus",
591
+ verb: "updated",
592
+ detail:
593
+ "installed v2.0.0, but Claude Code still loads v1.0.0 in this folder",
594
+ },
595
+ {
596
+ pluginId: "bravo@magus",
597
+ verb: "updated",
598
+ detail:
599
+ "installed v7.1.3, but Claude Code still loads v7.0.9 in this folder",
600
+ },
601
+ {
602
+ pluginId: "delta@magus",
603
+ verb: "updated",
604
+ detail: "the folder could not be written",
605
+ },
606
+ ];
607
+ const report = pluginFailureReport(failures) as FailureReport;
608
+
609
+ expect(report.rows).toHaveLength(2);
610
+ expect(rowNaming(report, "alpha")[0]).toBe(rowNaming(report, "bravo")[0]);
611
+ expect(rowNaming(report, "delta")[0]).not.toBe(
612
+ rowNaming(report, "alpha")[0],
613
+ );
614
+ });
615
+ });
616
+
617
+ // ---------------------------------------------------------------------------
618
+ // P3 exceptions
619
+ // ---------------------------------------------------------------------------
620
+
621
+ describe("P3 an unexpected exception", () => {
622
+ test("P3 gets its own entry, apart from plugins sharing a worded reason", async () => {
623
+ const { report } = await runKinds([
624
+ ["update lands, older version still loads", "alpha@magus"],
625
+ ["update lands, older version still loads", "bravo@magus"],
626
+ ["update threw", "omega@magus"],
627
+ ]);
628
+
629
+ const r = report as FailureReport;
630
+ expect(r.rows).toHaveLength(2);
631
+ const own = rowNaming(r, "omega");
632
+ expect(own).toHaveLength(1);
633
+ expect(own[0]?.subject).not.toContain("alpha");
634
+ expect(own[0]?.reason).toContain("network unreachable (ECONNRESET)");
635
+ });
636
+
637
+ test("P3 keeps only the FIRST line of a multi-line message", async () => {
638
+ const { report } = await runBoth([updateItem("omega@magus")], {
639
+ "omega@magus": {
640
+ lands: "2.0.0",
641
+ loads: "2.0.0",
642
+ throws:
643
+ "network unreachable (ECONNRESET)\n at fetchCatalog (/opt/x/fetch.js:12:7)\n at async main (/opt/x/main.js:3:1)",
644
+ },
645
+ });
646
+
647
+ const row = rowNaming(report as FailureReport, "omega")[0];
648
+ expect(row?.reason).toContain("network unreachable (ECONNRESET)");
649
+ expect(row?.reason).not.toContain("\n");
650
+ expect(row?.reason).not.toContain("fetchCatalog");
651
+ expect(row?.reason).not.toContain("main.js");
652
+ });
653
+
654
+ test("P3 two exceptions with the same message stay separate entries", async () => {
655
+ const { report } = await runBoth(
656
+ [updateItem("one@magus"), updateItem("two@magus")],
657
+ {
658
+ "one@magus": { lands: "2.0.0", loads: "2.0.0", throws: "boom" },
659
+ "two@magus": { lands: "2.0.0", loads: "2.0.0", throws: "boom" },
660
+ },
661
+ );
662
+
663
+ const r = report as FailureReport;
664
+ expect(r.rows).toHaveLength(2);
665
+ expect(rowNaming(r, "one")[0]).not.toBe(rowNaming(r, "two")[0]);
666
+ });
667
+
668
+ test("P3 pure report: an exception is not merged with a worded reason of the same text", () => {
669
+ const report = pluginFailureReport([
670
+ {
671
+ pluginId: "one@magus",
672
+ verb: "updated",
673
+ detail: "the folder could not be written",
674
+ },
675
+ {
676
+ pluginId: "two@magus",
677
+ verb: "updated",
678
+ detail: "the folder could not be written\nstack line",
679
+ exception: true,
680
+ },
681
+ ]) as FailureReport;
682
+
683
+ expect(report.rows).toHaveLength(2);
684
+ const two = rowNaming(report, "two")[0];
685
+ expect(two?.reason).not.toContain("stack line");
686
+ });
687
+ });
688
+
689
+ // ---------------------------------------------------------------------------
690
+ // P4 the shared fix
691
+ // ---------------------------------------------------------------------------
692
+
693
+ describe("P4 one fix, only when every entry shares it", () => {
694
+ /** Each failure kind's fix, from a report where two plugins fail that way. */
695
+ async function fixPerKind() {
696
+ const fixOf = new Map<string, string | null>();
697
+ for (const kind of Object.keys(KINDS)) {
698
+ const { report } = await runKinds([
699
+ [kind, "first@magus"],
700
+ [kind, "second@magus"],
701
+ ]);
702
+ if (report) fixOf.set(kind, report.fix);
703
+ }
704
+ return fixOf;
705
+ }
706
+
707
+ test("P4 the spec's example fix is offered for at least one kind of plugin failure", async () => {
708
+ // P4 cites `Run magus update to try again`; if no plugin failure ever
709
+ // carries a fix, the popup's fix line is unreachable from this screen.
710
+ const fixOf = await fixPerKind();
711
+ expect(fixOf.size).toBeGreaterThanOrEqual(5);
712
+ expect(
713
+ [...fixOf.entries()].filter(([, f]) => f !== null).map(([k]) => k),
714
+ ).not.toEqual([]);
715
+ });
716
+
717
+ test("P4 a mixed report's fix is the shared fix when both kinds have it, else none", async () => {
718
+ const fixOf = await fixPerKind();
719
+ // Every kind above is a failure the engine reports.
720
+ expect(fixOf.size).toBeGreaterThanOrEqual(5);
721
+
722
+ const mismatches: string[] = [];
723
+ const known = [...fixOf.keys()];
724
+ for (const a of known) {
725
+ for (const b of known) {
726
+ if (a >= b) continue;
727
+ const { report } = await runKinds([
728
+ [a, "first@magus"],
729
+ [b, "second@magus"],
730
+ ]);
731
+ const fa = fixOf.get(a) ?? null;
732
+ const fb = fixOf.get(b) ?? null;
733
+ const expected = fa === fb ? fa : null;
734
+ if ((report?.fix ?? null) !== expected) {
735
+ mismatches.push(
736
+ `${a} + ${b}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(report?.fix)}`,
737
+ );
738
+ }
739
+ }
740
+ }
741
+ expect(mismatches).toEqual([]);
742
+ });
743
+
744
+ test("P4 a fix is not repeated inside any entry", async () => {
745
+ for (const kind of Object.keys(KINDS)) {
746
+ const { report } = await runKinds([
747
+ [kind, "first@magus"],
748
+ [kind, "second@magus"],
749
+ ]);
750
+ if (!report?.fix) continue;
751
+ for (const row of report.rows) {
752
+ expect(row.reason).not.toContain(report.fix);
753
+ }
754
+ }
755
+ });
756
+
757
+ test("P4 rendered: the fix appears once, below every entry", async () => {
758
+ const fix = "Run magus update to try again";
759
+ const lines = await renderPopup(
760
+ {
761
+ headline: "3 plugins were not updated",
762
+ rows: [
763
+ { subject: "alpha, bravo", reason: "the download was interrupted" },
764
+ { subject: "charlie", reason: "the folder could not be written" },
765
+ ],
766
+ fix,
767
+ },
768
+ 100,
769
+ );
770
+
771
+ const at = lines.flatMap((l, i) => (l.includes(fix) ? [i] : []));
772
+ expect(at).toHaveLength(1);
773
+ const fixLine = at[0] as number;
774
+ for (const needle of [
775
+ "alpha",
776
+ "charlie",
777
+ "the download was interrupted",
778
+ "the folder could not be written",
779
+ ]) {
780
+ const idx = lines.findIndex((l) => l.includes(needle));
781
+ expect({ needle, before: idx >= 0 && idx < fixLine }).toEqual({
782
+ needle,
783
+ before: true,
784
+ });
785
+ }
786
+ });
787
+
788
+ test("P4 rendered: no fix → no fix line", async () => {
789
+ const lines = await renderPopup(
790
+ {
791
+ headline: "2 plugins were not updated",
792
+ rows: [
793
+ { subject: "alpha", reason: "the download was interrupted" },
794
+ { subject: "charlie", reason: "the folder could not be written" },
795
+ ],
796
+ fix: null,
797
+ },
798
+ 100,
799
+ );
800
+ const text = lines.join("\n");
801
+ expect(text).toContain("alpha");
802
+ expect(text).not.toContain("try again");
803
+ expect(text).not.toContain("Run magus");
804
+ });
805
+ });
806
+
807
+ // ---------------------------------------------------------------------------
808
+ // P5 successes
809
+ // ---------------------------------------------------------------------------
810
+
811
+ describe("P5 plugins that succeeded are not listed", () => {
812
+ test("P5 only the failed plugin is named; the headline counts only it", async () => {
813
+ const { report } = await runBoth(
814
+ [
815
+ updateItem("alpha@magus"),
816
+ updateItem("bravo@magus"),
817
+ installItem("charlie@magus"),
818
+ ],
819
+ {
820
+ "alpha@magus": { lands: "2.0.0", loads: "1.0.0" },
821
+ "bravo@magus": { lands: "2.0.0", loads: "2.0.0" },
822
+ "charlie@magus": { lands: "2.0.0", loads: "2.0.0" },
823
+ },
824
+ );
825
+
826
+ const r = report as FailureReport;
827
+ expect(r.headline).toBe("alpha was not updated");
828
+ const all = reportTexts(r).join("\n");
829
+ expect(all).not.toContain("bravo");
830
+ expect(all).not.toContain("charlie");
831
+ });
832
+
833
+ test("P5 control: nothing failed → no popup", async () => {
834
+ const { report } = await runBoth([updateItem("alpha@magus")], {
835
+ "alpha@magus": { lands: "2.0.0", loads: "2.0.0" },
836
+ });
837
+ expect(report).toBeNull();
838
+ });
839
+ });
840
+
841
+ // ---------------------------------------------------------------------------
842
+ // P6 one source of wording
843
+ // ---------------------------------------------------------------------------
844
+
845
+ describe("P6 an entry's reason is the CLI's ✗ row wording for that item", () => {
846
+ test("P6 each single-plugin entry's reason is contained in that plugin's CLI ✗ row", async () => {
847
+ const spec = Object.keys(KINDS).map(
848
+ (kind, i) => [kind, `plug${i}@magus`] as [string, string],
849
+ );
850
+ const { report, result, cliRowFor } = await runKinds(spec);
851
+
852
+ const problems: string[] = [];
853
+ for (const id of result.failed) {
854
+ const name = pluginName(id);
855
+ const row = rowNaming(report as FailureReport, name)[0];
856
+ const cli = cliRowFor(id);
857
+ if (!row || !cli) {
858
+ problems.push(`${name}: entry ${!!row}, cli row ${!!cli}`);
859
+ continue;
860
+ }
861
+ // A group at differing versions drops them (P2); that is not this test.
862
+ const shared = result.failed.filter(
863
+ (other) =>
864
+ other !== id &&
865
+ rowNaming(report as FailureReport, pluginName(other))[0] === row,
866
+ );
867
+ if (shared.length > 0) continue;
868
+ if (!cli.includes(row.reason)) {
869
+ problems.push(`${name}: "${row.reason}" not in "${cli}"`);
870
+ }
871
+ }
872
+ expect(problems).toEqual([]);
873
+ });
874
+
875
+ test("P6 plugins grouped at the SAME versions: the shared reason is in each one's CLI row", async () => {
876
+ const { report, cliRowFor } = await runKinds([
877
+ ["update lands, older version still loads", "alpha@magus"],
878
+ ["update lands, older version still loads", "bravo@magus"],
879
+ ]);
880
+
881
+ const r = report as FailureReport;
882
+ expect(r.rows).toHaveLength(1);
883
+ const reason = r.rows[0]?.reason ?? "";
884
+ expect(cliRowFor("alpha@magus")).toContain(reason);
885
+ expect(cliRowFor("bravo@magus")).toContain(reason);
886
+ });
887
+ });
888
+
889
+ // ---------------------------------------------------------------------------
890
+ // P7 internal words
891
+ // ---------------------------------------------------------------------------
892
+
893
+ describe("P7 the popup names no internals", () => {
894
+ test("P7 no headline, subject, reason or fix carries an internal word, across worded failures", async () => {
895
+ const spec = Object.keys(KINDS)
896
+ .filter((k) => k !== "update threw")
897
+ .map((kind, i) => [kind, `plug${i}@magus`] as [string, string]);
898
+ const { report } = await runKinds(spec);
899
+
900
+ const texts = reportTexts(report as FailureReport);
901
+ expect(texts.length).toBeGreaterThan(2);
902
+ for (const t of texts) {
903
+ expect({ text: t, leaks: leaks(t) }).toEqual({ text: t, leaks: [] });
904
+ }
905
+ });
906
+
907
+ test("P7 each single-kind report is clean, including its fix", async () => {
908
+ for (const kind of Object.keys(KINDS)) {
909
+ const { report } = await runKinds([
910
+ [kind, "first@magus"],
911
+ [kind, "second@magus"],
912
+ ]);
913
+ if (!report) continue;
914
+ for (const t of reportTexts(report)) {
915
+ expect({ kind, text: t, leaks: leaks(t) }).toEqual({
916
+ kind,
917
+ text: t,
918
+ leaks: [],
919
+ });
920
+ }
921
+ }
922
+ });
923
+ });
924
+
925
+ // ---------------------------------------------------------------------------
926
+ // P8 rendering at 50 columns
927
+ // ---------------------------------------------------------------------------
928
+
929
+ describe("P8 the popup at 50 columns", () => {
930
+ const names = [
931
+ "design-tokens-kit",
932
+ "storybook-bridge",
933
+ "screenshot-differ",
934
+ "component-auditor",
935
+ ];
936
+
937
+ test("P8 plugin names sharing a long subject are each drawn whole on one line", async () => {
938
+ const report = pluginFailureReport(
939
+ names.map((n) => ({
940
+ pluginId: `${n}@magus`,
941
+ verb: "updated" as const,
942
+ detail:
943
+ "installed v2.0.0, but Claude Code still loads v1.0.0 in this folder",
944
+ })),
945
+ ) as FailureReport;
946
+ // The subject is wider than the popup can be, so it must break somewhere.
947
+ expect(report.rows).toHaveLength(1);
948
+ expect(report.rows[0]?.subject.length).toBeGreaterThan(50);
949
+
950
+ const lines = await renderPopup(report, 50);
951
+
952
+ for (const n of names) {
953
+ expect({ name: n, whole: lines.some((l) => l.includes(n)) }).toEqual({
954
+ name: n,
955
+ whole: true,
956
+ });
957
+ }
958
+ });
959
+
960
+ test("P8 a long reason wraps onto several lines with nothing cut off", async () => {
961
+ const reason =
962
+ "the plugin catalogue could not be fetched because the remote host answered with an unexpected status while this project folder was being refreshed";
963
+ const report = pluginFailureReport([
964
+ {
965
+ pluginId: "design-tokens-kit@magus",
966
+ verb: "updated",
967
+ detail: reason,
968
+ exception: true,
969
+ },
970
+ ]) as FailureReport;
971
+
972
+ const lines = await renderPopup(report, 50);
973
+
974
+ expect(flatten(lines)).toContain(reason.replace(/\s/g, ""));
975
+ const first = lines.findIndex((l) => l.includes("catalogue"));
976
+ const last = lines.findIndex((l) => l.includes("refreshed"));
977
+ expect(first).toBeGreaterThanOrEqual(0);
978
+ expect(last).toBeGreaterThan(first);
979
+ expect(lines.some((l) => l.includes("design-tokens-kit"))).toBe(true);
980
+ });
981
+
982
+ test("P8 the popup fits in 50 columns: nothing drawn is cut at the right edge", async () => {
983
+ const report = pluginFailureReport(
984
+ names.map((n, i) => ({
985
+ pluginId: `${n}@magus`,
986
+ verb: "updated" as const,
987
+ detail:
988
+ i % 2 === 0
989
+ ? "installed v2.0.0, but Claude Code still loads v1.0.0 in this folder"
990
+ : "the folder could not be written because another program holds it open",
991
+ })),
992
+ ) as FailureReport;
993
+
994
+ const lines = await renderPopup(report, 50);
995
+ const flat = flatten(lines);
996
+
997
+ for (const t of [report.headline, ...report.rows.map((r) => r.reason)]) {
998
+ expect({ text: t, drawn: flat.includes(t.replace(/\s/g, "")) }).toEqual({
999
+ text: t,
1000
+ drawn: true,
1001
+ });
1002
+ }
1003
+ });
1004
+ });