test: add comprehensive MCP UI test coverage with MSW infrastructure
## MSW Infrastructure Enhancement - Add 5 MCP API handlers to tests/msw/handlers.ts: - get_mcp_config: Fetch MCP configuration for app type - import_mcp_from_claude/codex: Mock import operations (returns count: 1) - set_mcp_enabled: Toggle MCP server enabled state - upsert_mcp_server_in_config: Create/update MCP server - delete_mcp_server_in_config: Remove MCP server - Add MCP state management to tests/msw/state.ts: - McpConfigState type with per-app server storage - Default test data (stdio server for Claude, http server for Codex) - CRUD functions: getMcpConfig, setMcpServerEnabled, upsertMcpServer, deleteMcpServer - Immutable state operations with deep cloning ## McpFormModal Component Tests (4 tests) - Test preset application: Verify ID and config JSON auto-fill from preset selection - Test conflict detection: Async validation shows warning when syncing to conflicting ID - Test field sanitization: Verify trim whitespace, split tags, clean URLs before save - Test validation errors: Block submit and show toast error for invalid stdio config (missing command) ## McpPanel Integration Tests (3 tests) - Test toggle enabled state: Click toggle button triggers useMcpActions.toggleEnabled with correct params - Test create server flow: Open form → submit → saveServer called with syncOtherSide option - Test delete server flow: Click delete → confirm dialog → deleteServer called with ID ## Test Utilities - Add createTestQueryClient helper with retry: false for faster test execution ## Test Coverage - Test files: 15 → 17 (+2) - Total tests: 105 → 112 (+6.7%) - All 112 tests passing - Execution time: 3.15s
This commit is contained in:
227
tests/components/McpFormModal.test.tsx
Normal file
227
tests/components/McpFormModal.test.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
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: {} }));
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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/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: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
mcpApi: {
|
||||||
|
...actual.mcpApi,
|
||||||
|
getConfig: (...args: unknown[]) => getConfigMock(...args),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("McpFormModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
toastErrorMock.mockReset();
|
||||||
|
toastSuccessMock.mockReset();
|
||||||
|
getConfigMock.mockReset();
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
|
||||||
|
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("在同步另一侧存在冲突时展示警告", 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 () => {
|
||||||
|
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"}' },
|
||||||
|
});
|
||||||
|
|
||||||
|
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.mock.calls[0];
|
||||||
|
expect(id).toBe("my-server");
|
||||||
|
expect(payload).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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(options).toEqual({ syncOtherSide: true });
|
||||||
|
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(onSave).not.toHaveBeenCalled();
|
||||||
|
const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
|
||||||
|
expect(message).toBe("mcp.error.jsonInvalid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
231
tests/integration/McpPanel.test.tsx
Normal file
231
tests/integration/McpPanel.test.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
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,
|
||||||
|
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={() => {}} appType="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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import type { AppType } from "@/lib/api/types";
|
import type { AppType } from "@/lib/api/types";
|
||||||
import type { Provider, Settings } from "@/types";
|
import type { McpServer, Provider, Settings } from "@/types";
|
||||||
import {
|
import {
|
||||||
addProvider,
|
addProvider,
|
||||||
deleteProvider,
|
deleteProvider,
|
||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
setSettings,
|
setSettings,
|
||||||
getAppConfigDirOverride,
|
getAppConfigDirOverride,
|
||||||
setAppConfigDirOverrideState,
|
setAppConfigDirOverrideState,
|
||||||
|
getMcpConfig,
|
||||||
|
setMcpConfig,
|
||||||
|
setMcpServerEnabled,
|
||||||
|
upsertMcpServer,
|
||||||
|
deleteMcpServer,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
|
|
||||||
const TAURI_ENDPOINT = "http://tauri.local";
|
const TAURI_ENDPOINT = "http://tauri.local";
|
||||||
@@ -100,6 +105,41 @@ export const handlers = [
|
|||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/open_external`, () => success(true)),
|
http.post(`${TAURI_ENDPOINT}/open_external`, () => success(true)),
|
||||||
|
|
||||||
|
// MCP APIs
|
||||||
|
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
|
||||||
|
const { app } = await withJson<{ app: AppType }>(request);
|
||||||
|
return success(getMcpConfig(app));
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${TAURI_ENDPOINT}/import_mcp_from_claude`, () => success(1)),
|
||||||
|
http.post(`${TAURI_ENDPOINT}/import_mcp_from_codex`, () => success(1)),
|
||||||
|
|
||||||
|
http.post(`${TAURI_ENDPOINT}/set_mcp_enabled`, async ({ request }) => {
|
||||||
|
const { app, id, enabled } = await withJson<{
|
||||||
|
app: AppType;
|
||||||
|
id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}>(request);
|
||||||
|
setMcpServerEnabled(app, id, enabled);
|
||||||
|
return success(true);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${TAURI_ENDPOINT}/upsert_mcp_server_in_config`, async ({ request }) => {
|
||||||
|
const { app, id, spec } = await withJson<{
|
||||||
|
app: AppType;
|
||||||
|
id: string;
|
||||||
|
spec: McpServer;
|
||||||
|
}>(request);
|
||||||
|
upsertMcpServer(app, id, spec);
|
||||||
|
return success(true);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${TAURI_ENDPOINT}/delete_mcp_server_in_config`, async ({ request }) => {
|
||||||
|
const { app, id } = await withJson<{ app: AppType; id: string }>(request);
|
||||||
|
deleteMcpServer(app, id);
|
||||||
|
return success(true);
|
||||||
|
}),
|
||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/restart_app`, () => success(true)),
|
http.post(`${TAURI_ENDPOINT}/restart_app`, () => success(true)),
|
||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())),
|
http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { AppType } from "@/lib/api/types";
|
import type { AppType } from "@/lib/api/types";
|
||||||
import type { Provider, Settings } from "@/types";
|
import type { McpServer, Provider, Settings } from "@/types";
|
||||||
|
|
||||||
type ProvidersByApp = Record<AppType, Record<string, Provider>>;
|
type ProvidersByApp = Record<AppType, Record<string, Provider>>;
|
||||||
type CurrentProviderState = Record<AppType, string>;
|
type CurrentProviderState = Record<AppType, string>;
|
||||||
|
type McpConfigState = Record<AppType, Record<string, McpServer>>;
|
||||||
|
|
||||||
const createDefaultProviders = (): ProvidersByApp => ({
|
const createDefaultProviders = (): ProvidersByApp => ({
|
||||||
claude: {
|
claude: {
|
||||||
@@ -59,6 +60,30 @@ let settingsState: Settings = {
|
|||||||
language: "zh",
|
language: "zh",
|
||||||
};
|
};
|
||||||
let appConfigDirOverride: string | null = null;
|
let appConfigDirOverride: string | null = null;
|
||||||
|
let mcpConfigs: McpConfigState = {
|
||||||
|
claude: {
|
||||||
|
sample: {
|
||||||
|
id: "sample",
|
||||||
|
name: "Sample Claude Server",
|
||||||
|
enabled: true,
|
||||||
|
server: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "claude-server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
httpServer: {
|
||||||
|
id: "httpServer",
|
||||||
|
name: "HTTP Codex Server",
|
||||||
|
enabled: false,
|
||||||
|
server: {
|
||||||
|
type: "http",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const cloneProviders = (value: ProvidersByApp) =>
|
const cloneProviders = (value: ProvidersByApp) =>
|
||||||
JSON.parse(JSON.stringify(value)) as ProvidersByApp;
|
JSON.parse(JSON.stringify(value)) as ProvidersByApp;
|
||||||
@@ -75,6 +100,30 @@ export const resetProviderState = () => {
|
|||||||
language: "zh",
|
language: "zh",
|
||||||
};
|
};
|
||||||
appConfigDirOverride = null;
|
appConfigDirOverride = null;
|
||||||
|
mcpConfigs = {
|
||||||
|
claude: {
|
||||||
|
sample: {
|
||||||
|
id: "sample",
|
||||||
|
name: "Sample Claude Server",
|
||||||
|
enabled: true,
|
||||||
|
server: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "claude-server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
httpServer: {
|
||||||
|
id: "httpServer",
|
||||||
|
name: "HTTP Codex Server",
|
||||||
|
enabled: false,
|
||||||
|
server: {
|
||||||
|
type: "http",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProviders = (appType: AppType) =>
|
export const getProviders = (appType: AppType) =>
|
||||||
@@ -143,3 +192,45 @@ export const getAppConfigDirOverride = () => appConfigDirOverride;
|
|||||||
export const setAppConfigDirOverrideState = (value: string | null) => {
|
export const setAppConfigDirOverrideState = (value: string | null) => {
|
||||||
appConfigDirOverride = value;
|
appConfigDirOverride = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMcpConfig = (appType: AppType) => {
|
||||||
|
const servers = JSON.parse(
|
||||||
|
JSON.stringify(mcpConfigs[appType] ?? {}),
|
||||||
|
) as Record<string, McpServer>;
|
||||||
|
return {
|
||||||
|
configPath: `/mock/${appType}.mcp.json`,
|
||||||
|
servers,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setMcpConfig = (appType: AppType, value: Record<string, McpServer>) => {
|
||||||
|
mcpConfigs[appType] = JSON.parse(JSON.stringify(value)) as Record<string, McpServer>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setMcpServerEnabled = (
|
||||||
|
appType: AppType,
|
||||||
|
id: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) => {
|
||||||
|
if (!mcpConfigs[appType]?.[id]) return;
|
||||||
|
mcpConfigs[appType][id] = {
|
||||||
|
...mcpConfigs[appType][id],
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertMcpServer = (
|
||||||
|
appType: AppType,
|
||||||
|
id: string,
|
||||||
|
server: McpServer,
|
||||||
|
) => {
|
||||||
|
if (!mcpConfigs[appType]) {
|
||||||
|
mcpConfigs[appType] = {};
|
||||||
|
}
|
||||||
|
mcpConfigs[appType][id] = JSON.parse(JSON.stringify(server)) as McpServer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteMcpServer = (appType: AppType, id: string) => {
|
||||||
|
if (!mcpConfigs[appType]) return;
|
||||||
|
delete mcpConfigs[appType][id];
|
||||||
|
};
|
||||||
|
|||||||
11
tests/utils/testQueryClient.ts
Normal file
11
tests/utils/testQueryClient.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const createTestQueryClient = () =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user