From 8ada34fe2cfa2ae948a75f8b6bd6a3395a0c1274 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 13:45:33 -0700 Subject: [PATCH 1/3] test(ui): characterise guardrails route behaviour before shadcn migration Rewrite the PatternModal test off antd class selectors onto role and text queries, and add characterisation tests for the guardrails components that had none: the keyword modal, the content filter display and configuration, the guardrail garden and the custom code modal. Also covers two behaviours the migration must preserve: the action and severity dropdowns in the content filter tables, and test playground state surviving a tab switch away and back. Every assertion here passes against the current antd and Tremor components so the same file can prove the shadcn versions unedited. --- .../_components/GuardrailsPanel.test.tsx | 32 +++- .../ContentFilterConfiguration.test.tsx | 136 +++++++++++++++ .../ContentFilterDisplay.test.tsx | 89 ++++++++++ .../ContentFilterManager.test.tsx | 24 +-- .../ContentFilterTables.test.tsx | 73 ++++++++ .../content_filter/KeywordModal.test.tsx | 87 ++++++++++ .../content_filter/PatternModal.test.tsx | 130 ++++++++------ .../custom_code/CustomCodeModal.test.tsx | 162 ++++++++++++++++++ .../_components/guardrail_garden.test.tsx | 104 +++++++++++ 9 files changed, 763 insertions(+), 74 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx index 29655e5910a..88e52e0cdd1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx @@ -35,10 +35,19 @@ vi.mock("./guardrail_info", () => ({ default: () =>
Mock Guardrail Info View
, })); -vi.mock("./GuardrailTestPlayground", () => ({ - __esModule: true, - default: () =>
Mock Guardrail Test Playground
, -})); +vi.mock("./GuardrailTestPlayground", async () => { + const { useState } = await import("react"); + const MockGuardrailTestPlayground = () => { + const [draft, setDraft] = useState(""); + return ( +
+
Mock Guardrail Test Playground
+ setDraft(e.target.value)} /> +
+ ); + }; + return { __esModule: true, default: MockGuardrailTestPlayground }; +}); vi.mock("./TeamGuardrailsTab", () => ({ TeamGuardrailsTab: () =>
Mock Team Guardrails Tab
, @@ -129,6 +138,21 @@ describe("GuardrailsPanel", () => { expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2); }); + it("should keep test playground state when switching tabs away and back", async () => { + render(); + + fireEvent.click(screen.getByText("Test Playground")); + + const draft = await screen.findByLabelText("playground draft"); + fireEvent.change(draft, { target: { value: "keep me" } }); + expect(draft).toHaveValue("keep me"); + + fireEvent.click(screen.getByText("Guardrails")); + fireEvent.click(screen.getByText("Test Playground")); + + expect(await screen.findByLabelText("playground draft")).toHaveValue("keep me"); + }); + it("should not delete anything when the modal is cancelled", async () => { render(); fireEvent.click(screen.getByText("Guardrails")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx new file mode 100644 index 00000000000..37a3d40128e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderWithProviders, screen } from "@/../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import ContentFilterConfiguration from "./ContentFilterConfiguration"; + +vi.mock("@/components/networking", () => ({ + validateBlockedWordsFile: vi.fn(), + getCategoryYaml: vi.fn(), +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() }, +})); + +const PREBUILT = [ + { name: "us_ssn", display_name: "US Social Security Number", category: "PII Patterns", description: "d" }, +]; + +describe("ContentFilterConfiguration", () => { + const handlers = { + onPatternAdd: vi.fn(), + onPatternRemove: vi.fn(), + onPatternActionChange: vi.fn(), + onBlockedWordAdd: vi.fn(), + onBlockedWordRemove: vi.fn(), + onBlockedWordUpdate: vi.fn(), + }; + + const renderConfig = (overrides = {}) => + renderWithProviders( + , + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the pattern and keyword sections", () => { + renderConfig(); + + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect( + screen.getByText("Detect sensitive information using regex patterns (SSN, credit cards, API keys, etc.)"), + ).toBeInTheDocument(); + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + expect(screen.getByText("Block or mask specific sensitive terms and phrases")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add prebuilt pattern/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add custom regex/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add keyword/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /upload yaml file/i })).toBeInTheDocument(); + }); + + it("should show the empty states for patterns and keywords", () => { + renderConfig(); + + expect(screen.getByText("No patterns added.")).toBeInTheDocument(); + expect(screen.getByText("No keywords added.")).toBeInTheDocument(); + }); + + it("should open the prebuilt pattern modal", async () => { + const user = userEvent.setup(); + renderConfig(); + + expect(screen.queryByText("Pattern type")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add prebuilt pattern/i })); + + expect(await screen.findByText("Pattern type")).toBeInTheDocument(); + }); + + it("should open the custom regex modal", async () => { + const user = userEvent.setup(); + renderConfig(); + + expect(screen.queryByText("Add custom regex pattern")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add custom regex/i })); + + expect(await screen.findByText("Add custom regex pattern")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., ID-[0-9]{6}")).toBeInTheDocument(); + }); + + it("should open the keyword modal", async () => { + const user = userEvent.setup(); + renderConfig(); + + expect(screen.queryByText("Add blocked keyword")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add keyword/i })); + + expect(await screen.findByText("Add blocked keyword")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter sensitive keyword or phrase")).toBeInTheDocument(); + }); + + it("should list already selected patterns and keywords", () => { + renderConfig({ + selectedPatterns: [ + { + id: "pattern-1", + type: "prebuilt" as const, + name: "us_ssn", + display_name: "US Social Security Number", + action: "BLOCK" as const, + }, + ], + blockedWords: [{ id: "word-1", keyword: "secret", action: "MASK" as const, description: "Sensitive" }], + }); + + expect(screen.getByText("US Social Security Number")).toBeInTheDocument(); + expect(screen.getByText("secret")).toBeInTheDocument(); + expect(screen.queryByText("No patterns added.")).not.toBeInTheDocument(); + expect(screen.queryByText("No keywords added.")).not.toBeInTheDocument(); + }); + + it("should show only the keyword section when the keywords step is requested", () => { + renderConfig({ showStep: "keywords" }); + + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + expect(screen.queryByText("Pattern Detection")).not.toBeInTheDocument(); + }); + + it("should show only the pattern section when the patterns step is requested", () => { + renderConfig({ showStep: "patterns" }); + + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect(screen.queryByText("Blocked Keywords")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx new file mode 100644 index 00000000000..8fb48791f76 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { renderWithProviders, screen } from "@/../tests/test-utils"; +import ContentFilterDisplay from "./ContentFilterDisplay"; + +const PATTERN = { + id: "pattern-1", + type: "prebuilt" as const, + name: "email", + display_name: "Email address", + action: "BLOCK" as const, +}; + +const KEYWORD = { + id: "word-1", + keyword: "secret", + action: "MASK" as const, + description: "Sensitive term", +}; + +const CATEGORY = { + id: "category-1", + category: "self_harm", + display_name: "Self Harm", + action: "BLOCK" as const, + severity_threshold: "high" as const, +}; + +describe("ContentFilterDisplay", () => { + it("should render nothing when there is no content filter data", () => { + const { container } = renderWithProviders(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("should render the categories section with a configured count", () => { + renderWithProviders(); + + expect(screen.getByText("Content Categories")).toBeInTheDocument(); + expect(screen.getByText("1 categories configured")).toBeInTheDocument(); + expect(screen.getByText("Self Harm")).toBeInTheDocument(); + expect(screen.queryByText("Pattern Detection")).not.toBeInTheDocument(); + expect(screen.queryByText("Blocked Keywords")).not.toBeInTheDocument(); + }); + + it("should render the patterns section with a configured count", () => { + renderWithProviders(); + + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect(screen.getByText("1 patterns configured")).toBeInTheDocument(); + expect(screen.getByText("Email address")).toBeInTheDocument(); + expect(screen.queryByText("Content Categories")).not.toBeInTheDocument(); + }); + + it("should render the keywords section with a configured count", () => { + renderWithProviders(); + + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + expect(screen.getByText("1 keywords configured")).toBeInTheDocument(); + expect(screen.getByText("secret")).toBeInTheDocument(); + expect(screen.getByText("Sensitive term")).toBeInTheDocument(); + }); + + it("should render every section when all three kinds of data are present", () => { + renderWithProviders(); + + expect(screen.getByText("Content Categories")).toBeInTheDocument(); + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + }); + + it("should render category severity and action as static text in read-only mode", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("HIGH")).toBeInTheDocument(); + expect(screen.getByText("BLOCK")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /delete/i })).toHaveLength(2); + }); + + it("should render category severity and action as editable controls when not read-only", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("HIGH")).not.toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /delete/i })).toHaveLength(3); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx index 66758ac72f1..c3fd2e3eb59 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import ContentFilterManager, { formatContentFilterDataForAPI } from "./ContentFilterManager"; -import React from "react"; const CONTENT_FILTER_GUARDRAIL_DATA = { litellm_params: { @@ -85,18 +84,7 @@ vi.mock("./ContentFilterDisplay", () => ({ ), })); -vi.mock("antd", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Divider: ({ children }: { children: React.ReactNode }) =>
{children}
, - Alert: ({ message, type }: { message: React.ReactNode; type: string }) => ( -
- {message} -
- ), - }; -}); +const UNSAVED_CHANGES_TEXT = /You have unsaved changes to patterns or keywords/; describe("ContentFilterManager", () => { beforeEach(() => { @@ -117,7 +105,7 @@ describe("ContentFilterManager", () => { expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); }); - expect(screen.getByTestId("divider")).toHaveTextContent("Content Filter Configuration"); + expect(screen.getByText("Content Filter Configuration")).toBeInTheDocument(); }); it("should return null when guardrail is not litellm_content_filter", () => { @@ -275,15 +263,15 @@ describe("ContentFilterManager", () => { expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); }); - expect(screen.queryByTestId("unsaved-alert")).not.toBeInTheDocument(); + expect(screen.queryByText(UNSAVED_CHANGES_TEXT)).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /add pattern/i })); await waitFor(() => { - expect(screen.getByTestId("unsaved-alert")).toBeInTheDocument(); + expect(screen.getByText(UNSAVED_CHANGES_TEXT)).toBeInTheDocument(); }); - expect(screen.getByTestId("unsaved-alert")).toHaveTextContent(/unsaved changes.*Save Changes/i); + expect(screen.getByText(UNSAVED_CHANGES_TEXT)).toHaveTextContent(/Save Changes/i); }); it("should call onDataChange when patterns or keywords change", async () => { @@ -371,7 +359,7 @@ describe("ContentFilterManager", () => { ); await waitFor(() => { - expect(screen.getByTestId("divider")).toBeInTheDocument(); + expect(screen.getByText("Content Filter Configuration")).toBeInTheDocument(); }); expect(screen.queryByTestId("content-filter-config")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx index cc27394f2c3..c56ee8ff528 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx @@ -130,4 +130,77 @@ describe("content filter tables", () => { expect(onCategoryRemove).toHaveBeenCalledWith("category-1"); }); + + it("should report a pattern action change", async () => { + const onActionChange = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(onActionChange).toHaveBeenCalledWith("pattern-1", "MASK"); + }); + + it("should report a keyword action change", async () => { + const onActionChange = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(onActionChange).toHaveBeenCalledWith("keyword-1", "action", "MASK"); + }); + + it("should report category severity and action changes", async () => { + const onSeverityChange = vi.fn(); + const onActionChange = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getAllByRole("combobox")[0]); + const lowOptions = await screen.findAllByText("Low"); + await user.click(lowOptions[lowOptions.length - 1]); + + expect(onSeverityChange).toHaveBeenCalledWith("category-1", "low"); + + await user.click(screen.getAllByRole("combobox")[1]); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(onActionChange).toHaveBeenCalledWith("category-1", "MASK"); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx new file mode 100644 index 00000000000..dda479f1579 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import KeywordModal from "./KeywordModal"; + +describe("KeywordModal", () => { + const handlers = { + onKeywordChange: vi.fn(), + onActionChange: vi.fn(), + onDescriptionChange: vi.fn(), + onAdd: vi.fn(), + onCancel: vi.fn(), + }; + + const renderModal = (overrides: Partial> = {}) => + render(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the keyword, action and description fields", async () => { + renderModal(); + + expect(await screen.findByText("Add blocked keyword")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter sensitive keyword or phrase")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Explain why this keyword is sensitive")).toBeInTheDocument(); + expect(screen.getByText("Description (optional)")).toBeInTheDocument(); + expect( + screen.getByText("Choose what action the guardrail should take when this keyword is detected"), + ).toBeInTheDocument(); + }); + + it("should report keyword edits", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(await screen.findByPlaceholderText("Enter sensitive keyword or phrase"), "s"); + + expect(handlers.onKeywordChange).toHaveBeenCalledWith("s"); + }); + + it("should report description edits", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(await screen.findByPlaceholderText("Explain why this keyword is sensitive"), "x"); + + expect(handlers.onDescriptionChange).toHaveBeenCalledWith("x"); + }); + + it("should report the chosen action", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("combobox")); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(handlers.onActionChange).toHaveBeenCalled(); + expect(handlers.onActionChange.mock.calls[0][0]).toBe("MASK"); + }); + + it("should show the current keyword and description values", async () => { + renderModal({ keyword: "secret", description: "sensitive term" }); + + expect(await screen.findByDisplayValue("secret")).toBeInTheDocument(); + expect(screen.getByDisplayValue("sensitive term")).toBeInTheDocument(); + }); + + it("should add and cancel through the footer buttons", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("button", { name: "Add" })); + expect(handlers.onAdd).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(handlers.onCancel).toHaveBeenCalledTimes(1); + }); + + it("should not render its content when not visible", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("Add blocked keyword")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx index 4e6fd0db809..4311f4e13aa 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import PatternModal from "./PatternModal"; @@ -10,25 +10,25 @@ describe("PatternModal", () => { const mockOnActionChange = vi.fn(); const mockPrebuiltPatterns = [ - { name: "us_ssn", category: "PII Patterns", description: "US Social Security Number" }, - { name: "email", category: "PII Patterns", description: "Email addresses" }, - { name: "visa", category: "Financial Patterns", description: "Visa credit card numbers" }, - { name: "aws_access_key", category: "Credential Patterns", description: "AWS Access Keys" }, + { + name: "us_ssn", + display_name: "US Social Security Number", + category: "PII Patterns", + description: "US Social Security Number", + }, + { name: "email", display_name: "Email address", category: "PII Patterns", description: "Email addresses" }, + { name: "visa", display_name: "Visa card", category: "Financial Patterns", description: "Visa credit cards" }, + { + name: "aws_access_key", + display_name: "AWS access key", + category: "Credential Patterns", + description: "AWS Access Keys", + }, ]; const mockCategories = ["PII Patterns", "Financial Patterns", "Credential Patterns"]; - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should show dropdown with prebuilt pattern options grouped by category", async () => { - /** - * Tests that the modal displays a dropdown with prebuilt patterns - * organized by category. This verifies the pattern selection UI is working. - */ - const user = userEvent.setup(); - + const renderModal = () => render( { />, ); - // Wait for modal to be visible - await waitFor(() => { - expect(screen.getByText("Add prebuilt pattern")).toBeInTheDocument(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - // Find the pattern type dropdown by looking for the first combobox input - const comboboxes = screen.getAllByRole("combobox"); - const dropdown = comboboxes[0]; // First combobox is the pattern selector - expect(dropdown).toBeInTheDocument(); + it("should show prebuilt pattern options grouped by category and report the picked pattern", async () => { + const user = userEvent.setup(); + renderModal(); - // Click to open the dropdown - await user.click(dropdown); + expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument(); - // Verify that pattern options are available in the dropdown - // Ant Design renders Select options in a portal, so we need to query the whole document - await waitFor(() => { - const options = document.querySelectorAll(".ant-select-item-option"); - expect(options.length).toBeGreaterThan(0); - }); + await user.click(screen.getAllByRole("combobox")[0]); - // Verify categories are shown as group labels - await waitFor(() => { - expect(document.body).toHaveTextContent("PII Patterns"); - expect(document.body).toHaveTextContent("Financial Patterns"); - expect(document.body).toHaveTextContent("Credential Patterns"); - }); + expect(await screen.findByText("PII Patterns")).toBeInTheDocument(); + expect(screen.getByText("Financial Patterns")).toBeInTheDocument(); + expect(screen.getByText("Credential Patterns")).toBeInTheDocument(); - // Verify pattern options are available - expect(document.body).toHaveTextContent("us_ssn"); - expect(document.body).toHaveTextContent("email"); - expect(document.body).toHaveTextContent("visa"); - expect(document.body).toHaveTextContent("aws_access_key"); + expect(screen.getByText("Email address")).toBeInTheDocument(); + expect(screen.getByText("Visa card")).toBeInTheDocument(); + expect(screen.getByText("AWS access key")).toBeInTheDocument(); - // Select a pattern by clicking on its option element - const ssnOption = Array.from(document.querySelectorAll(".ant-select-item-option")).find( - (el) => el.textContent === "us_ssn", - ) as HTMLElement; - await user.click(ssnOption); + const ssnOptions = await screen.findAllByText("US Social Security Number"); + await user.click(ssnOptions[ssnOptions.length - 1]); - // Verify the change handler was called with the pattern name - // Note: Ant Design Select calls onChange with (value, option), so we check if it was called expect(mockOnPatternNameChange).toHaveBeenCalled(); - const callArgs = mockOnPatternNameChange.mock.calls[0]; - expect(callArgs[0]).toBe("us_ssn"); + expect(mockOnPatternNameChange.mock.calls[0][0]).toBe("us_ssn"); + }); + + it("should report the chosen action", async () => { + const user = userEvent.setup(); + renderModal(); + + expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument(); + + await user.click(screen.getAllByRole("combobox")[1]); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(mockOnActionChange).toHaveBeenCalled(); + expect(mockOnActionChange.mock.calls[0][0]).toBe("MASK"); + }); + + it("should add and cancel through the footer buttons", async () => { + const user = userEvent.setup(); + renderModal(); + + expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Add" })); + expect(mockOnAdd).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it("should not render its content when not visible", () => { + render( + , + ); + + expect(screen.queryByText("Add prebuilt pattern")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx new file mode 100644 index 00000000000..ff3432a0d82 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CustomCodeModal from "./CustomCodeModal"; +import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + createGuardrailCall: vi.fn(), + updateGuardrailCall: vi.fn(), + testCustomCodeGuardrail: vi.fn(), +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() }, +})); + +const mockCreate = vi.mocked(createGuardrailCall); +const mockUpdate = vi.mocked(updateGuardrailCall); +const mockTest = vi.mocked(testCustomCodeGuardrail); + +describe("CustomCodeModal", () => { + const onClose = vi.fn(); + const onSuccess = vi.fn(); + + const renderModal = (overrides = {}) => + render(); + + beforeEach(() => { + vi.clearAllMocks(); + mockCreate.mockResolvedValue({} as never); + mockUpdate.mockResolvedValue({} as never); + }); + + it("should render the create heading and the editor scaffolding", async () => { + renderModal(); + + expect(await screen.findByText("Create Custom Guardrail")).toBeInTheDocument(); + expect(screen.getByText("Define custom logic using Python-like syntax")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., block-pii-custom")).toBeInTheDocument(); + expect(screen.getByText("Guardrail Name")).toBeInTheDocument(); + expect(screen.getByText("Mode (can select multiple)")).toBeInTheDocument(); + expect(screen.getByText("Available Primitives")).toBeInTheDocument(); + expect(screen.getByText("Python Logic")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save guardrail/i })).toBeInTheDocument(); + }); + + it("should seed the editor with the empty template", async () => { + renderModal(); + + const editor = await screen.findByDisplayValue(/async def apply_guardrail/); + expect(editor).toBeInTheDocument(); + }); + + it("should not render its content when not visible", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("Create Custom Guardrail")).not.toBeInTheDocument(); + }); + + it("should render the edit heading and existing values in edit mode", async () => { + renderModal({ + editData: { + guardrail_id: "g-1", + guardrail_name: "existing-guardrail", + litellm_params: { mode: "post_call", default_on: true, custom_code: "def apply_guardrail(): pass" }, + }, + }); + + expect(await screen.findByText("Edit Custom Guardrail")).toBeInTheDocument(); + expect(screen.getByDisplayValue("existing-guardrail")).toBeInTheDocument(); + expect(screen.getByDisplayValue("def apply_guardrail(): pass")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /update guardrail/i })).toBeInTheDocument(); + }); + + it("should keep save disabled until a guardrail name is entered", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("button", { name: /save guardrail/i })); + expect(mockCreate).not.toHaveBeenCalled(); + + await user.type(screen.getByPlaceholderText("e.g., block-pii-custom"), "my-guardrail"); + await user.click(screen.getByRole("button", { name: /save guardrail/i })); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + }); + + it("should create the guardrail with the entered name, mode and code", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(await screen.findByPlaceholderText("e.g., block-pii-custom"), "block-pii"); + await user.click(screen.getByRole("button", { name: /save guardrail/i })); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalled(); + }); + + const [token, payload] = mockCreate.mock.calls[0] as [string, Record]; + expect(token).toBe("test-token"); + expect(payload).toMatchObject({ + guardrail_name: "block-pii", + litellm_params: { guardrail: "custom_code", mode: ["pre_call"], default_on: false }, + }); + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it("should switch the editor contents when a template is chosen", async () => { + const user = userEvent.setup(); + renderModal(); + + expect(await screen.findByDisplayValue(/async def apply_guardrail/)).toBeInTheDocument(); + + const comboboxes = screen.getAllByRole("combobox"); + await user.click(comboboxes[comboboxes.length - 1]); + const options = await screen.findAllByText("Block SSN"); + await user.click(options[options.length - 1]); + + expect(await screen.findByDisplayValue(/SSN detected/)).toBeInTheDocument(); + }); + + it("should expand the test section and run a test against the backend", async () => { + const user = userEvent.setup(); + mockTest.mockResolvedValue({ success: true, result: { action: "allow" } } as never); + renderModal(); + + await user.click(await screen.findByText("Test Your Guardrail")); + + const runButton = await screen.findByRole("button", { name: /run test/i }); + await user.click(runButton); + + await waitFor(() => { + expect(mockTest).toHaveBeenCalled(); + }); + expect(await screen.findByText("Allowed")).toBeInTheDocument(); + }); + + it("should surface a backend test error", async () => { + const user = userEvent.setup(); + mockTest.mockResolvedValue({ success: false, error: "boom", error_type: "SyntaxError" } as never); + renderModal(); + + await user.click(await screen.findByText("Test Your Guardrail")); + await user.click(await screen.findByRole("button", { name: /run test/i })); + + expect(await screen.findByText("boom")).toBeInTheDocument(); + expect(screen.getByText("[SyntaxError]")).toBeInTheDocument(); + }); + + it("should cancel through the footer button", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx new file mode 100644 index 00000000000..e20c8a6fa55 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import GuardrailGarden from "./guardrail_garden"; +import { ALL_CARDS } from "./guardrail_garden_data"; + +vi.mock("./guardrail_garden_detail", () => ({ + __esModule: true, + default: ({ card, onBack }: { card: { name: string }; onBack: () => void }) => ( +
+ Detail for {card.name} + +
+ ), +})); + +const LITELLM_CARDS = ALL_CARDS.filter((c) => c.category === "litellm"); +const PARTNER_CARDS = ALL_CARDS.filter((c) => c.category === "partner"); + +describe("GuardrailGarden", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderGarden = () => render(); + + it("should render both sections with their descriptions", () => { + renderGarden(); + + expect(screen.getByText("LiteLLM Content Filter")).toBeInTheDocument(); + expect( + screen.getByText( + "Built-in guardrails powered by LiteLLM. Zero latency, no external dependencies, no additional cost.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Partner Guardrails")).toBeInTheDocument(); + expect( + screen.getByText("Third-party guardrail integrations from leading AI security providers."), + ).toBeInTheDocument(); + }); + + it("should show a capped set of litellm cards behind a show all toggle", async () => { + const user = userEvent.setup(); + renderGarden(); + + expect(screen.getByText(`Show all (${LITELLM_CARDS.length})`)).toBeInTheDocument(); + expect(screen.getByText(LITELLM_CARDS[0].name)).toBeInTheDocument(); + expect(screen.queryByText(LITELLM_CARDS[LITELLM_CARDS.length - 1].name)).not.toBeInTheDocument(); + + await user.click(screen.getByText(`Show all (${LITELLM_CARDS.length})`)); + + expect(screen.getByText("Show less")).toBeInTheDocument(); + expect(screen.getByText(LITELLM_CARDS[LITELLM_CARDS.length - 1].name)).toBeInTheDocument(); + }); + + it("should always render every partner card", () => { + renderGarden(); + + PARTNER_CARDS.forEach((card) => { + expect(screen.getByText(card.name)).toBeInTheDocument(); + }); + }); + + it("should filter cards by the search query", async () => { + const user = userEvent.setup(); + renderGarden(); + + const target = PARTNER_CARDS[0]; + await user.type(screen.getByPlaceholderText("Search guardrails"), target.name); + + expect(await screen.findByText(target.name)).toBeInTheDocument(); + const otherPartner = PARTNER_CARDS.find((c) => c.name !== target.name); + if (otherPartner) { + expect(screen.queryByText(otherPartner.name)).not.toBeInTheDocument(); + } + }); + + it("should show an empty result set for a query that matches nothing", async () => { + const user = userEvent.setup(); + renderGarden(); + + await user.type(screen.getByPlaceholderText("Search guardrails"), "zzzzznotaguardrailzzzzz"); + + expect(screen.getByText("Show all (0)")).toBeInTheDocument(); + PARTNER_CARDS.forEach((card) => { + expect(screen.queryByText(card.name)).not.toBeInTheDocument(); + }); + }); + + it("should open the detail view for a clicked card and return to the garden", async () => { + const user = userEvent.setup(); + renderGarden(); + + const target = PARTNER_CARDS[0]; + await user.click(screen.getByText(target.name)); + + expect(await screen.findByText(`Detail for ${target.name}`)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Search guardrails")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Back to garden" })); + + expect(await screen.findByPlaceholderText("Search guardrails")).toBeInTheDocument(); + }); +}); From 771c36c1eec0d67fc9ee98150e14244e0a71ecce Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 15:05:09 -0700 Subject: [PATCH 2/3] refactor(ui): migrate guardrails route to shadcn Ports the 21 route-owned guardrails components off antd and Tremor onto the installed shadcn base-vega primitives. Forms, tables and shared components stay where they are: the route analyzer buckets add_guardrail_form, guardrail_info and six others as DEFERRED, and eleven components under src/components as SHARED, so none of them are touched here. Three behaviours needed explicit handling because Base UI does not match antd by default. Base UI Tabs mounts only the active panel, so the playground draft and every panel's local state would reset on a tab switch; TabsContent now carries keepMounted. ComboboxList takes a function child to render the filtered items, and mapping over the array instead renders everything and silently disables the type to filter behaviour antd gave for free. SelectContent gets alignItemWithTrigger={false} so popups anchor below the trigger the way antd's did, which also removes a focus race that made the template picker intermittently unclickable. The three dialogs reached from inside the still-antd wizard need a layer above its z-index of 1000 to be visible at all. Adds three tests rather than editing the characterisation tests from the previous commit: a guard that every tab panel stays mounted, and one filtering regression each for the pattern and mode dropdowns. Prunes the guardrails entries from eslint-suppressions.json. --- ui/litellm-dashboard/eslint-suppressions.json | 86 +--- .../_components/GuardrailTestPanel.tsx | 98 +++-- .../_components/GuardrailTestPlayground.tsx | 207 +++++---- .../_components/GuardrailTestResults.tsx | 107 ++--- .../_components/GuardrailsPanel.test.tsx | 7 + .../_components/GuardrailsPanel.tsx | 219 +++++----- .../content_filter/CategoryTable.tsx | 71 +-- .../ContentCategoryConfiguration.tsx | 320 +++++++------- .../ContentFilterConfiguration.tsx | 113 +++-- .../content_filter/ContentFilterDisplay.tsx | 67 +-- .../content_filter/ContentFilterManager.tsx | 28 +- .../content_filter/CustomPatternModal.tsx | 110 +++-- .../content_filter/KeywordModal.tsx | 109 +++-- .../content_filter/KeywordTable.tsx | 30 +- .../content_filter/PatternModal.test.tsx | 14 + .../content_filter/PatternModal.tsx | 152 ++++--- .../content_filter/PatternTable.tsx | 42 +- .../content_filter/action_options.ts | 10 + .../content_filter/dialog_layering.ts | 1 + .../custom_code/CustomCodeModal.test.tsx | 11 + .../custom_code/CustomCodeModal.tsx | 403 +++++++++--------- .../_components/guardrail_garden.tsx | 68 ++- .../_components/guardrail_garden_card.tsx | 38 +- .../_components/guardrail_garden_detail.tsx | 31 +- .../guardrails/_components/pii_components.tsx | 229 +++++----- .../_components/pii_configuration.tsx | 9 +- .../ToolPermissionRulesEditor.tsx | 348 ++++++++------- 27 files changed, 1490 insertions(+), 1438 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/action_options.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/dialog_layering.ts diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 2f4def9ffa2..8ba5d7aab3f 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -326,28 +326,12 @@ "count": 1 } }, - "src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx": { - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -386,11 +370,6 @@ "count": 2 } }, - "src/app/(dashboard)/guardrails/_components/content_filter/CategoryTable.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx": { "no-nested-ternary": { "count": 1 @@ -403,14 +382,8 @@ } }, "src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, "no-nested-ternary": { - "count": 3 - }, - "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 @@ -419,62 +392,20 @@ "src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.tsx": { "local/no-complex-jsx-arrow": { "count": 3 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.tsx": { - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.tsx": { "max-params": { "count": 2 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/guardrails/_components/content_filter/PatternTable.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx": { "no-nested-ternary": { "count": 6 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -487,9 +418,6 @@ "src/app/(dashboard)/guardrails/_components/guardrail_garden.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/guardrails/_components/guardrail_garden_card.tsx": { @@ -500,9 +428,6 @@ "src/app/(dashboard)/guardrails/_components/guardrail_garden_detail.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/guardrails/_components/guardrail_info.tsx": { @@ -568,23 +493,14 @@ "src/app/(dashboard)/guardrails/_components/pii_components.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/guardrails/_components/pii_configuration.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/purity": { "count": 1 } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx index e310da55063..8297c1e1e3b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx @@ -1,13 +1,12 @@ import React, { useState } from "react"; -import { Button } from "@tremor/react"; -import { Input, Typography, Tooltip } from "antd"; -import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons"; +import { Copy, Info } from "lucide-react"; import NotificationsManager from "@/components/molecules/notifications_manager"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import GuardrailTestResults from "./GuardrailTestResults"; -const { TextArea } = Input; -const { Text } = Typography; - interface GuardrailTestPanelProps { guardrailNames: string[]; onSubmit: (text: string, metadata?: Record | null) => void; @@ -108,23 +107,23 @@ export function GuardrailTestPanel({ return (
{/* Header */} -
+
-
-

Test Guardrails:

+
+

Test Guardrails:

{guardrailNames.map((name) => (
- {name} + {name}
))}
-

+

Test {guardrailNames.length > 1 ? "guardrails" : "guardrail"} and compare results

@@ -135,46 +134,63 @@ export function GuardrailTestPanel({
-
+
- - - + + + + + + } + /> + Press Enter to submit. Use Shift+Enter for new line.
{inputText && ( - )}
-