mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
Merge pull request #21968 from BerriAI/litellm_ui_testing_coverage_00
[Infra] UI - Unit Testing Coverage: MCP Semantic Filter
This commit is contained in:
commit
8f46a6917b
4 changed files with 423 additions and 30 deletions
|
|
@ -0,0 +1,165 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import MCPSemanticFilterSettings from "./MCPSemanticFilterSettings";
|
||||
import { useMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings";
|
||||
import { useUpdateMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useUpdateMCPSemanticFilterSettings";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings",
|
||||
() => ({ useMCPSemanticFilterSettings: vi.fn() })
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useUpdateMCPSemanticFilterSettings",
|
||||
() => ({ useUpdateMCPSemanticFilterSettings: vi.fn() })
|
||||
);
|
||||
|
||||
vi.mock("@/components/playground/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("./MCPSemanticFilterTestPanel", () => ({
|
||||
default: () => <div data-testid="mcp-test-panel" />,
|
||||
}));
|
||||
|
||||
vi.mock("./semanticFilterTestUtils", () => ({
|
||||
getCurlCommand: vi.fn().mockReturnValue("curl ..."),
|
||||
runSemanticFilterTest: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
|
||||
const defaultSettingsData = {
|
||||
field_schema: {
|
||||
properties: {
|
||||
enabled: { description: "Enable semantic filtering for MCP tools" },
|
||||
},
|
||||
},
|
||||
values: {
|
||||
enabled: false,
|
||||
embedding_model: "text-embedding-3-small",
|
||||
top_k: 10,
|
||||
similarity_threshold: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
// Helper that renders the component and flushes the fetchAvailableModels effect
|
||||
async function renderSettings(props: React.ComponentProps<typeof MCPSemanticFilterSettings>) {
|
||||
render(<MCPSemanticFilterSettings {...props} />);
|
||||
if (props.accessToken) {
|
||||
// Let the async fetchAvailableModels effect settle to avoid act() warnings
|
||||
await act(async () => {});
|
||||
}
|
||||
}
|
||||
|
||||
describe("MCPSemanticFilterSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({
|
||||
data: defaultSettingsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.mocked(useUpdateMCPSemanticFilterSettings).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("should render", async () => {
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.getByText("Semantic Tool Filtering")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a login prompt when accessToken is null", () => {
|
||||
render(<MCPSemanticFilterSettings accessToken={null} />);
|
||||
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the form when accessToken is null", () => {
|
||||
render(<MCPSemanticFilterSettings accessToken={null} />);
|
||||
expect(screen.queryByText("Enable Semantic Filtering")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show the settings content while loading", async () => {
|
||||
vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.queryByText("Semantic Tool Filtering")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show an error alert when data fails to load", async () => {
|
||||
vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error("Network error"),
|
||||
} as any);
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(
|
||||
screen.getByText("Could not load MCP Semantic Filter settings")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the error message from the error object when loading fails", async () => {
|
||||
vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error("Connection refused"),
|
||||
} as any);
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.getByText("Connection refused")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the info alert and form fields when data is loaded", async () => {
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.getByText("Semantic Tool Filtering")).toBeInTheDocument();
|
||||
expect(screen.getByText("Enable Semantic Filtering")).toBeInTheDocument();
|
||||
expect(screen.getByText("Top K Results")).toBeInTheDocument();
|
||||
expect(screen.getByText("Similarity Threshold")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the test panel", async () => {
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.getByTestId("mcp-test-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have Save Settings button disabled initially", async () => {
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(
|
||||
screen.getByRole("button", { name: /save settings/i })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable Save Settings button after a form field is changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
|
||||
expect(screen.getByRole("button", { name: /save settings/i })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
|
||||
expect(screen.getByRole("button", { name: /save settings/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show an error alert when the mutation fails", async () => {
|
||||
vi.mocked(useUpdateMCPSemanticFilterSettings).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
error: new Error("Failed to update settings"),
|
||||
} as any);
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.getByText("Could not update settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Failed to update settings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import MCPSemanticFilterTestPanel from "./MCPSemanticFilterTestPanel";
|
||||
import { TestResult } from "./semanticFilterTestUtils";
|
||||
|
||||
vi.mock("@/components/common_components/ModelSelector", () => ({
|
||||
default: ({ onChange, value, labelText, disabled }: any) => (
|
||||
<div>
|
||||
<label htmlFor="model-selector">{labelText ?? "Select Model"}</label>
|
||||
<select
|
||||
id="model-selector"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="gpt-4o">gpt-4o</option>
|
||||
<option value="gpt-3.5-turbo">gpt-3.5-turbo</option>
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const buildProps = (
|
||||
overrides: Partial<React.ComponentProps<typeof MCPSemanticFilterTestPanel>> = {}
|
||||
) => ({
|
||||
accessToken: "test-token",
|
||||
testQuery: "",
|
||||
setTestQuery: vi.fn(),
|
||||
testModel: "gpt-4o",
|
||||
setTestModel: vi.fn(),
|
||||
isTesting: false,
|
||||
onTest: vi.fn(),
|
||||
filterEnabled: true,
|
||||
testResult: null as TestResult | null,
|
||||
curlCommand: "curl --location 'http://localhost:4000/v1/responses'",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("MCPSemanticFilterTestPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the Test Configuration card", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps()} />);
|
||||
expect(screen.getByText("Test Configuration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the test query textarea", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps()} />);
|
||||
expect(
|
||||
screen.getByPlaceholderText(/enter a test query to see which tools/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call setTestQuery when user types in the query field", () => {
|
||||
const mockSetTestQuery = vi.fn();
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ setTestQuery: mockSetTestQuery })} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/enter a test query to see which tools/i);
|
||||
fireEvent.change(textarea, { target: { value: "find relevant tools" } });
|
||||
|
||||
expect(mockSetTestQuery).toHaveBeenCalledWith("find relevant tools");
|
||||
});
|
||||
|
||||
it("should disable the Test Filter button when testQuery is empty", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ testQuery: "" })} />);
|
||||
expect(screen.getByRole("button", { name: /test filter/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable the Test Filter button when filterEnabled is false", () => {
|
||||
render(
|
||||
<MCPSemanticFilterTestPanel
|
||||
{...buildProps({ testQuery: "search query", filterEnabled: false })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /test filter/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable the Test Filter button when testQuery is set and filter is enabled", () => {
|
||||
render(
|
||||
<MCPSemanticFilterTestPanel {...buildProps({ testQuery: "search query" })} />
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /test filter/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call onTest when the Test Filter button is clicked", async () => {
|
||||
const mockOnTest = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MCPSemanticFilterTestPanel
|
||||
{...buildProps({ testQuery: "search query", onTest: mockOnTest })}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /test filter/i }));
|
||||
expect(mockOnTest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show a warning when semantic filtering is disabled", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ filterEnabled: false })} />);
|
||||
expect(screen.getByText("Semantic filtering is disabled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show the disabled warning when filterEnabled is true", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ filterEnabled: true })} />);
|
||||
expect(screen.queryByText("Semantic filtering is disabled")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display test results when testResult is provided", () => {
|
||||
const testResult: TestResult = {
|
||||
totalTools: 10,
|
||||
selectedTools: 3,
|
||||
tools: ["wiki-fetch", "github-search", "slack-post"],
|
||||
};
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ testResult })} />);
|
||||
|
||||
expect(screen.getByText("3 tools selected")).toBeInTheDocument();
|
||||
expect(screen.getByText("Filtered from 10 available tools")).toBeInTheDocument();
|
||||
expect(screen.getByText("wiki-fetch")).toBeInTheDocument();
|
||||
expect(screen.getByText("github-search")).toBeInTheDocument();
|
||||
expect(screen.getByText("slack-post")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the results section when testResult is null", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ testResult: null })} />);
|
||||
expect(screen.queryByText("Results")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the curl command in the API Usage tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const curlCommand = "curl --location 'http://localhost:4000/v1/responses' --header 'Authorization: Bearer sk-1234'";
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ curlCommand })} />);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "API Usage" }));
|
||||
|
||||
expect(screen.getByText(curlCommand)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getCurlCommand, runSemanticFilterTest } from "./semanticFilterTestUtils";
|
||||
import { testMCPSemanticFilter } from "@/components/networking";
|
||||
import NotificationManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
testMCPSemanticFilter: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getCurlCommand", () => {
|
||||
it("should include the model name in the curl command", () => {
|
||||
const result = getCurlCommand("gpt-4o", "test query");
|
||||
expect(result).toContain('"gpt-4o"');
|
||||
});
|
||||
|
||||
it("should include the query in the curl command", () => {
|
||||
const result = getCurlCommand("gpt-4o", "find relevant files");
|
||||
expect(result).toContain("find relevant files");
|
||||
});
|
||||
|
||||
it("should use a placeholder when query is empty", () => {
|
||||
const result = getCurlCommand("gpt-4o", "");
|
||||
expect(result).toContain("Your query here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSemanticFilterTest", () => {
|
||||
const mockSetIsTesting = vi.fn();
|
||||
const mockSetTestResult = vi.fn();
|
||||
const baseArgs = {
|
||||
accessToken: "test-token",
|
||||
testModel: "gpt-4o",
|
||||
testQuery: "find relevant files",
|
||||
setIsTesting: mockSetIsTesting,
|
||||
setTestResult: mockSetTestResult,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should call NotificationManager.error and not set isTesting when testQuery is empty", async () => {
|
||||
await runSemanticFilterTest({ ...baseArgs, testQuery: "" });
|
||||
expect(NotificationManager.error).toHaveBeenCalledWith("Please enter a query and select a model");
|
||||
expect(mockSetIsTesting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call NotificationManager.error and not set isTesting when testModel is empty", async () => {
|
||||
await runSemanticFilterTest({ ...baseArgs, testModel: "" });
|
||||
expect(NotificationManager.error).toHaveBeenCalledWith("Please enter a query and select a model");
|
||||
expect(mockSetIsTesting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set isTesting to true then false around the API call", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({
|
||||
data: {},
|
||||
headers: { filter: "5->2", tools: "tool-a,tool-b" },
|
||||
});
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(mockSetIsTesting).toHaveBeenNthCalledWith(1, true);
|
||||
expect(mockSetIsTesting).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it("should clear the previous test result before making a new request", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({
|
||||
data: {},
|
||||
headers: { filter: "5->2", tools: "tool-a,tool-b" },
|
||||
});
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(mockSetTestResult).toHaveBeenNthCalledWith(1, null);
|
||||
});
|
||||
|
||||
it("should set test result with parsed data on success", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({
|
||||
data: {},
|
||||
headers: { filter: "10->3", tools: "wiki,github,slack" },
|
||||
});
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(mockSetTestResult).toHaveBeenCalledWith({
|
||||
totalTools: 10,
|
||||
selectedTools: 3,
|
||||
tools: ["wiki", "github", "slack"],
|
||||
});
|
||||
expect(NotificationManager.success).toHaveBeenCalledWith(
|
||||
"Semantic filter test completed successfully"
|
||||
);
|
||||
});
|
||||
|
||||
it("should show a warning when the filter header is missing", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({
|
||||
data: {},
|
||||
headers: { filter: null, tools: null },
|
||||
});
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(NotificationManager.warning).toHaveBeenCalledWith(
|
||||
"Semantic filter is not enabled or no tools were filtered"
|
||||
);
|
||||
expect(mockSetTestResult).not.toHaveBeenCalledWith(expect.objectContaining({ totalTools: expect.any(Number) }));
|
||||
});
|
||||
|
||||
it("should show an error notification and finish testing when the API call fails", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(NotificationManager.error).toHaveBeenCalledWith("Failed to test semantic filter");
|
||||
expect(mockSetIsTesting).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,36 +9,6 @@ import NotificationsManager from "./molecules/notifications_manager";
|
|||
|
||||
vi.mock("./networking");
|
||||
|
||||
vi.mock("@tremor/react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tremor/react")>();
|
||||
const React = await import("react");
|
||||
const Card = ({ children }: { children: React.ReactNode }) => React.createElement("div", { "data-testid": "card" }, children);
|
||||
Card.displayName = "Card";
|
||||
const Title = ({ children }: { children: React.ReactNode }) => React.createElement("h2", {}, children);
|
||||
Title.displayName = "Title";
|
||||
const Text = ({ children }: { children: React.ReactNode }) => React.createElement("span", {}, children);
|
||||
Text.displayName = "Text";
|
||||
const Divider = () => React.createElement("hr", {});
|
||||
Divider.displayName = "Divider";
|
||||
const TextInput = ({ value, onChange, placeholder, className }: any) =>
|
||||
React.createElement("input", {
|
||||
type: "text",
|
||||
value: value || "",
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
});
|
||||
TextInput.displayName = "TextInput";
|
||||
return {
|
||||
...actual,
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Divider,
|
||||
TextInput,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./common_components/budget_duration_dropdown", () => {
|
||||
const BudgetDurationDropdown = ({ value, onChange }: { value: string | null; onChange: (value: string) => void }) => (
|
||||
<select
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue