diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts new file mode 100644 index 00000000000..785f003d2f8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts @@ -0,0 +1,185 @@ +import { getUiSettings } from "@/components/networking"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import React, { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useUISettings } from "./useUISettings"; + +// Mock the networking function +vi.mock("@/components/networking", () => ({ + getUiSettings: vi.fn(), +})); + +// Mock useAuthorized hook - we can override this in individual tests +const mockUseAuthorized = vi.fn(); +vi.mock("../useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +// Mock data +const mockUISettings: Record = { + theme: "dark", + language: "en", + notifications: true, + dashboard_layout: "compact", + api_keys_visible: false, +}; + +describe("useUISettings", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + // Reset all mocks + vi.clearAllMocks(); + + // Set default mock for useAuthorized (enabled state) + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userRole: "Admin", + userId: "test-user-id", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should return UI settings data when query is successful", async () => { + // Mock successful API call + (getUiSettings as any).mockResolvedValue(mockUISettings); + + const { result } = renderHook(() => useUISettings(), { wrapper }); + + // Initially loading + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + + // Wait for success + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockUISettings); + expect(result.current.error).toBeNull(); + expect(getUiSettings).toHaveBeenCalledWith("test-access-token"); + expect(getUiSettings).toHaveBeenCalledTimes(1); + }); + + it("should handle error when getUiSettings fails", async () => { + const errorMessage = "Failed to fetch UI settings"; + const testError = new Error(errorMessage); + + // Mock failed API call + (getUiSettings as any).mockRejectedValue(testError); + + const { result } = renderHook(() => useUISettings(), { wrapper }); + + // Initially loading + expect(result.current.isLoading).toBe(true); + + // Wait for error + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(testError); + expect(result.current.data).toBeUndefined(); + expect(getUiSettings).toHaveBeenCalledWith("test-access-token"); + expect(getUiSettings).toHaveBeenCalledTimes(1); + }); + + it("should not execute query when accessToken is missing", async () => { + // Mock missing accessToken + mockUseAuthorized.mockReturnValue({ + accessToken: null, + userRole: "Admin", + userId: "test-user-id", + token: null, + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useUISettings(), { wrapper }); + + // Query should not execute + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + + // API should not be called + expect(getUiSettings).not.toHaveBeenCalled(); + }); + + it("should not execute query when accessToken is empty string", async () => { + // Mock empty accessToken + mockUseAuthorized.mockReturnValue({ + accessToken: "", + userRole: "Admin", + userId: "test-user-id", + token: "", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useUISettings(), { wrapper }); + + // Query should not execute + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + + // API should not be called + expect(getUiSettings).not.toHaveBeenCalled(); + }); + + it("should return empty object when API returns empty settings", async () => { + // Mock API returning empty object + (getUiSettings as any).mockResolvedValue({}); + + const { result } = renderHook(() => useUISettings(), { wrapper }); + + // Wait for success + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual({}); + expect(getUiSettings).toHaveBeenCalledWith("test-access-token"); + }); + + it("should handle network timeout error", async () => { + const timeoutError = new Error("Network timeout"); + + // Mock network timeout + (getUiSettings as any).mockRejectedValue(timeoutError); + + const { result } = renderHook(() => useUISettings(), { wrapper }); + + // Wait for error + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(timeoutError); + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/EndpointUtils.test.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/EndpointUtils.test.tsx new file mode 100644 index 00000000000..6eb481381ac --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/EndpointUtils.test.tsx @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelGroup } from "../llm_calls/fetch_models"; +import { determineEndpointType } from "./EndpointUtils"; +import { EndpointType } from "./mode_endpoint_mapping"; + +// Mock the getEndpointType function +vi.mock("./mode_endpoint_mapping", () => ({ + EndpointType: { + IMAGE: "image", + VIDEO: "video", + CHAT: "chat", + RESPONSES: "responses", + IMAGE_EDITS: "image_edits", + ANTHROPIC_MESSAGES: "anthropic_messages", + EMBEDDINGS: "embeddings", + SPEECH: "speech", + TRANSCRIPTION: "transcription", + A2A_AGENTS: "a2a_agents", + }, + getEndpointType: vi.fn(), + ModelMode: { + AUDIO_SPEECH: "audio_speech", + AUDIO_TRANSCRIPTION: "audio_transcription", + IMAGE_GENERATION: "image_generation", + VIDEO_GENERATION: "video_generation", + CHAT: "chat", + RESPONSES: "responses", + IMAGE_EDITS: "image_edits", + ANTHROPIC_MESSAGES: "anthropic_messages", + EMBEDDING: "embedding", + }, +})); + +// Import the mocked function +import { getEndpointType } from "./mode_endpoint_mapping"; + +describe("determineEndpointType", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return the correct endpoint type when model is found and has a valid mode", () => { + const mockModelInfo: ModelGroup[] = [ + { + model_group: "gpt-3.5-turbo", + mode: "chat", + }, + { + model_group: "dall-e-3", + mode: "image_generation", + }, + ]; + + // Mock getEndpointType to return IMAGE for image_generation mode + vi.mocked(getEndpointType).mockReturnValue(EndpointType.IMAGE); + + const result = determineEndpointType("dall-e-3", mockModelInfo); + + expect(getEndpointType).toHaveBeenCalledWith("image_generation"); + expect(result).toBe(EndpointType.IMAGE); + }); + + it("should return CHAT endpoint type when model is found but has no mode", () => { + const mockModelInfo: ModelGroup[] = [ + { + model_group: "gpt-3.5-turbo", + // No mode property + }, + ]; + + const result = determineEndpointType("gpt-3.5-turbo", mockModelInfo); + + expect(getEndpointType).not.toHaveBeenCalled(); + expect(result).toBe(EndpointType.CHAT); + }); + + it("should return CHAT endpoint type when model is not found in modelInfo", () => { + const mockModelInfo: ModelGroup[] = [ + { + model_group: "gpt-3.5-turbo", + mode: "chat", + }, + ]; + + const result = determineEndpointType("non-existent-model", mockModelInfo); + + expect(getEndpointType).not.toHaveBeenCalled(); + expect(result).toBe(EndpointType.CHAT); + }); + + it("should return CHAT endpoint type when modelInfo array is empty", () => { + const mockModelInfo: ModelGroup[] = []; + + const result = determineEndpointType("any-model", mockModelInfo); + + expect(getEndpointType).not.toHaveBeenCalled(); + expect(result).toBe(EndpointType.CHAT); + }); + + it("should handle different mode types correctly", () => { + const mockModelInfo: ModelGroup[] = [ + { + model_group: "tts-model", + mode: "audio_speech", + }, + { + model_group: "whisper-model", + mode: "audio_transcription", + }, + { + model_group: "embedding-model", + mode: "embedding", + }, + { + model_group: "video-model", + mode: "video_generation", + }, + ]; + + // Test speech mode + vi.mocked(getEndpointType).mockReturnValueOnce(EndpointType.SPEECH); + const speechResult = determineEndpointType("tts-model", mockModelInfo); + expect(getEndpointType).toHaveBeenCalledWith("audio_speech"); + expect(speechResult).toBe(EndpointType.SPEECH); + + // Reset mock for next test + vi.clearAllMocks(); + + // Test transcription mode + vi.mocked(getEndpointType).mockReturnValueOnce(EndpointType.TRANSCRIPTION); + const transcriptionResult = determineEndpointType("whisper-model", mockModelInfo); + expect(getEndpointType).toHaveBeenCalledWith("audio_transcription"); + expect(transcriptionResult).toBe(EndpointType.TRANSCRIPTION); + + // Reset mock for next test + vi.clearAllMocks(); + + // Test embedding mode + vi.mocked(getEndpointType).mockReturnValueOnce(EndpointType.EMBEDDINGS); + const embeddingResult = determineEndpointType("embedding-model", mockModelInfo); + expect(getEndpointType).toHaveBeenCalledWith("embedding"); + expect(embeddingResult).toBe(EndpointType.EMBEDDINGS); + + // Reset mock for next test + vi.clearAllMocks(); + + // Test video mode + vi.mocked(getEndpointType).mockReturnValueOnce(EndpointType.VIDEO); + const videoResult = determineEndpointType("video-model", mockModelInfo); + expect(getEndpointType).toHaveBeenCalledWith("video_generation"); + expect(videoResult).toBe(EndpointType.VIDEO); + }); + + it("should prioritize the first matching model when there are duplicates", () => { + const mockModelInfo: ModelGroup[] = [ + { + model_group: "gpt-3.5-turbo", + mode: "chat", + }, + { + model_group: "gpt-3.5-turbo", + mode: "image_generation", // Different mode for same model name + }, + ]; + + vi.mocked(getEndpointType).mockReturnValue(EndpointType.CHAT); + + const result = determineEndpointType("gpt-3.5-turbo", mockModelInfo); + + expect(getEndpointType).toHaveBeenCalledWith("chat"); + expect(result).toBe(EndpointType.CHAT); + }); + + it("should handle models with undefined mode property explicitly set", () => { + const mockModelInfo: ModelGroup[] = [ + { + model_group: "test-model", + mode: undefined, + }, + ]; + + const result = determineEndpointType("test-model", mockModelInfo); + + expect(getEndpointType).not.toHaveBeenCalled(); + expect(result).toBe(EndpointType.CHAT); + }); + + it("should handle models with empty string mode", () => { + const mockModelInfo: ModelGroup[] = [ + { + model_group: "test-model", + mode: "", + }, + ]; + + const result = determineEndpointType("test-model", mockModelInfo); + + // Empty string is falsy, so getEndpointType should not be called + expect(getEndpointType).not.toHaveBeenCalled(); + expect(result).toBe(EndpointType.CHAT); + }); + + it("should handle case-sensitive model group matching", () => { + const mockModelInfo: ModelGroup[] = [ + { + model_group: "GPT-3.5-TURBO", + mode: "chat", + }, + ]; + + vi.mocked(getEndpointType).mockReturnValue(EndpointType.CHAT); + + // Test with different case - should not match + const result = determineEndpointType("gpt-3.5-turbo", mockModelInfo); + + expect(getEndpointType).not.toHaveBeenCalled(); + expect(result).toBe(EndpointType.CHAT); + }); +}); diff --git a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/ToolsCard.test.tsx b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/ToolsCard.test.tsx new file mode 100644 index 00000000000..742b8aa37fc --- /dev/null +++ b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/ToolsCard.test.tsx @@ -0,0 +1,82 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ToolsCard from "./ToolsCard"; +import { Tool } from "./types"; + +describe("ToolsCard", () => { + const mockTools: Tool[] = [ + { + name: "Calculator", + description: "Performs mathematical calculations", + json: '{"type": "function", "function": {"name": "calculate"}}', + }, + { + name: "Weather API", + description: "Gets current weather information", + json: '{"type": "function", "function": {"name": "get_weather"}}', + }, + ]; + + const defaultProps = { + tools: [] as Tool[], + onAddTool: vi.fn(), + onEditTool: vi.fn(), + onRemoveTool: vi.fn(), + }; + + it("should render the component", () => { + render(); + expect(screen.getByText("Tools")).toBeInTheDocument(); + }); + + it("should display no tools message when tools array is empty", () => { + render(); + expect(screen.getByText("No tools added")).toBeInTheDocument(); + }); + + it("should render tools when provided", () => { + render(); + + expect(screen.getByText("Calculator")).toBeInTheDocument(); + expect(screen.getByText("Performs mathematical calculations")).toBeInTheDocument(); + expect(screen.getByText("Weather API")).toBeInTheDocument(); + expect(screen.getByText("Gets current weather information")).toBeInTheDocument(); + }); + + it("should call onAddTool when Add button is clicked", () => { + const mockOnAddTool = vi.fn(); + render(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /add/i })); + }); + + expect(mockOnAddTool).toHaveBeenCalledTimes(1); + }); + + it("should call onEditTool with correct index when Edit button is clicked", () => { + const mockOnEditTool = vi.fn(); + render(); + + const editButtons = screen.getAllByText("Edit"); + act(() => { + fireEvent.click(editButtons[0]); + }); + + expect(mockOnEditTool).toHaveBeenCalledWith(0); + expect(mockOnEditTool).toHaveBeenCalledTimes(1); + }); + + it("should call onRemoveTool with correct index when remove button is clicked", () => { + const mockOnRemoveTool = vi.fn(); + render(); + + const removeButtons = screen.getAllByRole("button", { name: "" }); + act(() => { + fireEvent.click(removeButtons[0]); + }); + + expect(mockOnRemoveTool).toHaveBeenCalledWith(0); + expect(mockOnRemoveTool).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/VersionHistorySidePanel.test.tsx b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/VersionHistorySidePanel.test.tsx new file mode 100644 index 00000000000..b0346e03a2d --- /dev/null +++ b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/VersionHistorySidePanel.test.tsx @@ -0,0 +1,473 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import VersionHistorySidePanel from "./VersionHistorySidePanel"; +import { getPromptVersions } from "../../networking"; +import type { PromptSpec } from "../../networking"; + +// Mock the networking function +vi.mock("../../networking", () => ({ + getPromptVersions: vi.fn(), +})); + +const mockGetPromptVersions = getPromptVersions as Mock; + +// Mock Ant Design components that might need special handling +vi.mock("antd", async () => { + const actual = await vi.importActual("antd"); + return { + ...actual, + Drawer: ({ children, title, onClose, open, width, placement, mask, maskClosable }: any) => ( +
+
{title}
+ +
{children}
+
+ ), + List: ({ children, dataSource, renderItem }: any) => ( +
{dataSource?.map((item: any, index: number) => renderItem(item, index))}
+ ), + Skeleton: ({ active }: any) => ( +
+ Loading... +
+ ), + Tag: ({ children, color, className }: any) => ( + + {children} + + ), + Typography: { + Text: ({ children, type, className }: any) => ( + + {children} + + ), + }, + }; +}); + +describe("VersionHistorySidePanel", () => { + // Mock data + const mockPromptVersions: PromptSpec[] = [ + { + prompt_id: "test-prompt.v2", + litellm_params: { prompt_id: "test-prompt.v2" }, + prompt_info: { prompt_type: "db" }, + version: 2, + created_at: "2024-01-15T10:30:00Z", + }, + { + prompt_id: "test-prompt.v1", + litellm_params: { prompt_id: "test-prompt.v1" }, + prompt_info: { prompt_type: "db" }, + version: 1, + created_at: "2024-01-10T09:00:00Z", + }, + { + prompt_id: "test-prompt.v3", + litellm_params: { prompt_id: "test-prompt.v3" }, + prompt_info: { prompt_type: "config" }, + version: 3, + created_at: "2024-01-20T14:15:00Z", + }, + ]; + + const mockPromptVersionsWithoutExplicitVersion = [ + { + prompt_id: "test-prompt.v2", + litellm_params: { prompt_id: "test-prompt.v2" }, + prompt_info: { prompt_type: "db" }, + created_at: "2024-01-15T10:30:00Z", + }, + { + prompt_id: "test-prompt.v1", + litellm_params: { prompt_id: "test-prompt.v1" }, + prompt_info: { prompt_type: "db" }, + created_at: "2024-01-10T09:00:00Z", + }, + ]; + + const defaultProps = { + isOpen: true, + onClose: vi.fn(), + accessToken: "test-token", + promptId: "test-prompt.v2", + activeVersionId: "test-prompt.v2", + onSelectVersion: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Mock successful response by default + mockGetPromptVersions.mockResolvedValue({ + prompts: mockPromptVersions, + }); + }); + + afterEach(() => { + vi.clearAllTimers(); + }); + + describe("Component Rendering", () => { + it("should render the component with drawer", async () => { + await act(async () => { + render(); + }); + expect(screen.getByTestId("drawer")).toBeInTheDocument(); + expect(screen.getByText("Version History")).toBeInTheDocument(); + }); + + it("should not render when isOpen is false", async () => { + await act(async () => { + render(); + }); + // The drawer should still be rendered but with open=false + const drawer = screen.getByTestId("drawer"); + expect(drawer).toHaveAttribute("data-open", "false"); + }); + + it("should show loading skeleton initially", async () => { + // Mock a delayed response to show loading state + mockGetPromptVersions.mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ prompts: mockPromptVersions }), 100)), + ); + + render(); + expect(screen.getByTestId("skeleton")).toBeInTheDocument(); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.queryByTestId("skeleton")).not.toBeInTheDocument(); + }); + }); + + it("should show empty state when no versions are available", async () => { + mockGetPromptVersions.mockResolvedValueOnce({ prompts: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByText("No version history available.")).toBeInTheDocument(); + }); + }); + + it("should render version list when data is loaded", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("v2")).toBeInTheDocument(); + expect(screen.getByText("v1")).toBeInTheDocument(); + expect(screen.getByText("v3")).toBeInTheDocument(); + }); + + // Check that Latest tag is shown for the first item + const latestTags = screen.getAllByText("Latest"); + expect(latestTags.length).toBeGreaterThan(0); + + // Check Active tag is shown for the active version + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + }); + + describe("Version Selection and Highlighting", () => { + it("should highlight the active version correctly", async () => { + render(); + + await waitFor(() => { + const versionItems = screen.getAllByTestId("tag"); + // Should have Active tag for the selected version + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + }); + + it("should highlight the latest version when no activeVersionId is provided", async () => { + render(); + + await waitFor(() => { + const latestTags = screen.getAllByText("Latest"); + expect(latestTags.length).toBeGreaterThan(0); + }); + }); + + it("should call onSelectVersion when a version is clicked", async () => { + const mockOnSelectVersion = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByText("v1")).toBeInTheDocument(); + }); + + const versionItem = screen.getByText("v1").closest("div"); + expect(versionItem).toBeInTheDocument(); + + act(() => { + fireEvent.click(versionItem!); + }); + + expect(mockOnSelectVersion).toHaveBeenCalledWith(mockPromptVersions[1]); + }); + }); + + describe("Version Number Extraction", () => { + it("should extract version from explicit version field", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("v2")).toBeInTheDocument(); + expect(screen.getByText("v3")).toBeInTheDocument(); + }); + }); + + it("should extract version from prompt_id with .v suffix", async () => { + mockGetPromptVersions.mockResolvedValueOnce({ + prompts: mockPromptVersionsWithoutExplicitVersion, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("v2")).toBeInTheDocument(); + expect(screen.getByText("v1")).toBeInTheDocument(); + }); + }); + + it("should extract version from prompt_id with _v suffix", async () => { + const versionsWithUnderscore = [ + { + prompt_id: "test-prompt_v2", + litellm_params: { prompt_id: "test-prompt_v2" }, + prompt_info: { prompt_type: "db" }, + created_at: "2024-01-15T10:30:00Z", + }, + ]; + + mockGetPromptVersions.mockResolvedValueOnce({ + prompts: versionsWithUnderscore, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("v2")).toBeInTheDocument(); + }); + }); + + it("should default to v1 when no version info is available", async () => { + const versionWithoutVersionInfo = [ + { + prompt_id: "test-prompt", + litellm_params: { prompt_id: "test-prompt" }, + prompt_info: { prompt_type: "db" }, + created_at: "2024-01-15T10:30:00Z", + }, + ]; + + mockGetPromptVersions.mockResolvedValueOnce({ + prompts: versionWithoutVersionInfo, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("v1")).toBeInTheDocument(); + }); + }); + }); + + describe("Date Formatting", () => { + it("should format dates correctly", async () => { + render(); + + await waitFor(() => { + // Check that dates are displayed (format: YYYY-MM-DD HH:MM:SS) + const dateElements = screen.getAllByTestId("text"); + const dateText = dateElements.find((el) => el.textContent?.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)); + expect(dateText).toBeTruthy(); + }); + }); + + it("should show dash for missing dates", async () => { + const versionsWithoutDates = [ + { + prompt_id: "test-prompt.v1", + litellm_params: { prompt_id: "test-prompt.v1" }, + prompt_info: { prompt_type: "db" }, + version: 1, + }, + ]; + + mockGetPromptVersions.mockResolvedValueOnce({ + prompts: versionsWithoutDates, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("-")).toBeInTheDocument(); + }); + }); + }); + + describe("Prompt Type Display", () => { + it("should show 'Saved to Database' for db prompts", async () => { + render(); + + await waitFor(() => { + const dbTexts = screen.getAllByText("Saved to Database"); + expect(dbTexts.length).toBeGreaterThan(0); + }); + }); + + it("should show 'Config Prompt' for config prompts", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Config Prompt")).toBeInTheDocument(); + }); + }); + }); + + describe("Network Calls and Data Fetching", () => { + it("should call getPromptVersions with correct parameters", async () => { + render(); + + await waitFor(() => { + expect(getPromptVersions).toHaveBeenCalledWith("test-token", "test-prompt"); + }); + }); + + it("should strip .v suffix from promptId when fetching versions", async () => { + render(); + + await waitFor(() => { + expect(getPromptVersions).toHaveBeenCalledWith("test-token", "test-prompt"); + }); + }); + + it("should not fetch versions when isOpen is false", () => { + render(); + + expect(getPromptVersions).not.toHaveBeenCalled(); + }); + + it("should not fetch versions when accessToken is null", () => { + render(); + + expect(getPromptVersions).not.toHaveBeenCalled(); + }); + + it("should not fetch versions when promptId is not provided", () => { + render(); + + expect(getPromptVersions).not.toHaveBeenCalled(); + }); + + it("should refetch versions when props change", async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(getPromptVersions).toHaveBeenCalledTimes(1); + }); + + rerender(); + + await waitFor(() => { + expect(getPromptVersions).toHaveBeenCalledTimes(2); + expect(getPromptVersions).toHaveBeenCalledWith("test-token", "different-prompt"); + }); + }); + }); + + describe("Error Handling", () => { + it("should handle network errors gracefully", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockGetPromptVersions.mockRejectedValueOnce(new Error("Network error")); + + render(); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith("Error fetching prompt versions:", expect.any(Error)); + }); + + // Should show empty state when there's an error + expect(screen.getByText("No version history available.")).toBeInTheDocument(); + + consoleSpy.mockRestore(); + }); + }); + + describe("User Interactions", () => { + it("should call onClose when close button is clicked", () => { + const mockOnClose = vi.fn(); + render(); + + const drawer = screen.getByTestId("drawer"); + act(() => { + fireEvent.click(drawer); // Simulate close action + }); + + // Note: This test assumes the drawer handles close events. + // In a real scenario, you'd test the actual close trigger. + }); + + it("should prevent interaction with main content when drawer is open", () => { + render(); + + const drawer = screen.getByTestId("drawer"); + // The mask and maskClosable props are passed as boolean false to disable them + expect(drawer).toHaveAttribute("data-mask", "false"); + expect(drawer).toHaveAttribute("data-maskclosable", "false"); + }); + }); + + describe("Edge Cases", () => { + it("should handle activeVersionId with .v suffix correctly", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + }); + + it("should handle activeVersionId with _v suffix correctly", async () => { + const versionsWithUnderscore = [ + { + prompt_id: "test-prompt_v2", + litellm_params: { prompt_id: "test-prompt_v2" }, + prompt_info: { prompt_type: "db" }, + version: 2, + created_at: "2024-01-15T10:30:00Z", + }, + ]; + + mockGetPromptVersions.mockResolvedValueOnce({ + prompts: versionsWithUnderscore, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + }); + + it("should sort versions correctly with version field", async () => { + // The component doesn't explicitly sort, but we can verify the order from the API response + render(); + + await waitFor(() => { + const versionElements = screen.getAllByTestId("tag"); + // Verify versions are displayed as they come from the API + expect(screen.getByText("v2")).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/utils.test.ts b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/utils.test.ts new file mode 100644 index 00000000000..59fcaccb639 --- /dev/null +++ b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/utils.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, it } from "vitest"; +import { PromptType } from "./types"; +import { + convertToDotPrompt, + extractVariables, + getVersionNumber, + parseExistingPrompt, + stripVersionFromPromptId, +} from "./utils"; + +describe("extractVariables", () => { + it("should extract variables from messages", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: {}, + tools: [], + developerMessage: "", + messages: [ + { role: "user", content: "Hello {{name}}, how are you?" }, + { role: "assistant", content: "I am fine {{name}}" }, + ], + }; + + const result = extractVariables(prompt); + expect(result).toEqual(["name"]); + }); + + it("should extract variables from developer message", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: {}, + tools: [], + developerMessage: "You are {{role}} assistant", + messages: [{ role: "user", content: "Hello" }], + }; + + const result = extractVariables(prompt); + expect(result).toEqual(["role"]); + }); + + it("should extract variables from both messages and developer message", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: {}, + tools: [], + developerMessage: "You are {{role}} assistant", + messages: [ + { role: "user", content: "Hello {{name}}" }, + { role: "assistant", content: "Hi {{name}}, I am {{role}}" }, + ], + }; + + const result = extractVariables(prompt); + expect(result.sort()).toEqual(["name", "role"].sort()); + }); + + it("should return empty array when no variables present", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: {}, + tools: [], + developerMessage: "You are an assistant", + messages: [{ role: "user", content: "Hello world" }], + }; + + const result = extractVariables(prompt); + expect(result).toEqual([]); + }); + + it("should handle duplicate variables", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: {}, + tools: [], + developerMessage: "", + messages: [ + { role: "user", content: "Hello {{name}}" }, + { role: "assistant", content: "Hi {{name}} again" }, + ], + }; + + const result = extractVariables(prompt); + expect(result).toEqual(["name"]); + }); +}); + +describe("convertToDotPrompt", () => { + it("should convert basic prompt to dot prompt format", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: {}, + tools: [], + developerMessage: "", + messages: [{ role: "user", content: "Hello world" }], + }; + + const result = convertToDotPrompt(prompt); + expect(result).toContain("---"); + expect(result).toContain("model: gpt-4"); + expect(result).toContain("input:"); + expect(result).toContain("schema:"); + expect(result).toContain("output:"); + expect(result).toContain("format: text"); + expect(result).toContain("User: Hello world"); + }); + + it("should include config parameters when set", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: { + temperature: 0.7, + max_tokens: 100, + top_p: 0.9, + }, + tools: [], + developerMessage: "", + messages: [{ role: "user", content: "Hello" }], + }; + + const result = convertToDotPrompt(prompt); + expect(result).toContain("temperature: 0.7"); + expect(result).toContain("max_tokens: 100"); + expect(result).toContain("top_p: 0.9"); + }); + + it("should include input schema with variables", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: {}, + tools: [], + developerMessage: "", + messages: [{ role: "user", content: "Hello {{name}}" }], + }; + + const result = convertToDotPrompt(prompt); + expect(result).toContain("input:"); + expect(result).toContain("schema:"); + expect(result).toContain("name: string"); + }); + + it("should include developer message when present", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: {}, + tools: [], + developerMessage: "You are a helpful assistant", + messages: [{ role: "user", content: "Hello" }], + }; + + const result = convertToDotPrompt(prompt); + expect(result).toContain("Developer: You are a helpful assistant"); + }); + + it("should include tools when present", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: {}, + tools: [ + { + name: "get_weather", + description: "Get weather information", + json: '{"type": "function", "function": {"name": "get_weather"}}', + }, + ], + developerMessage: "", + messages: [{ role: "user", content: "Hello" }], + }; + + const result = convertToDotPrompt(prompt); + expect(result).toContain("tools:"); + expect(result).toContain('{"type":"function","function":{"name":"get_weather"}}'); + }); + + it("should handle multiple messages with different roles", () => { + const prompt: PromptType = { + name: "test", + model: "gpt-4", + config: {}, + tools: [], + developerMessage: "", + messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + { role: "user", content: "How are you?" }, + ], + }; + + const result = convertToDotPrompt(prompt); + expect(result).toContain("User: Hello"); + expect(result).toContain("Assistant: Hi there"); + expect(result).toContain("User: How are you?"); + }); +}); + +describe("parseExistingPrompt", () => { + it("should parse basic dotprompt content", () => { + const apiResponse = { + prompt_spec: { + litellm_params: { + dotprompt_content: `--- +model: gpt-4 +input: + schema: +output: + format: text +--- + +User: Hello world`, + }, + prompt_id: "test-prompt", + }, + }; + + const result = parseExistingPrompt(apiResponse); + expect(result.name).toBe("test-prompt"); + expect(result.model).toBe("gpt-4"); + expect(result.messages).toEqual([{ role: "user", content: "Hello world" }]); + }); + + it("should parse with config parameters", () => { + const apiResponse = { + prompt_spec: { + litellm_params: { + dotprompt_content: `--- +model: gpt-4 +temperature: 0.7 +max_tokens: 100 +top_p: 0.9 +input: + schema: +output: + format: text +--- + +User: Hello`, + }, + prompt_id: "test-prompt", + }, + }; + + const result = parseExistingPrompt(apiResponse); + expect(result.config.temperature).toBe(0.7); + expect(result.config.max_tokens).toBe(100); + expect(result.config.top_p).toBe(0.9); + }); + + it("should parse with developer message", () => { + const apiResponse = { + prompt_spec: { + litellm_params: { + dotprompt_content: `--- +model: gpt-4 +input: + schema: +output: + format: text +--- + +Developer: You are a helpful assistant + +User: Hello`, + }, + prompt_id: "test-prompt", + }, + }; + + const result = parseExistingPrompt(apiResponse); + expect(result.developerMessage).toBe("You are a helpful assistant"); + }); + + it("should parse multiple messages", () => { + const apiResponse = { + prompt_spec: { + litellm_params: { + dotprompt_content: `--- +model: gpt-4 +input: + schema: +output: + format: text +--- + +User: Hello +How are you? + +Assistant: I am fine +Thank you for asking + +User: Great!`, + }, + prompt_id: "test-prompt", + }, + }; + + const result = parseExistingPrompt(apiResponse); + expect(result.messages).toEqual([ + { role: "user", content: "Hello\nHow are you?" }, + { role: "assistant", content: "I am fine\nThank you for asking" }, + { role: "user", content: "Great!" }, + ]); + }); + + it("should handle prompt with version suffix", () => { + const apiResponse = { + prompt_spec: { + litellm_params: { + dotprompt_content: `--- +model: gpt-4 +input: + schema: +output: + format: text +--- + +User: Hello`, + }, + prompt_id: "test-prompt.v2", + }, + }; + + const result = parseExistingPrompt(apiResponse); + expect(result.name).toBe("test-prompt"); + }); + + it("should throw error when no dotprompt_content", () => { + const apiResponse = { + prompt_spec: { + litellm_params: {}, + }, + }; + + expect(() => parseExistingPrompt(apiResponse)).toThrow("No dotprompt_content found in API response"); + }); + + it("should throw error for invalid dotprompt format", () => { + const apiResponse = { + prompt_spec: { + litellm_params: { + dotprompt_content: "invalid format", + }, + }, + }; + + expect(() => parseExistingPrompt(apiResponse)).toThrow("Invalid dotprompt format"); + }); + + it("should provide default values when parsing fails", () => { + const apiResponse = { + prompt_spec: { + litellm_params: { + dotprompt_content: `--- +model: gpt-4 +input: + schema: +output: + format: text +--- + +`, + }, + prompt_id: "test-prompt", + }, + }; + + const result = parseExistingPrompt(apiResponse); + expect(result.messages).toEqual([ + { role: "user", content: "Enter task specifics. Use {{template_variables}} for dynamic inputs" }, + ]); + }); +}); + +describe("getVersionNumber", () => { + it("should return '1' for undefined promptId", () => { + const result = getVersionNumber(undefined); + expect(result).toBe("1"); + }); + + it("should return '1' for promptId without version", () => { + const result = getVersionNumber("test-prompt"); + expect(result).toBe("1"); + }); + + it("should extract version with dot separator", () => { + const result = getVersionNumber("test-prompt.v2"); + expect(result).toBe("2"); + }); + + it("should extract version with underscore separator", () => { + const result = getVersionNumber("test-prompt_v3"); + expect(result).toBe("3"); + }); + + it("should extract version with hyphen separator", () => { + const result = getVersionNumber("test-prompt-v4"); + expect(result).toBe("4"); + }); + + it("should extract multi-digit version", () => { + const result = getVersionNumber("test-prompt.v123"); + expect(result).toBe("123"); + }); +}); + +describe("stripVersionFromPromptId", () => { + it("should return empty string for undefined promptId", () => { + const result = stripVersionFromPromptId(undefined); + expect(result).toBe(""); + }); + + it("should return promptId unchanged when no version present", () => { + const result = stripVersionFromPromptId("test-prompt"); + expect(result).toBe("test-prompt"); + }); + + it("should strip version with dot separator", () => { + const result = stripVersionFromPromptId("test-prompt.v2"); + expect(result).toBe("test-prompt"); + }); + + it("should strip version with underscore separator", () => { + const result = stripVersionFromPromptId("test-prompt_v3"); + expect(result).toBe("test-prompt"); + }); + + it("should strip version with hyphen separator", () => { + const result = stripVersionFromPromptId("test-prompt-v4"); + expect(result).toBe("test-prompt"); + }); + + it("should strip multi-digit version", () => { + const result = stripVersionFromPromptId("test-prompt.v123"); + expect(result).toBe("test-prompt"); + }); +});