test: extend MCP UI test coverage with wizard, TOML, and error handling
## McpFormModal Component Tests (+5 tests)
### Infrastructure Improvements
- Enhance McpWizardModal mock from null to functional mock with testable onApply callback
- Refactor renderForm helper to support custom onSave/onClose mock injection
- Add McpServer type import for type-safe test data
### New Test Cases
1. **Wizard Integration**: Verify wizard generates config and auto-fills ID + JSON fields
- Click "Use Wizard" → Apply → Form fields populated with wizard-id and config
- Uses act() wrapper for React 18 async state updates
2. **TOML Auto-extraction (Codex)**: Test TOML → JSON conversion with ID extraction
- Parse `[mcp.servers.demo]` → auto-fill ID as "demo"
- Verify server object correctly parsed from TOML format
- Codex-specific feature for config.toml compatibility
3. **TOML Validation Error**: Test missing required field handling
- TOML with type="stdio" but no command → block submit
- Display localized error toast: mcp.error.idRequired (3s duration)
4. **Edit Mode Immutability**: Verify ID field disabled during edit
- ID input has disabled attribute and keeps original value
- Config updates applied while enabled state preserved
- syncOtherSide defaults to false in edit mode
5. **Error Recovery**: Test save failure button state restoration
- Inject failing onSave mock → trigger error
- Verify toast error displays translated message
- Submit button disabled state resets to false for retry
## useMcpActions Hook Tests (+2 tests)
### New API Mocks
- Add syncEnabledToClaude and syncEnabledToCodex mock functions
### New Test Cases
1. **Backend Error Message Mapping**: Map Chinese error to i18n key
- Backend: "stdio 类型的 MCP 服务器必须包含 command 字段"
- Frontend: mcp.error.commandRequired (6s toast duration)
2. **Cross-app Sync Logic**: Verify conditional sync behavior
- claude → claude: setEnabled called, syncEnabledToClaude NOT called
- Validates sync only occurs when crossing app types
## Minor Changes
- McpPanel.test.tsx: Add trailing newline (formatter compliance)
## Test Coverage
- Test files: 17 (unchanged)
- Total tests: 112 → 119 (+7, +6.3%)
- Execution time: 3.20s
- All 119 tests passing ✅
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import type { McpServer } from "@/types";
|
||||||
import McpFormModal from "@/components/mcp/McpFormModal";
|
import McpFormModal from "@/components/mcp/McpFormModal";
|
||||||
|
|
||||||
const toastErrorMock = vi.hoisted(() => vi.fn());
|
const toastErrorMock = vi.hoisted(() => vi.fn());
|
||||||
@@ -72,7 +72,21 @@ vi.mock("@/components/ui/dialog", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/mcp/McpWizardModal", () => ({
|
vi.mock("@/components/mcp/McpWizardModal", () => ({
|
||||||
default: () => null,
|
default: ({ isOpen, onApply }: any) =>
|
||||||
|
isOpen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="wizard-apply"
|
||||||
|
onClick={() =>
|
||||||
|
onApply(
|
||||||
|
"wizard-id",
|
||||||
|
JSON.stringify({ type: "stdio", command: "wizard-cmd" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
wizard-apply
|
||||||
|
</button>
|
||||||
|
) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/api", async () => {
|
vi.mock("@/lib/api", async () => {
|
||||||
@@ -95,15 +109,16 @@ describe("McpFormModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>) => {
|
const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>) => {
|
||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
const { onSave: overrideOnSave, onClose: overrideOnClose, ...rest } = props ?? {};
|
||||||
const onClose = vi.fn();
|
const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const onClose = overrideOnClose ?? vi.fn();
|
||||||
render(
|
render(
|
||||||
<McpFormModal
|
<McpFormModal
|
||||||
appType="claude"
|
appType="claude"
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
existingIds={[]}
|
existingIds={[]}
|
||||||
{...props}
|
{...rest}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
return { onSave, onClose };
|
return { onSave, onClose };
|
||||||
@@ -223,5 +238,136 @@ describe("McpFormModal", () => {
|
|||||||
const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
|
const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
|
||||||
expect(message).toBe("mcp.error.jsonInvalid");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const getConfigMock = vi.fn();
|
|||||||
const setEnabledMock = vi.fn();
|
const setEnabledMock = vi.fn();
|
||||||
const upsertServerInConfigMock = vi.fn();
|
const upsertServerInConfigMock = vi.fn();
|
||||||
const deleteServerInConfigMock = vi.fn();
|
const deleteServerInConfigMock = vi.fn();
|
||||||
|
const syncEnabledToClaudeMock = vi.fn();
|
||||||
|
const syncEnabledToCodexMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/lib/api", () => ({
|
vi.mock("@/lib/api", () => ({
|
||||||
mcpApi: {
|
mcpApi: {
|
||||||
@@ -24,6 +26,8 @@ vi.mock("@/lib/api", () => ({
|
|||||||
setEnabled: (...args: unknown[]) => setEnabledMock(...args),
|
setEnabled: (...args: unknown[]) => setEnabledMock(...args),
|
||||||
upsertServerInConfig: (...args: unknown[]) => upsertServerInConfigMock(...args),
|
upsertServerInConfig: (...args: unknown[]) => upsertServerInConfigMock(...args),
|
||||||
deleteServerInConfig: (...args: unknown[]) => deleteServerInConfigMock(...args),
|
deleteServerInConfig: (...args: unknown[]) => deleteServerInConfigMock(...args),
|
||||||
|
syncEnabledToClaude: (...args: unknown[]) => syncEnabledToClaudeMock(...args),
|
||||||
|
syncEnabledToCodex: (...args: unknown[]) => syncEnabledToCodexMock(...args),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -60,6 +64,8 @@ describe("useMcpActions", () => {
|
|||||||
setEnabledMock.mockReset();
|
setEnabledMock.mockReset();
|
||||||
upsertServerInConfigMock.mockReset();
|
upsertServerInConfigMock.mockReset();
|
||||||
deleteServerInConfigMock.mockReset();
|
deleteServerInConfigMock.mockReset();
|
||||||
|
syncEnabledToClaudeMock.mockReset();
|
||||||
|
syncEnabledToCodexMock.mockReset();
|
||||||
toastSuccessMock.mockReset();
|
toastSuccessMock.mockReset();
|
||||||
toastErrorMock.mockReset();
|
toastErrorMock.mockReset();
|
||||||
|
|
||||||
@@ -239,4 +245,36 @@ describe("useMcpActions", () => {
|
|||||||
expect(captured).toBe(failure);
|
expect(captured).toBe(failure);
|
||||||
expect(toastErrorMock).toHaveBeenCalledWith("delete failed", { duration: 6000 });
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -229,3 +229,4 @@ describe("McpPanel integration", () => {
|
|||||||
await waitFor(() => expect(deleteServerMock).toHaveBeenCalledWith("sample"));
|
await waitFor(() => expect(deleteServerMock).toHaveBeenCalledWith("sample"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user