refactor(mcp): complete v3.7.0 cleanup - remove legacy code and warnings
This commit finalizes the v3.7.0 unified MCP architecture migration by removing all deprecated code paths and eliminating compiler warnings. Frontend Changes (~950 lines removed): - Remove deprecated components: McpPanel, McpListItem, McpToggle - Remove deprecated hook: useMcpActions - Remove unused API methods: importFrom*, syncEnabledTo*, syncAllServers - Simplify McpFormModal by removing dual-mode logic (unified/legacy) - Remove syncOtherSide checkbox and conflict detection - Clean up unused imports and state variables - Delete associated test files Backend Changes (~400 lines cleaned): - Remove unused Tauri commands: import_mcp_from_*, sync_enabled_mcp_to_* - Delete unused Gemini MCP functions: get_mcp_status, upsert/delete_mcp_server - Add #[allow(deprecated)] to compatibility layer commands - Add #[allow(dead_code)] to legacy helper functions for future migration - Simplify boolean expression in mcp.rs per Clippy suggestion API Deprecation: - Mark legacy APIs with @deprecated JSDoc (getConfig, upsertServerInConfig, etc.) - Preserve backward compatibility for v3.x, planned removal in v4.0 Verification: - ✅ Zero TypeScript errors (pnpm typecheck) - ✅ Zero Clippy warnings (cargo clippy) - ✅ All code formatted (prettier + cargo fmt) - ✅ Builds successfully Total cleanup: ~1,350 lines of code removed/marked Breaking changes: None (all legacy APIs still functional)
This commit is contained in:
@@ -1,281 +0,0 @@
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { useMcpActions } from "@/hooks/useMcpActions";
|
||||
import type { McpServer } from "@/types";
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
const toastErrorMock = vi.fn();
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||
error: (...args: unknown[]) => toastErrorMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const getConfigMock = vi.fn();
|
||||
const setEnabledMock = vi.fn();
|
||||
const upsertServerInConfigMock = vi.fn();
|
||||
const deleteServerInConfigMock = vi.fn();
|
||||
const syncEnabledToClaudeMock = vi.fn();
|
||||
const syncEnabledToCodexMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
mcpApi: {
|
||||
getConfig: (...args: unknown[]) => getConfigMock(...args),
|
||||
setEnabled: (...args: unknown[]) => setEnabledMock(...args),
|
||||
upsertServerInConfig: (...args: unknown[]) => upsertServerInConfigMock(...args),
|
||||
deleteServerInConfig: (...args: unknown[]) => deleteServerInConfigMock(...args),
|
||||
syncEnabledToClaude: (...args: unknown[]) => syncEnabledToClaudeMock(...args),
|
||||
syncEnabledToCodex: (...args: unknown[]) => syncEnabledToCodexMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const createServer = (overrides: Partial<McpServer> = {}): McpServer => ({
|
||||
id: "server-1",
|
||||
name: "Test Server",
|
||||
description: "desc",
|
||||
enabled: false,
|
||||
apps: { claude: false, codex: false, gemini: false },
|
||||
server: {
|
||||
type: "stdio",
|
||||
command: "run.sh",
|
||||
args: [],
|
||||
env: {},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockConfigResponse = (servers: Record<string, McpServer>) => ({
|
||||
configPath: "/mock/config.json",
|
||||
servers,
|
||||
});
|
||||
|
||||
const createDeferred = <T,>() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe("useMcpActions", () => {
|
||||
beforeEach(() => {
|
||||
getConfigMock.mockReset();
|
||||
setEnabledMock.mockReset();
|
||||
upsertServerInConfigMock.mockReset();
|
||||
deleteServerInConfigMock.mockReset();
|
||||
syncEnabledToClaudeMock.mockReset();
|
||||
syncEnabledToCodexMock.mockReset();
|
||||
toastSuccessMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
|
||||
getConfigMock.mockResolvedValue(mockConfigResponse({}));
|
||||
setEnabledMock.mockResolvedValue(true);
|
||||
upsertServerInConfigMock.mockResolvedValue(true);
|
||||
deleteServerInConfigMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
const renderUseMcpActions = () => renderHook(() => useMcpActions("claude"));
|
||||
|
||||
it("reloads servers and toggles loading state", async () => {
|
||||
const server = createServer();
|
||||
const deferred = createDeferred<ReturnType<typeof mockConfigResponse>>();
|
||||
getConfigMock.mockReturnValueOnce(deferred.promise);
|
||||
const { result } = renderUseMcpActions();
|
||||
|
||||
let reloadPromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
reloadPromise = result.current.reload();
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(true));
|
||||
deferred.resolve(mockConfigResponse({ [server.id]: server }));
|
||||
await act(async () => {
|
||||
await reloadPromise;
|
||||
});
|
||||
|
||||
expect(getConfigMock).toHaveBeenCalledWith("claude");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.servers).toEqual({ [server.id]: server });
|
||||
});
|
||||
|
||||
it("shows toast error when reload fails", async () => {
|
||||
const error = new Error("load failed");
|
||||
getConfigMock.mockRejectedValueOnce(error);
|
||||
const { result } = renderUseMcpActions();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reload();
|
||||
});
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("load failed", { duration: 6000 });
|
||||
expect(result.current.servers).toEqual({});
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("toggles enabled flag optimistically and emits success toasts", async () => {
|
||||
const server = createServer({ enabled: false });
|
||||
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
|
||||
const { result } = renderUseMcpActions();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reload();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleEnabled(server.id, true);
|
||||
});
|
||||
|
||||
expect(setEnabledMock).toHaveBeenCalledWith("claude", server.id, true);
|
||||
expect(result.current.servers[server.id].enabled).toBe(true);
|
||||
expect(toastSuccessMock).toHaveBeenLastCalledWith("mcp.msg.enabled", { duration: 1500 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleEnabled(server.id, false);
|
||||
});
|
||||
|
||||
expect(setEnabledMock).toHaveBeenLastCalledWith("claude", server.id, false);
|
||||
expect(result.current.servers[server.id].enabled).toBe(false);
|
||||
expect(toastSuccessMock).toHaveBeenLastCalledWith("mcp.msg.disabled", { duration: 1500 });
|
||||
});
|
||||
|
||||
it("rolls back state and shows error toast when toggle fails", async () => {
|
||||
const server = createServer({ enabled: false });
|
||||
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
|
||||
const { result } = renderUseMcpActions();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reload();
|
||||
});
|
||||
|
||||
setEnabledMock.mockRejectedValueOnce(new Error("toggle failed"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleEnabled(server.id, true);
|
||||
});
|
||||
|
||||
expect(result.current.servers[server.id].enabled).toBe(false);
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("toggle failed", { duration: 6000 });
|
||||
});
|
||||
|
||||
it("saves server configuration and refreshes list", async () => {
|
||||
const serverInput = createServer({ id: "old-id", enabled: true });
|
||||
const savedServer = { ...serverInput, id: "new-server" };
|
||||
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [savedServer.id]: savedServer }));
|
||||
const { result } = renderUseMcpActions();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveServer("new-server", serverInput, { syncOtherSide: true });
|
||||
});
|
||||
|
||||
expect(upsertServerInConfigMock).toHaveBeenCalledWith(
|
||||
"claude",
|
||||
"new-server",
|
||||
{ ...serverInput, id: "new-server" },
|
||||
{ syncOtherSide: true },
|
||||
);
|
||||
expect(result.current.servers["new-server"]).toEqual(savedServer);
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith("mcp.msg.saved", { duration: 1500 });
|
||||
});
|
||||
|
||||
it("propagates error when saveServer fails", async () => {
|
||||
const serverInput = createServer({ id: "input-id" });
|
||||
const failure = new Error("cannot save");
|
||||
upsertServerInConfigMock.mockRejectedValueOnce(failure);
|
||||
const { result } = renderUseMcpActions();
|
||||
|
||||
let captured: unknown;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.saveServer("server-1", serverInput);
|
||||
} catch (err) {
|
||||
captured = err;
|
||||
}
|
||||
});
|
||||
|
||||
expect(upsertServerInConfigMock).toHaveBeenCalled();
|
||||
expect(getConfigMock).not.toHaveBeenCalled();
|
||||
expect(captured).toBe(failure);
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("cannot save", { duration: 6000 });
|
||||
expect(toastSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes server and refreshes list", async () => {
|
||||
const server = createServer();
|
||||
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
|
||||
const { result } = renderUseMcpActions();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reload();
|
||||
});
|
||||
|
||||
getConfigMock.mockResolvedValueOnce(mockConfigResponse({}));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteServer(server.id);
|
||||
});
|
||||
|
||||
expect(deleteServerInConfigMock).toHaveBeenCalledWith("claude", server.id);
|
||||
expect(result.current.servers[server.id]).toBeUndefined();
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith("mcp.msg.deleted", { duration: 1500 });
|
||||
});
|
||||
|
||||
it("propagates delete error and keeps state", async () => {
|
||||
const server = createServer();
|
||||
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
|
||||
const { result } = renderUseMcpActions();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reload();
|
||||
});
|
||||
|
||||
const failure = new Error("delete failed");
|
||||
deleteServerInConfigMock.mockRejectedValueOnce(failure);
|
||||
|
||||
let captured: unknown;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.deleteServer(server.id);
|
||||
} catch (err) {
|
||||
captured = err;
|
||||
}
|
||||
});
|
||||
|
||||
expect(deleteServerInConfigMock).toHaveBeenCalledWith("claude", server.id);
|
||||
expect(result.current.servers[server.id]).toEqual(server);
|
||||
expect(captured).toBe(failure);
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("delete failed", { duration: 6000 });
|
||||
});
|
||||
|
||||
it("maps backend error message when save fails with known detail", async () => {
|
||||
const serverInput = createServer({ id: "input-id" });
|
||||
const backendError = { message: "stdio 类型的 MCP 服务器必须包含 command 字段" };
|
||||
upsertServerInConfigMock.mockRejectedValueOnce(backendError);
|
||||
const { result } = renderUseMcpActions();
|
||||
|
||||
await expect(async () =>
|
||||
result.current.saveServer("server-1", serverInput),
|
||||
).rejects.toEqual(backendError);
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("mcp.error.commandRequired", {
|
||||
duration: 6000,
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs enabled state to counterpart when appType is claude", async () => {
|
||||
const server = createServer();
|
||||
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
|
||||
const { result } = renderUseMcpActions();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reload();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleEnabled(server.id, true);
|
||||
});
|
||||
|
||||
expect(setEnabledMock).toHaveBeenCalledWith("claude", server.id, true);
|
||||
expect(syncEnabledToClaudeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
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,
|
||||
apps: { claude: true, codex: false, gemini: false },
|
||||
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={() => {}} appId="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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user