refactor(mcp): complete form refactoring for unified MCP management
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.
This commit is contained in:
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -30,26 +31,28 @@ import { useMcpValidation } from "./useMcpValidation";
|
||||
import { useUpsertMcpServer } from "@/hooks/useMcp";
|
||||
|
||||
interface McpFormModalProps {
|
||||
appId: AppId;
|
||||
editingId?: string;
|
||||
initialData?: McpServer;
|
||||
onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调
|
||||
onClose: () => void;
|
||||
existingIds?: string[];
|
||||
defaultFormat?: "json" | "toml"; // 默认配置格式(可选,默认为 JSON)
|
||||
defaultEnabledApps?: AppId[]; // 默认启用到哪些应用(可选,默认为 Claude)
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP 表单模态框组件(简化版)
|
||||
* Claude: 使用 JSON 格式
|
||||
* Codex: 使用 TOML 格式
|
||||
* MCP 表单模态框组件(v3.7.0 完整重构版)
|
||||
* - 支持 JSON 和 TOML 两种格式
|
||||
* - 统一管理,通过复选框选择启用到哪些应用
|
||||
*/
|
||||
const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
appId,
|
||||
editingId,
|
||||
initialData,
|
||||
onSave,
|
||||
onClose,
|
||||
existingIds = [],
|
||||
defaultFormat = "json",
|
||||
defaultEnabledApps = ["claude"],
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
||||
@@ -68,6 +71,23 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
|
||||
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
|
||||
|
||||
// 启用状态:编辑模式使用现有值,新增模式使用默认值
|
||||
const [enabledApps, setEnabledApps] = useState<{
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
}>(() => {
|
||||
if (initialData?.apps) {
|
||||
return { ...initialData.apps };
|
||||
}
|
||||
// 新增模式:根据 defaultEnabledApps 设置初始值
|
||||
return {
|
||||
claude: defaultEnabledApps.includes("claude"),
|
||||
codex: defaultEnabledApps.includes("codex"),
|
||||
gemini: defaultEnabledApps.includes("gemini"),
|
||||
};
|
||||
});
|
||||
|
||||
// 编辑模式下禁止修改 ID
|
||||
const isEditing = !!editingId;
|
||||
|
||||
@@ -84,11 +104,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
isEditing ? hasAdditionalInfo : false,
|
||||
);
|
||||
|
||||
// 根据 appId 决定初始格式
|
||||
// 配置格式:优先使用 defaultFormat,编辑模式下可从现有数据推断
|
||||
const useTomlFormat = useMemo(() => {
|
||||
if (initialData?.server) {
|
||||
// 编辑模式:尝试从现有数据推断格式(这里简化处理,默认 JSON)
|
||||
return defaultFormat === "toml";
|
||||
}
|
||||
return defaultFormat === "toml";
|
||||
}, [defaultFormat, initialData]);
|
||||
|
||||
// 根据格式决定初始配置
|
||||
const [formConfig, setFormConfig] = useState(() => {
|
||||
const spec = initialData?.server;
|
||||
if (!spec) return "";
|
||||
if (appId === "codex") {
|
||||
if (useTomlFormat) {
|
||||
return mcpServerToToml(spec);
|
||||
}
|
||||
return JSON.stringify(spec, null, 2);
|
||||
@@ -99,8 +128,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [idError, setIdError] = useState("");
|
||||
|
||||
// 判断是否使用 TOML 格式
|
||||
const useToml = appId === "codex";
|
||||
// 判断是否使用 TOML 格式(向后兼容,后续可扩展为格式切换按钮)
|
||||
const useToml = useTomlFormat;
|
||||
|
||||
const wizardInitialSpec = useMemo(() => {
|
||||
const fallback = initialData?.server;
|
||||
@@ -333,12 +362,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
id: trimmedId,
|
||||
name: finalName,
|
||||
server: serverSpec,
|
||||
// 确保 apps 字段始终存在(v3.7.0 新架构必需)
|
||||
apps: initialData?.apps || {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
},
|
||||
// 使用表单中的启用状态(v3.7.0 完整重构)
|
||||
apps: enabledApps,
|
||||
};
|
||||
|
||||
const descriptionTrimmed = formDescription.trim();
|
||||
@@ -387,11 +412,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
};
|
||||
|
||||
const getFormTitle = () => {
|
||||
if (appId === "claude") {
|
||||
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
|
||||
} else {
|
||||
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
|
||||
}
|
||||
return isEditing ? t("mcp.editServer") : t("mcp.addServer");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -477,6 +498,62 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 启用到哪些应用(v3.7.0 新增) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t("mcp.form.enabledApps")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-claude"
|
||||
checked={enabledApps.claude}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, claude: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-claude"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.claude")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-codex"
|
||||
checked={enabledApps.codex}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, codex: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-codex"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.codex")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-gemini"
|
||||
checked={enabledApps.gemini}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, gemini: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-gemini"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.gemini")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可折叠的附加信息按钮 */}
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -191,12 +191,13 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
||||
{/* Form Modal */}
|
||||
{isFormOpen && (
|
||||
<McpFormModal
|
||||
appId="claude" // Default to claude for unified panel
|
||||
editingId={editingId || undefined}
|
||||
initialData={
|
||||
editingId && serversMap ? serversMap[editingId] : undefined
|
||||
}
|
||||
existingIds={serversMap ? Object.keys(serversMap) : []}
|
||||
defaultFormat="json"
|
||||
defaultEnabledApps={["claude"]} // 默认启用 Claude
|
||||
onSave={async () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingId(null);
|
||||
|
||||
@@ -478,6 +478,8 @@
|
||||
"titlePlaceholder": "my-mcp-server",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "e.g. @modelcontextprotocol/server-time",
|
||||
"enabledApps": "Enable to Apps",
|
||||
"noAppsWarning": "At least one app must be selected",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Optional description",
|
||||
"tags": "Tags (comma separated)",
|
||||
|
||||
@@ -478,6 +478,8 @@
|
||||
"titlePlaceholder": "my-mcp-server",
|
||||
"name": "显示名称",
|
||||
"namePlaceholder": "例如 @modelcontextprotocol/server-time",
|
||||
"enabledApps": "启用到应用",
|
||||
"noAppsWarning": "至少选择一个应用",
|
||||
"description": "描述",
|
||||
"descriptionPlaceholder": "可选的描述信息",
|
||||
"tags": "标签(逗号分隔)",
|
||||
|
||||
@@ -5,7 +5,11 @@ import McpFormModal from "@/components/mcp/McpFormModal";
|
||||
|
||||
const toastErrorMock = vi.hoisted(() => vi.fn());
|
||||
const toastSuccessMock = vi.hoisted(() => vi.fn());
|
||||
const getConfigMock = vi.hoisted(() => vi.fn().mockResolvedValue({ servers: {} }));
|
||||
const upsertMock = vi.hoisted(() => {
|
||||
const fn = vi.fn();
|
||||
fn.mockResolvedValue(undefined);
|
||||
return fn;
|
||||
});
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
@@ -65,6 +69,18 @@ vi.mock("@/components/ui/textarea", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
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>,
|
||||
@@ -91,40 +107,44 @@ vi.mock("@/components/mcp/McpWizardModal", () => ({
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
||||
vi.mock("@/hooks/useMcp", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/hooks/useMcp")>(
|
||||
"@/hooks/useMcp",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
mcpApi: {
|
||||
...actual.mcpApi,
|
||||
getConfig: (...args: unknown[]) => getConfigMock(...args),
|
||||
},
|
||||
useUpsertMcpServer: () => ({
|
||||
mutateAsync: (...args: unknown[]) => upsertMock(...args),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("McpFormModal", () => {
|
||||
beforeEach(() => {
|
||||
toastErrorMock.mockReset();
|
||||
toastSuccessMock.mockReset();
|
||||
getConfigMock.mockReset();
|
||||
getConfigMock.mockResolvedValue({ servers: {} });
|
||||
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
|
||||
appId="claude"
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
existingIds={[]}
|
||||
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 };
|
||||
};
|
||||
/>,
|
||||
);
|
||||
return { onSave, onClose };
|
||||
};
|
||||
|
||||
it("应用预设后填充 ID 与配置内容", async () => {
|
||||
renderForm();
|
||||
@@ -145,30 +165,7 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
|
||||
expect(configTextarea.value).toBe('{\n "type": "stdio",\n "command": "preset-cmd"\n}');
|
||||
});
|
||||
|
||||
it("在同步另一侧存在冲突时展示警告", async () => {
|
||||
getConfigMock.mockResolvedValue({ servers: { conflict: {} } });
|
||||
renderForm();
|
||||
|
||||
const idInput = screen.getByPlaceholderText(
|
||||
"mcp.form.titlePlaceholder",
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(idInput, { target: { value: "conflict" } });
|
||||
|
||||
await waitFor(() => expect(getConfigMock).toHaveBeenCalled());
|
||||
|
||||
const checkbox = screen.getByLabelText(
|
||||
'mcp.form.syncOtherSide:{"target":"apps.codex"}',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('mcp.form.willOverwriteWarning:{"target":"apps.codex"}'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("提交时清洗字段并调用 onSave", async () => {
|
||||
it("提交时清洗字段并调用 upsert 与 onSave", async () => {
|
||||
const { onSave } = renderForm();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
||||
@@ -197,17 +194,11 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
|
||||
target: { value: '{"type":"stdio","command":"run"}' },
|
||||
});
|
||||
|
||||
const syncCheckbox = screen.getByLabelText(
|
||||
'mcp.form.syncOtherSide:{"target":"apps.codex"}',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.click(syncCheckbox);
|
||||
|
||||
fireEvent.click(screen.getByText("common.add"));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
const [id, payload, options] = (onSave as any).mock.calls[0];
|
||||
expect(id).toBe("my-server");
|
||||
expect(payload).toMatchObject({
|
||||
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
||||
expect(entry).toMatchObject({
|
||||
id: "my-server",
|
||||
name: "Friendly",
|
||||
description: "Description",
|
||||
@@ -218,8 +209,14 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
|
||||
type: "stdio",
|
||||
command: "run",
|
||||
},
|
||||
apps: {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
},
|
||||
});
|
||||
expect(options).toEqual({ syncOtherSide: true });
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onSave).toHaveBeenCalledWith();
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -236,7 +233,7 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
|
||||
fireEvent.click(screen.getByText("common.add"));
|
||||
|
||||
await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(upsertMock).not.toHaveBeenCalled();
|
||||
const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
|
||||
expect(message).toBe("mcp.error.jsonInvalid");
|
||||
});
|
||||
@@ -262,7 +259,7 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
|
||||
});
|
||||
|
||||
it("TOML 模式下自动提取 ID 并成功保存", async () => {
|
||||
const { onSave } = renderForm({ appId: "codex" });
|
||||
const { onSave } = renderForm({ defaultFormat: "toml" });
|
||||
|
||||
const configTextarea = screen.getByPlaceholderText(
|
||||
"mcp.form.tomlPlaceholder",
|
||||
@@ -282,15 +279,17 @@ command = "run"
|
||||
|
||||
fireEvent.click(screen.getByText("common.add"));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
const [id, payload] = (onSave as any).mock.calls[0];
|
||||
expect(id).toBe("demo");
|
||||
expect(payload.server).toEqual({ type: "stdio", command: "run" });
|
||||
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({ appId: "codex" });
|
||||
const { onSave } = renderForm({ defaultFormat: "toml" });
|
||||
|
||||
const configTextarea = screen.getByPlaceholderText(
|
||||
"mcp.form.tomlPlaceholder",
|
||||
@@ -304,11 +303,11 @@ type = "stdio"
|
||||
fireEvent.click(screen.getByText("common.add"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("mcp.error.idRequired", {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("mcp.error.tomlInvalid", {
|
||||
duration: 3000,
|
||||
}),
|
||||
);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(upsertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("编辑模式下保持 ID 并更新配置", async () => {
|
||||
@@ -321,7 +320,6 @@ type = "stdio"
|
||||
} as McpServer;
|
||||
|
||||
const { onSave } = renderForm({
|
||||
appId: "claude",
|
||||
editingId: "existing",
|
||||
initialData,
|
||||
});
|
||||
@@ -343,12 +341,48 @@ type = "stdio"
|
||||
|
||||
fireEvent.click(screen.getByText("common.save"));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
const [id, entry, options] = (onSave as any).mock.calls[0];
|
||||
expect(id).toBe("existing");
|
||||
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(options).toEqual({ syncOtherSide: false });
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user