## 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 ✅
233 lines
6.6 KiB
TypeScript
233 lines
6.6 KiB
TypeScript
import React from "react";
|
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import McpPanel from "@/components/mcp/McpPanel";
|
|
import type { McpServer } from "@/types";
|
|
import { createTestQueryClient } from "../utils/testQueryClient";
|
|
|
|
const toastSuccessMock = vi.hoisted(() => vi.fn());
|
|
const toastErrorMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: {
|
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
|
error: (...args: unknown[]) => toastErrorMock(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, params?: Record<string, unknown>) =>
|
|
params ? `${key}:${JSON.stringify(params)}` : key,
|
|
}),
|
|
}));
|
|
|
|
const importFromClaudeMock = vi.hoisted(() => vi.fn().mockResolvedValue(1));
|
|
const importFromCodexMock = vi.hoisted(() => vi.fn().mockResolvedValue(1));
|
|
|
|
const toggleEnabledMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
const saveServerMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
const deleteServerMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
const reloadMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
|
|
const baseServers: Record<string, McpServer> = {
|
|
sample: {
|
|
id: "sample",
|
|
name: "Sample Claude Server",
|
|
enabled: true,
|
|
server: {
|
|
type: "stdio",
|
|
command: "claude-server",
|
|
},
|
|
},
|
|
};
|
|
|
|
vi.mock("@/lib/api", async () => {
|
|
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
|
return {
|
|
...actual,
|
|
mcpApi: {
|
|
...actual.mcpApi,
|
|
importFromClaude: (...args: unknown[]) =>
|
|
importFromClaudeMock(...args),
|
|
importFromCodex: (...args: unknown[]) => importFromCodexMock(...args),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@/components/mcp/McpListItem", () => ({
|
|
default: ({ id, server, onToggle, onEdit, onDelete }: any) => (
|
|
<div data-testid={`mcp-item-${id}`}>
|
|
<span>{server.name || id}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggle(id, !server.enabled)}
|
|
data-testid={`toggle-${id}`}
|
|
>
|
|
toggle
|
|
</button>
|
|
<button type="button" onClick={() => onEdit(id)} data-testid={`edit-${id}`}>
|
|
edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onDelete(id)}
|
|
data-testid={`delete-${id}`}
|
|
>
|
|
delete
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/mcp/McpFormModal", () => ({
|
|
default: ({ onSave, onClose }: any) => (
|
|
<div data-testid="mcp-form">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
onSave(
|
|
"new-server",
|
|
{
|
|
id: "new-server",
|
|
name: "New Server",
|
|
enabled: true,
|
|
server: { type: "stdio", command: "new.cmd" },
|
|
},
|
|
{ syncOtherSide: true },
|
|
)
|
|
}
|
|
>
|
|
submit-form
|
|
</button>
|
|
<button type="button" onClick={onClose}>
|
|
close-form
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/button", () => ({
|
|
Button: ({ children, onClick, ...rest }: any) => (
|
|
<button type="button" onClick={onClick} {...rest}>
|
|
{children}
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/dialog", () => ({
|
|
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
|
|
DialogContent: ({ children }: any) => <div>{children}</div>,
|
|
DialogHeader: ({ children }: any) => <div>{children}</div>,
|
|
DialogTitle: ({ children }: any) => <div>{children}</div>,
|
|
DialogFooter: ({ children }: any) => <div>{children}</div>,
|
|
}));
|
|
|
|
vi.mock("@/components/ConfirmDialog", () => ({
|
|
ConfirmDialog: ({ isOpen, onConfirm }: any) =>
|
|
isOpen ? (
|
|
<div data-testid="confirm-dialog">
|
|
<button type="button" onClick={onConfirm}>
|
|
confirm-delete
|
|
</button>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
const renderPanel = (props?: Partial<React.ComponentProps<typeof McpPanel>>) => {
|
|
const client = createTestQueryClient();
|
|
return render(
|
|
<QueryClientProvider client={client}>
|
|
<McpPanel open onOpenChange={() => {}} appType="claude" {...props} />
|
|
</QueryClientProvider>,
|
|
);
|
|
};
|
|
|
|
const useMcpActionsMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/hooks/useMcpActions", () => ({
|
|
useMcpActions: (...args: unknown[]) => useMcpActionsMock(...args),
|
|
}));
|
|
|
|
describe("McpPanel integration", () => {
|
|
beforeEach(() => {
|
|
toastSuccessMock.mockReset();
|
|
toastErrorMock.mockReset();
|
|
importFromClaudeMock.mockClear();
|
|
importFromClaudeMock.mockResolvedValue(1);
|
|
importFromCodexMock.mockClear();
|
|
importFromCodexMock.mockResolvedValue(1);
|
|
|
|
toggleEnabledMock.mockClear();
|
|
saveServerMock.mockClear();
|
|
deleteServerMock.mockClear();
|
|
reloadMock.mockClear();
|
|
|
|
useMcpActionsMock.mockReturnValue({
|
|
servers: baseServers,
|
|
loading: false,
|
|
reload: reloadMock,
|
|
toggleEnabled: toggleEnabledMock,
|
|
saveServer: saveServerMock,
|
|
deleteServer: deleteServerMock,
|
|
});
|
|
});
|
|
|
|
it("加载并切换 MCP 启用状态", async () => {
|
|
renderPanel();
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("mcp-item-sample")).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId("toggle-sample"));
|
|
|
|
await waitFor(() =>
|
|
expect(toggleEnabledMock).toHaveBeenCalledWith("sample", false),
|
|
);
|
|
});
|
|
|
|
it("新增 MCP 并触发保存与同步选项", async () => {
|
|
renderPanel();
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByText((content) => content.startsWith("mcp.serverCount")),
|
|
).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("mcp.add"));
|
|
await waitFor(() => expect(screen.getByTestId("mcp-form")).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByText("submit-form"));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.queryByTestId("mcp-form")).not.toBeInTheDocument(),
|
|
);
|
|
await waitFor(() =>
|
|
expect(saveServerMock).toHaveBeenCalledWith(
|
|
"new-server",
|
|
expect.objectContaining({ id: "new-server" }),
|
|
{ syncOtherSide: true },
|
|
),
|
|
);
|
|
});
|
|
|
|
it("删除 MCP 并发送确认请求", async () => {
|
|
renderPanel();
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("mcp-item-sample")).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId("delete-sample"));
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("confirm-dialog")).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("confirm-delete"));
|
|
|
|
await waitFor(() => expect(deleteServerMock).toHaveBeenCalledWith("sample"));
|
|
});
|
|
});
|
|
|