magus-cli 7.11.5 → 7.11.6

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,732 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { testRender } from "@opentui/react/test-utils";
13
+ import { act, useEffect } from "react";
14
+ import type { McpRegistryServer, McpServerConfig } from "../types/index.js";
15
+ import { ModalContainer } from "../ui/components/modals/index.js";
16
+ import { KeyRouter } from "../ui/input/KeyRouter.js";
17
+ import McpRegistryScreen from "../ui/screens/McpRegistryScreen.js";
18
+ import { AppProvider, useNavigation } from "../ui/state/AppContext.js";
19
+ import { DimensionsProvider } from "../ui/state/DimensionsContext.js";
20
+
21
+ // The mount/provider/keyboard approach is copied from the supplied
22
+ // mcp-registry-screen-debounce.test.tsx. Only external registry data is faked.
23
+ // Expected configs are contract literals, never registryInstallPlan's output.
24
+ const realFetch = globalThis.fetch;
25
+ let cleanup: Array<() => Promise<void> | void> = [];
26
+
27
+ afterEach(async () => {
28
+ const callbacks = cleanup.reverse();
29
+ cleanup = [];
30
+ try {
31
+ for (const fn of callbacks) await fn();
32
+ } finally {
33
+ globalThis.fetch = realFetch;
34
+ }
35
+ });
36
+
37
+ type ProjectFiles = { mcp: string | null; approvals: string | null };
38
+ type ProjectSeed = Partial<ProjectFiles>;
39
+ type McpDocument = { mcpServers: Record<string, McpServerConfig> };
40
+ type ApprovalDocument = {
41
+ enabledMcpjsonServers?: string[];
42
+ enableAllProjectMcpServers?: boolean;
43
+ };
44
+
45
+ const WARNING =
46
+ "It may need API keys or settings to work. magus doesn't add any.";
47
+ const SOURCE_GUIDANCE = "The source below says what to set.";
48
+ const KEEP_NAME = "qa/keep";
49
+ const KEEP_CONFIG: McpServerConfig = {
50
+ type: "http",
51
+ url: "https://keep.example.test/mcp",
52
+ };
53
+
54
+ function server(
55
+ name: string,
56
+ url: string,
57
+ extra: Partial<Omit<McpRegistryServer, "name" | "url">> = {},
58
+ ): McpRegistryServer {
59
+ return { name, url, short_description: `${name} description`, ...extra };
60
+ }
61
+
62
+ function projectSeed(
63
+ entries: Record<string, McpServerConfig>,
64
+ approvedNames = Object.keys(entries),
65
+ enableAllProjectMcpServers = true,
66
+ ): ProjectSeed {
67
+ return {
68
+ mcp: `${JSON.stringify({ mcpServers: entries }, null, 2)}\n`,
69
+ approvals: `${JSON.stringify(
70
+ { enabledMcpjsonServers: approvedNames, enableAllProjectMcpServers },
71
+ null,
72
+ 2,
73
+ )}\n`,
74
+ };
75
+ }
76
+
77
+ function GoToRegistry() {
78
+ const { navigateToScreen } = useNavigation();
79
+ // biome-ignore lint/correctness/useExhaustiveDependencies: navigate once on mount
80
+ useEffect(() => navigateToScreen("mcp-registry"), []);
81
+ return null;
82
+ }
83
+
84
+ async function mount(
85
+ initialRows: Array<string | McpRegistryServer>,
86
+ seed: ProjectSeed = {},
87
+ ) {
88
+ const project = mkdtempSync(join(tmpdir(), "mcp-registry-install-screen-"));
89
+ cleanup.push(() => rmSync(project, { recursive: true, force: true }));
90
+ const mcpPath = join(project, ".mcp.json");
91
+ const approvalsPath = join(project, ".claude", "settings.local.json");
92
+
93
+ if (seed.mcp != null) writeFileSync(mcpPath, seed.mcp);
94
+ if (seed.approvals != null) {
95
+ mkdirSync(join(project, ".claude"), { recursive: true });
96
+ writeFileSync(approvalsPath, seed.approvals);
97
+ }
98
+ const rows = initialRows.map((row) =>
99
+ typeof row === "string"
100
+ ? server(row, `https://${row.replace(/[^a-z]/g, "")}.example.test/mcp`)
101
+ : row,
102
+ );
103
+ globalThis.fetch = (async () =>
104
+ new Response(JSON.stringify({ servers: rows }), {
105
+ status: 200,
106
+ headers: { "content-type": "application/json" },
107
+ })) as typeof fetch;
108
+
109
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
110
+ await testRender(
111
+ <AppProvider initialProjectPath={project}>
112
+ <DimensionsProvider>
113
+ <KeyRouter>
114
+ <GoToRegistry />
115
+ <McpRegistryScreen />
116
+ <ModalContainer />
117
+ </KeyRouter>
118
+ </DimensionsProvider>
119
+ </AppProvider>,
120
+ { width: 180, height: 40 },
121
+ );
122
+ cleanup.push(async () => {
123
+ await act(async () => renderer.destroy());
124
+ });
125
+
126
+ const settle = async () => {
127
+ await new Promise<void>((resolve) => setTimeout(resolve, 10));
128
+ await renderOnce();
129
+ };
130
+ const send = async (fn: () => void | Promise<void>) =>
131
+ act(async () => {
132
+ await fn();
133
+ await settle();
134
+ });
135
+ // Poll outcomes while advancing the real renderer and debounce timer.
136
+ // A failed assertion is rethrown at the deadline rather than hidden.
137
+ const until = async (assertion: () => void) => {
138
+ const deadline = Date.now() + 3000;
139
+ while (true) {
140
+ await act(async () => settle());
141
+ try {
142
+ assertion();
143
+ return;
144
+ } catch (error) {
145
+ if (Date.now() >= deadline) throw error;
146
+ }
147
+ }
148
+ };
149
+ const read = (path: string) =>
150
+ existsSync(path) ? readFileSync(path, "utf8") : null;
151
+ const files = (): ProjectFiles => ({
152
+ mcp: read(mcpPath),
153
+ approvals: read(approvalsPath),
154
+ });
155
+ const mcp = (): McpDocument | undefined => {
156
+ const text = read(mcpPath);
157
+ return text === null ? undefined : (JSON.parse(text) as McpDocument);
158
+ };
159
+ const approvals = (): ApprovalDocument | undefined => {
160
+ const text = read(approvalsPath);
161
+ return text === null ? undefined : (JSON.parse(text) as ApprovalDocument);
162
+ };
163
+
164
+ const first = rows[0];
165
+ if (first) {
166
+ await until(() => {
167
+ expect(captureCharFrame()).toContain(first.name);
168
+ expect(captureCharFrame()).toContain("Status");
169
+ });
170
+ }
171
+
172
+ return {
173
+ mockInput,
174
+ send,
175
+ until,
176
+ files,
177
+ mcp,
178
+ approvals,
179
+ frame: captureCharFrame,
180
+ };
181
+ }
182
+
183
+ // Ignore terminal box separators and whitespace, not the wording being tested.
184
+ function text(frame: string) {
185
+ return frame
186
+ .replace(/[│┃║┆┊]/g, " ")
187
+ .replace(/\s+/g, " ")
188
+ .trim();
189
+ }
190
+
191
+ function statusLine(frame: string) {
192
+ const line = frame
193
+ .split("\n")
194
+ .find((candidate) => /\bStatus\b/.test(candidate));
195
+ expect(line).toBeDefined();
196
+ return line ?? "";
197
+ }
198
+
199
+ function expectInstalled(frame: string) {
200
+ const status = statusLine(frame);
201
+ expect(status).toContain("Installed");
202
+ expect(status).not.toMatch(/\bnot\s+installed\b/i);
203
+ }
204
+
205
+ function expectNotInstalled(frame: string) {
206
+ // The contract fixes "Installed", but not the exact uninstalled label.
207
+ const status = statusLine(frame).replace(/\bnot\s+installed\b/gi, "");
208
+ expect(status).not.toMatch(/\binstalled\b/i);
209
+ }
210
+
211
+ type InstallCase = [
212
+ scenario: string,
213
+ row: McpRegistryServer,
214
+ config: McpServerConfig,
215
+ ];
216
+ const installCases: InstallCase[] = [
217
+ [
218
+ "TEST-42 REQ-2 REQ-8 REQ-9 REQ-12 REQ-14: Enter installs PyPI by row name and shows Installed",
219
+ {
220
+ name: "qa/pypi",
221
+ url: "pypi:mcparmory-notion",
222
+ short_description: "qa/pypi description",
223
+ package_registry: "pypi",
224
+ version: "99.0.0",
225
+ },
226
+ {
227
+ command: "uvx",
228
+ args: ["mcparmory-notion"],
229
+ },
230
+ ],
231
+ [
232
+ "TEST-43 REQ-3 REQ-8 REQ-9 REQ-12 REQ-14: Enter installs unscoped npm by row name and shows Installed",
233
+ {
234
+ name: "qa/npm",
235
+ url: "npm:npm-search-mcp",
236
+ short_description: "qa/npm description",
237
+ package_registry: "npm",
238
+ version: "99.0.0",
239
+ },
240
+ {
241
+ command: "npx",
242
+ args: ["-y", "npm-search-mcp"],
243
+ },
244
+ ],
245
+ [
246
+ "TEST-44 REQ-3 REQ-8 REQ-9 REQ-12 REQ-14: Enter installs scoped npm by row name and shows Installed",
247
+ {
248
+ name: "qa/npm-scoped",
249
+ url: "npm:@yawlabs/npmjs-mcp",
250
+ short_description: "qa/npm-scoped description",
251
+ package_registry: "npm",
252
+ version: "99.0.0",
253
+ },
254
+ {
255
+ command: "npx",
256
+ args: ["-y", "@yawlabs/npmjs-mcp"],
257
+ },
258
+ ],
259
+ [
260
+ "TEST-45 REQ-1 REQ-8 REQ-9 REQ-12 REQ-14: Enter keeps hosted HTTPS as HTTP despite npm metadata",
261
+ {
262
+ name: "qa/https",
263
+ url: "https://mcp.notion.com/mcp",
264
+ short_description: "qa/https description",
265
+ package_registry: "npm",
266
+ },
267
+ {
268
+ type: "http",
269
+ url: "https://mcp.notion.com/mcp",
270
+ },
271
+ ],
272
+ [
273
+ "TEST-46 REQ-1 REQ-8 REQ-9 REQ-12 REQ-14: Enter installs an HTTP row without changing its config",
274
+ {
275
+ name: "qa/http",
276
+ url: "http://localhost:8080/mcp",
277
+ short_description: "qa/http description",
278
+ },
279
+ {
280
+ type: "http",
281
+ url: "http://localhost:8080/mcp",
282
+ },
283
+ ],
284
+ ];
285
+
286
+ describe("registry screen: Enter installs the row's contracted transport", () => {
287
+ test.each(installCases)("%s", async (_scenario, row, config) => {
288
+ const m = await mount([row]);
289
+ expect(m.files()).toStrictEqual({ mcp: null, approvals: null });
290
+
291
+ await m.send(() => m.mockInput.pressEnter());
292
+ await m.until(() => {
293
+ expectInstalled(m.frame());
294
+ expect(m.approvals()?.enabledMcpjsonServers).toContain(row.name);
295
+ });
296
+
297
+ expect(m.mcp()?.mcpServers).toStrictEqual({ [row.name]: config });
298
+ expect(m.approvals()?.enabledMcpjsonServers).toStrictEqual([row.name]);
299
+ expect(m.approvals()?.enableAllProjectMcpServers).toBe(true);
300
+ });
301
+
302
+ test("TEST-47 REQ-3 REQ-8 REQ-9: installing npm retains an existing entry and approval", async () => {
303
+ const row = server("qa/add-npm", "npm:npm-search-mcp", {
304
+ version: "99.0.0",
305
+ });
306
+ const m = await mount(
307
+ [row],
308
+ projectSeed({ [KEEP_NAME]: KEEP_CONFIG }, [KEEP_NAME], false),
309
+ );
310
+
311
+ await m.send(() => m.mockInput.pressEnter());
312
+ await m.until(() => {
313
+ expectInstalled(m.frame());
314
+ expect(m.approvals()?.enabledMcpjsonServers).toContain(row.name);
315
+ });
316
+
317
+ expect(m.mcp()?.mcpServers).toStrictEqual({
318
+ [KEEP_NAME]: KEEP_CONFIG,
319
+ [row.name]: { command: "npx", args: ["-y", "npm-search-mcp"] },
320
+ });
321
+ expect(
322
+ [...(m.approvals()?.enabledMcpjsonServers ?? [])].sort(),
323
+ ).toStrictEqual([KEEP_NAME, row.name].sort());
324
+ expect(m.approvals()?.enableAllProjectMcpServers).toBe(true);
325
+ });
326
+ });
327
+
328
+ type DetailCase = [
329
+ scenario: string,
330
+ row: McpRegistryServer,
331
+ config: McpServerConfig,
332
+ runs: string,
333
+ installed: boolean,
334
+ ];
335
+ const detailCases: DetailCase[] = [
336
+ [
337
+ "TEST-48 REQ-2 REQ-11 REQ-12 REQ-14: pypi details not installed without a source show package setup guidance",
338
+ {
339
+ name: "qa/pypi-detail",
340
+ url: "pypi:mcparmory-notion",
341
+ short_description: "qa/pypi-detail description",
342
+ package_registry: "pypi",
343
+ version: "99.0.0",
344
+ },
345
+ {
346
+ command: "uvx",
347
+ args: ["mcparmory-notion"],
348
+ },
349
+ "uvx mcparmory-notion",
350
+ false,
351
+ ],
352
+ [
353
+ "TEST-49 REQ-2 REQ-11 REQ-12 REQ-14: pypi details not installed with a source show package setup guidance",
354
+ {
355
+ name: "qa/pypi-detail",
356
+ url: "pypi:mcparmory-notion",
357
+ short_description: "qa/pypi-detail description",
358
+ package_registry: "pypi",
359
+ version: "99.0.0",
360
+ source_code_url: "https://example.test/pypi/source",
361
+ },
362
+ {
363
+ command: "uvx",
364
+ args: ["mcparmory-notion"],
365
+ },
366
+ "uvx mcparmory-notion",
367
+ false,
368
+ ],
369
+ [
370
+ "TEST-50 REQ-2 REQ-11 REQ-12 REQ-14: pypi details installed without a source show package setup guidance",
371
+ {
372
+ name: "qa/pypi-detail",
373
+ url: "pypi:mcparmory-notion",
374
+ short_description: "qa/pypi-detail description",
375
+ package_registry: "pypi",
376
+ version: "99.0.0",
377
+ },
378
+ {
379
+ command: "uvx",
380
+ args: ["mcparmory-notion"],
381
+ },
382
+ "uvx mcparmory-notion",
383
+ true,
384
+ ],
385
+ [
386
+ "TEST-51 REQ-2 REQ-11 REQ-12 REQ-14: pypi details installed with a source show package setup guidance",
387
+ {
388
+ name: "qa/pypi-detail",
389
+ url: "pypi:mcparmory-notion",
390
+ short_description: "qa/pypi-detail description",
391
+ package_registry: "pypi",
392
+ version: "99.0.0",
393
+ source_code_url: "https://example.test/pypi/source",
394
+ },
395
+ {
396
+ command: "uvx",
397
+ args: ["mcparmory-notion"],
398
+ },
399
+ "uvx mcparmory-notion",
400
+ true,
401
+ ],
402
+ [
403
+ "TEST-52 REQ-3 REQ-11 REQ-12 REQ-14: npm details not installed without a source show package setup guidance",
404
+ {
405
+ name: "qa/npm-detail",
406
+ url: "npm:@yawlabs/npmjs-mcp",
407
+ short_description: "qa/npm-detail description",
408
+ package_registry: "npm",
409
+ version: "99.0.0",
410
+ },
411
+ {
412
+ command: "npx",
413
+ args: ["-y", "@yawlabs/npmjs-mcp"],
414
+ },
415
+ "npx -y @yawlabs/npmjs-mcp",
416
+ false,
417
+ ],
418
+ [
419
+ "TEST-53 REQ-3 REQ-11 REQ-12 REQ-14: npm details not installed with a source show package setup guidance",
420
+ {
421
+ name: "qa/npm-detail",
422
+ url: "npm:@yawlabs/npmjs-mcp",
423
+ short_description: "qa/npm-detail description",
424
+ package_registry: "npm",
425
+ version: "99.0.0",
426
+ source_code_url: "https://example.test/npm/source",
427
+ },
428
+ {
429
+ command: "npx",
430
+ args: ["-y", "@yawlabs/npmjs-mcp"],
431
+ },
432
+ "npx -y @yawlabs/npmjs-mcp",
433
+ false,
434
+ ],
435
+ [
436
+ "TEST-54 REQ-3 REQ-11 REQ-12 REQ-14: npm details installed without a source show package setup guidance",
437
+ {
438
+ name: "qa/npm-detail",
439
+ url: "npm:@yawlabs/npmjs-mcp",
440
+ short_description: "qa/npm-detail description",
441
+ package_registry: "npm",
442
+ version: "99.0.0",
443
+ },
444
+ {
445
+ command: "npx",
446
+ args: ["-y", "@yawlabs/npmjs-mcp"],
447
+ },
448
+ "npx -y @yawlabs/npmjs-mcp",
449
+ true,
450
+ ],
451
+ [
452
+ "TEST-55 REQ-3 REQ-11 REQ-12 REQ-14: npm details installed with a source show package setup guidance",
453
+ {
454
+ name: "qa/npm-detail",
455
+ url: "npm:@yawlabs/npmjs-mcp",
456
+ short_description: "qa/npm-detail description",
457
+ package_registry: "npm",
458
+ version: "99.0.0",
459
+ source_code_url: "https://example.test/npm/source",
460
+ },
461
+ {
462
+ command: "npx",
463
+ args: ["-y", "@yawlabs/npmjs-mcp"],
464
+ },
465
+ "npx -y @yawlabs/npmjs-mcp",
466
+ true,
467
+ ],
468
+ ];
469
+
470
+ describe("registry screen: package details explain the runner and user setup", () => {
471
+ test.each(detailCases)(
472
+ "%s",
473
+ async (_scenario, row, config, runs, installed) => {
474
+ const seed = installed ? projectSeed({ [row.name]: config }) : {};
475
+ const expectedFiles: ProjectFiles = {
476
+ mcp: seed.mcp ?? null,
477
+ approvals: seed.approvals ?? null,
478
+ };
479
+
480
+ const m = await mount([row], seed);
481
+
482
+ const visible = text(m.frame());
483
+ expect(visible).toContain(`Runs ${runs}`);
484
+ expect(visible).toContain(WARNING);
485
+ expect(visible.indexOf("Status")).toBeLessThan(visible.indexOf("Runs "));
486
+ expect(visible.indexOf("Runs ")).toBeLessThan(visible.indexOf(WARNING));
487
+ if (installed) {
488
+ expectInstalled(m.frame());
489
+ } else {
490
+ expectNotInstalled(m.frame());
491
+ expect(visible).toContain("Press Enter to install");
492
+ }
493
+ if (row.source_code_url) {
494
+ expect(visible).toContain(SOURCE_GUIDANCE);
495
+ expect(visible).toContain(row.source_code_url);
496
+ expect(visible.indexOf("Status")).toBeLessThan(
497
+ visible.indexOf(SOURCE_GUIDANCE),
498
+ );
499
+ } else {
500
+ expect(visible).not.toContain(SOURCE_GUIDANCE);
501
+ }
502
+ expect(m.files()).toStrictEqual(expectedFiles);
503
+ },
504
+ );
505
+ });
506
+
507
+ type RefusalCase = [
508
+ scenario: string,
509
+ row: McpRegistryServer,
510
+ kind: string,
511
+ reason: string,
512
+ ];
513
+ const refusalCases: RefusalCase[] = [
514
+ [
515
+ "TEST-56 REQ-4 REQ-7 REQ-10 REQ-13: oci shows its refusal and creates neither file on Enter",
516
+ {
517
+ name: "qa/oci",
518
+ url: "oci:ghcr.io/x/y:1",
519
+ short_description: "qa/oci description",
520
+ },
521
+ "oci",
522
+ "magus can't install oci servers. It can install https, pypi and npm ones.",
523
+ ],
524
+ [
525
+ "TEST-57 REQ-4 REQ-7 REQ-10 REQ-13: nuget shows its refusal and creates neither file on Enter",
526
+ {
527
+ name: "qa/nuget",
528
+ url: "nuget:Foo",
529
+ short_description: "qa/nuget description",
530
+ },
531
+ "nuget",
532
+ "magus can't install nuget servers. It can install https, pypi and npm ones.",
533
+ ],
534
+ [
535
+ "TEST-58 REQ-4 REQ-7 REQ-10 REQ-13: foo shows its refusal and creates neither file on Enter",
536
+ {
537
+ name: "qa/foo",
538
+ url: "foo:bar",
539
+ short_description: "qa/foo description",
540
+ },
541
+ "foo",
542
+ "magus can't install foo servers. It can install https, pypi and npm ones.",
543
+ ],
544
+ [
545
+ "TEST-59 REQ-4 REQ-7 REQ-10 REQ-13: unreadable shows its refusal and creates neither file on Enter",
546
+ {
547
+ name: "qa/unreadable",
548
+ url: "just-text",
549
+ short_description: "qa/unreadable description",
550
+ },
551
+ "unknown",
552
+ "magus can't read this server's address.",
553
+ ],
554
+ [
555
+ "TEST-60 REQ-5 REQ-7 REQ-10 REQ-13: pypi-empty shows its refusal and creates neither file on Enter",
556
+ {
557
+ name: "qa/pypi-empty",
558
+ url: "pypi:",
559
+ short_description: "qa/pypi-empty description",
560
+ },
561
+ "pypi",
562
+ "This pypi server has no package name, so there's nothing to run.",
563
+ ],
564
+ [
565
+ "TEST-61 REQ-5 REQ-7 REQ-10 REQ-13: pypi-bad shows its refusal and creates neither file on Enter",
566
+ {
567
+ name: "qa/pypi-bad",
568
+ url: "pypi:pkg[extra]",
569
+ short_description: "qa/pypi-bad description",
570
+ },
571
+ "pypi",
572
+ "The package name of this pypi server looks wrong, so magus won't run it.",
573
+ ],
574
+ [
575
+ "TEST-62 REQ-5 REQ-7 REQ-10 REQ-13: npm-injection shows its refusal and creates neither file on Enter",
576
+ {
577
+ name: "qa/npm-injection",
578
+ url: "npm:--package=x",
579
+ short_description: "qa/npm-injection description",
580
+ },
581
+ "npm",
582
+ "The package name of this npm server looks wrong, so magus won't run it.",
583
+ ],
584
+ [
585
+ "TEST-63 REQ-5 REQ-7 REQ-10 REQ-13: npm-empty shows its refusal and creates neither file on Enter",
586
+ {
587
+ name: "qa/npm-empty",
588
+ url: "npm:",
589
+ short_description: "qa/npm-empty description",
590
+ },
591
+ "npm",
592
+ "This npm server has no package name, so there's nothing to run.",
593
+ ],
594
+ [
595
+ "TEST-64 REQ-4 REQ-7 REQ-10 REQ-13: https-no-host shows its refusal and creates neither file on Enter",
596
+ {
597
+ name: "qa/https-no-host",
598
+ url: "https://",
599
+ short_description: "qa/https-no-host description",
600
+ },
601
+ "unknown",
602
+ "magus can't read this server's address.",
603
+ ],
604
+ ];
605
+
606
+ describe("registry screen: refusals explain the kind without writing state", () => {
607
+ test.each(refusalCases)("%s", async (_scenario, row, kind, reason) => {
608
+ const m = await mount([row]);
609
+ const pane = text(m.frame());
610
+ // REQ-4b: every refused row shows the same reason in its pane and popup.
611
+ expect(pane).toContain(reason);
612
+ if (kind === "pypi" || kind === "npm" || kind === "unknown") {
613
+ expect(pane).not.toContain(
614
+ `magus can't install ${kind} servers. It can install https, pypi and npm ones.`,
615
+ );
616
+ }
617
+ expect(text(m.frame())).not.toContain("Press Enter to install");
618
+ expect(m.files()).toStrictEqual({ mcp: null, approvals: null });
619
+
620
+ await m.send(() => m.mockInput.pressEnter());
621
+ await m.until(() => {
622
+ expect(text(m.frame())).toContain(`${row.name} wasn't installed`);
623
+ });
624
+
625
+ expect(text(m.frame())).toContain(reason);
626
+ expect(m.files()).toStrictEqual({ mcp: null, approvals: null });
627
+ });
628
+
629
+ test("TEST-65 REQ-4 REQ-7 REQ-10 REQ-13: a refused install preserves both existing files byte-for-byte", async () => {
630
+ const row = server("qa/refused-oci", "oci:ghcr.io/x/y:1");
631
+ const seed = projectSeed({ [KEEP_NAME]: KEEP_CONFIG });
632
+ const m = await mount([row], seed);
633
+ const before = m.files();
634
+ expect(before).toStrictEqual({
635
+ mcp: seed.mcp,
636
+ approvals: seed.approvals,
637
+ });
638
+ expect(text(m.frame())).toContain(
639
+ "magus can't install oci servers. It can install https, pypi and npm ones.",
640
+ );
641
+ expect(text(m.frame())).not.toContain("Press Enter to install");
642
+
643
+ await m.send(() => m.mockInput.pressEnter());
644
+ await m.until(() => {
645
+ expect(text(m.frame())).toContain(`${row.name} wasn't installed`);
646
+ });
647
+
648
+ expect(text(m.frame())).toContain(
649
+ "magus can't install oci servers. It can install https, pypi and npm ones.",
650
+ );
651
+ expect(m.files()).toStrictEqual(before);
652
+ });
653
+ });
654
+
655
+ const removeCases: InstallCase[] = [
656
+ [
657
+ "TEST-66 REQ-12: d then confirmation removes installed PyPI by registry name",
658
+ {
659
+ name: "qa/remove-pypi",
660
+ url: "pypi:mcparmory-notion",
661
+ short_description: "qa/remove-pypi description",
662
+ },
663
+ {
664
+ command: "uvx",
665
+ args: ["mcparmory-notion"],
666
+ },
667
+ ],
668
+ [
669
+ "TEST-67 REQ-12: d then confirmation removes installed npm by registry name",
670
+ {
671
+ name: "qa/remove-npm",
672
+ url: "npm:@yawlabs/npmjs-mcp",
673
+ short_description: "qa/remove-npm description",
674
+ },
675
+ {
676
+ command: "npx",
677
+ args: ["-y", "@yawlabs/npmjs-mcp"],
678
+ },
679
+ ],
680
+ [
681
+ "TEST-68 REQ-12: d then confirmation removes installed HTTP by registry name",
682
+ {
683
+ name: "qa/remove-http",
684
+ url: "https://remove.example.test/mcp",
685
+ short_description: "qa/remove-http description",
686
+ },
687
+ {
688
+ type: "http",
689
+ url: "https://remove.example.test/mcp",
690
+ },
691
+ ],
692
+ ];
693
+
694
+ describe("registry screen: installed state and removal use the registry name", () => {
695
+ test.each(removeCases)("%s", async (_scenario, row, config) => {
696
+ const m = await mount(
697
+ [row],
698
+ projectSeed({ [KEEP_NAME]: KEEP_CONFIG, [row.name]: config }),
699
+ );
700
+ expectInstalled(m.frame());
701
+ const before = m.files();
702
+
703
+ await m.send(() => m.mockInput.typeText("d"));
704
+ expect(m.files()).toStrictEqual(before);
705
+ // Confirm through the mounted modal using its documented affirmative key.
706
+ await m.send(() => m.mockInput.typeText("y"));
707
+ await m.until(() => {
708
+ expect(m.mcp()?.mcpServers).toStrictEqual({ [KEEP_NAME]: KEEP_CONFIG });
709
+ expect(text(m.frame())).toContain("Press Enter to install");
710
+ expectNotInstalled(m.frame());
711
+ });
712
+ });
713
+ });
714
+
715
+ describe("registry screen: hosted HTTP remains the existing path", () => {
716
+ test("TEST-69 REQ-1 REQ-6 REQ-11 REQ-14: HTTP details do not acquire package-only guidance", async () => {
717
+ const row = server("qa/http-detail", "https://hosted.example.test/mcp", {
718
+ package_registry: "npm",
719
+ source_code_url: "https://example.test/http/source",
720
+ });
721
+
722
+ const m = await mount([row]);
723
+
724
+ const visible = text(m.frame());
725
+ expectNotInstalled(m.frame());
726
+ expect(visible).toContain("Press Enter to install");
727
+ expect(visible).not.toMatch(/\bRuns\b/);
728
+ expect(visible).not.toContain(WARNING);
729
+ expect(visible).not.toContain(SOURCE_GUIDANCE);
730
+ expect(m.files()).toStrictEqual({ mcp: null, approvals: null });
731
+ });
732
+ });