feat(webui): add MCP management dialog

This commit is contained in:
chengyongru
2026-08-12 18:28:23 +08:00
committed by chengyongru
parent 19997d20bb
commit e455a2b7fa
16 changed files with 1414 additions and 303 deletions
@@ -0,0 +1,135 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
McpManagementDialog,
type McpManagementTab,
} from "@/components/settings/system/McpManagementDialog";
import i18n from "@/i18n";
import type { McpPresetInfo } from "@/lib/types";
const connectedPreset: McpPresetInfo = {
name: "docs",
display_name: "Docs MCP",
category: "productivity",
description: "Search and maintain the team knowledge base.",
docs_url: "https://example.com/docs-mcp",
transport: "streamableHttp",
auth: null,
requires: "",
note: "",
install_supported: true,
installed: true,
configured: true,
available: true,
status: "configured",
runtime_status: "connected",
required_fields: [],
connection_summary: "https://mcp.example.com/mcp",
tool_count: 3,
tool_names: ["search_docs", "write_note", "list_sources"],
enabled_tools: ["*"],
checked_at: "2026-08-12T08:00:00Z",
source: "custom",
};
describe("McpManagementDialog", () => {
beforeEach(async () => {
await i18n.changeLanguage("en");
});
it("keeps tool scope changes as a draft until Save changes", () => {
const onToolsChange = vi.fn();
renderDialog({ initialTab: "tools", onToolsChange });
const dialog = screen.getByRole("dialog", { name: "Docs MCP" });
expect(dialog).toHaveClass("h-[min(34rem,calc(100dvh-2rem))]");
expect(within(dialog).getByRole("tab", { name: /Tools/ })).toHaveAttribute("aria-selected", "true");
expect(within(dialog).getByText("3 enabled")).toBeInTheDocument();
expect(within(dialog).queryByText("Close")).not.toBeInTheDocument();
const searchDocs = within(dialog).getByRole("checkbox", { name: /search_docs/ });
expect(searchDocs).toBeChecked();
fireEvent.click(searchDocs);
expect(searchDocs).not.toBeChecked();
expect(within(dialog).getByText("2 enabled")).toBeInTheDocument();
expect(onToolsChange).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole("button", { name: "Save changes" }));
expect(onToolsChange).toHaveBeenCalledWith("docs", ["write_note", "list_sources"]);
});
it("searches the inventory and exposes connection management in the same modal", () => {
const onAction = vi.fn();
renderDialog({ initialTab: "tools", onAction });
const dialog = screen.getByRole("dialog", { name: "Docs MCP" });
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search tools" }), {
target: { value: "write" },
});
expect(within(dialog).getByRole("checkbox", { name: /write_note/ })).toBeInTheDocument();
expect(within(dialog).queryByRole("checkbox", { name: /search_docs/ })).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("tab", { name: "Connection" }));
expect(within(dialog).getByText("https://mcp.example.com/mcp")).toBeInTheDocument();
expect(within(dialog).getByText("Streamable HTTP")).toBeInTheDocument();
expect(within(dialog).getByRole("link", { name: "Open docs" })).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "Remove connection" })).toBeInTheDocument();
expect(within(dialog).queryByText("Last checked")).not.toBeInTheDocument();
expect(within(dialog).queryByText("Connection actions apply immediately.")).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Reconnect" }));
expect(onAction).toHaveBeenCalledWith("reconnect", "docs", {});
});
it("loads tools on entry and replaces passive inspection copy with recovery", async () => {
const onAction = vi.fn();
renderDialog({
initialTab: "tools",
onAction,
preset: { ...connectedPreset, tool_count: 0, tool_names: [], enabled_tools: ["*"] },
});
const dialog = screen.getByRole("dialog", { name: "Docs MCP" });
await waitFor(() => expect(onAction).toHaveBeenCalledWith("test", "docs"));
expect(within(dialog).queryByText("Not inspected")).not.toBeInTheDocument();
expect(within(dialog).getByText("No tools available")).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Reload tools" }));
expect(onAction).toHaveBeenCalledTimes(2);
});
});
function renderDialog({
initialTab,
onAction = vi.fn(),
onToolsChange = vi.fn(),
preset = connectedPreset,
}: {
initialTab: McpManagementTab;
onAction?: ReturnType<typeof vi.fn>;
onToolsChange?: ReturnType<typeof vi.fn>;
preset?: McpPresetInfo;
}) {
function Harness() {
const [tab, setTab] = useState(initialTab);
return (
<McpManagementDialog
preset={preset}
values={{}}
actionKey={null}
statusLabel="Connected"
statusTone="success"
tab={tab}
icon={<span aria-hidden>DM</span>}
onTabChange={setTab}
onOpenChange={vi.fn()}
onFieldChange={vi.fn()}
onAction={onAction}
onOAuthConnect={vi.fn()}
onToolsChange={onToolsChange}
/>
);
}
return render(<Harness />);
}
+26 -22
View File
@@ -128,8 +128,8 @@ describe("SettingsView Apps catalog", () => {
);
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
.toHaveTextContent("Configured");
expect(await screen.findByRole("button", { name: "Manage Xmind" }, { timeout: 2500 }))
.toHaveTextContent("Manage");
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
expect(screen.queryByText("Xmind connected.")).not.toBeInTheDocument();
expect(screen.queryByText(/some servers did not connect: notion/i)).not.toBeInTheDocument();
@@ -182,22 +182,21 @@ describe("SettingsView Apps catalog", () => {
expect(failed.closest("button")).toBeNull();
expect(failed.closest("p")?.querySelector(".lucide-triangle-alert")).not.toBeNull();
expect(row?.querySelector(".lucide-check")).toBeNull();
expect(within(row as HTMLElement).getByRole("button", { name: "Reconnect Xmind" })).toHaveTextContent(
"Reconnect",
expect(within(row as HTMLElement).getByRole("button", { name: "Manage Xmind" })).toHaveTextContent(
"Fix connection",
);
const actions = within(row as HTMLElement).getByRole("button", { name: "Actions for Xmind" });
expect(actions).toHaveAttribute("aria-haspopup", "menu");
fireEvent.pointerDown(actions, { button: 0, ctrlKey: false });
expect(await screen.findByRole("menuitem", { name: "Test" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
await act(() => i18n.changeLanguage("zh-CN"));
expect(within(row as HTMLElement).getByText("连接失败。")).toBeInTheDocument();
expect(within(row as HTMLElement).getByRole("button", { name: "Xmind 操作" }))
.toBeInTheDocument();
const reconnect = screen.getByRole("button", { name: "重新连接 Xmind" });
expect(reconnect).toHaveTextContent("重新连接");
const manage = within(row as HTMLElement).getByRole("button", { name: "管理 Xmind" });
expect(manage).toHaveTextContent("修复连接");
fireEvent.click(manage);
const dialog = screen.getByRole("dialog", { name: "Xmind" });
expect(within(dialog).getByRole("tab", { name: "连接" })).toHaveAttribute("aria-selected", "true");
expect(within(dialog).getByText("创建、读取和编辑 Xmind 云端思维导图。")).toHaveClass("sr-only");
expect(within(dialog).getByText("连接失败", { exact: true })).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "移除连接" })).toBeInTheDocument();
const reconnect = within(dialog).getByRole("button", { name: "重新连接" });
fireEvent.click(reconnect);
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
@@ -246,9 +245,9 @@ describe("SettingsView Apps catalog", () => {
.toHaveTextContent("Connecting…");
expect(await screen.findByRole(
"button",
{ name: "Xmind: Connected." },
{ name: "Manage Xmind" },
{ timeout: 2_500 },
)).toHaveTextContent("Connected.");
)).toHaveTextContent("Manage");
expect(mcpPresetRequests).toBe(2);
fireEvent.click(screen.getByRole("button", { name: "Ready" }));
@@ -299,15 +298,17 @@ describe("SettingsView Apps catalog", () => {
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
expect(await screen.findByText("Connection failed.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Reconnect team-docs" }));
fireEvent.click(screen.getByRole("button", { name: "Manage team-docs" }));
fireEvent.click(within(screen.getByRole("dialog", { name: "team-docs" }))
.getByRole("button", { name: "Reconnect" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.reconnect",
{ name: "team-docs" },
20_000,
));
const connected = await screen.findByRole("button", { name: "team-docs: Connected." });
expect(connected).toHaveTextContent("Connected.");
const connected = await screen.findByRole("button", { name: "Manage team-docs" });
expect(connected).toHaveTextContent("Manage");
expect(connected.querySelector(".lucide-check")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Ready" }));
const readyHeading = await screen.findByRole("heading", { name: "team-docs" });
@@ -500,8 +501,8 @@ describe("SettingsView Apps catalog", () => {
{ flow_id: "flow-manual", callback_url: callbackUrl },
20_000,
));
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
.toHaveTextContent("Configured");
expect(await screen.findByRole("button", { name: "Manage Xmind" }, { timeout: 2500 }))
.toHaveTextContent("Manage");
expect(screen.queryByRole("textbox", { name: "Full callback URL" })).not.toBeInTheDocument();
expect(popup.close).toHaveBeenCalledTimes(1);
});
@@ -610,7 +611,10 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Remove" }));
fireEvent.click(await screen.findByRole("button", { name: "Manage Xmind" }));
const dialog = screen.getByRole("dialog", { name: "Xmind" });
fireEvent.click(within(dialog).getByRole("tab", { name: "Connection" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Remove connection" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.remove",