mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge pull request #23489 from BerriAI/litellm_mcp_tools_tests
[Test] MCP tools component unit tests
This commit is contained in:
commit
8c96e43aab
5 changed files with 338 additions and 0 deletions
|
|
@ -0,0 +1,55 @@
|
|||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import MCPLogoSelector from "./MCPLogoSelector";
|
||||
|
||||
describe("MCPLogoSelector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the logo grid and custom URL input", () => {
|
||||
render(<MCPLogoSelector />);
|
||||
expect(screen.getByPlaceholderText(/paste a custom logo URL/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a preview when a value is provided", () => {
|
||||
render(<MCPLogoSelector value="/ui/assets/logos/github.svg" />);
|
||||
expect(screen.getByAltText("Selected logo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show a preview when no value is provided", () => {
|
||||
render(<MCPLogoSelector />);
|
||||
expect(screen.queryByAltText("Selected logo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange with undefined when the clear button is clicked", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<MCPLogoSelector value="/ui/assets/logos/github.svg" onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /✕/ }));
|
||||
expect(onChange).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("should call onChange with the logo URL when a grid logo is clicked", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<MCPLogoSelector onChange={onChange} />);
|
||||
|
||||
const githubButton = screen.getByRole("button", { name: /GitHub/i });
|
||||
await user.click(githubButton);
|
||||
expect(onChange).toHaveBeenCalledWith("/ui/assets/logos/github.svg");
|
||||
});
|
||||
|
||||
it("should deselect a logo when clicking the already-selected logo", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<MCPLogoSelector value="/ui/assets/logos/github.svg" onChange={onChange} />);
|
||||
|
||||
const githubButton = screen.getByRole("button", { name: /GitHub/i });
|
||||
await user.click(githubButton);
|
||||
expect(onChange).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { FIELD_GROUPS, MCP_REQUIRED_FIELD_DEFS, SETTINGS_KEY } from "./MCPStandardsSettings";
|
||||
import { MCPServer } from "./types";
|
||||
|
||||
const makeServer = (overrides: Partial<MCPServer> = {}): MCPServer => ({
|
||||
server_id: "s1",
|
||||
created_at: "2024-01-01",
|
||||
created_by: "user",
|
||||
updated_at: "2024-01-01",
|
||||
updated_by: "user",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("FIELD_GROUPS", () => {
|
||||
it("should contain four groups", () => {
|
||||
expect(FIELD_GROUPS).toHaveLength(4);
|
||||
expect(FIELD_GROUPS.map((g) => g.label)).toEqual([
|
||||
"Documentation",
|
||||
"Source",
|
||||
"Connection",
|
||||
"Security",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP_REQUIRED_FIELD_DEFS", () => {
|
||||
it("should flatten all fields from groups", () => {
|
||||
const totalFields = FIELD_GROUPS.reduce((sum, g) => sum + g.fields.length, 0);
|
||||
expect(MCP_REQUIRED_FIELD_DEFS).toHaveLength(totalFields);
|
||||
});
|
||||
});
|
||||
|
||||
describe("field check functions", () => {
|
||||
const findCheck = (key: string) =>
|
||||
MCP_REQUIRED_FIELD_DEFS.find((f) => f.key === key)!.check;
|
||||
|
||||
it("should pass description check when description is present", () => {
|
||||
expect(findCheck("description")(makeServer({ description: "A service" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("should fail description check when description is empty", () => {
|
||||
expect(findCheck("description")(makeServer({ description: " " }))).toBe(false);
|
||||
});
|
||||
|
||||
it("should pass auth check when auth_type is not none", () => {
|
||||
expect(findCheck("auth_type")(makeServer({ auth_type: "oauth2" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("should fail auth check when auth_type is none", () => {
|
||||
expect(findCheck("auth_type")(makeServer({ auth_type: "none" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("should fail auth check when auth_type is missing", () => {
|
||||
expect(findCheck("auth_type")(makeServer())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SETTINGS_KEY", () => {
|
||||
it("should equal mcp_required_fields", () => {
|
||||
expect(SETTINGS_KEY).toBe("mcp_required_fields");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import MCPConnectionStatus from "./mcp_connection_status";
|
||||
|
||||
describe("MCPConnectionStatus", () => {
|
||||
const defaultProps = {
|
||||
formValues: { url: "https://example.com/mcp" },
|
||||
tools: [] as any[],
|
||||
isLoadingTools: false,
|
||||
toolsError: null,
|
||||
toolsErrorStackTrace: null,
|
||||
canFetchTools: false,
|
||||
fetchTools: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render nothing when canFetchTools is false and no URL is set", () => {
|
||||
const { container } = render(
|
||||
<MCPConnectionStatus {...defaultProps} formValues={{}} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should show 'Complete required fields' message when URL is set but canFetchTools is false", () => {
|
||||
render(<MCPConnectionStatus {...defaultProps} />);
|
||||
expect(screen.getByText(/Complete required fields to test connection/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Connection successful' when tools are loaded", () => {
|
||||
render(
|
||||
<MCPConnectionStatus
|
||||
{...defaultProps}
|
||||
canFetchTools={true}
|
||||
tools={[{ name: "tool1" }]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Connection successful")).toBeInTheDocument();
|
||||
expect(screen.getByText("Connected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show loading state when isLoadingTools is true", () => {
|
||||
render(
|
||||
<MCPConnectionStatus
|
||||
{...defaultProps}
|
||||
canFetchTools={true}
|
||||
isLoadingTools={true}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Testing connection to MCP server/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Connecting...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error state with retry button when toolsError is set", async () => {
|
||||
const fetchTools = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MCPConnectionStatus
|
||||
{...defaultProps}
|
||||
canFetchTools={true}
|
||||
toolsError="Connection refused"
|
||||
fetchTools={fetchTools}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Connection Failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Connection refused")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /retry/i }));
|
||||
expect(fetchTools).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show 'No tools found' when connection succeeds but no tools returned", () => {
|
||||
render(
|
||||
<MCPConnectionStatus
|
||||
{...defaultProps}
|
||||
canFetchTools={true}
|
||||
tools={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/No tools found for this MCP server/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
59
ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx
Normal file
59
ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT, handleTransport, handleAuth } from "./types";
|
||||
|
||||
describe("handleTransport", () => {
|
||||
it("should default to SSE when transport is null", () => {
|
||||
expect(handleTransport(null)).toBe(TRANSPORT.SSE);
|
||||
});
|
||||
|
||||
it("should default to SSE when transport is undefined", () => {
|
||||
expect(handleTransport(undefined)).toBe(TRANSPORT.SSE);
|
||||
});
|
||||
|
||||
it("should return openapi when specPath is present and transport is not stdio", () => {
|
||||
expect(handleTransport("http", "/spec.yaml")).toBe(TRANSPORT.OPENAPI);
|
||||
});
|
||||
|
||||
it("should keep stdio even when specPath is present", () => {
|
||||
expect(handleTransport(TRANSPORT.STDIO, "/spec.yaml")).toBe(TRANSPORT.STDIO);
|
||||
});
|
||||
|
||||
it("should return the transport as-is when no specPath", () => {
|
||||
expect(handleTransport("http")).toBe("http");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAuth", () => {
|
||||
it("should default to NONE when authType is null", () => {
|
||||
expect(handleAuth(null)).toBe(AUTH_TYPE.NONE);
|
||||
});
|
||||
|
||||
it("should default to NONE when authType is undefined", () => {
|
||||
expect(handleAuth(undefined)).toBe(AUTH_TYPE.NONE);
|
||||
});
|
||||
|
||||
it("should return the provided auth type", () => {
|
||||
expect(handleAuth(AUTH_TYPE.OAUTH2)).toBe("oauth2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants", () => {
|
||||
it("should define all expected auth types", () => {
|
||||
expect(AUTH_TYPE.NONE).toBe("none");
|
||||
expect(AUTH_TYPE.API_KEY).toBe("api_key");
|
||||
expect(AUTH_TYPE.BEARER_TOKEN).toBe("bearer_token");
|
||||
expect(AUTH_TYPE.OAUTH2).toBe("oauth2");
|
||||
});
|
||||
|
||||
it("should define all expected transport types", () => {
|
||||
expect(TRANSPORT.SSE).toBe("sse");
|
||||
expect(TRANSPORT.HTTP).toBe("http");
|
||||
expect(TRANSPORT.STDIO).toBe("stdio");
|
||||
expect(TRANSPORT.OPENAPI).toBe("openapi");
|
||||
});
|
||||
|
||||
it("should define OAuth flow types", () => {
|
||||
expect(OAUTH_FLOW.INTERACTIVE).toBe("interactive");
|
||||
expect(OAUTH_FLOW.M2M).toBe("m2m");
|
||||
});
|
||||
});
|
||||
75
ui/litellm-dashboard/src/components/mcp_tools/utils.test.tsx
Normal file
75
ui/litellm-dashboard/src/components/mcp_tools/utils.test.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractMCPToken,
|
||||
maskUrl,
|
||||
getMaskedAndFullUrl,
|
||||
validateMCPServerUrl,
|
||||
validateMCPServerName,
|
||||
} from "./utils";
|
||||
|
||||
describe("extractMCPToken", () => {
|
||||
it("should extract token after /mcp/", () => {
|
||||
const result = extractMCPToken("https://example.com/mcp/abc123");
|
||||
expect(result).toEqual({ token: "abc123", baseUrl: "https://example.com/mcp/" });
|
||||
});
|
||||
|
||||
it("should return null token when URL has no /mcp/ segment", () => {
|
||||
const result = extractMCPToken("https://example.com/api/v1");
|
||||
expect(result).toEqual({ token: null, baseUrl: "https://example.com/api/v1" });
|
||||
});
|
||||
|
||||
it("should return null token when nothing follows /mcp/", () => {
|
||||
const result = extractMCPToken("https://example.com/mcp/");
|
||||
expect(result).toEqual({ token: null, baseUrl: "https://example.com/mcp/" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskUrl", () => {
|
||||
it("should replace the token with ellipsis", () => {
|
||||
expect(maskUrl("https://example.com/mcp/secret-token")).toBe("https://example.com/mcp/...");
|
||||
});
|
||||
|
||||
it("should return the original URL when there is no token", () => {
|
||||
expect(maskUrl("https://example.com/api")).toBe("https://example.com/api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMaskedAndFullUrl", () => {
|
||||
it("should return hasToken true when a token exists", () => {
|
||||
const result = getMaskedAndFullUrl("https://example.com/mcp/tok");
|
||||
expect(result).toEqual({ maskedUrl: "https://example.com/mcp/...", hasToken: true });
|
||||
});
|
||||
|
||||
it("should return hasToken false when no token exists", () => {
|
||||
const result = getMaskedAndFullUrl("https://example.com/api");
|
||||
expect(result).toEqual({ maskedUrl: "https://example.com/api", hasToken: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateMCPServerUrl", () => {
|
||||
it("should resolve for a valid HTTP URL", async () => {
|
||||
await expect(validateMCPServerUrl("https://example.com/path")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should resolve for an empty string", async () => {
|
||||
await expect(validateMCPServerUrl("")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reject for an invalid URL", async () => {
|
||||
await expect(validateMCPServerUrl("not-a-url")).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateMCPServerName", () => {
|
||||
it("should resolve for a valid underscore name", async () => {
|
||||
await expect(validateMCPServerName("my_server")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reject names containing hyphens", async () => {
|
||||
await expect(validateMCPServerName("my-server")).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("should reject names containing spaces", async () => {
|
||||
await expect(validateMCPServerName("my server")).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue