返回 DeepSeek-Reasonix
plugin-settings-actions.test.tsx
根目录 / desktop / frontend / src / __tests__ / plugin-settings-actions.test.tsx
1 import React, { act } from "react";
2 import { createRoot } from "react-dom/client";
3 import { PluginsSettingsPage } from "../components/CapabilitiesPanel";
4 import type { AppBindings } from "../lib/bridge";
5 import { LocaleProvider } from "../lib/i18n";
6 import type { Meta, PluginInstallOptions, PluginView, TabMeta } from "../lib/types";
7 import { findButton, flush, installDom, setInputValue, waitFor } from "./capabilities-test-helpers";
8 import { installDesktopHostStub } from "./desktopHostStub";
9
10 function ok(value: unknown, message: string) {
11 if (!value) throw new Error(message);
12 }
13
14 {
15 const dom = installDom();
16 const rootEl = document.getElementById("root");
17 if (!rootEl) throw new Error("missing root");
18 const root = createRoot(rootEl);
19 const meta: Meta = { label: "test", ready: true, eventChannel: "plugin-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
20 const tabs: TabMeta[] = [{
21 id: "tab-plugin",
22 scope: "project",
23 workspaceRoot: "/tmp/reasonix-test",
24 workspaceName: "reasonix-test",
25 topicId: "topic-plugin",
26 topicTitle: "Plugins",
27 label: "Plugins",
28 ready: true,
29 running: false,
30 mode: "normal",
31 toolApprovalMode: "auto",
32 active: true,
33 cwd: "/tmp/reasonix-test",
34 }];
35 let planCalls = 0;
36 let installCalls = 0;
37 let toggleCalls = 0;
38 let updateCalls = 0;
39 let doctorCalls = 0;
40 let removeCalls = 0;
41 let pickFolderCalls = 0;
42 const plannedSources: string[] = [];
43 const installedSources: string[] = [];
44 let plugins: PluginView[] = [{
45 name: "superpowers",
46 version: "0.1.0",
47 description: "Shared agent skills and hooks.",
48 source: "git:github.com/obra/superpowers",
49 root: "~/.reasonix/plugins/superpowers",
50 manifestKind: "reasonix",
51 enabled: true,
52 skills: 2,
53 hooks: 1,
54 mcpServers: 0,
55 }];
56 installDesktopHostStub(({
57 main: {
58 App: {
59 Meta: async () => meta,
60 ListTabs: async () => tabs,
61 Plugins: async () => plugins.map((plugin) => ({ ...plugin, warnings: [...(plugin.warnings ?? [])] })),
62 PlanPluginInstall: async (source: string, options: PluginInstallOptions) => {
63 planCalls += 1;
64 plannedSources.push(source);
65 ok(options.dryRun === true, "plugin preview asks for dry-run planning");
66 return JSON.stringify({
67 ok: true,
68 status: "planned",
69 name: "superpowers",
70 actions: [{
71 kind: "plugin", action: "install_plugin_package", name: "superpowers", source, status: "planned",
72 compatibility: "partial", mappedCapabilities: ["skills", "agents"],
73 skippedCapabilities: [{ capability: "hook", path: "hooks/hooks.json", reason: "unsupported event" }],
74 }],
75 });
76 },
77 InstallPlugin: async (source: string, _options: PluginInstallOptions) => {
78 installCalls += 1;
79 installedSources.push(source);
80 const next: PluginView = {
81 name: "superpowers",
82 version: "0.1.1",
83 description: "Shared agent skills and hooks.",
84 source,
85 root: "~/.reasonix/plugins/superpowers",
86 manifestKind: "reasonix",
87 enabled: true,
88 skills: 3,
89 commands: 2,
90 agents: 1,
91 hooks: 1,
92 mcpServers: 1,
93 compatibility: "full",
94 mappedCapabilities: ["skills", "agents", "hooks", "mcp"],
95 skillDetails: [{ name: "plan", description: "Plan work before implementation.", invocation: "/superpowers:plan", runAs: "inline" }],
96 agentDetails: [{ name: "reviewer", description: "Review changes.", invocation: "/superpowers:reviewer", model: "sonnet" }],
97 commandDetails: [{
98 name: "plan",
99 description: "Plugin planning prompt.",
100 invocation: "/superpowers:plan",
101 }, {
102 name: "blocked",
103 description: "Occupied canonical command.",
104 invocation: "/superpowers:blocked",
105 shadowed: true,
106 }],
107 hookDetails: [{ event: "SessionStart", contextFile: "CLAUDE.md", description: "Load startup context." }],
108 mcpServerDetails: [{ name: "context", displayName: "Context Search", transport: "stdio", command: "node server.js", autoStart: false }],
109 };
110 plugins = plugins.filter((plugin) => plugin.name !== next.name).concat(next);
111 return JSON.stringify({ ok: true, status: "done", actions: [{ action: "install_plugin_package", name: next.name, status: "done" }] });
112 },
113 SetPluginEnabled: async (name: string, enabled: boolean) => {
114 toggleCalls += 1;
115 plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, enabled } : plugin);
116 },
117 UpdatePlugin: async (name: string) => {
118 updateCalls += 1;
119 plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, version: "0.1.2" } : plugin);
120 return JSON.stringify({ ok: true, status: "done", name });
121 },
122 PluginDoctor: async (name: string) => {
123 doctorCalls += 1;
124 return { ...(plugins.find((plugin) => plugin.name === name) ?? plugins[0]), warnings: ["manifest exports no MCP auth metadata"] };
125 },
126 RemovePlugin: async (name: string) => {
127 removeCalls += 1;
128 plugins = plugins.filter((plugin) => plugin.name !== name);
129 },
130 PickPluginFolder: async () => {
131 pickFolderCalls += 1;
132 return "/tmp/superpowers-plugin";
133 },
134 } as Partial<AppBindings> as AppBindings,
135 },
136 }).main.App);
137
138 await act(async () => {
139 root.render(React.createElement(LocaleProvider, null, React.createElement(PluginsSettingsPage)));
140 await flush();
141 });
142 await waitFor("superpowers plugin row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("superpowers")));
143 ok(document.querySelector<HTMLElement>("#settings-plugin-install")?.hidden === true, "plugin installer starts collapsed");
144 const openInstaller = findButton("Install plugin");
145 if (!openInstaller) throw new Error("missing plugin installer entry");
146 await act(async () => {
147 openInstaller.click();
148 await flush();
149 });
150 ok(document.querySelector<HTMLElement>("#settings-plugin-install")?.hidden === false, "install entry reveals the plugin installer");
151 ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--local")), "local plugin install mode uses the shared form grid");
152 const localOptionTexts = Array.from(document.querySelectorAll(".cap-plugin-installer__options > .cap-plugin-option-block"))
153 .map((option) => option.textContent ?? "");
154 ok(localOptionTexts[0]?.includes("Overwrite same-name plugin"), "local install mode shows overwrite before link mode");
155 ok(localOptionTexts[1]?.includes("Developer mode: link source folder"), "local install mode shows link mode after overwrite");
156
157 const chooseFolder = findButton("Choose plugin folder");
158 if (!chooseFolder) throw new Error("missing plugin folder picker button");
159 await act(async () => {
160 chooseFolder.click();
161 await flush();
162 });
163 await waitFor("picked plugin folder source", () => document.body.textContent?.includes("/tmp/superpowers-plugin") ?? false);
164 ok(pickFolderCalls === 1, "clicking Choose folder invokes the plugin folder picker once");
165
166 const gitMode = findButton("Git repository");
167 if (!gitMode) throw new Error("missing Git repository install mode");
168 await act(async () => {
169 gitMode.click();
170 await flush();
171 });
172 ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--git")), "Git plugin install mode uses the shared form grid");
173 const sourceInput = document.querySelector<HTMLInputElement>('input[aria-label="Git repository URL"]');
174 if (!sourceInput) throw new Error("missing plugin git source input");
175 await act(async () => {
176 setInputValue(sourceInput, "git:github.com/obra/superpowers");
177 await flush();
178 });
179 await waitFor("plugin preview enabled", () => findButton("Preview")?.disabled === false);
180
181 const preview = findButton("Preview");
182 if (!preview) throw new Error("missing plugin preview button");
183 await act(async () => {
184 preview.click();
185 await flush();
186 });
187 await waitFor("plugin install plan", () => document.body.textContent?.includes("install_plugin_package") ?? false);
188 ok(planCalls === 1, "clicking Preview invokes plugin install planning once");
189 ok(plannedSources[0] === "git:github.com/obra/superpowers", "plugin preview receives the entered Git source");
190 ok(document.body.textContent?.includes("Partially compatible") ?? false, "preview renders compatibility status");
191 ok(document.body.textContent?.includes("Mapped: skills, agents") ?? false, "preview renders mapped capabilities");
192 ok(document.body.textContent?.includes("hook: unsupported event") ?? false, "preview renders skipped capability reasons");
193
194 const install = findButton("Install plugin");
195 if (!install) throw new Error("missing plugin install button");
196 await act(async () => {
197 install.click();
198 await flush();
199 });
200 await waitFor("plugin install result", () => installCalls === 1 && plugins[0]?.version === "0.1.1");
201 ok(installedSources[0] === "git:github.com/obra/superpowers", "plugin install receives the entered Git source");
202
203 const disclosure = document.querySelector<HTMLButtonElement>(".cap-plugin-entry .cap-disclosure");
204 if (!disclosure) throw new Error("missing plugin disclosure");
205 await act(async () => {
206 disclosure.click();
207 await flush();
208 });
209 await waitFor("plugin update action", () => Boolean(findButton("Update")));
210 ok(document.body.textContent?.includes("How to use") ?? false, "expanded plugin details explain how to use the plugin");
211 ok(document.body.textContent?.includes("/superpowers:plan") ?? false, "expanded plugin details list qualified skill invocations");
212 ok(document.body.textContent?.includes("/superpowers:plan") ?? false, "plugin details show the canonical qualified invocation");
213 ok(document.body.textContent?.includes("qualified name is occupied by a user or project command") ?? false, "occupied canonical command explains the winning source");
214 ok(document.body.textContent?.includes("SessionStart") ?? false, "expanded plugin details list exported hooks");
215 ok(document.body.textContent?.includes("Fully compatible") ?? false, "plugin details show structured compatibility");
216 ok(document.body.textContent?.includes("/superpowers:reviewer") ?? false, "plugin details list imported agents");
217 ok(document.body.textContent?.includes("Context Search") ?? false, "plugin details retain MCP display names");
218 ok(document.body.textContent?.includes("on demand") ?? false, "imported MCP servers are labeled on demand");
219 ok(document.body.textContent?.includes("context") ?? false, "expanded plugin details list exported MCP servers");
220
221 const update = findButton("Update");
222 if (!update) throw new Error("missing plugin update button");
223 await act(async () => {
224 update.click();
225 await flush();
226 });
227 await waitFor("plugin update call", () => updateCalls === 1 && plugins[0]?.version === "0.1.2");
228
229 const doctor = findButton("Doctor");
230 if (!doctor) throw new Error("missing plugin doctor button");
231 await act(async () => {
232 doctor.click();
233 await flush();
234 });
235 await waitFor("plugin diagnostic warning", () => document.body.textContent?.includes("manifest exports no MCP auth metadata") ?? false);
236 ok(doctorCalls === 1, "clicking Doctor invokes plugin diagnostics once");
237
238 const toggle = document.querySelector<HTMLInputElement>(".cap-plugin-entry .cap-switch input");
239 if (!toggle) throw new Error("missing plugin enable toggle");
240 await act(async () => {
241 toggle.click();
242 await flush();
243 });
244 await waitFor("plugin disabled", () => toggleCalls === 1 && plugins[0]?.enabled === false);
245
246 const remove = findButton("Remove plugin");
247 if (!remove) throw new Error("missing plugin remove button");
248 await act(async () => {
249 remove.click();
250 await flush();
251 });
252 const confirmRemove = findButton("Confirm remove");
253 if (!confirmRemove) throw new Error("missing plugin confirm remove button");
254 await act(async () => {
255 confirmRemove.click();
256 await flush();
257 });
258 await waitFor("plugin removed", () => removeCalls === 1 && plugins.length === 0);
259
260 await act(async () => {
261 root.unmount();
262 });
263 dom.window.close();
264 }
265
266 console.log("plugin settings actions passed");
267
267 lines Plain Text