mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
test(ui): pin mcp-servers, tag-management and tool-policies behaviour before the shadcn migration
Rewrite the two markup-coupled assertions off antd class selectors and onto role/text queries, and add characterisation tests for the nine route-owned components that had none. Both rewritten tests and all nine new ones are green against the current antd and Tremor components, so the migration that follows can be judged by tests it never touched.
This commit is contained in:
parent
64aad5877a
commit
f2d531737a
12 changed files with 952 additions and 39 deletions
|
|
@ -0,0 +1,92 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import MCPNetworkSettings from "./MCPNetworkSettings";
|
||||
import {
|
||||
getGeneralSettingsCall,
|
||||
updateConfigFieldSetting,
|
||||
deleteConfigFieldSetting,
|
||||
fetchMCPClientIp,
|
||||
} from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getGeneralSettingsCall: vi.fn(),
|
||||
updateConfigFieldSetting: vi.fn(),
|
||||
deleteConfigFieldSetting: vi.fn(),
|
||||
fetchMCPClientIp: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderSettings = () => render(<MCPNetworkSettings accessToken="tok" />);
|
||||
|
||||
describe("MCPNetworkSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([]);
|
||||
vi.mocked(fetchMCPClientIp).mockResolvedValue(null);
|
||||
vi.mocked(updateConfigFieldSetting).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteConfigFieldSetting).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("renders the stored private ranges once settings load", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8", "192.168.0.0/16"] },
|
||||
]);
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByText("10.0.0.0/8")).toBeInTheDocument();
|
||||
expect(screen.getByText("192.168.0.0/16")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores unrelated config fields", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "some_other_setting", field_value: ["should-not-show"] },
|
||||
]);
|
||||
|
||||
renderSettings();
|
||||
|
||||
await screen.findByText("Private IP Ranges");
|
||||
expect(screen.queryByText("should-not-show")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("suggests the caller's /24 range from the detected client IP", async () => {
|
||||
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByText("203.0.113.45")).toBeInTheDocument();
|
||||
expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds the suggested range to the list when clicked, and stops suggesting it", async () => {
|
||||
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
|
||||
|
||||
renderSettings();
|
||||
await userEvent.click(await screen.findByText("203.0.113.0/24"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves the configured ranges", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] },
|
||||
]);
|
||||
|
||||
renderSettings();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", ["10.0.0.0/8"]),
|
||||
);
|
||||
expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the setting instead of saving an empty list", async () => {
|
||||
renderSettings();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges"));
|
||||
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import OpenAPIQuickPicker, { type OpenAPIRegistryEntry } from "./OpenAPIQuickPicker";
|
||||
import { fetchOpenAPIRegistry } from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
fetchOpenAPIRegistry: vi.fn(),
|
||||
}));
|
||||
|
||||
const stripe: OpenAPIRegistryEntry = {
|
||||
name: "stripe",
|
||||
title: "Stripe",
|
||||
description: "Payments API",
|
||||
icon_url: "https://cdn.example.com/stripe.svg",
|
||||
spec_url: "https://example.com/stripe.json",
|
||||
};
|
||||
|
||||
const github: OpenAPIRegistryEntry = {
|
||||
name: "github",
|
||||
title: "GitHub",
|
||||
description: "Code hosting API",
|
||||
icon_url: "https://cdn.example.com/github.svg",
|
||||
spec_url: "https://example.com/github.json",
|
||||
};
|
||||
|
||||
describe("OpenAPIQuickPicker", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders one selectable entry per registry API", async () => {
|
||||
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] });
|
||||
|
||||
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /Stripe/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /GitHub/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Popular APIs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the whole registry entry to onSelect when one is clicked", async () => {
|
||||
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] });
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={onSelect} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Stripe/ }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(stripe);
|
||||
});
|
||||
|
||||
it("renders nothing when the registry is empty", async () => {
|
||||
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [] });
|
||||
|
||||
const { container } = render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(container).toBeEmptyDOMElement());
|
||||
});
|
||||
|
||||
it("renders nothing when the registry fetch fails", async () => {
|
||||
vi.mocked(fetchOpenAPIRegistry).mockRejectedValue(new Error("boom"));
|
||||
|
||||
const { container } = render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(container).toBeEmptyDOMElement());
|
||||
});
|
||||
|
||||
it("does not fetch without an access token", () => {
|
||||
render(<OpenAPIQuickPicker accessToken={null} selectedName={null} onSelect={vi.fn()} />);
|
||||
|
||||
expect(fetchOpenAPIRegistry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a letter avatar when the icon fails to load", async () => {
|
||||
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe] });
|
||||
|
||||
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
|
||||
|
||||
fireEvent.error(await screen.findByAltText("Stripe"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByAltText("Stripe")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import TruePassthroughWarning from "./TruePassthroughWarning";
|
||||
import { AUTH_TYPE } from "@/components/mcp_tools/types";
|
||||
|
||||
describe("TruePassthroughWarning", () => {
|
||||
it("warns when auth type is true_passthrough", () => {
|
||||
render(<TruePassthroughWarning authType={AUTH_TYPE.TRUE_PASSTHROUGH} />);
|
||||
|
||||
expect(screen.getByText("True Passthrough disables LiteLLM authentication for this server")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anyone who can reach the gateway can call this server/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing for any other auth type", () => {
|
||||
const { container } = render(<TruePassthroughWarning authType={AUTH_TYPE.OAUTH2} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when no auth type is set", () => {
|
||||
const { container } = render(<TruePassthroughWarning authType={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import MCPDiscovery from "./mcp_discovery";
|
||||
import { fetchDiscoverableMCPServers } from "@/components/networking";
|
||||
import type { DiscoverableMCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
fetchDiscoverableMCPServers: vi.fn(),
|
||||
}));
|
||||
|
||||
const githubServer = {
|
||||
name: "github",
|
||||
title: "GitHub",
|
||||
description: "Code hosting",
|
||||
category: "Developer Tools",
|
||||
icon_url: "",
|
||||
} as DiscoverableMCPServer;
|
||||
|
||||
const slackServer = {
|
||||
name: "slack",
|
||||
title: "Slack",
|
||||
description: "Team chat",
|
||||
category: "Communication",
|
||||
icon_url: "",
|
||||
} as DiscoverableMCPServer;
|
||||
|
||||
const defaultProps = {
|
||||
isVisible: true,
|
||||
onClose: vi.fn(),
|
||||
onSelectServer: vi.fn(),
|
||||
onCustomServer: vi.fn(),
|
||||
accessToken: "tok",
|
||||
};
|
||||
|
||||
describe("MCPDiscovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({
|
||||
servers: [githubServer, slackServer],
|
||||
categories: ["Developer Tools", "Communication"],
|
||||
});
|
||||
});
|
||||
|
||||
// Each category name renders twice: once as a filter pill (a button) and once
|
||||
// as the heading of its group. Only the heading is not a button.
|
||||
const groupHeading = (category: string) => screen.getAllByText(category).filter((el) => el.tagName !== "BUTTON");
|
||||
|
||||
it("lists every discoverable server grouped under its category", async () => {
|
||||
render(<MCPDiscovery {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText("GitHub")).toBeInTheDocument();
|
||||
expect(screen.getByText("Slack")).toBeInTheDocument();
|
||||
expect(groupHeading("Developer Tools")).toHaveLength(1);
|
||||
expect(groupHeading("Communication")).toHaveLength(1);
|
||||
expect(screen.getByText("Add MCP Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters the list down to the chosen category", async () => {
|
||||
render(<MCPDiscovery {...defaultProps} />);
|
||||
await screen.findByText("GitHub");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Communication" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Slack")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters the list by the search term", async () => {
|
||||
render(<MCPDiscovery {...defaultProps} />);
|
||||
await screen.findByText("GitHub");
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText("Search servers..."), "chat");
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Slack")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hands the picked server back to the caller", async () => {
|
||||
const onSelectServer = vi.fn();
|
||||
render(<MCPDiscovery {...defaultProps} onSelectServer={onSelectServer} />);
|
||||
|
||||
await userEvent.click(await screen.findByText("GitHub"));
|
||||
|
||||
expect(onSelectServer).toHaveBeenCalledWith(githubServer);
|
||||
});
|
||||
|
||||
it("offers a custom-server escape hatch", async () => {
|
||||
const onCustomServer = vi.fn();
|
||||
render(<MCPDiscovery {...defaultProps} onCustomServer={onCustomServer} />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "+ Custom Server" }));
|
||||
|
||||
expect(onCustomServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a fetch failure", async () => {
|
||||
vi.mocked(fetchDiscoverableMCPServers).mockRejectedValue(new Error("registry down"));
|
||||
|
||||
render(<MCPDiscovery {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText(/Failed to load servers: registry down/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the custom-server link when nothing matches", async () => {
|
||||
vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ servers: [], categories: [] });
|
||||
|
||||
render(<MCPDiscovery {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText(/No servers found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch while hidden", () => {
|
||||
render(<MCPDiscovery {...defaultProps} isVisible={false} />);
|
||||
|
||||
expect(fetchDiscoverableMCPServers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
|
||||
const tools = [
|
||||
{ name: "search", description: "Search the index" },
|
||||
{ name: "fetch", description: "Fetch a document" },
|
||||
];
|
||||
|
||||
describe("MCPServerCostConfig", () => {
|
||||
it("renders the default cost field with the current value", () => {
|
||||
render(<MCPServerCostConfig value={{ default_cost_per_query: 0.02 }} tools={[]} />);
|
||||
|
||||
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("0.0000")).toHaveValue("0.0200");
|
||||
});
|
||||
|
||||
it("reports the edited default cost as a number", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<MCPServerCostConfig value={{}} tools={[]} onChange={onChange} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText("0.0000"), "0.5");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({ default_cost_per_query: 0.5 });
|
||||
});
|
||||
|
||||
it("disables the default cost field when disabled", () => {
|
||||
render(<MCPServerCostConfig value={{}} tools={[]} disabled />);
|
||||
|
||||
expect(screen.getByPlaceholderText("0.0000")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hides the per-tool section when the server exposes no tools", () => {
|
||||
render(<MCPServerCostConfig value={{}} tools={[]} />);
|
||||
|
||||
expect(screen.queryByText("Available Tools")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a per-tool override for every tool once tools are loaded", async () => {
|
||||
render(<MCPServerCostConfig value={{}} tools={tools} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Available Tools"));
|
||||
|
||||
expect(screen.getByText("search")).toBeInTheDocument();
|
||||
expect(screen.getByText("Search the index")).toBeInTheDocument();
|
||||
expect(screen.getByText("fetch")).toBeInTheDocument();
|
||||
expect(screen.getAllByPlaceholderText("Use default")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("merges a per-tool override into the existing cost map", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<MCPServerCostConfig
|
||||
value={{ default_cost_per_query: 0.01, tool_name_to_cost_per_query: { fetch: 0.2 } }}
|
||||
tools={tools}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Available Tools"));
|
||||
await userEvent.type(screen.getAllByPlaceholderText("Use default")[0], "3");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
default_cost_per_query: 0.01,
|
||||
tool_name_to_cost_per_query: { fetch: 0.2, search: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("summarises the configured costs", () => {
|
||||
render(
|
||||
<MCPServerCostConfig
|
||||
value={{ default_cost_per_query: 0.01, tool_name_to_cost_per_query: { search: 0.25 } }}
|
||||
tools={tools}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("• Default cost: $0.0100 per query")).toBeInTheDocument();
|
||||
expect(screen.getByText("• search: $0.2500 per query")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no summary when nothing is configured", () => {
|
||||
render(<MCPServerCostConfig value={{}} tools={tools} />);
|
||||
|
||||
expect(screen.queryByText("Cost Summary:")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import MCPServerCostDisplay from "./mcp_server_cost_display";
|
||||
|
||||
describe("MCPServerCostDisplay", () => {
|
||||
it("explains that calls are free when no cost config exists", () => {
|
||||
render(<MCPServerCostDisplay costConfig={null} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats a config with only a null default cost as unconfigured", () => {
|
||||
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: null }} />);
|
||||
|
||||
expect(screen.getByText(/No cost configuration set for this server/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a zero default cost rather than falling back to the empty state", () => {
|
||||
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: 0 }} />);
|
||||
|
||||
expect(screen.getByText("Default Cost per Query")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.0000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the default cost to four decimal places and summarises it", () => {
|
||||
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: 0.0125 }} />);
|
||||
|
||||
expect(screen.getByText("$0.0125")).toBeInTheDocument();
|
||||
expect(screen.getByText("• Default cost: $0.0125 per query")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists each tool-specific cost and counts them in the summary", () => {
|
||||
render(
|
||||
<MCPServerCostDisplay
|
||||
costConfig={{ tool_name_to_cost_per_query: { search: 0.5, fetch: 0.25, skipped: null } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("search")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.5000 per query")).toBeInTheDocument();
|
||||
expect(screen.getByText("fetch")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.2500 per query")).toBeInTheDocument();
|
||||
expect(screen.queryByText("skipped")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("• 3 tool(s) with custom pricing")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { MCPServerView } from "./mcp_server_view";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
vi.mock(".", () => ({
|
||||
MCPToolsViewer: () => <div>tools viewer</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./mcp_server_edit", () => ({
|
||||
default: () => <div>edit form</div>,
|
||||
EDIT_OAUTH_UI_STATE_KEY: "litellm-mcp-oauth-edit-state",
|
||||
}));
|
||||
|
||||
const baseServer = {
|
||||
server_id: "srv-1",
|
||||
server_name: "demo server",
|
||||
alias: "demo_alias",
|
||||
description: "A demo MCP server",
|
||||
transport: "http",
|
||||
url: "https://example.com/mcp",
|
||||
auth_type: "api_key",
|
||||
} as MCPServer;
|
||||
|
||||
const renderView = (overrides: Partial<MCPServer> = {}, props: Record<string, unknown> = {}) =>
|
||||
render(
|
||||
<MCPServerView
|
||||
mcpServer={{ ...baseServer, ...overrides } as MCPServer}
|
||||
onBack={vi.fn()}
|
||||
isProxyAdmin
|
||||
isEditing={false}
|
||||
accessToken="tok"
|
||||
userRole="Admin"
|
||||
userID="u1"
|
||||
availableAccessGroups={[]}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("MCPServerView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Name, alias and description each label the header and a Settings row, so
|
||||
// only the server id is unique to the header.
|
||||
it("shows the server identity in the header", () => {
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText("srv-1")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("demo server").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("A demo MCP server").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("demo_alias").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to a placeholder name when the server has neither name nor alias", () => {
|
||||
renderView({ server_name: undefined, alias: undefined });
|
||||
|
||||
expect(screen.getByText("Unnamed Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// "Transport" and "Authentication" label both an Overview card and a Settings
|
||||
// row, so only Overview-exclusive labels identify the Overview panel.
|
||||
it("summarises the connection on the Overview tab", () => {
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText("Host URL")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("HTTP").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("https://example.com/mcp").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("offers a Settings tab to proxy admins only", () => {
|
||||
renderView();
|
||||
expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Settings tab from non-admins", () => {
|
||||
renderView({}, { isProxyAdmin: false });
|
||||
expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the tools viewer on the MCP Tools tab", async () => {
|
||||
renderView();
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "MCP Tools" }));
|
||||
|
||||
expect(await screen.findByText("tools viewer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the read-only settings summary before editing", async () => {
|
||||
renderView({ allow_all_keys: true, available_on_public_internet: false });
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByText("MCP Server Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Allow All Keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Enabled")).toBeInTheDocument();
|
||||
expect(screen.getByText("Internal only")).toBeInTheDocument();
|
||||
expect(screen.queryByText("edit form")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps in the edit form when Edit Settings is pressed", async () => {
|
||||
renderView();
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Edit Settings" }));
|
||||
|
||||
expect(await screen.findByText("edit form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens straight into the edit form when isEditing is set", async () => {
|
||||
renderView({}, { isEditing: true });
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByText("edit form")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens on the tab named by initialTabIndex", async () => {
|
||||
renderView({}, { initialTabIndex: 1 });
|
||||
|
||||
expect(await screen.findByText("tools viewer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the server list when Back is pressed", async () => {
|
||||
const onBack = vi.fn();
|
||||
renderView({}, { onBack });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Back to All Servers/ }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the allowed tools, or says all tools are enabled", async () => {
|
||||
renderView({ allowed_tools: ["search", "fetch"] });
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByText("search")).toBeInTheDocument();
|
||||
expect(screen.getByText("fetch")).toBeInTheDocument();
|
||||
expect(screen.queryByText("All tools enabled")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says all tools are enabled when no allowlist is stored", async () => {
|
||||
renderView({ allowed_tools: [] });
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByText("All tools enabled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { render, waitFor, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { render, waitFor, screen, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import MCPServers from "./mcp_servers";
|
||||
|
|
@ -307,36 +308,15 @@ describe("MCPServers", () => {
|
|||
expect(screen.getByText("Team B Server")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team A Server 2")).toBeInTheDocument();
|
||||
|
||||
// Find the team select dropdown by looking for the "Team" label
|
||||
// Find the team select by its "Team" label, then the combobox it labels
|
||||
const teamLabel = screen.getByText("Team");
|
||||
const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select");
|
||||
expect(teamSelectContainer).toBeTruthy();
|
||||
const teamSelect = within(teamLabel.parentElement!).getByRole("combobox");
|
||||
|
||||
// Open the dropdown by clicking on the selector
|
||||
const selectSelector = teamSelectContainer?.querySelector(".ant-select-selector");
|
||||
expect(selectSelector).toBeTruthy();
|
||||
await userEvent.click(teamSelect);
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseDown(selectSelector!);
|
||||
});
|
||||
|
||||
// Wait for dropdown to open
|
||||
await waitFor(
|
||||
() => {
|
||||
const dropdownOptions = document.querySelectorAll(".ant-select-item-option");
|
||||
expect(dropdownOptions.length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
// Find and click on "Team A" option
|
||||
const dropdownOptions = document.querySelectorAll(".ant-select-item-option");
|
||||
const teamAOption = Array.from(dropdownOptions).find((option) => option.textContent?.includes("Team A"));
|
||||
expect(teamAOption).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(teamAOption!);
|
||||
});
|
||||
// Pick the "Team A" option once the listbox opens
|
||||
const teamAOption = await screen.findByText("Team A");
|
||||
await userEvent.click(teamAOption);
|
||||
|
||||
// Wait for filtering to complete
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
197
ui/litellm-dashboard/src/components/ToolDetail.test.tsx
Normal file
197
ui/litellm-dashboard/src/components/ToolDetail.test.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ToolDetail } from "./ToolDetail";
|
||||
import {
|
||||
deleteToolPolicyOverride,
|
||||
fetchToolDetail,
|
||||
fetchToolPolicyOptions,
|
||||
getToolUsageLogs,
|
||||
keyListCall,
|
||||
teamListCall,
|
||||
updateToolPolicy,
|
||||
type ToolDetailResponse,
|
||||
type ToolPolicyOption,
|
||||
type ToolPolicyOverrideRow,
|
||||
type ToolRow,
|
||||
type ToolUsageLogsResponse,
|
||||
} from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
deleteToolPolicyOverride: vi.fn(),
|
||||
fetchToolDetail: vi.fn(),
|
||||
fetchToolPolicyOptions: vi.fn(),
|
||||
getToolUsageLogs: vi.fn(),
|
||||
keyListCall: vi.fn(),
|
||||
teamListCall: vi.fn(),
|
||||
updateToolPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/team_dropdown", () => ({
|
||||
default: ({ onChange }: { onChange: (id: string) => void }) => (
|
||||
<button type="button" onClick={() => onChange("team-1")}>
|
||||
pick team
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({
|
||||
LogViewer: ({ totalLogs }: { totalLogs: number }) => <div>log viewer ({totalLogs})</div>,
|
||||
}));
|
||||
|
||||
const detail = {
|
||||
tool: {
|
||||
tool_name: "search_docs",
|
||||
input_policy: "untrusted",
|
||||
output_policy: "trusted",
|
||||
origin: "mcp",
|
||||
call_count: 42,
|
||||
user_agent: "litellm-python/1.0",
|
||||
created_at: "2026-03-04T10:00:00Z",
|
||||
},
|
||||
overrides: [],
|
||||
} as unknown as ToolDetailResponse;
|
||||
|
||||
const renderDetail = (onBack = vi.fn()) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToolDetail toolName="search_docs" onBack={onBack} accessToken="tok" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("ToolDetail", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchToolDetail).mockResolvedValue(detail);
|
||||
vi.mocked(fetchToolPolicyOptions).mockResolvedValue({ input_policies: [], output_policies: [] });
|
||||
vi.mocked(teamListCall).mockResolvedValue({ data: [] });
|
||||
vi.mocked(keyListCall).mockResolvedValue({ keys: [] });
|
||||
vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 0 } as unknown as ToolUsageLogsResponse);
|
||||
vi.mocked(updateToolPolicy).mockResolvedValue(undefined as unknown as ToolRow);
|
||||
vi.mocked(deleteToolPolicyOverride).mockResolvedValue(
|
||||
undefined as unknown as { deleted: boolean; tool_name: string },
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the tool identity once loaded", async () => {
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("search_docs")).toBeInTheDocument();
|
||||
expect(screen.getByText("mcp")).toBeInTheDocument();
|
||||
expect(screen.getByText("42 calls")).toBeInTheDocument();
|
||||
expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both policy panels with the tool's current policies", async () => {
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("Input Policy")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output Policy")).toBeInTheDocument();
|
||||
expect(screen.getByText("untrusted")).toBeInTheDocument();
|
||||
expect(screen.getByText("trusted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the policy option descriptions when the backend supplies them", async () => {
|
||||
vi.mocked(fetchToolPolicyOptions).mockResolvedValue({
|
||||
input_policies: [{ value: "untrusted", description: "Treat inputs as hostile" } as ToolPolicyOption],
|
||||
output_policies: [{ value: "trusted", description: "Outputs may be chained" } as ToolPolicyOption],
|
||||
});
|
||||
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("Treat inputs as hostile")).toBeInTheDocument();
|
||||
expect(screen.getByText("Outputs may be chained")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the list when Back is pressed", async () => {
|
||||
const onBack = vi.fn();
|
||||
renderDetail(onBack);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Back to Tool Policies/ }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a failed detail load and still offers a way back", async () => {
|
||||
vi.mocked(fetchToolDetail).mockRejectedValue(new Error("nope"));
|
||||
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("Failed to load tool details.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Back to Tool Policies/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the overrides panel when the tool has none", async () => {
|
||||
renderDetail();
|
||||
|
||||
await screen.findByText("Input Policy");
|
||||
expect(screen.queryByText("Blocked for team or key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists existing overrides and removes the chosen one", async () => {
|
||||
vi.mocked(fetchToolDetail).mockResolvedValue({
|
||||
...detail,
|
||||
overrides: [
|
||||
{
|
||||
override_id: "o1",
|
||||
team_id: "team-alpha",
|
||||
key_hash: null,
|
||||
key_alias: null,
|
||||
} as unknown as ToolPolicyOverrideRow,
|
||||
],
|
||||
});
|
||||
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("Team: team-alpha")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Remove" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(deleteToolPolicyOverride).toHaveBeenCalledWith("tok", "search_docs", {
|
||||
team_id: "team-alpha",
|
||||
key_hash: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the block button disabled until a team is chosen, then blocks that team", async () => {
|
||||
renderDetail();
|
||||
|
||||
const blockButton = await screen.findByRole("button", { name: /Block for team/ });
|
||||
expect(blockButton).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "pick team" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /Block for team/ })).toBeEnabled());
|
||||
await userEvent.click(screen.getByRole("button", { name: /Block for team/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateToolPolicy).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
"search_docs",
|
||||
{ input_policy: "blocked" },
|
||||
{ team_id: "team-1", key_hash: undefined, key_alias: undefined },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("switches the block scope to a key", async () => {
|
||||
renderDetail();
|
||||
|
||||
await screen.findByText("Block for team or key");
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Key" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: /Block for key/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "pick team" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the usage-log total through to the log viewer", async () => {
|
||||
vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 7 } as unknown as ToolUsageLogsResponse);
|
||||
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("log viewer (7)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -30,12 +30,12 @@ describe("PolicySelect", () => {
|
|||
it("should be disabled when saving is true", () => {
|
||||
renderWithProviders(<PolicySelect value="untrusted" toolName="test-tool" saving={true} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled");
|
||||
expect(screen.getByRole("combobox")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should not be disabled when saving is false", () => {
|
||||
renderWithProviders(<PolicySelect value="untrusted" toolName="test-tool" saving={false} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled");
|
||||
expect(screen.getByRole("combobox")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -67,21 +67,27 @@ const row = (toolId: string): HTMLElement => {
|
|||
const policySelect = (toolId: string, kind: "input" | "output"): HTMLElement =>
|
||||
within(row(toolId)).getAllByRole("combobox")[kind === "input" ? 0 : 1];
|
||||
|
||||
/** Exact selected-value text. Never assert with toHaveTextContent here: it substring-matches, so "untrusted" satisfies "trusted". */
|
||||
const policyValue = (toolId: string, kind: "input" | "output"): string =>
|
||||
policySelect(toolId, kind).closest(".ant-select")?.querySelector(".ant-select-selection-item")?.textContent ?? "";
|
||||
/**
|
||||
* Exact selected-value text, read off the policy cell and stripped of anything
|
||||
* that is not a letter (the control draws a status dot and a chevron around the
|
||||
* label). Never assert with toHaveTextContent here: it substring-matches, so
|
||||
* "untrusted" satisfies "trusted".
|
||||
*/
|
||||
const policyValue = (toolId: string, kind: "input" | "output"): string => {
|
||||
const cell = policySelect(toolId, kind).closest("td");
|
||||
return (cell?.textContent ?? "").replace(/[^a-z]/gi, "");
|
||||
};
|
||||
|
||||
const isSaving = (toolId: string, kind: "input" | "output"): boolean =>
|
||||
policySelect(toolId, kind).closest(".ant-select")?.classList.contains("ant-select-disabled") ?? false;
|
||||
policySelect(toolId, kind).hasAttribute("disabled");
|
||||
|
||||
const chooseOption = async (user: ReturnType<typeof userEvent.setup>, trigger: HTMLElement, label: string) => {
|
||||
await user.click(trigger);
|
||||
// The label also renders in the trigger once selected, so take the last match:
|
||||
// the popup is portalled after the table in document order.
|
||||
const option = await waitFor(() => {
|
||||
const match = Array.from(document.querySelectorAll(".ant-select-item-option")).find(
|
||||
(element) => element.textContent === label,
|
||||
);
|
||||
if (match === undefined) throw new Error(`option ${label} not open`);
|
||||
return match as HTMLElement;
|
||||
const matches = screen.getAllByText(label);
|
||||
return matches[matches.length - 1];
|
||||
});
|
||||
await user.click(option);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
|
||||
import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns";
|
||||
import type { ToolRow } from "@/components/networking";
|
||||
|
||||
const row: ToolRow = {
|
||||
tool_name: "search_docs",
|
||||
input_policy: "untrusted",
|
||||
output_policy: "trusted",
|
||||
call_count: 1234,
|
||||
team_id: "team-alpha",
|
||||
key_hash: "abc123def456",
|
||||
key_alias: "prod-key",
|
||||
user_agent: "litellm-python/1.0",
|
||||
created_at: "2026-03-04T10:00:00Z",
|
||||
} as ToolRow;
|
||||
|
||||
const defaultDeps = {
|
||||
onSelectTool: vi.fn(),
|
||||
savingInput: new Set<string>(),
|
||||
savingOutput: new Set<string>(),
|
||||
onInputPolicyChange: vi.fn(),
|
||||
onOutputPolicyChange: vi.fn(),
|
||||
};
|
||||
|
||||
// Renders the column definitions through a real TanStack table so each `cell`
|
||||
// renderer runs exactly as the DataTable runs it.
|
||||
function TableHarness({ columns, data }: { columns: ColumnDef<ToolRow>[]; data: ToolRow[] }) {
|
||||
const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() });
|
||||
return (
|
||||
<table>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
{r.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
const renderTable = (deps = {}, data: ToolRow[] = [row]) =>
|
||||
render(<TableHarness columns={getToolPoliciesTableColumns({ ...defaultDeps, ...deps })} data={data} />);
|
||||
|
||||
describe("getToolPoliciesTableColumns", () => {
|
||||
it("defines the expected columns in order", () => {
|
||||
const columns = getToolPoliciesTableColumns(defaultDeps);
|
||||
|
||||
expect(columns.map((c) => c.id)).toEqual([
|
||||
"created_at",
|
||||
"tool_name",
|
||||
"input_policy",
|
||||
"output_policy",
|
||||
"call_count",
|
||||
"team_id",
|
||||
"key_hash",
|
||||
"key_alias",
|
||||
"user_agent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders the row's identifying fields", () => {
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByText("search_docs")).toBeInTheDocument();
|
||||
expect(screen.getByText("team-alpha")).toBeInTheDocument();
|
||||
expect(screen.getByText("prod-key")).toBeInTheDocument();
|
||||
expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("formats the call count with thousands separators", () => {
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a zero call count rather than a blank cell", () => {
|
||||
renderTable({}, [{ ...row, call_count: undefined } as ToolRow]);
|
||||
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to a dash for a missing key alias and user agent", () => {
|
||||
renderTable({}, [{ ...row, key_alias: undefined, user_agent: undefined } as ToolRow]);
|
||||
|
||||
expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("notifies the caller when the tool name is clicked", async () => {
|
||||
const onSelectTool = vi.fn();
|
||||
renderTable({ onSelectTool });
|
||||
|
||||
await userEvent.click(screen.getByText("search_docs"));
|
||||
|
||||
expect(onSelectTool).toHaveBeenCalledWith("search_docs");
|
||||
});
|
||||
|
||||
it("renders a policy control for each direction, showing the row's current policies", () => {
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByText("untrusted")).toBeInTheDocument();
|
||||
expect(screen.getByText("trusted")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("disables only the input policy control while that direction is saving", () => {
|
||||
renderTable({ savingInput: new Set(["search_docs"]) });
|
||||
|
||||
const [input, output] = screen.getAllByRole("combobox");
|
||||
expect(input).toBeDisabled();
|
||||
expect(output).toBeEnabled();
|
||||
});
|
||||
|
||||
it("disables only the output policy control while that direction is saving", () => {
|
||||
renderTable({ savingOutput: new Set(["search_docs"]) });
|
||||
|
||||
const [input, output] = screen.getAllByRole("combobox");
|
||||
expect(input).toBeEnabled();
|
||||
expect(output).toBeDisabled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue