magus-cli 7.6.0 → 7.7.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.
@@ -0,0 +1,604 @@
1
+ /**
2
+ * Spec "no confirmation screens in the magus TUI" — rules 1, 3, 4, 5: results become
3
+ * non-blocking notice lines instead of success modals.
4
+ *
5
+ * Written blind: from the spec and the exported signatures and types only
6
+ * (reducer actions NOTIFY / EXPIRE_NOTICES, `useNotify`, `NOTICE_DURATION_MS`, and the
7
+ * three pure wording functions). No implementation body was read.
8
+ *
9
+ * Coverage map (requirement → describe block):
10
+ * REQ-3a notify adds a notice → "REQ-3 reducer"
11
+ * REQ-3b same text replaces, never stacks → "REQ-3 reducer"
12
+ * REQ-3c at most MAX_NOTICES, newest kept → "REQ-3 reducer"
13
+ * REQ-3d expiry drops only expired notices → "REQ-3 reducer"
14
+ * REQ-3e warnings stay longer than successes → "REQ-3 durations", "REQ-3 useNotify"
15
+ * REQ-3f disappears by itself (no key) → reducer expiry only (see below)
16
+ * REQ-5a activation → `Using profile "<id>"` → "REQ-5 activationNotices"
17
+ * REQ-5b backup ("moved aside") lines dropped → "REQ-5 activationNotices"
18
+ * REQ-5c missing plugin → warning, short name, `magus install`, one line if several
19
+ * REQ-4 wording: one line, no internals → "REQ-4 wording"
20
+ * REQ-1 gitignore fix / stale-load repair results are notices with a tone
21
+ *
22
+ * Not covered: rendering of NoticeBanner and "never takes keys" — the banner is not
23
+ * exported and is only reachable by rendering the whole App, which reads the machine's
24
+ * real plugin state. For the same reason the timer that dispatches EXPIRE_NOTICES is not
25
+ * exercised: the contract does not say which component owns it, and under AppProvider
26
+ * alone a success notice was still present after its duration.
27
+ *
28
+ * Rule 4 lets a notice name a file the user must act on. Untracking generated files
29
+ * leaves a staged removal the user has to commit, so the gitignore fix may name those
30
+ * files, and may word that line as a warning (rule 6: a result the user must act on).
31
+ */
32
+ import { afterAll, describe, expect, test } from "bun:test";
33
+ import { mkdtempSync, rmSync } from "node:fs";
34
+ import { tmpdir } from "node:os";
35
+ import path from "node:path";
36
+ import { testRender } from "@opentui/react/test-utils";
37
+ import { act, useEffect } from "react";
38
+ import type { StartupFixResult } from "../services/gitignore-service.js";
39
+ import type { ActivationResult } from "../services/profile-activate.js";
40
+ import { startupFixNotices } from "../ui/hooks/useGitignoreModal.js";
41
+ import { activationNotices } from "../ui/hooks/useProfileStartupModal.js";
42
+ import { staleLoadRepairNotices } from "../ui/hooks/useStaleLoadModal.js";
43
+ import {
44
+ AppProvider,
45
+ NOTICE_DURATION_MS,
46
+ useApp,
47
+ useNotify,
48
+ } from "../ui/state/AppContext.js";
49
+ import { MAX_NOTICES, appReducer, initialState } from "../ui/state/reducer.js";
50
+ import type {
51
+ AppState,
52
+ Notice,
53
+ NoticeLine,
54
+ NoticeTone,
55
+ } from "../ui/state/types.js";
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Builders
59
+ // ---------------------------------------------------------------------------
60
+
61
+ let nextId = 1;
62
+ function notice(
63
+ text: string,
64
+ tone: NoticeTone = "success",
65
+ expiresAt = 10_000,
66
+ ): Notice {
67
+ return { id: nextId++, text, tone, expiresAt };
68
+ }
69
+
70
+ function stateWith(notices: Notice[] = []): AppState {
71
+ return { ...initialState, notices };
72
+ }
73
+
74
+ function notify(state: AppState, n: Notice): AppState {
75
+ return appReducer(state, { type: "NOTIFY", notice: n });
76
+ }
77
+
78
+ function activation(
79
+ overrides: Partial<ActivationResult> = {},
80
+ ): ActivationResult {
81
+ return {
82
+ profileId: "magus-main",
83
+ displaced: [],
84
+ skills: { skills: {}, installed: [], removed: [], failed: [] },
85
+ conversion: {
86
+ converted: false,
87
+ managed: {},
88
+ restored: [],
89
+ conflicts: [],
90
+ keptAt: null,
91
+ },
92
+ tracked: [],
93
+ keptAt: null,
94
+ ...overrides,
95
+ };
96
+ }
97
+
98
+ /** The spec's own example: a file moved aside into the backup folder. */
99
+ const DISPLACED = [
100
+ {
101
+ link: ".claude/settings.json",
102
+ savedTo: ".claude/.magus-backups/2026-09-28T04-06-00-138Z/settings.json",
103
+ },
104
+ ];
105
+
106
+ const FORBIDDEN = [
107
+ ".magus-backups",
108
+ "plugin.json",
109
+ "settings.json",
110
+ "installed_plugins",
111
+ "registry",
112
+ "marker",
113
+ ];
114
+
115
+ /** `allowed`: file names the user must act on, which rule 4 permits naming. */
116
+ function expectUserTerms(
117
+ lines: readonly NoticeLine[],
118
+ allowed: readonly string[] = [],
119
+ ): void {
120
+ for (const line of lines) {
121
+ expect(line.text.length).toBeGreaterThan(0);
122
+ expect(line.text).not.toContain("\n");
123
+ expect(["success", "warning"]).toContain(line.tone);
124
+ for (const word of FORBIDDEN.filter((w) => !allowed.includes(w))) {
125
+ expect({ text: line.text, leaks: line.text.includes(word) }).toEqual({
126
+ text: line.text,
127
+ leaks: false,
128
+ });
129
+ }
130
+ }
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // REQ-3 reducer
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("REQ-3 reducer: NOTIFY", () => {
138
+ test("REQ-3a adds the notice to an empty banner", () => {
139
+ const n = notice("Profile saved");
140
+
141
+ const next = notify(stateWith(), n);
142
+
143
+ expect(next.notices).toEqual([n]);
144
+ });
145
+
146
+ test("REQ-3a keeps earlier distinct notices, oldest first", () => {
147
+ const a = notice("first");
148
+ const b = notice("second", "warning");
149
+
150
+ const next = notify(notify(stateWith(), a), b);
151
+
152
+ expect(next.notices).toEqual([a, b]);
153
+ });
154
+
155
+ test("REQ-3b the same text replaces the earlier notice instead of stacking", () => {
156
+ const first = notice('Using profile "magus-main"', "success", 1_000);
157
+ const again = notice('Using profile "magus-main"', "success", 9_000);
158
+
159
+ const next = notify(notify(stateWith(), first), again);
160
+
161
+ expect(next.notices).toEqual([again]);
162
+ });
163
+
164
+ test("REQ-3b same text replaces while other notices stay", () => {
165
+ const other = notice("other");
166
+ const first = notice("repeat");
167
+ const again = notice("repeat", "warning", 20_000);
168
+
169
+ const next = notify(notify(notify(stateWith(), other), first), again);
170
+
171
+ expect(next.notices.filter((n) => n.text === "repeat")).toEqual([again]);
172
+ expect(next.notices.map((n) => n.text).sort()).toEqual(["other", "repeat"]);
173
+ });
174
+
175
+ test("REQ-3c MAX_NOTICES is 4", () => {
176
+ expect(MAX_NOTICES).toBe(4);
177
+ });
178
+
179
+ test("REQ-3c a fifth distinct notice drops the oldest one", () => {
180
+ const five = ["one", "two", "three", "four", "five"].map((t) => notice(t));
181
+
182
+ const next = five.reduce(notify, stateWith());
183
+
184
+ expect(next.notices).toEqual(five.slice(1));
185
+ });
186
+ });
187
+
188
+ describe("REQ-3 reducer: EXPIRE_NOTICES", () => {
189
+ test("REQ-3d removes only the notices whose time has passed", () => {
190
+ const expired = notice("old", "success", 1_000);
191
+ const live = notice("new", "warning", 5_000);
192
+
193
+ const next = appReducer(stateWith([expired, live]), {
194
+ type: "EXPIRE_NOTICES",
195
+ now: 3_000,
196
+ });
197
+
198
+ expect(next.notices).toEqual([live]);
199
+ });
200
+
201
+ test("REQ-3d removes every notice once all have expired", () => {
202
+ const next = appReducer(
203
+ stateWith([notice("a", "success", 1_000), notice("b", "warning", 2_000)]),
204
+ { type: "EXPIRE_NOTICES", now: 50_000 },
205
+ );
206
+
207
+ expect(next.notices).toEqual([]);
208
+ });
209
+
210
+ test("REQ-3d returns the very same state object when nothing expired", () => {
211
+ const state = stateWith([notice("a", "success", 5_000)]);
212
+
213
+ const next = appReducer(state, { type: "EXPIRE_NOTICES", now: 1_000 });
214
+
215
+ expect(next).toBe(state);
216
+ });
217
+
218
+ test("REQ-3d returns the very same state object when there are no notices", () => {
219
+ const state = stateWith();
220
+
221
+ const next = appReducer(state, { type: "EXPIRE_NOTICES", now: 1_000 });
222
+
223
+ expect(next).toBe(state);
224
+ });
225
+ });
226
+
227
+ describe("REQ-3 durations", () => {
228
+ test("REQ-3e a warning stays visible longer than a success", () => {
229
+ expect(NOTICE_DURATION_MS.warning).toBeGreaterThan(
230
+ NOTICE_DURATION_MS.success,
231
+ );
232
+ });
233
+
234
+ test("REQ-3e a success disappears within a few seconds", () => {
235
+ expect(NOTICE_DURATION_MS.success).toBeGreaterThan(0);
236
+ expect(NOTICE_DURATION_MS.success).toBeLessThanOrEqual(10_000);
237
+ });
238
+
239
+ test("REQ-3e posted together, the success expires while the warning is still shown", () => {
240
+ const posted = 100_000;
241
+ const ok = notice("done", "success", posted + NOTICE_DURATION_MS.success);
242
+ const warn = notice(
243
+ "careful",
244
+ "warning",
245
+ posted + NOTICE_DURATION_MS.warning,
246
+ );
247
+
248
+ const next = appReducer(stateWith([ok, warn]), {
249
+ type: "EXPIRE_NOTICES",
250
+ now: posted + NOTICE_DURATION_MS.success + 1,
251
+ });
252
+
253
+ expect(next.notices).toEqual([warn]);
254
+ });
255
+ });
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // REQ-3 useNotify, through the real provider
259
+ // ---------------------------------------------------------------------------
260
+
261
+ const scratch = mkdtempSync(path.join(tmpdir(), "magus-notices-"));
262
+ afterAll(() => rmSync(scratch, { recursive: true, force: true }));
263
+
264
+ interface Posted {
265
+ text: string;
266
+ tone?: NoticeTone;
267
+ }
268
+
269
+ /** Renders a provider whose child posts `posts` on mount; `seen()` reads the banner state. */
270
+ async function withProvider(posts: Posted[]) {
271
+ let current: Notice[] = [];
272
+ let modal: unknown = "never rendered";
273
+ function Probe() {
274
+ const notifyFn = useNotify();
275
+ const { state } = useApp();
276
+ current = state.notices;
277
+ modal = state.modal;
278
+ useEffect(() => {
279
+ for (const p of posts) {
280
+ if (p.tone === undefined) notifyFn(p.text);
281
+ else notifyFn(p.text, p.tone);
282
+ }
283
+ }, [notifyFn, posts]);
284
+ return <text>probe</text>;
285
+ }
286
+ const before = Date.now();
287
+ const { renderOnce, renderer } = await testRender(
288
+ <AppProvider initialProjectPath={scratch}>
289
+ <Probe />
290
+ </AppProvider>,
291
+ { width: 40, height: 5 },
292
+ );
293
+ await act(async () => {
294
+ await renderOnce();
295
+ await renderOnce();
296
+ });
297
+ const after = Date.now();
298
+ return {
299
+ before,
300
+ after,
301
+ seen: () => current,
302
+ modal: () => modal,
303
+ destroy: () => act(async () => renderer.destroy()),
304
+ };
305
+ }
306
+
307
+ describe("REQ-3 useNotify", () => {
308
+ test("REQ-3a notify without a tone posts a success that expires after the success duration", async () => {
309
+ const h = await withProvider([{ text: "Plugin updated" }]);
310
+
311
+ const seen = h.seen();
312
+ await h.destroy();
313
+
314
+ expect(seen.map((n) => ({ text: n.text, tone: n.tone }))).toEqual([
315
+ { text: "Plugin updated", tone: "success" },
316
+ ]);
317
+ expect(seen[0].expiresAt).toBeGreaterThanOrEqual(
318
+ h.before + NOTICE_DURATION_MS.success,
319
+ );
320
+ expect(seen[0].expiresAt).toBeLessThanOrEqual(
321
+ h.after + NOTICE_DURATION_MS.success,
322
+ );
323
+ });
324
+
325
+ test("REQ-3e a warning notice expires after the longer warning duration", async () => {
326
+ const h = await withProvider([
327
+ { text: "typescript-lsp isn't downloaded yet", tone: "warning" },
328
+ ]);
329
+
330
+ const seen = h.seen();
331
+ await h.destroy();
332
+
333
+ expect(seen).toHaveLength(1);
334
+ expect(seen[0].tone).toBe("warning");
335
+ expect(seen[0].expiresAt).toBeGreaterThanOrEqual(
336
+ h.before + NOTICE_DURATION_MS.warning,
337
+ );
338
+ expect(seen[0].expiresAt).toBeLessThanOrEqual(
339
+ h.after + NOTICE_DURATION_MS.warning,
340
+ );
341
+ });
342
+
343
+ test("REQ-3b notifying the same text twice shows it once", async () => {
344
+ const h = await withProvider([{ text: "Saved" }, { text: "Saved" }]);
345
+
346
+ const seen = h.seen();
347
+ await h.destroy();
348
+
349
+ expect(seen.map((n) => n.text)).toEqual(["Saved"]);
350
+ });
351
+
352
+ test("REQ-3 a notice never occupies the modal slot", async () => {
353
+ const h = await withProvider([{ text: "Done", tone: "warning" }]);
354
+
355
+ const seen = h.seen();
356
+ const modal = h.modal();
357
+ await h.destroy();
358
+
359
+ expect(seen).toHaveLength(1);
360
+ expect(modal).toBeNull();
361
+ });
362
+ });
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // REQ-5 activationNotices
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe("REQ-5 activationNotices", () => {
369
+ test('REQ-5a a clean activation of magus-main is one success line: Using profile "magus-main"', () => {
370
+ const lines = activationNotices(activation());
371
+
372
+ expect(lines).toHaveLength(1);
373
+ expect(lines[0].tone).toBe("success");
374
+ expect(lines[0].text).toContain('Using profile "magus-main"');
375
+ });
376
+
377
+ test("REQ-5a the success line names the activated profile, whatever it is", () => {
378
+ const lines = activationNotices(activation({ profileId: "frontend" }));
379
+
380
+ const success = lines.filter((l) => l.tone === "success");
381
+ expect(
382
+ success.some((l) => l.text.includes('Using profile "frontend"')),
383
+ ).toBe(true);
384
+ expect(lines.some((l) => l.text.includes("magus-main"))).toBe(false);
385
+ });
386
+
387
+ test("REQ-5b files moved aside at activation add nothing to the notices", () => {
388
+ const clean = activationNotices(activation());
389
+
390
+ const withBackup = activationNotices(activation({ displaced: DISPLACED }));
391
+
392
+ expect(withBackup).toEqual(clean);
393
+ for (const line of withBackup) {
394
+ expect(line.text).not.toContain(".magus-backups");
395
+ expect(line.text.toLowerCase()).not.toContain("moved aside");
396
+ }
397
+ });
398
+
399
+ test("REQ-5c a plugin whose details are missing is a warning naming its short name and magus install", () => {
400
+ const lines = activationNotices(
401
+ activation({
402
+ dependencies: {
403
+ derived: [],
404
+ unreadable: ["typescript-lsp@claude-plugins-official"],
405
+ blocked: [],
406
+ },
407
+ }),
408
+ );
409
+
410
+ const warnings = lines.filter((l) => l.tone === "warning");
411
+ expect(warnings).toHaveLength(1);
412
+ expect(warnings[0].text).toContain("typescript-lsp");
413
+ expect(warnings[0].text).toContain("magus install");
414
+ expect(warnings[0].text).not.toContain("@claude-plugins-official");
415
+ expect(warnings[0].text).not.toContain("plugin.json");
416
+ });
417
+
418
+ test("REQ-5c a missing plugin still reports the profile as in use", () => {
419
+ const lines = activationNotices(
420
+ activation({
421
+ dependencies: {
422
+ derived: [],
423
+ unreadable: ["typescript-lsp@claude-plugins-official"],
424
+ blocked: [],
425
+ },
426
+ }),
427
+ );
428
+
429
+ expect(
430
+ lines.some(
431
+ (l) =>
432
+ l.tone === "success" && l.text.includes('Using profile "magus-main"'),
433
+ ),
434
+ ).toBe(true);
435
+ });
436
+
437
+ test("REQ-5c several missing plugins combine into one warning line naming each", () => {
438
+ const lines = activationNotices(
439
+ activation({
440
+ dependencies: {
441
+ derived: [],
442
+ unreadable: [
443
+ "typescript-lsp@claude-plugins-official",
444
+ "gopls-lsp@claude-plugins-official",
445
+ "code-search@magus",
446
+ ],
447
+ blocked: [],
448
+ },
449
+ }),
450
+ );
451
+
452
+ const install = lines.filter((l) => l.text.includes("magus install"));
453
+ expect(install).toHaveLength(1);
454
+ expect(install[0].tone).toBe("warning");
455
+ for (const name of ["typescript-lsp", "gopls-lsp", "code-search"]) {
456
+ expect(install[0].text).toContain(name);
457
+ }
458
+ expect(install[0].text).not.toContain("@claude-plugins-official");
459
+ expect(install[0].text).not.toContain("@magus");
460
+ });
461
+ });
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // REQ-1 result notices for the gitignore fix and the stale-load repair
465
+ // ---------------------------------------------------------------------------
466
+
467
+ describe("REQ-1 startupFixNotices", () => {
468
+ test("REQ-1 adding missing ignore entries, with nothing to commit, yields success notices only", () => {
469
+ const lines = startupFixNotices({
470
+ untracked: [],
471
+ appended: [".claude/settings.json", ".mcp.json"],
472
+ failed: [],
473
+ });
474
+
475
+ expect(lines.length).toBeGreaterThan(0);
476
+ expect(lines.every((l) => l.tone === "success")).toBe(true);
477
+ });
478
+
479
+ test("REQ-1 untracking generated files yields at least one notice", () => {
480
+ const lines = startupFixNotices({
481
+ untracked: [".claude/settings.json", ".mcp.json"],
482
+ appended: [".claude/settings.json", ".mcp.json"],
483
+ failed: [],
484
+ });
485
+
486
+ expect(lines.length).toBeGreaterThan(0);
487
+ });
488
+
489
+ test("REQ-1 a fix with a file that could not be untracked yields a warning", () => {
490
+ const lines = startupFixNotices({
491
+ untracked: [".claude/settings.json"],
492
+ appended: [],
493
+ failed: [{ path: ".mcp.json", reason: "pathspec did not match" }],
494
+ });
495
+
496
+ expect(lines.some((l) => l.tone === "warning")).toBe(true);
497
+ });
498
+ });
499
+
500
+ describe("REQ-1 staleLoadRepairNotices", () => {
501
+ test("REQ-1 every repair succeeding yields success notices only", () => {
502
+ const lines = staleLoadRepairNotices([
503
+ { pluginId: "code-search@magus", target: "2.3.0", ok: true },
504
+ { pluginId: "dev@magus", target: "3.1.0", ok: true },
505
+ ]);
506
+
507
+ expect(lines.length).toBeGreaterThan(0);
508
+ expect(lines.every((l) => l.tone === "success")).toBe(true);
509
+ });
510
+
511
+ test("REQ-1 a repair that failed yields a warning", () => {
512
+ const lines = staleLoadRepairNotices([
513
+ { pluginId: "code-search@magus", target: "2.3.0", ok: true },
514
+ {
515
+ pluginId: "dev@magus",
516
+ target: "3.1.0",
517
+ ok: false,
518
+ error: "permission denied",
519
+ },
520
+ ]);
521
+
522
+ expect(lines.some((l) => l.tone === "warning")).toBe(true);
523
+ });
524
+ });
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // REQ-4 wording: one line, the user's terms, no internals
528
+ // ---------------------------------------------------------------------------
529
+
530
+ describe("REQ-4 wording", () => {
531
+ test("REQ-4 activationNotices never names backups, plugin.json, settings.json, the registry or the marker", () => {
532
+ const lines = activationNotices(
533
+ activation({
534
+ displaced: DISPLACED,
535
+ keptAt: ".claude/.magus-backups/2026-09-28T04-06-00-138Z/_profiles",
536
+ skills: {
537
+ skills: { "api-design": "abc123" },
538
+ installed: ["api-design"],
539
+ removed: ["old-skill"],
540
+ failed: [{ skill: "broken-skill", reason: "download failed" }],
541
+ },
542
+ conversion: {
543
+ converted: true,
544
+ managed: { "api-design": "abc123" },
545
+ restored: ["my-custom-skill"],
546
+ conflicts: [
547
+ { folder: "dup-skill", kept: "dup-skill", other: "dup-skill-2" },
548
+ ],
549
+ keptAt: ".claude/.magus-backups/2026-09-28T04-06-00-138Z/_profiles",
550
+ },
551
+ absorbed: ["Added the plugin dev@magus"],
552
+ dependencies: {
553
+ derived: ["claudish@magus"],
554
+ unreadable: ["typescript-lsp@claude-plugins-official"],
555
+ blocked: [
556
+ { plugin: "multimodel@magus", dependency: "claudish@magus" },
557
+ ],
558
+ },
559
+ committedSkills: ["team-skill"],
560
+ releasedCommitted: ["shared-skill"],
561
+ }),
562
+ );
563
+
564
+ expect(lines.length).toBeGreaterThan(0);
565
+ expectUserTerms(lines);
566
+ });
567
+
568
+ test("REQ-4 startupFixNotices for added ignore entries names no file and no internals", () => {
569
+ const lines = startupFixNotices({
570
+ untracked: [],
571
+ appended: [".claude/settings.json", ".claude/settings.local.json"],
572
+ failed: [],
573
+ });
574
+
575
+ expect(lines.length).toBeGreaterThan(0);
576
+ expectUserTerms(lines);
577
+ });
578
+
579
+ test("REQ-4 startupFixNotices never names backups, plugin.json, the registry or the marker, even when files must be committed", () => {
580
+ const lines = startupFixNotices({
581
+ untracked: [".claude/settings.json", ".claude/settings.local.json"],
582
+ appended: [".claude/settings.json"],
583
+ failed: [{ path: ".mcp.json", reason: "pathspec did not match" }],
584
+ });
585
+
586
+ expect(lines.length).toBeGreaterThan(0);
587
+ expectUserTerms(lines, ["settings.json"]);
588
+ });
589
+
590
+ test("REQ-4 staleLoadRepairNotices never names settings.json, plugin.json, backups, the registry or the marker", () => {
591
+ const lines = staleLoadRepairNotices([
592
+ { pluginId: "code-search@magus", target: "2.3.0", ok: true },
593
+ {
594
+ pluginId: "dev@magus",
595
+ target: "3.1.0",
596
+ ok: false,
597
+ error: "permission denied",
598
+ },
599
+ ]);
600
+
601
+ expect(lines.length).toBeGreaterThan(0);
602
+ expectUserTerms(lines);
603
+ });
604
+ });
@@ -789,11 +789,11 @@ describe("applyPlugins — read-back against beforeVersions", () => {
789
789
  reasons.reporter,
790
790
  );
791
791
  expect(result.unchanged).toEqual(["dev@magus"]);
792
- expect(reasons.lines(result.unchanged)).toBe(
792
+ expect(reasons.lines(result.unchanged)).toEqual([
793
793
  "dev@magus: the installer exited 0 but dev@magus is still 1.0.0",
794
- );
794
+ ]);
795
795
  // An item with no recorded cause still gets a line.
796
- expect(reasons.lines(["other@magus"])).toBe("other@magus");
796
+ expect(reasons.lines(["other@magus"])).toEqual(["other@magus"]);
797
797
  });
798
798
  });
799
799
 
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  buildStaleLoadModal,
4
- staleLoadRepairSummary,
4
+ staleLoadRepairNotices,
5
5
  } from "../ui/hooks/useStaleLoadModal";
6
6
 
7
7
  /**
@@ -138,19 +138,24 @@ describe("buildStaleLoadModal", () => {
138
138
  });
139
139
  });
140
140
 
141
- describe("staleLoadRepairSummary", () => {
142
- it("says what loads NOW for each repaired plugin", () => {
141
+ describe("staleLoadRepairNotices", () => {
142
+ it("says what loads NOW for the repaired plugins, in one line", () => {
143
143
  expect(
144
- staleLoadRepairSummary([
144
+ staleLoadRepairNotices([
145
145
  { pluginId: "madbench@magus", target: "0.5.0", ok: true },
146
146
  { pluginId: "dev@magus", target: "7.4.0", ok: true },
147
147
  ]),
148
- ).toBe("✓ madbench@magus now loads v0.5.0\n✓ dev@magus now loads v7.4.0");
148
+ ).toEqual([
149
+ {
150
+ text: "Now loading madbench@magus v0.5.0, dev@magus v7.4.0",
151
+ tone: "success",
152
+ },
153
+ ]);
149
154
  });
150
155
 
151
156
  it("reports a failed repair with its own reason, never as a success", () => {
152
157
  expect(
153
- staleLoadRepairSummary([
158
+ staleLoadRepairNotices([
154
159
  {
155
160
  pluginId: "madbench@magus",
156
161
  target: "0.5.0",
@@ -158,20 +163,30 @@ describe("staleLoadRepairSummary", () => {
158
163
  error: "the loader still resolves v0.3.0 here, not v0.5.0",
159
164
  },
160
165
  ]),
161
- ).toBe(
162
- "✗ madbench@magus — the loader still resolves v0.3.0 here, not v0.5.0",
163
- );
166
+ ).toEqual([
167
+ {
168
+ text: "madbench@magus not repaired: the loader still resolves v0.3.0 here, not v0.5.0",
169
+ tone: "warning",
170
+ },
171
+ ]);
164
172
  });
165
173
 
166
174
  it("falls back to a plain reason rather than an empty line", () => {
167
175
  expect(
168
- staleLoadRepairSummary([
176
+ staleLoadRepairNotices([
169
177
  { pluginId: "a@magus", target: "1.0.0", ok: false },
170
178
  ]),
171
- ).toBe("✗ a@magus — repair did not take effect");
179
+ ).toEqual([
180
+ {
181
+ text: "a@magus not repaired: repair did not take effect",
182
+ tone: "warning",
183
+ },
184
+ ]);
172
185
  });
173
186
 
174
187
  it("says so when there was nothing to repair", () => {
175
- expect(staleLoadRepairSummary([])).toBe("Nothing to repair.");
188
+ expect(staleLoadRepairNotices([])).toEqual([
189
+ { text: "Nothing needed repairing", tone: "success" },
190
+ ]);
176
191
  });
177
192
  });