diff --git a/tests/components/McpFormModal.test.tsx b/tests/components/McpFormModal.test.tsx
index d27c47b..f5d5595 100644
--- a/tests/components/McpFormModal.test.tsx
+++ b/tests/components/McpFormModal.test.tsx
@@ -1,6 +1,6 @@
import React from "react";
-import { render, screen, fireEvent, waitFor } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
+import type { McpServer } from "@/types";
import McpFormModal from "@/components/mcp/McpFormModal";
const toastErrorMock = vi.hoisted(() => vi.fn());
@@ -72,7 +72,21 @@ vi.mock("@/components/ui/dialog", () => ({
}));
vi.mock("@/components/mcp/McpWizardModal", () => ({
- default: () => null,
+ default: ({ isOpen, onApply }: any) =>
+ isOpen ? (
+
+ ) : null,
}));
vi.mock("@/lib/api", async () => {
@@ -94,20 +108,21 @@ describe("McpFormModal", () => {
getConfigMock.mockResolvedValue({ servers: {} });
});
- const renderForm = (props?: Partial>) => {
- const onSave = vi.fn().mockResolvedValue(undefined);
- const onClose = vi.fn();
- render(
- ,
- );
- return { onSave, onClose };
- };
+const renderForm = (props?: Partial>) => {
+ const { onSave: overrideOnSave, onClose: overrideOnClose, ...rest } = props ?? {};
+ const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined);
+ const onClose = overrideOnClose ?? vi.fn();
+ render(
+ ,
+ );
+ return { onSave, onClose };
+};
it("应用预设后填充 ID 与配置内容", async () => {
renderForm();
@@ -223,5 +238,136 @@ describe("McpFormModal", () => {
const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
expect(message).toBe("mcp.error.jsonInvalid");
});
-});
+ it("支持向导生成配置并自动填充 ID", async () => {
+ renderForm();
+ fireEvent.click(screen.getByText("mcp.form.useWizard"));
+
+ const applyButton = await screen.findByTestId("wizard-apply");
+ await act(async () => {
+ fireEvent.click(applyButton);
+ });
+
+ const idInput = screen.getByPlaceholderText(
+ "mcp.form.titlePlaceholder",
+ ) as HTMLInputElement;
+ expect(idInput.value).toBe("wizard-id");
+
+ const configTextarea = screen.getByPlaceholderText(
+ "mcp.form.jsonPlaceholder",
+ ) as HTMLTextAreaElement;
+ expect(configTextarea.value).toBe('{"type":"stdio","command":"wizard-cmd"}');
+ });
+
+ it("TOML 模式下自动提取 ID 并成功保存", async () => {
+ const { onSave } = renderForm({ appType: "codex" });
+
+ const configTextarea = screen.getByPlaceholderText(
+ "mcp.form.tomlPlaceholder",
+ ) as HTMLTextAreaElement;
+
+ const toml = `[mcp.servers.demo]
+type = "stdio"
+command = "run"
+`;
+ fireEvent.change(configTextarea, { target: { value: toml } });
+
+ const idInput = screen.getByPlaceholderText(
+ "mcp.form.titlePlaceholder",
+ ) as HTMLInputElement;
+
+ await waitFor(() => expect(idInput.value).toBe("demo"));
+
+ fireEvent.click(screen.getByText("common.add"));
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ const [id, payload] = onSave.mock.calls[0];
+ expect(id).toBe("demo");
+ expect(payload.server).toEqual({ type: "stdio", command: "run" });
+ expect(toastErrorMock).not.toHaveBeenCalled();
+ });
+
+ it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => {
+ const { onSave } = renderForm({ appType: "codex" });
+
+ const configTextarea = screen.getByPlaceholderText(
+ "mcp.form.tomlPlaceholder",
+ ) as HTMLTextAreaElement;
+
+ const invalidToml = `[mcp.servers.demo]
+type = "stdio"
+`;
+ fireEvent.change(configTextarea, { target: { value: invalidToml } });
+
+ fireEvent.click(screen.getByText("common.add"));
+
+ await waitFor(() =>
+ expect(toastErrorMock).toHaveBeenCalledWith("mcp.error.idRequired", {
+ duration: 3000,
+ }),
+ );
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("编辑模式下保持 ID 并更新配置", async () => {
+ const initialData: McpServer = {
+ id: "existing",
+ name: "Existing",
+ enabled: true,
+ description: "Old desc",
+ server: { type: "stdio", command: "old" },
+ } as McpServer;
+
+ const { onSave } = renderForm({
+ appType: "claude",
+ editingId: "existing",
+ initialData,
+ });
+
+ const idInput = screen.getByPlaceholderText(
+ "mcp.form.titlePlaceholder",
+ ) as HTMLInputElement;
+ expect(idInput.value).toBe("existing");
+ expect(idInput).toHaveAttribute("disabled");
+
+ const configTextarea = screen.getByPlaceholderText(
+ "mcp.form.jsonPlaceholder",
+ ) as HTMLTextAreaElement;
+ expect(configTextarea.value).toContain("\"command\": \"old\"");
+
+ fireEvent.change(configTextarea, {
+ target: { value: '{"type":"stdio","command":"updated"}' },
+ });
+
+ fireEvent.click(screen.getByText("common.save"));
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ const [id, entry, options] = onSave.mock.calls[0];
+ expect(id).toBe("existing");
+ expect(entry.server.command).toBe("updated");
+ expect(entry.enabled).toBe(true);
+ expect(options).toEqual({ syncOtherSide: false });
+ });
+
+ it("保存失败时展示翻译后的错误并恢复按钮", async () => {
+ const failingSave = vi.fn().mockRejectedValue(new Error("保存失败"));
+ renderForm({ onSave: failingSave });
+
+ fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
+ target: { value: "will-fail" },
+ });
+ fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), {
+ target: { value: '{"type":"stdio","command":"ok"}' },
+ });
+
+ fireEvent.click(screen.getByText("common.add"));
+
+ await waitFor(() => expect(failingSave).toHaveBeenCalled());
+ await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());
+ const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
+ expect(message).toBe("保存失败");
+
+ const addButton = screen.getByText("common.add") as HTMLButtonElement;
+ expect(addButton.disabled).toBe(false);
+ });
+});
diff --git a/tests/hooks/useMcpActions.test.tsx b/tests/hooks/useMcpActions.test.tsx
index 2931a8b..49645cf 100644
--- a/tests/hooks/useMcpActions.test.tsx
+++ b/tests/hooks/useMcpActions.test.tsx
@@ -17,6 +17,8 @@ const getConfigMock = vi.fn();
const setEnabledMock = vi.fn();
const upsertServerInConfigMock = vi.fn();
const deleteServerInConfigMock = vi.fn();
+const syncEnabledToClaudeMock = vi.fn();
+const syncEnabledToCodexMock = vi.fn();
vi.mock("@/lib/api", () => ({
mcpApi: {
@@ -24,6 +26,8 @@ vi.mock("@/lib/api", () => ({
setEnabled: (...args: unknown[]) => setEnabledMock(...args),
upsertServerInConfig: (...args: unknown[]) => upsertServerInConfigMock(...args),
deleteServerInConfig: (...args: unknown[]) => deleteServerInConfigMock(...args),
+ syncEnabledToClaude: (...args: unknown[]) => syncEnabledToClaudeMock(...args),
+ syncEnabledToCodex: (...args: unknown[]) => syncEnabledToCodexMock(...args),
},
}));
@@ -60,6 +64,8 @@ describe("useMcpActions", () => {
setEnabledMock.mockReset();
upsertServerInConfigMock.mockReset();
deleteServerInConfigMock.mockReset();
+ syncEnabledToClaudeMock.mockReset();
+ syncEnabledToCodexMock.mockReset();
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
@@ -239,4 +245,36 @@ describe("useMcpActions", () => {
expect(captured).toBe(failure);
expect(toastErrorMock).toHaveBeenCalledWith("delete failed", { duration: 6000 });
});
+
+ it("maps backend error message when save fails with known detail", async () => {
+ const serverInput = createServer({ id: "input-id" });
+ const backendError = { message: "stdio 类型的 MCP 服务器必须包含 command 字段" };
+ upsertServerInConfigMock.mockRejectedValueOnce(backendError);
+ const { result } = renderUseMcpActions();
+
+ await expect(async () =>
+ result.current.saveServer("server-1", serverInput),
+ ).rejects.toEqual(backendError);
+
+ expect(toastErrorMock).toHaveBeenCalledWith("mcp.error.commandRequired", {
+ duration: 6000,
+ });
+ });
+
+ it("syncs enabled state to counterpart when appType is claude", async () => {
+ const server = createServer();
+ getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
+ const { result } = renderUseMcpActions();
+
+ await act(async () => {
+ await result.current.reload();
+ });
+
+ await act(async () => {
+ await result.current.toggleEnabled(server.id, true);
+ });
+
+ expect(setEnabledMock).toHaveBeenCalledWith("claude", server.id, true);
+ expect(syncEnabledToClaudeMock).not.toHaveBeenCalled();
+ });
});
diff --git a/tests/integration/McpPanel.test.tsx b/tests/integration/McpPanel.test.tsx
index f27ca90..15451c0 100644
--- a/tests/integration/McpPanel.test.tsx
+++ b/tests/integration/McpPanel.test.tsx
@@ -229,3 +229,4 @@ describe("McpPanel integration", () => {
await waitFor(() => expect(deleteServerMock).toHaveBeenCalledWith("sample"));
});
});
+