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:
Jason
2025-10-26 15:03:05 +08:00
parent c3f712bc18
commit 885dd94803
3 changed files with 203 additions and 18 deletions

View File

@@ -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 ? (
<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 () => {
@@ -94,20 +108,21 @@ describe("McpFormModal", () => {
getConfigMock.mockResolvedValue({ servers: {} });
});
const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>) => {
const onSave = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
render(
<McpFormModal
appType="claude"
onSave={onSave}
onClose={onClose}
existingIds={[]}
{...props}
/>,
);
return { onSave, onClose };
};
const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>) => {
const { onSave: overrideOnSave, onClose: overrideOnClose, ...rest } = props ?? {};
const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined);
const onClose = overrideOnClose ?? vi.fn();
render(
<McpFormModal
appType="claude"
onSave={onSave}
onClose={onClose}
existingIds={[]}
{...rest}
/>,
);
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);
});
});