diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx new file mode 100644 index 0000000000..17b8b39360 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx @@ -0,0 +1,134 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// Mock the useWorker hook +const mockUseWorker = vi.fn(); +vi.mock("@/hooks/useWorker", () => ({ + useWorker: () => mockUseWorker(), +})); + +// Mock antd Select +vi.mock("antd", () => ({ + Select: ({ value, options, onChange, style, disabled, ...props }: any) => ( + + ), +})); + +// Mock icon +vi.mock("@ant-design/icons", () => ({ + CloudServerOutlined: () => , +})); + +import WorkerDropdown from "./WorkerDropdown"; + +describe("WorkerDropdown", () => { + const mockOnWorkerSwitch = vi.fn(); + const workers = [ + { worker_id: "w1", name: "Worker 1" }, + { worker_id: "w2", name: "Worker 2" }, + { worker_id: "w3", name: "Worker 3" }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders null when isControlPlane is false", () => { + mockUseWorker.mockReturnValue({ + isControlPlane: false, + selectedWorker: workers[0], + workers, + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders null when selectedWorker is null", () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: null, + workers, + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the select when isControlPlane and selectedWorker exist", () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[0], + workers, + }); + + render(); + expect(screen.getByTestId("worker-select")).toBeInTheDocument(); + }); + + it("renders all worker options", () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[0], + workers, + }); + + render(); + expect(screen.getByText("Worker 1")).toBeInTheDocument(); + expect(screen.getByText("Worker 2")).toBeInTheDocument(); + expect(screen.getByText("Worker 3")).toBeInTheDocument(); + }); + + it("sets current worker as selected value", () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + + render(); + const select = screen.getByTestId("worker-select") as HTMLSelectElement; + expect(select.value).toBe("w2"); + }); + + it("disables the currently selected worker in options", () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[0], + workers, + }); + + render(); + const options = screen.getAllByRole("option"); + const selectedOption = options.find((opt) => (opt as HTMLOptionElement).value === "w1"); + expect(selectedOption).toBeDisabled(); + }); + + it("calls onWorkerSwitch when selection changes", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[0], + workers, + }); + + render(); + const select = screen.getByTestId("worker-select"); + + const { default: userEvent } = await import("@testing-library/user-event"); + const user = userEvent.setup(); + await user.selectOptions(select, "w2"); + + expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w2"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/agent_management/AgentSelector.test.tsx b/ui/litellm-dashboard/src/components/agent_management/AgentSelector.test.tsx new file mode 100644 index 0000000000..26f903cd31 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agent_management/AgentSelector.test.tsx @@ -0,0 +1,144 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// Mock networking module +const mockGetAgentsList = vi.fn(); +vi.mock("../networking", () => ({ + getAgentsList: (...args: any[]) => mockGetAgentsList(...args), +})); + +// Mock antd Select +vi.mock("antd", () => { + const SelectComponent = ({ children, onChange, value, mode, placeholder, loading, disabled, ...props }: any) => ( +
+ + {loading && Loading} +
+ ); + + SelectComponent.Option = ({ children, value, ...props }: any) => ( + + ); + + return { Select: SelectComponent }; +}); + +import AgentSelector from "./AgentSelector"; + +describe("AgentSelector", () => { + const defaultProps = { + onChange: vi.fn(), + accessToken: "test-token", + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetAgentsList.mockResolvedValue({ + agents: [ + { agent_id: "agent-1", agent_name: "Agent One" }, + { agent_id: "agent-2", agent_name: "Agent Two", agent_access_groups: ["group-a", "group-b"] }, + ], + }); + }); + + it("renders the selector", () => { + render(); + expect(screen.getByTestId("agent-select")).toBeInTheDocument(); + }); + + it("fetches agents on mount with access token", async () => { + render(); + await waitFor(() => { + expect(mockGetAgentsList).toHaveBeenCalledWith("test-token"); + }); + }); + + it("does not fetch when accessToken is empty", () => { + render(); + expect(mockGetAgentsList).not.toHaveBeenCalled(); + }); + + it("shows loading state while fetching", async () => { + // Keep the promise pending + let resolve: any; + mockGetAgentsList.mockReturnValue(new Promise((r) => { resolve = r; })); + + render(); + expect(screen.getByTestId("agent-select")).toHaveAttribute("data-loading", "true"); + + // Resolve to clean up + resolve({ agents: [] }); + await waitFor(() => { + expect(screen.getByTestId("agent-select")).toHaveAttribute("data-loading", "false"); + }); + }); + + it("renders agent options after fetch", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("Agent One")).toBeInTheDocument(); + expect(screen.getByText("Agent Two")).toBeInTheDocument(); + }); + }); + + it("renders access group options with group prefix", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("group-a")).toBeInTheDocument(); + expect(screen.getByText("group-b")).toBeInTheDocument(); + }); + }); + + it("respects disabled prop", () => { + render(); + expect(screen.getByTestId("agent-select")).toHaveAttribute("data-disabled", "true"); + }); + + it("handles API error gracefully", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockGetAgentsList.mockRejectedValue(new Error("API error")); + + render(); + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith("Error fetching agents:", expect.any(Error)); + }); + + consoleSpy.mockRestore(); + }); + + it("passes value as flattened selectedValues", async () => { + render( + + ); + await waitFor(() => { + const select = screen.getByTestId("select-input"); + // The value should contain agent-1 and group:group-a + expect(select).toBeInTheDocument(); + }); + }); + + it("handles null response from API", async () => { + mockGetAgentsList.mockResolvedValue(null); + render(); + await waitFor(() => { + expect(screen.getByTestId("agent-select")).toHaveAttribute("data-loading", "false"); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts new file mode 100644 index 0000000000..6a754f5739 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from "vitest"; +import { + formatInstallCommand, + extractCategories, + validatePluginName, + getSourceDisplayText, + getSourceLink, + getCategoryBadgeColor, + formatDateString, + truncateText, + filterPluginsBySearch, + filterPluginsByCategory, + isValidSemanticVersion, + isValidEmail, + isValidUrl, + parseKeywords, + formatKeywords, +} from "./helpers"; +import { MarketplacePluginEntry, PluginSource } from "./types"; + +describe("formatInstallCommand", () => { + it("formats github source with repo", () => { + const plugin = { name: "my-plugin", source: { source: "github" as const, repo: "org/repo" } }; + expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add org/repo"); + }); + + it("formats url source", () => { + const plugin = { name: "my-plugin", source: { source: "url" as const, url: "https://example.com/plugin" } }; + expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add https://example.com/plugin"); + }); + + it("falls back to plugin name when no repo or url", () => { + const plugin = { name: "my-plugin", source: { source: "github" as const } }; + expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add my-plugin"); + }); +}); + +describe("extractCategories", () => { + it("returns All and Other for empty list", () => { + expect(extractCategories([])).toEqual(["All", "Other"]); + }); + + it("extracts and sorts unique categories", () => { + const plugins = [ + { category: "Development" }, + { category: "Analytics" }, + { category: "Development" }, + ]; + expect(extractCategories(plugins)).toEqual(["All", "Analytics", "Development", "Other"]); + }); + + it("ignores empty/whitespace categories", () => { + const plugins = [{ category: "" }, { category: " " }, { category: "Tools" }]; + expect(extractCategories(plugins)).toEqual(["All", "Tools", "Other"]); + }); + + it("handles undefined category", () => { + const plugins = [{ category: undefined }, { category: "Security" }]; + expect(extractCategories(plugins)).toEqual(["All", "Security", "Other"]); + }); +}); + +describe("validatePluginName", () => { + it("accepts valid kebab-case names", () => { + expect(validatePluginName("my-plugin")).toBe(true); + expect(validatePluginName("plugin123")).toBe(true); + expect(validatePluginName("a-b-c")).toBe(true); + }); + + it("rejects names with uppercase", () => { + expect(validatePluginName("MyPlugin")).toBe(false); + }); + + it("rejects names with spaces", () => { + expect(validatePluginName("my plugin")).toBe(false); + }); + + it("rejects empty/whitespace names", () => { + expect(validatePluginName("")).toBe(false); + expect(validatePluginName(" ")).toBe(false); + }); + + it("rejects names with special characters", () => { + expect(validatePluginName("my_plugin")).toBe(false); + expect(validatePluginName("my.plugin")).toBe(false); + }); +}); + +describe("getSourceDisplayText", () => { + it("shows github repo", () => { + expect(getSourceDisplayText({ source: "github", repo: "org/repo" })).toBe("GitHub: org/repo"); + }); + + it("shows url", () => { + expect(getSourceDisplayText({ source: "url", url: "https://example.com" })).toBe("https://example.com"); + }); + + it("returns unknown for missing data", () => { + expect(getSourceDisplayText({ source: "github" })).toBe("Unknown source"); + }); +}); + +describe("getSourceLink", () => { + it("returns github link for github source", () => { + expect(getSourceLink({ source: "github", repo: "org/repo" })).toBe("https://github.com/org/repo"); + }); + + it("returns url for url source", () => { + expect(getSourceLink({ source: "url", url: "https://example.com" })).toBe("https://example.com"); + }); + + it("returns null when no repo or url", () => { + expect(getSourceLink({ source: "github" })).toBeNull(); + }); +}); + +describe("getCategoryBadgeColor", () => { + it("returns blue for development categories", () => { + expect(getCategoryBadgeColor("Development")).toBe("blue"); + expect(getCategoryBadgeColor("dev-tools")).toBe("blue"); + }); + + it("returns green for productivity categories", () => { + expect(getCategoryBadgeColor("Productivity")).toBe("green"); + expect(getCategoryBadgeColor("Workflow")).toBe("green"); + }); + + it("returns purple for learning categories", () => { + expect(getCategoryBadgeColor("Learning")).toBe("purple"); + expect(getCategoryBadgeColor("Education")).toBe("purple"); + }); + + it("returns red for security categories", () => { + expect(getCategoryBadgeColor("Security")).toBe("red"); + expect(getCategoryBadgeColor("Safety")).toBe("red"); + }); + + it("returns orange for data categories", () => { + expect(getCategoryBadgeColor("Data")).toBe("orange"); + expect(getCategoryBadgeColor("Analytics")).toBe("orange"); + }); + + it("returns yellow for integration categories", () => { + expect(getCategoryBadgeColor("Integration")).toBe("yellow"); + expect(getCategoryBadgeColor("API")).toBe("yellow"); + }); + + it("returns gray for unknown or undefined categories", () => { + expect(getCategoryBadgeColor("Unknown")).toBe("gray"); + expect(getCategoryBadgeColor(undefined)).toBe("gray"); + }); +}); + +describe("formatDateString", () => { + it("formats valid date strings", () => { + const result = formatDateString("2024-01-15T12:00:00Z"); + expect(result).toContain("2024"); + expect(result).toContain("Jan"); + expect(result).toContain("15"); + }); + + it("returns N/A for undefined", () => { + expect(formatDateString(undefined)).toBe("N/A"); + }); + + it("returns N/A for empty string", () => { + expect(formatDateString("")).toBe("N/A"); + }); +}); + +describe("truncateText", () => { + it("returns text unchanged if shorter than max", () => { + expect(truncateText("hello", 10)).toBe("hello"); + }); + + it("truncates and adds ellipsis", () => { + expect(truncateText("hello world", 5)).toBe("hello..."); + }); + + it("handles exact length", () => { + expect(truncateText("hello", 5)).toBe("hello"); + }); + + it("handles empty text", () => { + expect(truncateText("", 5)).toBe(""); + }); +}); + +describe("filterPluginsBySearch", () => { + const plugins: MarketplacePluginEntry[] = [ + { + name: "code-formatter", + source: { source: "github", repo: "org/formatter" }, + description: "Formats code nicely", + keywords: ["format", "lint"], + }, + { + name: "data-viewer", + source: { source: "github", repo: "org/viewer" }, + description: "View data", + keywords: ["analytics"], + }, + ]; + + it("returns all plugins for empty search", () => { + expect(filterPluginsBySearch(plugins, "")).toEqual(plugins); + expect(filterPluginsBySearch(plugins, " ")).toEqual(plugins); + }); + + it("matches by name", () => { + expect(filterPluginsBySearch(plugins, "formatter")).toHaveLength(1); + expect(filterPluginsBySearch(plugins, "formatter")[0].name).toBe("code-formatter"); + }); + + it("matches by description", () => { + expect(filterPluginsBySearch(plugins, "nicely")).toHaveLength(1); + }); + + it("matches by keyword", () => { + expect(filterPluginsBySearch(plugins, "analytics")).toHaveLength(1); + expect(filterPluginsBySearch(plugins, "analytics")[0].name).toBe("data-viewer"); + }); + + it("is case insensitive", () => { + expect(filterPluginsBySearch(plugins, "FORMATTER")).toHaveLength(1); + }); +}); + +describe("filterPluginsByCategory", () => { + const plugins: MarketplacePluginEntry[] = [ + { name: "a", source: { source: "github" }, category: "Dev" }, + { name: "b", source: { source: "github" }, category: "Security" }, + { name: "c", source: { source: "github" }, category: "" }, + { name: "d", source: { source: "github" } }, + ]; + + it("returns all plugins for 'All'", () => { + expect(filterPluginsByCategory(plugins, "All")).toEqual(plugins); + }); + + it("returns uncategorized plugins for 'Other'", () => { + const result = filterPluginsByCategory(plugins, "Other"); + expect(result).toHaveLength(2); + expect(result.map((p) => p.name)).toEqual(["c", "d"]); + }); + + it("filters by specific category", () => { + const result = filterPluginsByCategory(plugins, "Dev"); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("a"); + }); +}); + +describe("isValidSemanticVersion", () => { + it("accepts valid semver", () => { + expect(isValidSemanticVersion("1.0.0")).toBe(true); + expect(isValidSemanticVersion("0.1.0-alpha")).toBe(true); + expect(isValidSemanticVersion("2.3.4+build.1")).toBe(true); + }); + + it("rejects invalid semver", () => { + expect(isValidSemanticVersion("1.0")).toBe(false); + expect(isValidSemanticVersion("abc")).toBe(false); + }); + + it("returns true for undefined (optional)", () => { + expect(isValidSemanticVersion(undefined)).toBe(true); + }); +}); + +describe("isValidEmail", () => { + it("accepts valid emails", () => { + expect(isValidEmail("user@example.com")).toBe(true); + }); + + it("rejects invalid emails", () => { + expect(isValidEmail("not-an-email")).toBe(false); + expect(isValidEmail("@example.com")).toBe(false); + }); + + it("returns true for undefined (optional)", () => { + expect(isValidEmail(undefined)).toBe(true); + }); +}); + +describe("isValidUrl", () => { + it("accepts valid urls", () => { + expect(isValidUrl("https://example.com")).toBe(true); + expect(isValidUrl("http://localhost:3000")).toBe(true); + }); + + it("rejects invalid urls", () => { + expect(isValidUrl("not a url")).toBe(false); + }); + + it("returns true for undefined (optional)", () => { + expect(isValidUrl(undefined)).toBe(true); + }); +}); + +describe("parseKeywords", () => { + it("splits comma-separated keywords", () => { + expect(parseKeywords("a, b, c")).toEqual(["a", "b", "c"]); + }); + + it("trims whitespace", () => { + expect(parseKeywords(" foo , bar ")).toEqual(["foo", "bar"]); + }); + + it("filters empty entries", () => { + expect(parseKeywords("a,,b,")).toEqual(["a", "b"]); + }); + + it("returns empty array for empty string", () => { + expect(parseKeywords("")).toEqual([]); + expect(parseKeywords(" ")).toEqual([]); + }); +}); + +describe("formatKeywords", () => { + it("joins keywords with comma and space", () => { + expect(formatKeywords(["a", "b", "c"])).toBe("a, b, c"); + }); + + it("returns empty string for empty/undefined array", () => { + expect(formatKeywords([])).toBe(""); + expect(formatKeywords(undefined)).toBe(""); + }); +}); diff --git a/ui/litellm-dashboard/src/components/molecules/message_manager.test.ts b/ui/litellm-dashboard/src/components/molecules/message_manager.test.ts new file mode 100644 index 0000000000..541efcbc4c --- /dev/null +++ b/ui/litellm-dashboard/src/components/molecules/message_manager.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// Use vi.hoisted so the mock object is available when vi.mock is hoisted +const mockStaticMessage = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + destroy: vi.fn(), +})); + +vi.mock("antd", () => ({ + message: mockStaticMessage, +})); + +import MessageManager, { setMessageInstance } from "./message_manager"; + +describe("MessageManager", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("when no instance is set (falls back to static message)", () => { + it("delegates success to static message", () => { + MessageManager.success("done!"); + expect(mockStaticMessage.success).toHaveBeenCalledWith("done!", undefined); + }); + + it("delegates error to static message", () => { + MessageManager.error("failed!", 5); + expect(mockStaticMessage.error).toHaveBeenCalledWith("failed!", 5); + }); + + it("delegates warning to static message", () => { + MessageManager.warning("watch out"); + expect(mockStaticMessage.warning).toHaveBeenCalledWith("watch out", undefined); + }); + + it("delegates info to static message", () => { + MessageManager.info("fyi"); + expect(mockStaticMessage.info).toHaveBeenCalledWith("fyi", undefined); + }); + + it("delegates loading to static message", () => { + MessageManager.loading("loading...", 3); + expect(mockStaticMessage.loading).toHaveBeenCalledWith("loading...", 3); + }); + + it("delegates destroy to static message", () => { + MessageManager.destroy(); + expect(mockStaticMessage.destroy).toHaveBeenCalled(); + }); + }); + + describe("when a custom instance is set", () => { + const mockInstance = { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + destroy: vi.fn(), + open: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + setMessageInstance(mockInstance as any); + }); + + it("delegates success to custom instance", () => { + MessageManager.success("done!"); + expect(mockInstance.success).toHaveBeenCalledWith("done!", undefined); + expect(mockStaticMessage.success).not.toHaveBeenCalled(); + }); + + it("delegates error with duration to custom instance", () => { + MessageManager.error("failed!", 5); + expect(mockInstance.error).toHaveBeenCalledWith("failed!", 5); + expect(mockStaticMessage.error).not.toHaveBeenCalled(); + }); + + it("delegates warning to custom instance", () => { + MessageManager.warning("watch out"); + expect(mockInstance.warning).toHaveBeenCalledWith("watch out", undefined); + }); + + it("delegates info to custom instance", () => { + MessageManager.info("fyi", 2); + expect(mockInstance.info).toHaveBeenCalledWith("fyi", 2); + }); + + it("delegates loading to custom instance and returns result", () => { + const mockReturn = { then: vi.fn() }; + mockInstance.loading.mockReturnValue(mockReturn); + const result = MessageManager.loading("loading...", 3); + expect(mockInstance.loading).toHaveBeenCalledWith("loading...", 3); + expect(result).toBe(mockReturn); + }); + + it("delegates destroy to custom instance", () => { + MessageManager.destroy(); + expect(mockInstance.destroy).toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ui/AntDLoadingSpinner.test.tsx b/ui/litellm-dashboard/src/components/ui/AntDLoadingSpinner.test.tsx new file mode 100644 index 0000000000..e86a765985 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/AntDLoadingSpinner.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +// Mock antd Spin component +vi.mock("antd", () => ({ + Spin: ({ indicator, size, ...props }: any) => ( +
+ {indicator} +
+ ), +})); + +// Mock the icon +vi.mock("@ant-design/icons", () => ({ + LoadingOutlined: ({ style, spin, ...props }: any) => ( + + ), +})); + +import { AntDLoadingSpinner } from "./AntDLoadingSpinner"; + +describe("AntDLoadingSpinner", () => { + it("renders without props", () => { + render(); + expect(screen.getByTestId("spin")).toBeInTheDocument(); + expect(screen.getByTestId("loading-icon")).toBeInTheDocument(); + }); + + it("passes size prop to Spin", () => { + render(); + expect(screen.getByTestId("spin")).toHaveAttribute("data-size", "large"); + }); + + it("passes small size to Spin", () => { + render(); + expect(screen.getByTestId("spin")).toHaveAttribute("data-size", "small"); + }); + + it("applies custom fontSize to the icon", () => { + render(); + const icon = screen.getByTestId("loading-icon"); + expect(icon).toHaveStyle({ fontSize: "32px" }); + }); + + it("does not set style when fontSize is not provided", () => { + render(); + const icon = screen.getByTestId("loading-icon"); + expect(icon.style.fontSize).toBe(""); + }); + + it("sets spin attribute on icon", () => { + render(); + const icon = screen.getByTestId("loading-icon"); + expect(icon).toHaveAttribute("data-spin", "true"); + }); +});