diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatImageUtils.test.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatImageUtils.test.tsx new file mode 100644 index 00000000000..ecc4914b0ab --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatImageUtils.test.tsx @@ -0,0 +1,187 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + convertImageToBase64, + createChatMultimodalMessage, + createChatDisplayMessage, + shouldShowChatAttachedImage, +} from "./ChatImageUtils"; +import { MessageType } from "./types"; + +describe("ChatImageUtils", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("convertImageToBase64", () => { + it("should convert file to base64 data URI", async () => { + const file = new File(["test content"], "test.png", { type: "image/png" }); + const result = await convertImageToBase64(file); + expect(result).toMatch(/^data:image\/png;base64,/); + }); + + it("should handle different file types", async () => { + const jpegFile = new File(["jpeg content"], "test.jpg", { type: "image/jpeg" }); + const result = await convertImageToBase64(jpegFile); + expect(result).toMatch(/^data:image\/jpeg;base64,/); + }); + + it("should reject on file read error", async () => { + const file = new File(["test"], "test.png", { type: "image/png" }); + const originalReadAsDataURL = FileReader.prototype.readAsDataURL; + + FileReader.prototype.readAsDataURL = vi.fn(function (this: FileReader) { + setTimeout(() => { + if (this.onerror) { + this.onerror(new Error("Read error") as any); + } + }, 0); + }); + + await expect(convertImageToBase64(file)).rejects.toThrow(); + + FileReader.prototype.readAsDataURL = originalReadAsDataURL; + }); + }); + + describe("createChatMultimodalMessage", () => { + it("should create multimodal message with text and image", async () => { + const file = new File(["test content"], "test.png", { type: "image/png" }); + const inputMessage = "What is in this image?"; + + const result = await createChatMultimodalMessage(inputMessage, file); + + expect(result.role).toBe("user"); + expect(result.content).toHaveLength(2); + expect(result.content[0]).toEqual({ type: "text", text: inputMessage }); + expect(result.content[1]).toMatchObject({ + type: "image_url", + image_url: { + url: expect.stringMatching(/^data:image\/png;base64,/), + }, + }); + }); + + it("should include base64 data URI in image_url", async () => { + const file = new File(["test content"], "test.png", { type: "image/png" }); + const result = await createChatMultimodalMessage("test", file); + + const imageContent = result.content[1]; + expect(imageContent.type).toBe("image_url"); + if ("image_url" in imageContent && imageContent.image_url) { + expect(imageContent.image_url.url).toMatch(/^data:/); + } + }); + }); + + describe("createChatDisplayMessage", () => { + it("should create display message without file", () => { + const result = createChatDisplayMessage("Hello world", false); + + expect(result.role).toBe("user"); + expect(result.content).toBe("Hello world"); + expect(result.imagePreviewUrl).toBeUndefined(); + }); + + it("should create display message with PDF file", () => { + const filePreviewUrl = "blob:test-url"; + const result = createChatDisplayMessage("Read this", true, filePreviewUrl, "document.pdf"); + + expect(result.content).toBe("Read this [PDF attached]"); + expect(result.imagePreviewUrl).toBe(filePreviewUrl); + }); + + it("should create display message with image file", () => { + const filePreviewUrl = "blob:test-url"; + const result = createChatDisplayMessage("Look at this", true, filePreviewUrl, "photo.jpg"); + + expect(result.content).toBe("Look at this [Image attached]"); + expect(result.imagePreviewUrl).toBe(filePreviewUrl); + }); + + it("should create display message with file but no fileName", () => { + const filePreviewUrl = "blob:test-url"; + const result = createChatDisplayMessage("Check this", true, filePreviewUrl); + + expect(result.content).toBe("Check this "); + expect(result.imagePreviewUrl).toBe(filePreviewUrl); + }); + + it("should create display message with file but no preview URL", () => { + const result = createChatDisplayMessage("See this", true, undefined, "image.png"); + + expect(result.content).toBe("See this [Image attached]"); + expect(result.imagePreviewUrl).toBeUndefined(); + }); + }); + + describe("shouldShowChatAttachedImage", () => { + it("should return true for user message with image attachment", () => { + const message: MessageType = { + role: "user", + content: "Check this [Image attached]", + imagePreviewUrl: "blob:test-url", + }; + + expect(shouldShowChatAttachedImage(message)).toBe(true); + }); + + it("should return true for user message with PDF attachment", () => { + const message: MessageType = { + role: "user", + content: "Read this [PDF attached]", + imagePreviewUrl: "blob:test-url", + }; + + expect(shouldShowChatAttachedImage(message)).toBe(true); + }); + + it("should return false for assistant message", () => { + const message: MessageType = { + role: "assistant", + content: "Here is the image [Image attached]", + imagePreviewUrl: "blob:test-url", + }; + + expect(shouldShowChatAttachedImage(message)).toBe(false); + }); + + it("should return false when content is not a string", () => { + const message: MessageType = { + role: "user", + content: [{ type: "input_text", text: "test" }], + imagePreviewUrl: "blob:test-url", + }; + + expect(shouldShowChatAttachedImage(message)).toBe(false); + }); + + it("should return false when content does not include attachment marker", () => { + const message: MessageType = { + role: "user", + content: "Just regular text", + imagePreviewUrl: "blob:test-url", + }; + + expect(shouldShowChatAttachedImage(message)).toBe(false); + }); + + it("should return false when imagePreviewUrl is missing", () => { + const message: MessageType = { + role: "user", + content: "Check this [Image attached]", + }; + + expect(shouldShowChatAttachedImage(message)).toBe(false); + }); + + it("should return false when imagePreviewUrl is empty string", () => { + const message: MessageType = { + role: "user", + content: "Check this [Image attached]", + imagePreviewUrl: "", + }; + + expect(shouldShowChatAttachedImage(message)).toBe(false); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/CodeInterpreterOutput.test.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/CodeInterpreterOutput.test.tsx new file mode 100644 index 00000000000..d87a74f4641 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/CodeInterpreterOutput.test.tsx @@ -0,0 +1,326 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import CodeInterpreterOutput from "./CodeInterpreterOutput"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn(() => "https://example.com"), +})); + +global.fetch = vi.fn(); + +describe("CodeInterpreterOutput", () => { + beforeEach(() => { + vi.clearAllMocks(); + URL.createObjectURL = vi.fn((blob) => `blob:${blob}`); + URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should render", () => { + render(); + + expect(screen.getByText("Python Code Executed")).toBeInTheDocument(); + }); + + it("should display code in syntax highlighter", async () => { + const user = userEvent.setup(); + const code = "print('hello world')"; + const { container } = render(); + + expect(screen.getByText("Python Code Executed")).toBeInTheDocument(); + + const collapseHeader = screen.getByRole("button"); + await user.click(collapseHeader); + + await waitFor(() => { + const codeElement = container.querySelector("code.language-python"); + expect(codeElement).toBeInTheDocument(); + expect(codeElement?.textContent).toContain(code); + }); + }); + + it("should fetch and display images from annotations", async () => { + const mockBlob = new Blob(["image data"], { type: "image/png" }); + const mockResponse = { + ok: true, + blob: vi.fn().mockResolvedValue(mockBlob), + }; + + (global.fetch as any).mockResolvedValue(mockResponse); + + const annotations = [ + { + type: "container_file_citation" as const, + container_id: "container-1", + file_id: "file-1", + filename: "chart.png", + start_index: 0, + end_index: 10, + }, + ]; + + render( + , + ); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/v1/containers/container-1/files/file-1/content", + expect.objectContaining({ + headers: { + Authorization: "Bearer test-token", + }, + }), + ); + }); + + await waitFor(() => { + expect(screen.getByText("chart.png")).toBeInTheDocument(); + }); + }); + + it("should show loading state while fetching images", async () => { + const mockBlob = new Blob(["image data"], { type: "image/png" }); + let resolveBlob: (value: Blob) => void; + const blobPromise = new Promise((resolve) => { + resolveBlob = resolve; + }); + + const mockResponse = { + ok: true, + blob: vi.fn().mockReturnValue(blobPromise), + }; + + (global.fetch as any).mockResolvedValue(mockResponse); + + const annotations = [ + { + type: "container_file_citation" as const, + container_id: "container-1", + file_id: "file-1", + filename: "chart.png", + start_index: 0, + end_index: 10, + }, + ]; + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Loading image...")).toBeInTheDocument(); + }); + + resolveBlob!(mockBlob); + await waitFor(() => { + expect(screen.queryByText("Loading image...")).not.toBeInTheDocument(); + }); + }); + + it("should handle download for image files", async () => { + const user = userEvent.setup(); + const mockBlob = new Blob(["image data"], { type: "image/png" }); + const mockResponse = { + ok: true, + blob: vi.fn().mockResolvedValue(mockBlob), + }; + + (global.fetch as any).mockResolvedValue(mockResponse); + + const annotations = [ + { + type: "container_file_citation" as const, + container_id: "container-1", + file_id: "file-1", + filename: "chart.png", + start_index: 0, + end_index: 10, + }, + ]; + + const createElementSpy = vi.spyOn(document, "createElement"); + const appendChildSpy = vi.spyOn(document.body, "appendChild"); + const removeChildSpy = vi.spyOn(document.body, "removeChild"); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("chart.png")).toBeInTheDocument(); + }); + + const downloadButton = screen.getByText("Download"); + await user.click(downloadButton); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/v1/containers/container-1/files/file-1/content", + expect.objectContaining({ + headers: { + Authorization: "Bearer test-token", + }, + }), + ); + }); + + createElementSpy.mockRestore(); + appendChildSpy.mockRestore(); + removeChildSpy.mockRestore(); + }); + + it("should handle download for non-image files", async () => { + const user = userEvent.setup(); + const mockBlob = new Blob(["file data"], { type: "text/plain" }); + const mockResponse = { + ok: true, + blob: vi.fn().mockResolvedValue(mockBlob), + }; + + (global.fetch as any).mockResolvedValue(mockResponse); + + const annotations = [ + { + type: "container_file_citation" as const, + container_id: "container-1", + file_id: "file-1", + filename: "data.csv", + start_index: 0, + end_index: 10, + }, + ]; + + render(); + + await waitFor(() => { + expect(screen.getByText("data.csv")).toBeInTheDocument(); + }); + + const downloadButton = screen.getByText("data.csv").closest("button"); + expect(downloadButton).toBeInTheDocument(); + if (downloadButton) { + await user.click(downloadButton); + } + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/v1/containers/container-1/files/file-1/content", + expect.objectContaining({ + headers: { + Authorization: "Bearer test-token", + }, + }), + ); + }); + }); + + it("should return null when no code and no annotations", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("should handle multiple image formats", async () => { + const mockBlob = new Blob(["image data"], { type: "image/png" }); + const mockResponse = { + ok: true, + blob: vi.fn().mockResolvedValue(mockBlob), + }; + + (global.fetch as any).mockResolvedValue(mockResponse); + + const annotations = [ + { + type: "container_file_citation" as const, + container_id: "container-1", + file_id: "file-1", + filename: "image.png", + start_index: 0, + end_index: 10, + }, + { + type: "container_file_citation" as const, + container_id: "container-1", + file_id: "file-2", + filename: "image.jpg", + start_index: 0, + end_index: 10, + }, + { + type: "container_file_citation" as const, + container_id: "container-1", + file_id: "file-3", + filename: "image.jpeg", + start_index: 0, + end_index: 10, + }, + { + type: "container_file_citation" as const, + container_id: "container-1", + file_id: "file-4", + filename: "image.gif", + start_index: 0, + end_index: 10, + }, + ]; + + render( + , + ); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(4); + }); + }); + + it("should handle fetch errors gracefully", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + (global.fetch as any).mockRejectedValue(new Error("Network error")); + + const annotations = [ + { + type: "container_file_citation" as const, + container_id: "container-1", + file_id: "file-1", + filename: "chart.png", + start_index: 0, + end_index: 10, + }, + ]; + + render( + , + ); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/playground/compareUI/components/UnifiedSelector.test.tsx b/ui/litellm-dashboard/src/components/playground/compareUI/components/UnifiedSelector.test.tsx new file mode 100644 index 00000000000..1c6951fc421 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/compareUI/components/UnifiedSelector.test.tsx @@ -0,0 +1,161 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { UnifiedSelector } from "./UnifiedSelector"; +import { EndpointId, ENDPOINT_CONFIGS } from "../endpoint_config"; + +describe("UnifiedSelector", () => { + it("should render", () => { + const onChange = vi.fn(); + const options = [ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + ]; + const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + + render(); + + const select = screen.getByRole("combobox"); + expect(select).toBeInTheDocument(); + }); + + it("should display placeholder when not loading", () => { + const onChange = vi.fn(); + const options = [{ value: "option1", label: "Option 1" }]; + const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + + const { container } = render( + , + ); + + const placeholder = container.querySelector(".ant-select-selection-placeholder"); + expect(placeholder).toHaveTextContent(config.selectorPlaceholder); + }); + + it("should display loading placeholder when loading", () => { + const onChange = vi.fn(); + const options = [{ value: "option1", label: "Option 1" }]; + const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + + const { container } = render( + , + ); + + const placeholder = container.querySelector(".ant-select-selection-placeholder"); + expect(placeholder).toHaveTextContent(`Loading ${config.selectorLabel.toLowerCase()}s...`); + }); + + it("should call onChange when option is selected", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const options = [ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + ]; + const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + + render(); + + const select = screen.getByRole("combobox"); + await user.click(select); + + await waitFor(() => { + const option = screen.getByText("Option 1"); + expect(option).toBeInTheDocument(); + }); + + const option = screen.getByText("Option 1"); + await user.click(option); + + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + }); + const callArgs = onChange.mock.calls[0]; + expect(callArgs[0]).toBe("option1"); + }); + + it("should display selected value", () => { + const onChange = vi.fn(); + const options = [ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + ]; + const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + + const { container } = render( + , + ); + + const selectedValue = container.querySelector(".ant-select-selection-item"); + expect(selectedValue).toHaveTextContent("Option 1"); + }); + + it("should filter options by search input", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const options = [ + { value: "option1", label: "Option One" }, + { value: "option2", label: "Option Two" }, + { value: "option3", label: "Different" }, + ]; + const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + + render(); + + const select = screen.getByRole("combobox"); + await user.click(select); + await user.type(select, "One"); + + await waitFor(() => { + expect(screen.getByText("Option One")).toBeInTheDocument(); + expect(screen.queryByText("Option Two")).not.toBeInTheDocument(); + expect(screen.queryByText("Different")).not.toBeInTheDocument(); + }); + }); + + it("should show loading spinner in notFoundContent when loading", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const options: { value: string; label: string }[] = []; + const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + + render(); + + const select = screen.getByRole("combobox"); + await user.click(select); + + await waitFor(() => { + const spin = document.querySelector(".ant-spin"); + expect(spin).toBeInTheDocument(); + }); + }); + + it("should show no options message when not loading and no options", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const options: { value: string; label: string }[] = []; + const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + + render(); + + const select = screen.getByRole("combobox"); + await user.click(select); + + await waitFor(() => { + expect(screen.getByText(`No ${config.selectorLabel.toLowerCase()}s available`)).toBeInTheDocument(); + }); + }); + + it("should work with agent endpoint config", () => { + const onChange = vi.fn(); + const options = [{ value: "agent1", label: "Agent One" }]; + const config = ENDPOINT_CONFIGS[EndpointId.A2A_AGENTS]; + + const { container } = render( + , + ); + + const placeholder = container.querySelector(".ant-select-selection-placeholder"); + expect(placeholder).toHaveTextContent(config.selectorPlaceholder); + }); +}); diff --git a/ui/litellm-dashboard/src/components/playground/compareUI/endpoint_config.test.ts b/ui/litellm-dashboard/src/components/playground/compareUI/endpoint_config.test.ts new file mode 100644 index 00000000000..67ecf32fc2a --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/compareUI/endpoint_config.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + EndpointId, + ENDPOINT_CONFIGS, + getAvailableEndpoints, + getEndpointConfig, + isAgentEndpoint, + isModelEndpoint, + modelOptionsToSelectorOptions, + agentOptionsToSelectorOptions, + getSelectionFieldName, + getComparisonSelection, + hasValidSelection, +} from "./endpoint_config"; +import { Agent } from "../llm_calls/fetch_agents"; + +describe("endpoint_config", () => { + it("should export EndpointId constants", () => { + expect(EndpointId.CHAT_COMPLETIONS).toBe("/v1/chat/completions"); + expect(EndpointId.A2A_AGENTS).toBe("/a2a"); + }); + + it("should have endpoint configs for all endpoint IDs", () => { + expect(ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]).toBeDefined(); + expect(ENDPOINT_CONFIGS[EndpointId.A2A_AGENTS]).toBeDefined(); + expect(ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS].selectorType).toBe("model"); + expect(ENDPOINT_CONFIGS[EndpointId.A2A_AGENTS].selectorType).toBe("agent"); + }); + + it("should get available endpoints", () => { + const endpoints = getAvailableEndpoints(); + expect(endpoints).toHaveLength(2); + expect(endpoints).toContainEqual({ + value: EndpointId.CHAT_COMPLETIONS, + label: "/v1/chat/completions", + }); + expect(endpoints).toContainEqual({ + value: EndpointId.A2A_AGENTS, + label: "/a2a (Agents)", + }); + }); + + it("should get endpoint config by ID", () => { + const config = getEndpointConfig(EndpointId.CHAT_COMPLETIONS); + expect(config.id).toBe(EndpointId.CHAT_COMPLETIONS); + expect(config.selectorType).toBe("model"); + expect(config.selectorLabel).toBe("Model"); + }); + + it("should check if endpoint is agent endpoint", () => { + expect(isAgentEndpoint(EndpointId.A2A_AGENTS)).toBe(true); + expect(isAgentEndpoint(EndpointId.CHAT_COMPLETIONS)).toBe(false); + }); + + it("should check if endpoint is model endpoint", () => { + expect(isModelEndpoint(EndpointId.CHAT_COMPLETIONS)).toBe(true); + expect(isModelEndpoint(EndpointId.A2A_AGENTS)).toBe(false); + }); + + it("should convert model options to selector options", () => { + const models = ["gpt-4", "gpt-3.5-turbo", "claude-3"]; + const options = modelOptionsToSelectorOptions(models); + expect(options).toHaveLength(3); + expect(options[0]).toEqual({ value: "gpt-4", label: "gpt-4" }); + expect(options[1]).toEqual({ value: "gpt-3.5-turbo", label: "gpt-3.5-turbo" }); + expect(options[2]).toEqual({ value: "claude-3", label: "claude-3" }); + }); + + it("should convert agent options to selector options", () => { + const agents: Agent[] = [ + { agent_id: "agent-1", agent_name: "Agent One" }, + { agent_id: "agent-2", agent_name: "Agent Two" }, + { agent_id: "agent-3", agent_name: undefined as any }, + ]; + const options = agentOptionsToSelectorOptions(agents); + expect(options).toHaveLength(3); + expect(options[0]).toEqual({ value: "Agent One", label: "Agent One" }); + expect(options[1]).toEqual({ value: "Agent Two", label: "Agent Two" }); + expect(options[2]).toEqual({ value: undefined, label: "agent-3" }); + }); + + it("should get selection field name based on endpoint", () => { + expect(getSelectionFieldName(EndpointId.CHAT_COMPLETIONS)).toBe("model"); + expect(getSelectionFieldName(EndpointId.A2A_AGENTS)).toBe("agent"); + }); + + it("should get comparison selection based on endpoint", () => { + const comparison = { model: "gpt-4", agent: "agent-1" }; + expect(getComparisonSelection(comparison, EndpointId.CHAT_COMPLETIONS)).toBe("gpt-4"); + expect(getComparisonSelection(comparison, EndpointId.A2A_AGENTS)).toBe("agent-1"); + }); + + it("should check if comparison has valid selection", () => { + const comparisonWithModel = { model: "gpt-4", agent: "" }; + const comparisonWithAgent = { model: "", agent: "agent-1" }; + const comparisonEmpty = { model: "", agent: "" }; + const comparisonWhitespace = { model: " ", agent: "" }; + + expect(hasValidSelection(comparisonWithModel, EndpointId.CHAT_COMPLETIONS)).toBe(true); + expect(hasValidSelection(comparisonWithAgent, EndpointId.A2A_AGENTS)).toBe(true); + expect(hasValidSelection(comparisonEmpty, EndpointId.CHAT_COMPLETIONS)).toBe(false); + expect(hasValidSelection(comparisonWhitespace, EndpointId.CHAT_COMPLETIONS)).toBe(false); + }); +});