Files
cc-switch/tests/components/McpFormModal.test.tsx
YoVinchen 3d69da5b66 feat: add model configuration support and fix Gemini deeplink bug (#251)
* feat(providers): add notes field for provider management

- Add notes field to Provider model (backend and frontend)
- Display notes with higher priority than URL in provider card
- Style notes as non-clickable text to differentiate from URLs
- Add notes input field in provider form
- Add i18n support (zh/en) for notes field

* chore: format code and clean up unused props

- Run cargo fmt on Rust backend code
- Format TypeScript imports and code style
- Remove unused appId prop from ProviderPresetSelector
- Clean up unused variables in tests
- Integrate notes field handling in provider dialogs

* feat(deeplink): implement ccswitch:// protocol for provider import

Add deep link support to enable one-click provider configuration import via ccswitch:// URLs.

Backend:
- Implement URL parsing and validation (src-tauri/src/deeplink.rs)
- Add Tauri commands for parse and import (src-tauri/src/commands/deeplink.rs)
- Register ccswitch:// protocol in macOS Info.plist
- Add comprehensive unit tests (src-tauri/tests/deeplink_import.rs)

Frontend:
- Create confirmation dialog with security review UI (src/components/DeepLinkImportDialog.tsx)
- Add API wrapper (src/lib/api/deeplink.ts)
- Integrate event listeners in App.tsx

Configuration:
- Update Tauri config for deep link handling
- Add i18n support for Chinese and English
- Include test page for deep link validation (deeplink-test.html)

Files: 15 changed, 1312 insertions(+)

* chore(deeplink): integrate deep link handling into app lifecycle

Wire up deep link infrastructure with app initialization and event handling.

Backend Integration:
- Register deep link module and commands in mod.rs
- Add URL handling in app setup (src-tauri/src/lib.rs:handle_deeplink_url)
- Handle deep links from single instance callback (Windows/Linux CLI)
- Handle deep links from macOS system events
- Add tauri-plugin-deep-link dependency (Cargo.toml)

Frontend Integration:
- Listen for deeplink-import/deeplink-error events in App.tsx
- Update DeepLinkImportDialog component imports

Configuration:
- Enable deep link plugin in tauri.conf.json
- Update Cargo.lock for new dependencies

Localization:
- Add Chinese translations for deep link UI (zh.json)
- Add English translations for deep link UI (en.json)

Files: 9 changed, 359 insertions(+), 18 deletions(-)

* refactor(deeplink): enhance Codex provider template generation

Align deep link import with UI preset generation logic by:
- Adding complete config.toml template matching frontend defaults
- Generating safe provider name from sanitized input
- Including model_provider, reasoning_effort, and wire_api settings
- Removing minimal template that only contained base_url
- Cleaning up deprecated test file deeplink-test.html

* style: fix clippy uninlined_format_args warnings

Apply clippy --fix to use inline format arguments in:
- src/mcp.rs (8 fixes)
- src/services/env_manager.rs (10 fixes)

* style: apply code formatting and cleanup

- Format TypeScript files with Prettier (App.tsx, EnvWarningBanner.tsx, formatters.ts)
- Organize Rust imports and module order alphabetically
- Add newline at end of JSON files (en.json, zh.json)
- Update Cargo.lock for dependency changes

* feat: add model name configuration support for Codex and fix Gemini model handling

- Add visual model name input field for Codex providers
  - Add model name extraction and update utilities in providerConfigUtils
  - Implement model name state management in useCodexConfigState hook
  - Add conditional model field rendering in CodexFormFields (non-official only)
  - Integrate model name sync with TOML config in ProviderForm

- Fix Gemini deeplink model injection bug
  - Correct environment variable name from GOOGLE_GEMINI_MODEL to GEMINI_MODEL
  - Add test cases for Gemini model injection (with/without model)
  - All tests passing (9/9)

- Fix Gemini model field binding in edit mode
  - Add geminiModel state to useGeminiConfigState hook
  - Extract model value during initialization and reset
  - Sync model field with geminiEnv state to prevent data loss on submit
  - Fix missing model value display when editing Gemini providers

Changes:
  - 6 files changed, 245 insertions(+), 13 deletions(-)
2025-11-19 09:03:18 +08:00

427 lines
13 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"
{...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: true,
gemini: true,
},
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith();
expect(toastErrorMock).not.toHaveBeenCalled();
});
it("缺少配置命令时阻止提交并提示错误", async () => {
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 () => {
renderForm({ defaultFormat: "toml" });
// 填写 ID 字段
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
target: { value: "test-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" },
apps: { claude: true, codex: false, gemini: false },
} 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);
const codexCheckbox = screen.getByLabelText(
"mcp.unifiedPanel.apps.codex",
) as HTMLInputElement;
expect(codexCheckbox.checked).toBe(true);
fireEvent.click(codexCheckbox);
const geminiCheckbox = screen.getByLabelText(
"mcp.unifiedPanel.apps.gemini",
) as HTMLInputElement;
expect(geminiCheckbox.checked).toBe(true);
fireEvent.click(geminiCheckbox);
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);
});
});