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")); }); }); +