From 212421207ecc59d35da919166f0bf372b90d49c2 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:25:05 -0700 Subject: [PATCH] test(ui): characterise budgets, skills and ui-theme panels before migration Adds a role/text-based characterisation test for UIThemeSettings, which had none, and extends the skills panel test to cover the delete confirmation. Both are green against the current antd/Tremor components so they can prove the shadcn migration keeps behaviour identical without being edited. --- .../ClaudeCodePluginsPanel.test.tsx | 82 ++++++++++- .../ui-theme/UIThemeSettings.test.tsx | 128 ++++++++++++++++++ 2 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx index 52f3dc21b7a..67bab398bd2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx @@ -1,7 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getClaudeCodePluginsList } from "@/components/networking"; +import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; +import type { Plugin } from "@/components/claude_code_plugins/types"; import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; @@ -12,8 +14,27 @@ vi.mock("@/components/networking", () => ({ vi.mock("./PluginTable", () => ({ __esModule: true, - default: ({ isLoading }: { isLoading: boolean }) => ( -
{isLoading ? "table-loading" : "table-loaded"}
+ default: ({ + isLoading, + pluginsList, + onDeleteClick, + }: { + isLoading: boolean; + pluginsList: Plugin[]; + onDeleteClick: (pluginName: string, displayName: string) => void; + }) => ( +
+ {isLoading ? "table-loading" : "table-loaded"} + {pluginsList.map((plugin) => ( + + ))} +
), })); @@ -21,6 +42,14 @@ vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null })); vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null })); const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList); +const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin); + +const skill: Plugin = { + id: "plugin-1", + name: "my-skill", + source: { source: "github", repo: "acme/my-skill" }, + enabled: true, +}; describe("ClaudeCodePluginsPanel loading state", () => { beforeEach(() => { @@ -48,3 +77,48 @@ describe("ClaudeCodePluginsPanel loading state", () => { expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false); }); }); + +describe("ClaudeCodePluginsPanel delete confirmation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill], count: 1 }); + }); + + it("should ask for confirmation before deleting and name the skill", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTestId("row-delete-plugin-1")); + + expect(await screen.findByText(/are you sure you want to delete skill/i)).toBeInTheDocument(); + expect(screen.getByText("my-skill")).toBeInTheDocument(); + expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument(); + expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled(); + }); + + it("should delete the skill and refresh the list once confirmed", async () => { + const user = userEvent.setup(); + mockDeleteClaudeCodePlugin.mockResolvedValue({}); + render(); + + await user.click(await screen.findByTestId("row-delete-plugin-1")); + await screen.findByText(/are you sure you want to delete skill/i); + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(mockDeleteClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "my-skill")); + await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument()); + }); + + it("should not delete the skill when the confirmation is cancelled", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTestId("row-delete-plugin-1")); + await screen.findByText(/are you sure you want to delete skill/i); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument()); + expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx new file mode 100644 index 00000000000..20f8960ae3a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx @@ -0,0 +1,128 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import NotificationsManager from "@/components/molecules/notifications_manager"; + +import UIThemeSettings from "./UIThemeSettings"; + +const setLogoUrl = vi.fn(); +const setFaviconUrl = vi.fn(); + +vi.mock("@/contexts/ThemeContext", () => ({ + useTheme: () => ({ logoUrl: null, setLogoUrl, faviconUrl: null, setFaviconUrl }), +})); + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "", + getGlobalLitellmHeaderName: () => "Authorization", +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + __esModule: true, + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +const LOGO_PLACEHOLDER = "https://example.com/logo.png"; +const FAVICON_PLACEHOLDER = "https://example.com/favicon.ico"; + +const okResponse = (values: Record = {}) => + Promise.resolve({ ok: true, json: () => Promise.resolve({ values }) } as Response); + +const fetchMock = vi.fn(); + +const patchCalls = () => fetchMock.mock.calls.filter(([, init]) => init?.method === "PATCH"); + +const bodyOf = (call: Parameters) => JSON.parse(String(call[1]?.body)); + +describe("UIThemeSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchMock.mockImplementation(() => okResponse()); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should render nothing without an access token", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should load the saved logo and favicon urls into the inputs", async () => { + fetchMock.mockImplementation(() => + okResponse({ logo_url: "https://cdn.example.com/logo.svg", favicon_url: "https://cdn.example.com/fav.ico" }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo.svg"); + }); + expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue("https://cdn.example.com/fav.ico"); + expect(setLogoUrl).toHaveBeenCalledWith("https://cdn.example.com/logo.svg"); + expect(setFaviconUrl).toHaveBeenCalledWith("https://cdn.example.com/fav.ico"); + }); + + it("should save the entered urls and report success", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + await user.type(screen.getByPlaceholderText(LOGO_PLACEHOLDER), "https://a.test/logo.png"); + await user.type(screen.getByPlaceholderText(FAVICON_PLACEHOLDER), "https://a.test/fav.ico"); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => expect(patchCalls()).toHaveLength(1)); + expect(bodyOf(patchCalls()[0])).toEqual({ + logo_url: "https://a.test/logo.png", + favicon_url: "https://a.test/fav.ico", + }); + await waitFor(() => + expect(NotificationsManager.success).toHaveBeenCalledWith("Theme settings updated successfully!"), + ); + }); + + it("should surface a backend failure when saving fails", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + fetchMock.mockImplementation(() => Promise.resolve({ ok: false } as Response)); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => + expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update theme settings"), + ); + expect(NotificationsManager.success).not.toHaveBeenCalled(); + }); + + it("should clear both inputs and persist nulls when resetting to default", async () => { + const user = userEvent.setup(); + fetchMock.mockImplementation(() => + okResponse({ logo_url: "https://cdn.example.com/logo.svg", favicon_url: "https://cdn.example.com/fav.ico" }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo.svg"); + }); + + await user.click(screen.getByRole("button", { name: "Reset to Default" })); + + await waitFor(() => expect(patchCalls()).toHaveLength(1)); + expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, favicon_url: null }); + expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue(""); + expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue(""); + expect(setLogoUrl).toHaveBeenLastCalledWith(null); + expect(setFaviconUrl).toHaveBeenLastCalledWith(null); + await waitFor(() => expect(NotificationsManager.success).toHaveBeenCalledWith("Theme settings reset to default!")); + }); +});