Complete the v3.7.0 MCP refactoring by updating the form layer to match
the unified architecture already implemented in data/service/API layers.
**Breaking Changes:**
- Remove confusing `appId` parameter from McpFormModal
- Replace with `defaultFormat` (json/toml) and `defaultEnabledApps` (array)
**Form Enhancements:**
- Add app enablement checkboxes (Claude/Codex/Gemini) directly in the form
- Smart defaults: new servers default to Claude enabled, editing preserves state
- Support "draft" mode: servers can be created without enabling any apps
**Architecture Improvements:**
- Eliminate semantic confusion: format selection separate from app targeting
- One-step workflow: configure and enable apps in single form submission
- Consistent with unified backend: `apps: { claude, codex, gemini }`
**Testing:**
- Update test mocks to use `useUpsertMcpServer` hook
- Add test case for creating servers with no apps enabled
- Fix parameter references from `appId` to `defaultFormat`
**i18n:**
- Add `mcp.form.enabledApps` translation (zh/en)
- Add `mcp.form.noAppsWarning` translation (zh/en)
This completes the MCP management refactoring, ensuring all layers
(data, service, API, UI) follow the same unified architecture pattern.
410 lines
12 KiB
TypeScript
410 lines
12 KiB
TypeScript
import React from "react";
|
|
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());
|
|
const toastSuccessMock = vi.hoisted(() => vi.fn());
|
|
const upsertMock = vi.hoisted(() => {
|
|
const fn = vi.fn();
|
|
fn.mockResolvedValue(undefined);
|
|
return fn;
|
|
});
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: {
|
|
error: (...args: unknown[]) => toastErrorMock(...args),
|
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, params?: Record<string, unknown>) =>
|
|
params ? `${key}:${JSON.stringify(params)}` : key,
|
|
}),
|
|
// 提供 initReactI18next 以兼容 i18n 初始化路径
|
|
initReactI18next: { type: "3rdParty", init: () => {} },
|
|
}));
|
|
|
|
vi.mock("@/config/mcpPresets", () => ({
|
|
mcpPresets: [
|
|
{
|
|
id: "preset-stdio",
|
|
server: { type: "stdio", command: "preset-cmd" },
|
|
},
|
|
],
|
|
getMcpPresetWithDescription: (preset: any) => ({
|
|
...preset,
|
|
description: "Preset description",
|
|
tags: ["preset"],
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/button", () => ({
|
|
Button: ({ children, onClick, type = "button", ...rest }: any) => (
|
|
<button type={type} onClick={onClick} {...rest}>
|
|
{children}
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/input", () => ({
|
|
Input: ({ value, onChange, ...rest }: any) => (
|
|
<input
|
|
value={value}
|
|
onChange={(event) => onChange?.({ target: { value: event.target.value } })}
|
|
{...rest}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/textarea", () => ({
|
|
Textarea: ({ value, onChange, ...rest }: any) => (
|
|
<textarea
|
|
value={value}
|
|
onChange={(event) => onChange?.({ target: { value: event.target.value } })}
|
|
{...rest}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/checkbox", () => ({
|
|
Checkbox: ({ id, checked, onCheckedChange, ...rest }: any) => (
|
|
<input
|
|
type="checkbox"
|
|
id={id}
|
|
checked={checked ?? false}
|
|
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
|
{...rest}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/dialog", () => ({
|
|
Dialog: ({ children }: any) => <div>{children}</div>,
|
|
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/mcp/McpWizardModal", () => ({
|
|
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("@/hooks/useMcp", async () => {
|
|
const actual = await vi.importActual<typeof import("@/hooks/useMcp")>(
|
|
"@/hooks/useMcp",
|
|
);
|
|
return {
|
|
...actual,
|
|
useUpsertMcpServer: () => ({
|
|
mutateAsync: (...args: unknown[]) => upsertMock(...args),
|
|
}),
|
|
};
|
|
});
|
|
|
|
describe("McpFormModal", () => {
|
|
beforeEach(() => {
|
|
toastErrorMock.mockClear();
|
|
toastSuccessMock.mockClear();
|
|
upsertMock.mockClear();
|
|
});
|
|
|
|
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
|
|
onSave={onSave}
|
|
onClose={onClose}
|
|
existingIds={[]}
|
|
defaultFormat="json"
|
|
defaultEnabledApps={["claude"]}
|
|
{...rest}
|
|
/>,
|
|
);
|
|
return { onSave, onClose };
|
|
};
|
|
|
|
it("应用预设后填充 ID 与配置内容", async () => {
|
|
renderForm();
|
|
await waitFor(() =>
|
|
expect(screen.getByPlaceholderText("mcp.form.titlePlaceholder")).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("preset-stdio"));
|
|
|
|
const idInput = screen.getByPlaceholderText(
|
|
"mcp.form.titlePlaceholder",
|
|
) as HTMLInputElement;
|
|
expect(idInput.value).toBe("preset-stdio");
|
|
|
|
const configTextarea = screen.getByPlaceholderText(
|
|
"mcp.form.jsonPlaceholder",
|
|
) as HTMLTextAreaElement;
|
|
expect(configTextarea.value).toBe('{\n "type": "stdio",\n "command": "preset-cmd"\n}');
|
|
});
|
|
|
|
it("提交时清洗字段并调用 upsert 与 onSave", async () => {
|
|
const { onSave } = renderForm();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
|
target: { value: " my-server " },
|
|
});
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.namePlaceholder"), {
|
|
target: { value: " Friendly " },
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("mcp.form.additionalInfo"));
|
|
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.descriptionPlaceholder"), {
|
|
target: { value: " Description " },
|
|
});
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.tagsPlaceholder"), {
|
|
target: { value: " tag1 , tag2 " },
|
|
});
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.homepagePlaceholder"), {
|
|
target: { value: " https://example.com " },
|
|
});
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.docsPlaceholder"), {
|
|
target: { value: " https://docs.example.com " },
|
|
});
|
|
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), {
|
|
target: { value: '{"type":"stdio","command":"run"}' },
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("common.add"));
|
|
|
|
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
|
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
|
expect(entry).toMatchObject({
|
|
id: "my-server",
|
|
name: "Friendly",
|
|
description: "Description",
|
|
homepage: "https://example.com",
|
|
docs: "https://docs.example.com",
|
|
tags: ["tag1", "tag2"],
|
|
server: {
|
|
type: "stdio",
|
|
command: "run",
|
|
},
|
|
apps: {
|
|
claude: true,
|
|
codex: false,
|
|
gemini: false,
|
|
},
|
|
});
|
|
expect(onSave).toHaveBeenCalledTimes(1);
|
|
expect(onSave).toHaveBeenCalledWith();
|
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("缺少配置命令时阻止提交并提示错误", async () => {
|
|
const { onSave } = renderForm();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
|
target: { value: "no-command" },
|
|
});
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), {
|
|
target: { value: '{"type":"stdio"}' },
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("common.add"));
|
|
|
|
await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());
|
|
expect(upsertMock).not.toHaveBeenCalled();
|
|
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({ defaultFormat: "toml" });
|
|
|
|
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(upsertMock).toHaveBeenCalledTimes(1));
|
|
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
|
expect(entry.id).toBe("demo");
|
|
expect(entry.server).toEqual({ type: "stdio", command: "run" });
|
|
expect(onSave).toHaveBeenCalledTimes(1);
|
|
expect(onSave).toHaveBeenCalledWith();
|
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => {
|
|
const { onSave } = renderForm({ defaultFormat: "toml" });
|
|
|
|
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.tomlInvalid", {
|
|
duration: 3000,
|
|
}),
|
|
);
|
|
expect(upsertMock).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({
|
|
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(upsertMock).toHaveBeenCalledTimes(1));
|
|
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
|
expect(entry.id).toBe("existing");
|
|
expect(entry.server.command).toBe("updated");
|
|
expect(entry.enabled).toBe(true);
|
|
expect(entry.apps).toEqual({
|
|
claude: true,
|
|
codex: false,
|
|
gemini: false,
|
|
});
|
|
expect(onSave).toHaveBeenCalledTimes(1);
|
|
expect(onSave).toHaveBeenCalledWith();
|
|
});
|
|
|
|
it("允许未选择任何应用保存配置,并保持 apps 全 false", async () => {
|
|
const { onSave } = renderForm();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
|
target: { value: "no-apps" },
|
|
});
|
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), {
|
|
target: { value: '{"type":"stdio","command":"run"}' },
|
|
});
|
|
|
|
const claudeCheckbox = screen.getByLabelText(
|
|
"mcp.unifiedPanel.apps.claude",
|
|
) as HTMLInputElement;
|
|
expect(claudeCheckbox.checked).toBe(true);
|
|
fireEvent.click(claudeCheckbox);
|
|
|
|
fireEvent.click(screen.getByText("common.add"));
|
|
|
|
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
|
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
|
expect(entry.id).toBe("no-apps");
|
|
expect(entry.apps).toEqual({
|
|
claude: false,
|
|
codex: false,
|
|
gemini: false,
|
|
});
|
|
expect(onSave).toHaveBeenCalledTimes(1);
|
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|