返回 DeepSeek-Reasonix
capabilities-panel-actions.test.ts
根目录 / desktop / frontend / src / __tests__ / capabilities-panel-actions.test.ts
1 import { JSDOM } from "jsdom";
2 import { readFileSync } from "node:fs";
3 import React from "react";
4 import { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import { MCPServersSettingsPage, PluginsSettingsPage, failureKind, mcpServerDraftJSON, parseMCPQuickDefinition, parseMCPServerJSON, summarizeServerError, withExplicitMCPClears } from "../components/CapabilitiesPanel";
7 import { slashCommandGroup, slashCommandKindTag, sortSlashCommandsForMenu } from "../components/SlashMenu";
8 import { selectToolsOnFirstCustomUse } from "../components/SubagentsPanel";
9 import type { AppBindings } from "../lib/bridge";
10 import { LocaleProvider, t } from "../lib/i18n";
11 import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
12 import type { MCPServerInput, Meta, PluginInstallOptions, PluginView, ServerView, TabMeta } from "../lib/types";
13
14 function ok(value: unknown, message: string) {
15 if (!value) throw new Error(message);
16 }
17
18 {
19 const dom = installDom();
20 const rootEl = document.getElementById("root");
21 if (!rootEl) throw new Error("missing root");
22 const root = createRoot(rootEl);
23 const meta: Meta = { label: "test", ready: true, eventChannel: "mcp-registry-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
24 const tabs: TabMeta[] = [{
25 id: "tab-mcp-registry",
26 scope: "project",
27 workspaceRoot: "/tmp/reasonix-test",
28 workspaceName: "reasonix-test",
29 topicId: "topic-mcp-registry",
30 topicTitle: "Registry",
31 label: "Registry",
32 ready: true,
33 running: false,
34 mode: "normal",
35 toolApprovalMode: "auto",
36 active: true,
37 cwd: "/tmp/reasonix-test",
38 }];
39 let servers: ServerView[] = [];
40 let installed: MCPServerInput | null = null;
41 let registryCached = false;
42 let resolvedRegistryName = "";
43 const registryEntry = {
44 name: "io.example/demo",
45 suggestedName: "demo",
46 title: "Demo MCP",
47 description: "Registry demo server",
48 version: "1.0.0",
49 installable: true,
50 transport: "http",
51 args: [],
52 url: "https://mcp.example.test/mcp",
53 };
54 window.go = {
55 main: {
56 App: {
57 Meta: async () => meta,
58 ListTabs: async () => tabs,
59 MCPServers: async () => servers,
60 MCPMarketplace: async () => ({
61 cached: registryCached,
62 warning: registryCached ? "offline" : undefined,
63 servers: [registryEntry],
64 }),
65 MCPMarketplaceResolve: async (registryName) => {
66 resolvedRegistryName = registryName;
67 return registryEntry;
68 },
69 AddMCPServer: async (input) => {
70 installed = input;
71 servers = [{
72 name: input.name,
73 transport: input.transport,
74 status: "connected",
75 configured: true,
76 autoStart: true,
77 tools: 1,
78 prompts: 0,
79 resources: 0,
80 url: input.url,
81 }];
82 return 1;
83 },
84 InstallMCPServer: async (input) => {
85 const app = window.go?.main?.App;
86 if (!app) throw new Error("missing App bindings");
87 const toolCount = await app.AddMCPServer(input);
88 return { name: input.name, state: "ready", toolCount, action: "none", message: "ready" };
89 },
90 } as Partial<AppBindings> as AppBindings,
91 },
92 };
93
94 await act(async () => {
95 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
96 await flush();
97 });
98 await waitFor("registry browse action", () => Boolean(findButton("Browse registry")));
99 await act(async () => {
100 findButton("Browse registry")?.click();
101 await flush();
102 });
103 await waitFor("registry result", () => document.body.textContent?.includes("Demo MCP") ?? false);
104 await act(async () => {
105 findButton("Install")?.click();
106 await flush();
107 });
108 await waitFor("registry install", () => installed !== null && document.body.textContent?.includes("demo") === true);
109 const installedEntry = installed as MCPServerInput | null;
110 ok(installedEntry?.name === "demo" && installedEntry.transport === "http" && installedEntry.url === "https://mcp.example.test/mcp", "registry install converts the selected entry into the normal add-and-connect input");
111 ok(resolvedRegistryName === "io.example/demo", "registry install re-resolves current metadata by canonical name");
112
113 registryCached = true;
114 installed = null;
115 await act(async () => {
116 findButton("Browse registry")?.click();
117 await flush();
118 findButton("Search")?.click();
119 await flush();
120 });
121 await waitFor("cached registry warning", () => document.body.textContent?.includes("Showing cached results") ?? false);
122 const cachedInstall = findButton("Install");
123 ok(cachedInstall?.disabled === true, "cached Registry results must remain browse-only");
124 cachedInstall?.click();
125 await flush();
126 ok(installed === null, "cached Registry result must not be installed");
127
128 await act(async () => {
129 root.unmount();
130 });
131 dom.window.close();
132 }
133
134 const quickCommand = parseMCPQuickDefinition("npx -y chrome-devtools-mcp@latest");
135 ok(quickCommand.name === "chrome-devtools-mcp" && quickCommand.transport === "stdio", "quick install should derive a stable name and stdio transport from one command");
136
137 const quickFilesystem = parseMCPQuickDefinition('npx -y @modelcontextprotocol/server-filesystem "/srv/shared data"');
138 ok(quickFilesystem.name === "server-filesystem", "quick install name should come from the launcher package, not a trailing server argument");
139
140 const quickPythonModule = parseMCPQuickDefinition("python -m mcp_server_time --local-timezone=UTC");
141 ok(quickPythonModule.name === "mcp-server-time", "python module quick install should derive its name from the module");
142 const quickURL = parseMCPQuickDefinition("https://mcp.linear.app/mcp");
143 ok(quickURL.name === "mcp" && quickURL.transport === "http", "quick install should derive HTTP transport from a URL");
144 const quickJSON = parseMCPQuickDefinition(JSON.stringify({ custom: { command: "uvx", args: ["demo-mcp"] } }));
145 ok(quickJSON.name === "custom" && quickJSON.args[0] === "demo-mcp", "quick install should preserve advanced JSON definitions");
146
147 const completeMCPJSON = JSON.stringify({
148 admin: {
149 type: "streamable-http",
150 url: "https://mcp.example.test/api",
151 auto_start: false,
152 call_timeout_seconds: 45,
153 tool_timeout_seconds: { wipe: 120 },
154 trusted_read_only_tools: ["status"],
155 default_tools_approval_mode: "writes",
156 tools: { wipe: { approval_mode: "prompt" } },
157 approvals_reviewer: "auto_review",
158 },
159 });
160 const completeMCP = parseMCPServerJSON(completeMCPJSON);
161 ok(completeMCP.input.transport === "http", "streamable-http should normalize to http");
162 ok(completeMCP.input.autoStart === false, "advanced JSON should preserve auto_start=false");
163 ok(completeMCP.input.callTimeoutSeconds === 45 && completeMCP.input.toolTimeoutSeconds?.wipe === 120, "advanced JSON should preserve timeouts");
164 const completeMCPRoundTrip = parseMCPServerJSON(mcpServerDraftJSON(completeMCP.draft));
165 ok(completeMCPRoundTrip.input.transport === "http" && completeMCPRoundTrip.input.toolTimeoutSeconds?.wipe === 120, "Form/JSON switching should preserve connection fields");
166 const normalizedMCPJSON = mcpServerDraftJSON(completeMCP.draft);
167 ok(!normalizedMCPJSON.includes("trusted_read_only_tools"), "Form/JSON switching should drop the removed reader setting");
168 ok(!normalizedMCPJSON.includes("approval_mode") && !normalizedMCPJSON.includes("approvals_reviewer"), "Form/JSON switching should drop retired MCP approval settings");
169 let unsupportedMCPFieldRejected = false;
170 try {
171 parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp", unsupported: true } }));
172 } catch (error) {
173 unsupportedMCPFieldRejected = error instanceof Error && error.message === "unsupported";
174 }
175 ok(unsupportedMCPFieldRejected, "unsupported advanced JSON fields should fail explicitly");
176 const incompleteMCPJSON = JSON.stringify({ admin: { type: "stdio", command: "" } });
177 let incompleteMCPRejected = false;
178 try {
179 parseMCPServerJSON(incompleteMCPJSON);
180 } catch (error) {
181 incompleteMCPRejected = error instanceof Error && error.message === "required";
182 }
183 ok(incompleteMCPRejected, "submitting incomplete MCP JSON must still require a command or URL");
184 const incompleteMCPDraft = parseMCPServerJSON(incompleteMCPJSON, undefined, { allowIncomplete: true });
185 ok(incompleteMCPDraft.draft.name === "admin" && incompleteMCPDraft.draft.command === "", "mode switching may recover an incomplete MCP draft for form editing");
186 parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp", default_tools_approval_mode: "", approvals_reviewer: "" } }));
187 let nullToolTimeoutRejected = false;
188 try {
189 parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp", tool_timeout_seconds: { wipe: null } } }));
190 } catch (error) {
191 nullToolTimeoutRejected = error instanceof Error && error.message === "invalid";
192 }
193 ok(nullToolTimeoutRejected, "a null per-tool timeout must be rejected instead of silently clearing all timeouts");
194 const sparseEdit = withExplicitMCPClears(parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp" } })).input);
195 ok(sparseEdit.callTimeoutSeconds === 0, "editing an existing server with fields removed must clear the timeout");
196 ok(sparseEdit.autoStart === true && Object.keys(sparseEdit.toolTimeoutSeconds ?? { x: 1 }).length === 0, "removed timeout fields must clear");
197 ok(sparseEdit.env === null && sparseEdit.headers === null, "absent env/headers must stay preserve-on-absent because their values are never seeded into the editor");
198
199 const refusedRegistryError = [
200 'plugin "fs": read EOF: stderr:',
201 "npm error code ECONNREFUSED",
202 "npm error syscall connect",
203 "npm error FetchError: request to https://registry.npmjs.org/@modelcontextprotocol%2fserver-filesystem failed, reason: connect ECONNREFUSED 127.0.0.1:7890",
204 ].join("\n");
205 ok(
206 summarizeServerError(refusedRegistryError) === "fs: npm ECONNREFUSED · registry.npmjs.org → 127.0.0.1:7890",
207 "npm connection failures should identify both the registry and the refused endpoint",
208 );
209 const legacyNpmRefusedRegistryError = [
210 'plugin "fs": read EOF: stderr:',
211 "npm ERR! code ECONNREFUSED",
212 "npm ERR! syscall connect",
213 "npm ERR! FetchError: request to https://registry.npmjs.org/@modelcontextprotocol%2fserver-filesystem failed, reason: connect ECONNREFUSED 127.0.0.1:7890",
214 ].join("\n");
215 ok(
216 summarizeServerError(legacyNpmRefusedRegistryError) === "fs: npm ECONNREFUSED · registry.npmjs.org → 127.0.0.1:7890",
217 "legacy npm ERR! failures should identify both the registry and the refused endpoint",
218 );
219 const credentialedRegistryError =
220 'plugin "private": stderr: npm error code ECONNREFUSED npm error request to https://build-user:registry-secret@packages.example.test/npm failed, reason: connect ECONNREFUSED proxy.internal.test:8443';
221 const credentialedRegistrySummary = summarizeServerError(credentialedRegistryError);
222 ok(credentialedRegistrySummary.includes("packages.example.test → proxy.internal.test:8443"), "private registries should keep actionable hosts");
223 ok(!credentialedRegistrySummary.includes("build-user") && !credentialedRegistrySummary.includes("registry-secret"), "registry credentials must not appear in the summary");
224 ok(
225 failureKind({ ...server("failed"), error: refusedRegistryError }) === "network",
226 "npm connection refusal should be grouped as a network/proxy issue",
227 );
228
229 const subagentTools = [
230 { name: "read_file", description: "Read files" },
231 { name: "edit_file", description: "Edit files" },
232 { name: "bash", description: "Run commands" },
233 ];
234 const firstCustomSelection = selectToolsOnFirstCustomUse(new Set(), subagentTools, false);
235 ok(firstCustomSelection.size === subagentTools.length, "first custom-mode use should select every available tool");
236 const savedCustomSelection = selectToolsOnFirstCustomUse(new Set(["read_file", "edit_file"]), subagentTools, true);
237 ok(savedCustomSelection.size === 2 && !savedCustomSelection.has("bash"), "saved custom tool selections should be preserved");
238 ok(selectToolsOnFirstCustomUse(new Set(), subagentTools, true).size === 0, "returning to custom mode should preserve a deliberate empty selection");
239
240 const subagentsSource = readFileSync(new URL("../components/SubagentsPanel.tsx", import.meta.url), "utf8");
241 const subagentsStyles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
242 const customGroupIndex = subagentsSource.indexOf('aria-labelledby="subagents-custom-title"');
243 const builtinGroupIndex = subagentsSource.indexOf('aria-labelledby="subagents-builtin-title"');
244 ok(customGroupIndex >= 0 && builtinGroupIndex > customGroupIndex, "custom subagents should render before built-in subagents");
245 ok((subagentsSource.match(/className="subagents-profile-group"/g) ?? []).length === 2, "custom and built-in subagents should use separate sections");
246 ok(subagentsSource.includes('className="btn btn--small subagents-reset-override"'), "override status and reset should share one compact action");
247 ok(subagentsStyles.includes("repeat(2, minmax(0, 1fr)) 152px"), "built-in subagent pickers should use equal shrinkable columns and reserve one stable status column");
248 ok(subagentsSource.includes('className="settings-model-picker subagents-effort-picker"'), "effort and model overrides should share the same picker interaction pattern");
249 ok(subagentsSource.includes("<SubagentInvocation name={skill.name}"), "every subagent card should show its chat invocation affordance");
250 ok(subagentsSource.includes("onUseInChat(command)"), "subagent cards should send their slash command to the chat composer");
251
252 function server(status: ServerView["status"]): ServerView {
253 return {
254 name: "codegraph",
255 transport: "stdio",
256 status,
257 configured: true,
258 autoStart: true,
259 tier: "background",
260 tools: 0,
261 prompts: 0,
262 resources: 0,
263 };
264 }
265
266 const initializing = mcpServerLifecycleActions(server("initializing"));
267 ok(initializing.enabled, "initializing server should still be treated as enabled");
268 ok(!initializing.showRetryInRow, "initializing server should not expose retry until it fails");
269 ok(!initializing.canReconnect, "initializing server should not expose reconnect while already connecting");
270 ok(!initializing.canConnectNow, "initializing server should not use the deferred connect-now action");
271
272 const connected = mcpServerLifecycleActions(server("connected"));
273 ok(!connected.showRetryInRow, "connected server row should keep the toggle UI");
274 ok(connected.canReconnect, "connected server details should expose reconnect");
275
276 const manuallyConnected = mcpServerLifecycleActions({ ...server("connected"), autoStart: false, startIntent: "off", runtimeState: "ready" });
277 ok(manuallyConnected.enabled, "connected manual server should still render as enabled");
278 ok(!manuallyConnected.canConnectNow, "connected manual server should not expose connect-now");
279 ok(manuallyConnected.canReconnect, "connected manual server should expose reconnect");
280
281 const automaticIdle = mcpServerLifecycleActions({ ...server("deferred"), startIntent: "automatic" });
282 ok(!automaticIdle.canConnectNow, "automatic idle server should not look like a manual connector");
283 ok(!automaticIdle.canReconnect, "automatic idle server should wait for background connection or failure");
284
285 const failed = mcpServerLifecycleActions({ ...server("failed"), runtimeState: "issue" });
286 ok(failed.showRetryInRow, "failed server row should expose retry");
287
288 ok(mcpServerRetryableFromAvailableList(server("initializing")), "connecting server should be included in available-list retry all");
289 ok(!mcpServerRetryableFromAvailableList({ ...server("deferred"), startIntent: "automatic" }), "healthy on-demand server should not be included in retry all");
290 ok(mcpServerRetryableFromAvailableList({ ...server("deferred"), startIntent: "automatic", action: "retry" }), "explicit retry action should remain available for an idle server");
291 ok(!mcpServerRetryableFromAvailableList(server("connected")), "connected server should be excluded from available-list retry all");
292 ok(!mcpServerRetryableFromAvailableList({ ...server("disabled"), startIntent: "off" }), "disabled server should be excluded from available-list retry all");
293 ok(!mcpServerRetryableFromAvailableList({ ...server("failed"), runtimeState: "issue" }), "failed server is handled by the failure banner retry all");
294
295 function flush(): Promise<void> {
296 return new Promise((resolve) => setTimeout(resolve, 0));
297 }
298
299 async function waitFor(label: string, predicate: () => boolean) {
300 for (let attempt = 0; attempt < 20; attempt += 1) {
301 await act(async () => {
302 await flush();
303 });
304 if (predicate()) return;
305 }
306 throw new Error(`timed out waiting for ${label}`);
307 }
308
309 function installDom() {
310 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
311 pretendToBeVisual: true,
312 url: "http://localhost/",
313 });
314 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
315 globalThis.window = dom.window as unknown as Window & typeof globalThis;
316 globalThis.document = dom.window.document;
317 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
318 globalThis.Node = dom.window.Node;
319 globalThis.HTMLElement = dom.window.HTMLElement;
320 globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
321 globalThis.HTMLInputElement = dom.window.HTMLInputElement;
322 globalThis.Event = dom.window.Event;
323 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
324 globalThis.MouseEvent = dom.window.MouseEvent;
325 globalThis.localStorage = dom.window.localStorage;
326 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
327 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
328 Object.defineProperty(window, "matchMedia", {
329 configurable: true,
330 value: () => ({
331 matches: true,
332 media: "(prefers-reduced-motion: reduce)",
333 onchange: null,
334 addEventListener() {},
335 removeEventListener() {},
336 addListener() {},
337 removeListener() {},
338 dispatchEvent: () => false,
339 }),
340 });
341 return dom;
342 }
343
344 function findButton(label: string): HTMLButtonElement | undefined {
345 return Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
346 }
347
348 function setInputValue(input: HTMLInputElement, value: string) {
349 const win = input.ownerDocument.defaultView;
350 const previous = input.value;
351 const setter = Object.getOwnPropertyDescriptor((win?.HTMLInputElement ?? HTMLInputElement).prototype, "value")?.set;
352 setter?.call(input, value);
353 (input as HTMLInputElement & { _valueTracker?: { setValue: (next: string) => void } })._valueTracker?.setValue(previous);
354 const eventCtor = win?.Event ?? Event;
355 input.dispatchEvent(new eventCtor("input", { bubbles: true }));
356 input.dispatchEvent(new eventCtor("change", { bubbles: true }));
357 }
358
359 ok(
360 slashCommandKindTag({ name: "pwf:plan", description: "Plugin planning prompt.", kind: "custom", plugin: "pwf" }, t) === "plugin · pwf",
361 "slash menu identifies the canonical plugin command source",
362 );
363 ok(
364 slashCommandGroup({ name: "explore", description: "Explore in isolation.", kind: "subagent" }) === "subagents",
365 "slash menu groups isolated skills as subagents",
366 );
367 ok(
368 slashCommandGroup({ name: "plugins", description: "Manage plugins.", kind: "builtin", group: "management" }) === "management",
369 "slash menu honors backend-provided command groups",
370 );
371 ok(
372 slashCommandGroup({ name: "plugins", description: "Manage plugins.", kind: "builtin" }) === "management"
373 && slashCommandGroup({ name: "new", description: "New session.", kind: "builtin" }) === "actions",
374 "slash menu keeps a safe grouping fallback for older backends",
375 );
376 ok(
377 sortSlashCommandsForMenu([
378 { name: "plugins", description: "Manage plugins.", kind: "builtin", group: "management" },
379 { name: "explore", description: "Explore in isolation.", kind: "subagent", group: "subagents" },
380 { name: "new", description: "New session.", kind: "builtin", group: "actions" },
381 ]).map((command) => command.name).join(",") === "new,explore,plugins",
382 "slash menu keyboard order follows the visible group order",
383 );
384
385 console.log("capabilities panel MCP actions");
386
387 {
388 const dom = installDom();
389 const rootEl = document.getElementById("root");
390 if (!rootEl) throw new Error("missing root");
391 const root = createRoot(rootEl);
392 const meta: Meta = { label: "test", ready: true, eventChannel: "test-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
393 const tabs: TabMeta[] = [{
394 id: "tab-1",
395 scope: "project",
396 workspaceRoot: "/tmp/reasonix-test",
397 workspaceName: "reasonix-test",
398 topicId: "topic-1",
399 topicTitle: "Test",
400 label: "Test",
401 ready: true,
402 running: false,
403 mode: "normal",
404 toolApprovalMode: "auto",
405 active: true,
406 cwd: "/tmp/reasonix-test",
407 }];
408 let servers: ServerView[] = [{
409 name: "github",
410 transport: "stdio",
411 status: "connected",
412 configured: true,
413 autoStart: true,
414 tools: 2,
415 prompts: 0,
416 resources: 0,
417 toolList: [
418 { name: "issue_read", description: "Read issues.", readOnlyHint: true },
419 { name: "issue_write", description: "Write issues." },
420 { name: "broken_read", description: "Broken tool.", readOnlyHint: true, schemaError: "invalid input schema: bad nested type" },
421 ],
422 }];
423 window.go = {
424 main: {
425 App: {
426 Meta: async () => meta,
427 ListTabs: async () => tabs,
428 MCPServers: async () => servers,
429 } as Partial<AppBindings> as AppBindings,
430 },
431 };
432
433 await act(async () => {
434 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
435 await flush();
436 });
437 await waitFor("github server row", () => Boolean(document.querySelector(".cap-mcp-list-row__name")?.textContent?.includes("github")));
438 ok(Boolean(findButton("Remove server")), "configured MCP exposes a removal action directly in the server list");
439 ok(document.body.textContent?.includes("1 unavailable"), "server list summary reports one quarantined tool");
440 ok(!document.body.textContent?.includes("invalid input schema: bad nested type"), "server list keeps raw tool diagnostics out of the overview");
441
442 const openServer = document.querySelector<HTMLButtonElement>(".cap-mcp-list-row__main");
443 if (!openServer) throw new Error("missing MCP server details button");
444 await act(async () => {
445 openServer.click();
446 await flush();
447 });
448
449 await waitFor("unavailable tool", () => Boolean(document.querySelector(".cap-tool-hint--error")?.textContent?.includes("Unavailable")));
450 ok(document.body.textContent?.includes("invalid input schema: bad nested type"), "tool list shows the schema diagnostic");
451 ok(document.body.textContent?.includes("issue_read") ?? false, "server details list read-only MCP tools normally");
452 ok(document.body.textContent?.includes("issue_write") ?? false, "server details list write-capable MCP tools normally");
453 ok(!findButton("Pre-trust read-only (1)"), "MCP details do not expose a bulk pre-trust action");
454 ok(!findButton("Pre-trust"), "MCP details do not expose per-tool pre-trust actions");
455 ok(!findButton("Untrust"), "MCP details do not expose an untrust action");
456 ok(!document.querySelector(".cap-tool-trust"), "MCP details do not expose a separate trust state");
457 ok(!document.body.textContent?.includes("read-only trust"), "MCP details do not describe the removed trust workflow");
458
459 await act(async () => {
460 root.unmount();
461 });
462 dom.window.close();
463 }
464
465 {
466 const dom = installDom();
467 const rootEl = document.getElementById("root");
468 if (!rootEl) throw new Error("missing root");
469 const root = createRoot(rootEl);
470 const meta: Meta = { label: "test", ready: true, eventChannel: "authorize-mcp-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
471 const tabs: TabMeta[] = [{
472 id: "tab-authorize-mcp",
473 scope: "project",
474 workspaceRoot: "/tmp/reasonix-test",
475 workspaceName: "reasonix-test",
476 topicId: "topic-authorize-mcp",
477 topicTitle: "Authorize MCP",
478 label: "Authorize MCP",
479 ready: true,
480 running: false,
481 mode: "normal",
482 toolApprovalMode: "auto",
483 active: true,
484 cwd: "/tmp/reasonix-test",
485 }];
486 let servers: ServerView[] = [{
487 name: "github",
488 transport: "stdio",
489 status: "connected",
490 runtimeState: "ready",
491 configured: true,
492 source: "project",
493 configSource: "reasonix.toml",
494 autoStart: true,
495 tools: 3,
496 prompts: 0,
497 resources: 0,
498 toolList: [
499 { name: "issue_read", description: "Read issues.", readOnlyHint: true },
500 { name: "issue_write", description: "Write issues." },
501 { name: "wipe", description: "Delete data.", destructiveHint: true },
502 ],
503 }, {
504 name: "linear",
505 transport: "http",
506 status: "connected",
507 runtimeState: "ready",
508 configured: true,
509 source: "user",
510 autoStart: true,
511 tools: 1,
512 prompts: 0,
513 resources: 0,
514 toolList: [{ name: "get_issue", description: "Read an issue.", readOnlyHint: true }],
515 }];
516 window.go = {
517 main: {
518 App: {
519 Meta: async () => meta,
520 ListTabs: async () => tabs,
521 MCPServers: async () => servers,
522 } as Partial<AppBindings> as AppBindings,
523 },
524 };
525
526 await act(async () => {
527 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
528 await flush();
529 });
530 const refreshStatus = async () => {
531 const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh MCP status"]');
532 if (!refresh) throw new Error("missing MCP status refresh action");
533 await act(async () => {
534 refresh.click();
535 await flush();
536 });
537 };
538 await waitFor("trusted project MCP", () => Boolean(document.querySelector('[data-status="connected"]')));
539 ok(document.body.textContent?.includes("This project"), "project MCP is grouped under This project");
540 ok(document.body.textContent?.includes("Global MCP"), "user-installed MCP is grouped by its global scope");
541 ok(document.body.textContent?.includes("Install once and use automatically in every Reasonix project."), "global MCP explains its cross-project availability");
542 ok(document.body.textContent?.includes("Project"), "project MCP row shows a project source badge");
543 ok(document.body.textContent?.includes("Declared by this project and available automatically."), "project MCP explains zero-confirmation availability");
544 ok(!findButton("Install and use"), "trusted project MCP has no install confirmation");
545 ok(!findButton("Authorize and connect"), "trusted project MCP has no authorization action");
546 ok(!findButton("Review changes"), "project MCP has no separate change-review workflow");
547 ok(!findButton("Refresh catalog"), "catalog maintenance is not part of the normal MCP workflow");
548 ok(!document.querySelector('[role="dialog"]'), "project MCP does not open a confirmation modal");
549
550 servers = servers.map((item) => ({
551 ...item,
552 status: "failed",
553 runtimeState: "issue",
554 error: "authentication required",
555 authStatus: "required",
556 authUrl: "https://mcp.example.test/authorize",
557 }));
558 await refreshStatus();
559 await waitFor("sign-in action", () => Boolean(findButton("Sign in")));
560 ok(!findButton("Review changes"), "OAuth failure does not expose a removed change-review action");
561
562 servers = servers.map((item) => ({
563 ...item,
564 status: "failed",
565 runtimeState: "issue",
566 error: "connection refused",
567 authStatus: "none",
568 authUrl: "",
569 }));
570 await refreshStatus();
571 await waitFor("ordinary retry action", () => Boolean(findButton("Retry")));
572 ok(!findButton("Review changes"), "ordinary startup failures keep only the retry action");
573
574 servers = servers.map((item) => ({
575 ...item,
576 status: "connected",
577 runtimeState: "ready",
578 error: "",
579 requiresLaunchApproval: false,
580 }));
581 await refreshStatus();
582 await waitFor("trusted project server row", () => Boolean(document.querySelector('[data-status="connected"]')));
583 await act(async () => {
584 (document.querySelector(".cap-mcp-list-row__main") as HTMLButtonElement | null)?.click();
585 await flush();
586 });
587 await waitFor("connected project server detail", () => Boolean(document.querySelector(".cap-mcp-subpage")));
588 ok(document.body.textContent?.includes("Current project · reasonix.toml"), "project MCP details show their configuration source");
589 ok(!findButton("Review changes"), "a trusted connected project server does not show a change alarm");
590 ok(!findButton("Revoke trust"), "normal MCP details do not expose a second authorization-management workflow");
591
592 await act(async () => {
593 root.unmount();
594 });
595 dom.window.close();
596 }
597
598 {
599 const dom = installDom();
600 const rootEl = document.getElementById("root");
601 if (!rootEl) throw new Error("missing root");
602 const root = createRoot(rootEl);
603 const meta: Meta = { label: "test", ready: true, eventChannel: "managed-mcp-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
604 const tabs: TabMeta[] = [{
605 id: "tab-managed-mcp",
606 scope: "project",
607 workspaceRoot: "/tmp/reasonix-test",
608 workspaceName: "reasonix-test",
609 topicId: "topic-managed-mcp",
610 topicTitle: "Managed MCP",
611 label: "Managed MCP",
612 ready: true,
613 running: false,
614 mode: "normal",
615 toolApprovalMode: "auto",
616 active: true,
617 cwd: "/tmp/reasonix-test",
618 }];
619 const servers: ServerView[] = [{
620 name: "helper",
621 transport: "http",
622 status: "connected",
623 configured: true,
624 managedByPlugin: "superpowers",
625 authConfigured: true,
626 autoStart: true,
627 tools: 1,
628 prompts: 0,
629 resources: 0,
630 toolList: [{ name: "echo", description: "Echo input", readOnlyHint: true }],
631 }];
632 window.go = {
633 main: {
634 App: {
635 Meta: async () => meta,
636 ListTabs: async () => tabs,
637 MCPServers: async () => servers,
638 } as Partial<AppBindings> as AppBindings,
639 },
640 };
641
642 await act(async () => {
643 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
644 await flush();
645 });
646 await waitFor("plugin-managed MCP row", () => Boolean(document.querySelector(".cap-mcp-list-row__name")?.textContent?.includes("helper")));
647 ok(document.body.textContent?.includes("Managed by plugin superpowers") ?? false, "plugin-managed MCP identifies its owner");
648
649 const openServer = document.querySelector<HTMLButtonElement>(".cap-mcp-list-row__main");
650 if (!openServer) throw new Error("missing plugin-managed MCP details button");
651 await act(async () => {
652 openServer.click();
653 await flush();
654 });
655 ok(!findButton("Remove server"), "plugin-managed MCP hides the misleading remove action");
656 ok(!findButton("Edit config"), "plugin-managed MCP hides direct config editing");
657 ok(!findButton("Clear auth"), "plugin-managed MCP hides auth persistence actions");
658 ok(!findButton("Pre-trust read-only (1)"), "plugin-managed MCP has no bulk pre-trust action");
659
660 ok(!findButton("View tools"), "standalone server details show tools without another disclosure step");
661 ok(document.body.textContent?.includes("echo") ?? false, "plugin-managed MCP details show its tools");
662 ok(!findButton("Pre-trust"), "plugin-managed MCP has no per-tool pre-trust action");
663
664 await act(async () => {
665 root.unmount();
666 });
667 dom.window.close();
668 }
669
670 {
671 const dom = installDom();
672 const rootEl = document.getElementById("root");
673 if (!rootEl) throw new Error("missing root");
674 const root = createRoot(rootEl);
675 const meta: Meta = { label: "test", ready: true, eventChannel: "runtime-mcp-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
676 const tabs: TabMeta[] = [{
677 id: "tab-runtime-mcp",
678 scope: "project",
679 workspaceRoot: "/tmp/reasonix-test",
680 workspaceName: "reasonix-test",
681 topicId: "topic-runtime-mcp",
682 topicTitle: "Runtime MCP",
683 label: "Runtime MCP",
684 ready: true,
685 running: false,
686 mode: "normal",
687 toolApprovalMode: "auto",
688 active: true,
689 cwd: "/tmp/reasonix-test",
690 }];
691 const servers: ServerView[] = [{
692 name: "runtime-only",
693 transport: "stdio",
694 status: "failed",
695 configured: false,
696 autoStart: false,
697 tools: 0,
698 prompts: 0,
699 resources: 0,
700 error: "command not found",
701 }];
702 window.go = {
703 main: {
704 App: {
705 Meta: async () => meta,
706 ListTabs: async () => tabs,
707 MCPServers: async () => servers,
708 } as Partial<AppBindings> as AppBindings,
709 },
710 };
711
712 await act(async () => {
713 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
714 await flush();
715 });
716 await waitFor("runtime-only failure row", () => Boolean(document.querySelector(".cap-mcp-list-row__name")?.textContent?.includes("runtime-only")));
717 const showDetails = document.querySelector<HTMLButtonElement>(".cap-mcp-list-row__main");
718 if (!showDetails) throw new Error("missing runtime-only failure details button");
719 await act(async () => {
720 showDetails.click();
721 await flush();
722 });
723 ok(document.body.textContent?.includes("command not found") ?? false, "runtime-only MCP detail preserves its failure diagnostic");
724 ok(!findButton("Remove server"), "runtime-only MCP failure hides an action the backend cannot persist");
725
726 await act(async () => {
727 root.unmount();
728 });
729 dom.window.close();
730 }
731
732 {
733 const dom = installDom();
734 const rootEl = document.getElementById("root");
735 if (!rootEl) throw new Error("missing root");
736 const root = createRoot(rootEl);
737 const meta: Meta = { label: "test", ready: true, eventChannel: "mcp-editor-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
738 const tabs: TabMeta[] = [{
739 id: "tab-mcp-editor",
740 scope: "project",
741 workspaceRoot: "/tmp/reasonix-test",
742 workspaceName: "reasonix-test",
743 topicId: "topic-mcp-editor",
744 topicTitle: "MCP editor",
745 label: "MCP editor",
746 ready: true,
747 running: false,
748 mode: "normal",
749 toolApprovalMode: "auto",
750 active: true,
751 cwd: "/tmp/reasonix-test",
752 }];
753 let addedInput: MCPServerInput | undefined;
754 let servers: ServerView[] = [
755 {
756 name: "github",
757 transport: "stdio",
758 status: "connected",
759 configured: true,
760 autoStart: true,
761 command: "github-mcp-server",
762 tools: 1,
763 prompts: 0,
764 resources: 0,
765 toolList: [{ name: "issue_read", description: "Read GitHub issues" }],
766 },
767 {
768 name: "yakit",
769 transport: "stdio",
770 status: "connected",
771 configured: true,
772 autoStart: true,
773 command: "yakit-mcp",
774 tools: 1,
775 prompts: 0,
776 resources: 0,
777 toolList: [{ name: "generate_yso_bytes", description: "Generate bytes" }],
778 },
779 ];
780 window.go = {
781 main: {
782 App: {
783 Meta: async () => meta,
784 ListTabs: async () => tabs,
785 MCPServers: async () => servers,
786 AddMCPServer: async (input: MCPServerInput) => {
787 addedInput = input;
788 servers = [...servers, {
789 name: input.name,
790 transport: input.transport,
791 status: "connected",
792 configured: true,
793 autoStart: true,
794 command: input.command,
795 args: input.args,
796 url: input.url,
797 tools: 0,
798 prompts: 0,
799 resources: 0,
800 }];
801 return 0;
802 },
803 InstallMCPServer: async (input: MCPServerInput) => {
804 addedInput = input;
805 servers = [...servers, {
806 name: input.name,
807 transport: input.transport,
808 status: "connected",
809 configured: true,
810 autoStart: true,
811 command: input.command,
812 args: input.args,
813 url: input.url,
814 tools: 0,
815 prompts: 0,
816 resources: 0,
817 }];
818 return { name: input.name, state: "ready", toolCount: 0, action: "none", message: "ready" };
819 },
820 } as Partial<AppBindings> as AppBindings,
821 },
822 };
823
824 await act(async () => {
825 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
826 await flush();
827 });
828 await waitFor("MCP editor server rows", () => document.querySelectorAll(".cap-mcp-list-row__name").length === 2);
829 const search = document.querySelector<HTMLInputElement>('.cap-mcp-search input[type="search"]');
830 if (!search) throw new Error("missing MCP server search");
831 await act(async () => {
832 setInputValue(search, "generate_yso_bytes");
833 await flush();
834 });
835 ok(search.value === "generate_yso_bytes", "MCP search accepts the entered query");
836 await waitFor("filtered Yakit row", () => document.querySelectorAll(".cap-mcp-list-row__name").length === 1);
837 ok(document.querySelector(".cap-mcp-list-row__name")?.textContent === "yakit", "server search includes MCP tool names");
838
839 const addServer = findButton("Add server");
840 if (!addServer) throw new Error("missing Add server button");
841 await act(async () => {
842 addServer.click();
843 await flush();
844 });
845 const quickInstall = findButton("Quick install");
846 const manualSetup = findButton("Manual setup");
847 ok(quickInstall?.getAttribute("aria-selected") === "true" && Boolean(manualSetup) && Boolean(findButton("JSON")), "new server install defaults to quick install while keeping manual and JSON configuration in the same editor");
848 const definitionEditor = document.querySelector<HTMLTextAreaElement>(".cap-mcp-quick__input");
849 if (!definitionEditor) throw new Error("missing quick MCP install input");
850 ok(definitionEditor.placeholder.includes("chrome-devtools-mcp@latest"), "the default install path asks only for a command, URL, or JSON definition");
851 await act(async () => {
852 manualSetup?.click();
853 await flush();
854 });
855 ok(Boolean(document.querySelector(".cap-mcp-field--name input")) && Boolean(findButton("Advanced options")), "manual setup restores name, transport, and advanced configuration without leaving the install page");
856 ok(!addedInput, "opening the quick installer does not mutate MCP state");
857
858 await act(async () => {
859 root.unmount();
860 });
861 dom.window.close();
862 }
863
864 console.log("capabilities panel plugin actions");
865
866 {
867 const dom = installDom();
868 const rootEl = document.getElementById("root");
869 if (!rootEl) throw new Error("missing root");
870 const root = createRoot(rootEl);
871 const meta: Meta = { label: "test", ready: true, eventChannel: "plugin-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
872 const tabs: TabMeta[] = [{
873 id: "tab-plugin",
874 scope: "project",
875 workspaceRoot: "/tmp/reasonix-test",
876 workspaceName: "reasonix-test",
877 topicId: "topic-plugin",
878 topicTitle: "Plugins",
879 label: "Plugins",
880 ready: true,
881 running: false,
882 mode: "normal",
883 toolApprovalMode: "auto",
884 active: true,
885 cwd: "/tmp/reasonix-test",
886 }];
887 let planCalls = 0;
888 let installCalls = 0;
889 let toggleCalls = 0;
890 let updateCalls = 0;
891 let doctorCalls = 0;
892 let removeCalls = 0;
893 let pickFolderCalls = 0;
894 const plannedSources: string[] = [];
895 const installedSources: string[] = [];
896 let plugins: PluginView[] = [{
897 name: "superpowers",
898 version: "0.1.0",
899 description: "Shared agent skills and hooks.",
900 source: "git:github.com/obra/superpowers",
901 root: "~/.reasonix/plugins/superpowers",
902 manifestKind: "reasonix",
903 enabled: true,
904 skills: 2,
905 hooks: 1,
906 mcpServers: 0,
907 }];
908 window.go = {
909 main: {
910 App: {
911 Meta: async () => meta,
912 ListTabs: async () => tabs,
913 Plugins: async () => plugins.map((plugin) => ({ ...plugin, warnings: [...(plugin.warnings ?? [])] })),
914 PlanPluginInstall: async (source: string, options: PluginInstallOptions) => {
915 planCalls += 1;
916 plannedSources.push(source);
917 ok(options.dryRun === true, "plugin preview asks for dry-run planning");
918 return JSON.stringify({
919 ok: true,
920 status: "planned",
921 name: "superpowers",
922 actions: [{
923 kind: "plugin", action: "install_plugin_package", name: "superpowers", source, status: "planned",
924 compatibility: "partial", mappedCapabilities: ["skills", "agents"],
925 skippedCapabilities: [{ capability: "hook", path: "hooks/hooks.json", reason: "unsupported event" }],
926 }],
927 });
928 },
929 InstallPlugin: async (source: string, _options: PluginInstallOptions) => {
930 installCalls += 1;
931 installedSources.push(source);
932 const next: PluginView = {
933 name: "superpowers",
934 version: "0.1.1",
935 description: "Shared agent skills and hooks.",
936 source,
937 root: "~/.reasonix/plugins/superpowers",
938 manifestKind: "reasonix",
939 enabled: true,
940 skills: 3,
941 commands: 2,
942 agents: 1,
943 hooks: 1,
944 mcpServers: 1,
945 compatibility: "full",
946 mappedCapabilities: ["skills", "agents", "hooks", "mcp"],
947 skillDetails: [{ name: "plan", description: "Plan work before implementation.", invocation: "/superpowers:plan", runAs: "inline" }],
948 agentDetails: [{ name: "reviewer", description: "Review changes.", invocation: "/superpowers:reviewer", model: "sonnet" }],
949 commandDetails: [{
950 name: "plan",
951 description: "Plugin planning prompt.",
952 invocation: "/superpowers:plan",
953 }, {
954 name: "blocked",
955 description: "Occupied canonical command.",
956 invocation: "/superpowers:blocked",
957 shadowed: true,
958 }],
959 hookDetails: [{ event: "SessionStart", contextFile: "CLAUDE.md", description: "Load startup context." }],
960 mcpServerDetails: [{ name: "context", displayName: "Context Search", transport: "stdio", command: "node server.js", autoStart: false }],
961 };
962 plugins = plugins.filter((plugin) => plugin.name !== next.name).concat(next);
963 return JSON.stringify({ ok: true, status: "done", actions: [{ action: "install_plugin_package", name: next.name, status: "done" }] });
964 },
965 SetPluginEnabled: async (name: string, enabled: boolean) => {
966 toggleCalls += 1;
967 plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, enabled } : plugin);
968 },
969 UpdatePlugin: async (name: string) => {
970 updateCalls += 1;
971 plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, version: "0.1.2" } : plugin);
972 return JSON.stringify({ ok: true, status: "done", name });
973 },
974 PluginDoctor: async (name: string) => {
975 doctorCalls += 1;
976 return { ...(plugins.find((plugin) => plugin.name === name) ?? plugins[0]), warnings: ["manifest exports no MCP auth metadata"] };
977 },
978 RemovePlugin: async (name: string) => {
979 removeCalls += 1;
980 plugins = plugins.filter((plugin) => plugin.name !== name);
981 },
982 PickPluginFolder: async () => {
983 pickFolderCalls += 1;
984 return "/tmp/superpowers-plugin";
985 },
986 } as Partial<AppBindings> as AppBindings,
987 },
988 };
989
990 await act(async () => {
991 root.render(React.createElement(LocaleProvider, null, React.createElement(PluginsSettingsPage)));
992 await flush();
993 });
994 await waitFor("superpowers plugin row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("superpowers")));
995 ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--local")), "local plugin install mode uses the shared form grid");
996 const localOptionTexts = Array.from(document.querySelectorAll(".cap-plugin-installer__options > .cap-plugin-option-block"))
997 .map((option) => option.textContent ?? "");
998 ok(localOptionTexts[0]?.includes("Overwrite same-name plugin"), "local install mode shows overwrite before link mode");
999 ok(localOptionTexts[1]?.includes("Developer mode: link source folder"), "local install mode shows link mode after overwrite");
1000
1001 const chooseFolder = findButton("Choose plugin folder");
1002 if (!chooseFolder) throw new Error("missing plugin folder picker button");
1003 await act(async () => {
1004 chooseFolder.click();
1005 await flush();
1006 });
1007 await waitFor("picked plugin folder source", () => document.body.textContent?.includes("/tmp/superpowers-plugin") ?? false);
1008 ok(pickFolderCalls === 1, "clicking Choose folder invokes the plugin folder picker once");
1009
1010 const gitMode = findButton("Git repository");
1011 if (!gitMode) throw new Error("missing Git repository install mode");
1012 await act(async () => {
1013 gitMode.click();
1014 await flush();
1015 });
1016 ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--git")), "Git plugin install mode uses the shared form grid");
1017 const sourceInput = document.querySelector<HTMLInputElement>('input[aria-label="Git repository URL"]');
1018 if (!sourceInput) throw new Error("missing plugin git source input");
1019 await act(async () => {
1020 setInputValue(sourceInput, "git:github.com/obra/superpowers");
1021 await flush();
1022 });
1023 await waitFor("plugin preview enabled", () => findButton("Preview")?.disabled === false);
1024
1025 const preview = findButton("Preview");
1026 if (!preview) throw new Error("missing plugin preview button");
1027 await act(async () => {
1028 preview.click();
1029 await flush();
1030 });
1031 await waitFor("plugin install plan", () => document.body.textContent?.includes("install_plugin_package") ?? false);
1032 ok(planCalls === 1, "clicking Preview invokes plugin install planning once");
1033 ok(plannedSources[0] === "git:github.com/obra/superpowers", "plugin preview receives the entered Git source");
1034 ok(document.body.textContent?.includes("Partially compatible") ?? false, "preview renders compatibility status");
1035 ok(document.body.textContent?.includes("Mapped: skills, agents") ?? false, "preview renders mapped capabilities");
1036 ok(document.body.textContent?.includes("hook: unsupported event") ?? false, "preview renders skipped capability reasons");
1037
1038 const install = findButton("Install plugin");
1039 if (!install) throw new Error("missing plugin install button");
1040 await act(async () => {
1041 install.click();
1042 await flush();
1043 });
1044 await waitFor("plugin install result", () => installCalls === 1 && plugins[0]?.version === "0.1.1");
1045 ok(installedSources[0] === "git:github.com/obra/superpowers", "plugin install receives the entered Git source");
1046
1047 const disclosure = document.querySelector<HTMLButtonElement>(".cap-plugin-entry .cap-disclosure");
1048 if (!disclosure) throw new Error("missing plugin disclosure");
1049 await act(async () => {
1050 disclosure.click();
1051 await flush();
1052 });
1053 await waitFor("plugin update action", () => Boolean(findButton("Update")));
1054 ok(document.body.textContent?.includes("How to use") ?? false, "expanded plugin details explain how to use the plugin");
1055 ok(document.body.textContent?.includes("/superpowers:plan") ?? false, "expanded plugin details list qualified skill invocations");
1056 ok(document.body.textContent?.includes("/superpowers:plan") ?? false, "plugin details show the canonical qualified invocation");
1057 ok(document.body.textContent?.includes("qualified name is occupied by a user or project command") ?? false, "occupied canonical command explains the winning source");
1058 ok(document.body.textContent?.includes("SessionStart") ?? false, "expanded plugin details list exported hooks");
1059 ok(document.body.textContent?.includes("Fully compatible") ?? false, "plugin details show structured compatibility");
1060 ok(document.body.textContent?.includes("/superpowers:reviewer") ?? false, "plugin details list imported agents");
1061 ok(document.body.textContent?.includes("Context Search") ?? false, "plugin details retain MCP display names");
1062 ok(document.body.textContent?.includes("on demand") ?? false, "imported MCP servers are labeled on demand");
1063 ok(document.body.textContent?.includes("context") ?? false, "expanded plugin details list exported MCP servers");
1064
1065 const update = findButton("Update");
1066 if (!update) throw new Error("missing plugin update button");
1067 await act(async () => {
1068 update.click();
1069 await flush();
1070 });
1071 await waitFor("plugin update call", () => updateCalls === 1 && plugins[0]?.version === "0.1.2");
1072
1073 const doctor = findButton("Doctor");
1074 if (!doctor) throw new Error("missing plugin doctor button");
1075 await act(async () => {
1076 doctor.click();
1077 await flush();
1078 });
1079 await waitFor("plugin diagnostic warning", () => document.body.textContent?.includes("manifest exports no MCP auth metadata") ?? false);
1080 ok(doctorCalls === 1, "clicking Doctor invokes plugin diagnostics once");
1081
1082 const toggle = document.querySelector<HTMLInputElement>(".cap-plugin-entry .cap-switch input");
1083 if (!toggle) throw new Error("missing plugin enable toggle");
1084 await act(async () => {
1085 toggle.click();
1086 await flush();
1087 });
1088 await waitFor("plugin disabled", () => toggleCalls === 1 && plugins[0]?.enabled === false);
1089
1090 const remove = findButton("Remove plugin");
1091 if (!remove) throw new Error("missing plugin remove button");
1092 await act(async () => {
1093 remove.click();
1094 await flush();
1095 });
1096 const confirmRemove = findButton("Confirm remove");
1097 if (!confirmRemove) throw new Error("missing plugin confirm remove button");
1098 await act(async () => {
1099 confirmRemove.click();
1100 await flush();
1101 });
1102 await waitFor("plugin removed", () => removeCalls === 1 && plugins.length === 0);
1103
1104 await act(async () => {
1105 root.unmount();
1106 });
1107 dom.window.close();
1108 }
1109
1109 lines TYPESCRIPT