From be3a7be1db6c4ee38d0d61957a2ae5179446c874 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Mon, 26 Oct 2026 14:12:11 -0400 Subject: [PATCH] fix(ui): restore clipboard copying in key and log details (#48425) --- ui/litellm-dashboard/eslint-suppressions.json | 5 - .../AuditLogDrawer/AuditLogDrawer.test.tsx | 23 +- .../audit/AuditLogDrawer/AuditLogDrawer.tsx | 19 +- .../logs/detail/DrawerHeader.test.tsx | 25 ++ .../components/logs/detail/DrawerHeader.tsx | 6 +- .../components/logs/detail/InputCard.test.tsx | 42 +-- .../src/components/logs/detail/InputCard.tsx | 5 +- .../LogDetailContent.integration.test.tsx | 41 +++ .../logs/detail/LogDetailContent.tsx | 6 +- .../logs/detail/LogDetailsDrawer.test.tsx | 22 ++ .../logs/detail/LogDetailsDrawer.tsx | 6 +- .../logs/detail/OutputCard.test.tsx | 35 +-- .../src/components/logs/detail/OutputCard.tsx | 5 +- .../logs/detail/RealtimePrettyView.test.tsx | 22 +- .../logs/detail/RealtimePrettyView.tsx | 3 +- .../src/components/shared/CopyButton.test.tsx | 55 +--- .../src/components/shared/CopyButton.tsx | 12 +- .../src/utils/dataUtils.test.ts | 277 +++++++----------- ui/litellm-dashboard/src/utils/dataUtils.ts | 23 +- 19 files changed, 287 insertions(+), 345 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 38d4c435e9a..32777b43504 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2175,11 +2175,6 @@ "count": 1 } }, - "src/utils/dataUtils.test.ts": { - "max-nested-callbacks": { - "count": 1 - } - }, "tests/CreateKeyPage.expiredToken.test.tsx": { "@typescript-eslint/no-require-imports": { "count": 3 diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.test.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.test.tsx index da158fb8450..8ac99760c01 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.test.tsx @@ -1,10 +1,16 @@ +import { copyToClipboard } from "@/utils/dataUtils"; import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; -import { render, screen, waitFor, within } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import moment from "moment"; import { AuditLogDrawer } from "./AuditLogDrawer"; import { AuditLogEntry } from "../AuditLogsTableColumns"; +vi.mock("@/utils/dataUtils", async (importOriginal) => ({ + ...(await importOriginal()), + copyToClipboard: vi.fn(), +})); + vi.mock("../../../common_components/DefaultProxyAdminTag", () => ({ default: ({ userId }: { userId: string }) => {userId}, })); @@ -120,16 +126,17 @@ describe("AuditLogDrawer", () => { expect(screen.queryByText(/"spend"/)).not.toBeInTheDocument(); }); - it("should copy the json of a block to the clipboard", async () => { + it.each([true, false])("confirms copied JSON only when copying succeeds (%s)", async (success) => { const user = userEvent.setup(); - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); - Object.defineProperty(window, "isSecureContext", { value: true, configurable: true }); - + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + vi.mocked(copyToClipboard).mockResolvedValue(success); render(); - await user.click(within(blockNamed("Before")).getByTitle("Copy JSON")); - await waitFor(() => expect(writeText).toHaveBeenCalledWith(JSON.stringify({ max_budget: 10 }, null, 2))); + const button = within(blockNamed("Before")).getByRole("button", { name: "Copy JSON" }); + await user.click(button); + + expect(copyToClipboard).toHaveBeenCalledWith(JSON.stringify({ max_budget: 10 }, null, 2)); + expect(button.querySelector(".lucide-check") !== null).toBe(success); }); describe("width", () => { diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.tsx index fae3c35b80c..eaddf1e507f 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.tsx @@ -1,3 +1,4 @@ +import { copyToClipboard } from "@/utils/dataUtils"; import { Check, Copy } from "lucide-react"; import { useState, useCallback } from "react"; import moment from "moment"; @@ -27,25 +28,9 @@ function CopyableJsonBlock({ label, value }: { label: string; value: Record { - try { - const text = JSON.stringify(value, null, 2); - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - } else { - const el = document.createElement("textarea"); - el.value = text; - el.style.position = "fixed"; - el.style.opacity = "0"; - document.body.appendChild(el); - el.focus(); - el.select(); - document.execCommand("copy"); - document.body.removeChild(el); - } + if (await copyToClipboard(JSON.stringify(value, null, 2))) { setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch (e) { - console.error("Copy failed:", e); } }, [value]); diff --git a/ui/litellm-dashboard/src/components/logs/detail/DrawerHeader.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/DrawerHeader.test.tsx index 564862acccd..b24667c6a2e 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/DrawerHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/DrawerHeader.test.tsx @@ -6,6 +6,12 @@ import { describe, expect, it, vi } from "vitest"; import { render } from "../../../../tests/test-utils"; import type { LogEntry } from "../types"; import { DrawerHeader } from "./DrawerHeader"; +import { copyToClipboard } from "@/utils/dataUtils"; + +vi.mock("@/utils/dataUtils", async (importOriginal) => ({ + ...(await importOriginal()), + copyToClipboard: vi.fn(), +})); const logEntry = (overrides: Partial): LogEntry => ({ @@ -118,3 +124,22 @@ describe("DrawerHeader back link", () => { expect(screen.queryByRole("button", { name: /^Back to/ })).not.toBeInTheDocument(); }); }); + +describe("DrawerHeader copying", () => { + it.each([true, false])("confirms both ids only when copying succeeds (%s)", async (success) => { + const user = userEvent.setup(); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + vi.mocked(copyToClipboard).mockReset().mockResolvedValue(success); + renderHeader(logEntry({ request_id: "request-copy", litellm_call_id: "call-copy" }), false); + + const requestButton = screen.getByRole("button", { name: "Copy Request ID" }); + const callButton = screen.getByRole("button", { name: "Copy x-litellm-call-id" }); + await user.click(requestButton); + await user.click(callButton); + + expect(copyToClipboard).toHaveBeenNthCalledWith(1, "request-copy"); + expect(copyToClipboard).toHaveBeenNthCalledWith(2, "call-copy"); + expect(requestButton).toHaveAccessibleName(success ? "Copied!" : "Copy Request ID"); + expect(callButton).toHaveAccessibleName(success ? "Copied!" : "Copy x-litellm-call-id"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/logs/detail/DrawerHeader.tsx b/ui/litellm-dashboard/src/components/logs/detail/DrawerHeader.tsx index 8ec615d444d..312a0797f1d 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/DrawerHeader.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/DrawerHeader.tsx @@ -1,3 +1,4 @@ +import { copyToClipboard } from "@/utils/dataUtils"; import { type ReactNode, useState } from "react"; import { ArrowLeft, Check, ChevronDown, ChevronUp, Copy, X } from "lucide-react"; import moment from "moment"; @@ -183,12 +184,9 @@ function CopyableId({ const [copied, setCopied] = useState(false); const handleCopy = async () => { - try { - await navigator.clipboard.writeText(value); + if (await copyToClipboard(value)) { setCopied(true); setTimeout(() => setCopied(false), 1200); - } catch { - /* clipboard unavailable in non-secure contexts */ } }; diff --git a/ui/litellm-dashboard/src/components/logs/detail/InputCard.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/InputCard.test.tsx index dfd9dd54dd7..87105d3f48f 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/InputCard.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/InputCard.test.tsx @@ -1,12 +1,15 @@ import React from "react"; -import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { InputCard } from "./InputCard"; import { ParsedMessage } from "./prettyMessagesTypes"; +import { copyToClipboard } from "@/utils/dataUtils"; +import { toast } from "@/lib/toast"; + +vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn() })); describe("InputCard", () => { - const mockWriteText = vi.fn().mockResolvedValue(undefined); const mockMessages: ParsedMessage[] = [ { role: "user", @@ -16,13 +19,7 @@ describe("InputCard", () => { beforeEach(() => { vi.clearAllMocks(); - Object.defineProperty(navigator, "clipboard", { - value: { - writeText: mockWriteText, - }, - writable: true, - configurable: true, - }); + vi.mocked(copyToClipboard).mockResolvedValue(true); }); it("should render the InputCard component", () => { @@ -91,27 +88,16 @@ describe("InputCard", () => { expect(screen.getByText(/Cost: \$0\.001500/)).toBeInTheDocument(); }); - it("should copy last message content when copy button is clicked", async () => { - const messages: ParsedMessage[] = [ - { - role: "user", - content: "Copy this text", - }, - ]; + it.each([true, false])("delegates copying and feedback to the helper (%s)", async (success) => { + const user = userEvent.setup(); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + vi.mocked(copyToClipboard).mockResolvedValue(success); + render(); - render(); + await user.click(screen.getByRole("button", { name: /copy/i })); - const copyButton = screen.getByRole("button", { name: /copy/i }); - - expect(copyButton).toBeInTheDocument(); - - await act(async () => { - fireEvent.click(copyButton); - }); - - await waitFor(() => { - expect(mockWriteText).toHaveBeenCalledWith("Copy this text"); - }); + expect(copyToClipboard).toHaveBeenCalledWith("Copy this text", "Input copied"); + expect(toast.success).not.toHaveBeenCalled(); }); it("should toggle collapse state when header is clicked", async () => { diff --git a/ui/litellm-dashboard/src/components/logs/detail/InputCard.tsx b/ui/litellm-dashboard/src/components/logs/detail/InputCard.tsx index d367c69f263..0b0bde85b91 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/InputCard.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/InputCard.tsx @@ -4,7 +4,7 @@ */ import { useState } from "react"; -import { toast } from "@/lib/toast"; +import { copyToClipboard } from "@/utils/dataUtils"; import { ParsedMessage } from "./prettyMessagesTypes"; import { SectionHeader } from "./SectionHeader"; import { CollapsibleMessage } from "./CollapsibleMessage"; @@ -32,8 +32,7 @@ export function InputCard({ messages, promptTokens, inputCost }: InputCardProps) const handleCopy = () => { const content = lastMessage?.content || ""; - navigator.clipboard.writeText(content); - toast.success("Input copied"); + void copyToClipboard(content, "Input copied"); }; return ( diff --git a/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.integration.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.integration.test.tsx index 7f39a0d8fe5..21ecf19235e 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.integration.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { GuardrailJumpLink, LogDetailContent } from "./LogDetailContent"; import type { LogEntry } from "../types"; +import { toast } from "@/lib/toast"; vi.mock("./sections/GuardrailViewer/GuardrailViewer", () => ({ default: ({ data }: { data: unknown }) =>
{JSON.stringify(data)}
, @@ -295,6 +296,46 @@ describe("LogDetailContent", () => { expect(screen.queryByRole("tab", { name: "Request" })).not.toBeInTheDocument(); }); + it.each([ + ["Request", true], + ["Response", true], + ["Metadata", true], + ["Request", false], + ] as const)("copies %s through the fallback and reports its outcome (%s)", async (section, success) => { + const user = userEvent.setup(); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, "execCommand"); + const writeText = vi.fn<(value: string) => boolean>().mockReturnValue(success); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: (command: string) => + command === "copy" && writeText((document.activeElement as HTMLTextAreaElement).value), + }); + vi.mocked(toast.success).mockClear(); + const log = createLogEntry(); + const expected = { Request: log.messages, Response: log.response, Metadata: log.metadata }[section]; + + try { + render(); + if (section !== "Metadata") { + await user.click(screen.getByRole("tab", { name: "JSON", exact: true })); + await user.click(screen.getByRole("tab", { name: section, exact: true })); + } + const label = section === "Metadata" ? "Copy Metadata" : "Copy JSON"; + const button = screen.getByRole("button", { name: label, exact: true }); + await user.click(button); + + expect(writeText).toHaveBeenCalledWith(JSON.stringify(expected, null, 2)); + expect(button).toHaveAccessibleName(success ? "Copied!" : label); + expect(toast.success).toHaveBeenCalledTimes(success ? 1 : 0); + } finally { + if (clipboardDescriptor) Object.defineProperty(navigator, "clipboard", clipboardDescriptor); + if (execCommandDescriptor) Object.defineProperty(document, "execCommand", execCommandDescriptor); + else Reflect.deleteProperty(document, "execCommand"); + } + }); + it.each(["object", "serialized", "messages only", "null captures"])( "preserves request inspection and copying for classifier logs with %s data", async (shape) => { diff --git a/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx index f6c63582938..f740b6c586a 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx @@ -1,3 +1,4 @@ +import { copyToClipboard } from "@/utils/dataUtils"; import { useState } from "react"; import { Check, ChevronDown, ChevronRight, CircleAlert, Copy, Info } from "lucide-react"; import moment from "moment"; @@ -304,12 +305,9 @@ function CopyButton({ const [copied, setCopied] = useState(false); const handleCopy = async () => { - try { - await navigator.clipboard.writeText(getText()); + if (await copyToClipboard(getText())) { setCopied(true); setTimeout(() => setCopied(false), 1200); - } catch { - /* clipboard unavailable in non-secure contexts */ } }; diff --git a/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.test.tsx index 1321a4a152a..36ab70ecf2a 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.test.tsx @@ -1,3 +1,5 @@ +import userEvent from "@testing-library/user-event"; +import { copyToClipboard } from "@/utils/dataUtils"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; @@ -6,6 +8,11 @@ import { sessionSpendLogsCall } from "../../networking"; import { LogEntry } from "../types"; import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells"; +vi.mock("@/utils/dataUtils", async (importOriginal) => ({ + ...(await importOriginal()), + copyToClipboard: vi.fn(), +})); + vi.mock("../../networking", () => ({ sessionSpendLogsCall: vi.fn(), })); @@ -201,3 +208,18 @@ describe("LogDetailsDrawer session sidebar auto-router icon", () => { expect(directRow.querySelector(".lucide-waypoints")).toBeNull(); }); }); + +describe("LogDetailsDrawer copying", () => { + it.each([true, false])("confirms a session id only when copying succeeds (%s)", async (success) => { + const user = userEvent.setup(); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + vi.mocked(copyToClipboard).mockReset().mockResolvedValue(success); + renderSessionDrawer(); + + const button = await screen.findByRole("button", { name: "Copy trace id" }); + await user.click(button); + + expect(copyToClipboard).toHaveBeenCalledWith("session-1"); + expect(button.querySelector(".lucide-check") !== null).toBe(success); + }); +}); diff --git a/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.tsx index 48b89ee8ac1..23ad629d572 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.tsx @@ -1,3 +1,4 @@ +import { copyToClipboard } from "@/utils/dataUtils"; import { useEffect, useMemo, useState } from "react"; import { Bot, Check, Copy, Sparkles, Wrench } from "lucide-react"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; @@ -289,12 +290,9 @@ export function LogDetailsDrawer({ const handleCopyLeftPanelId = async () => { if (!leftPanelId) return; - try { - await navigator.clipboard.writeText(leftPanelId); + if (await copyToClipboard(leftPanelId)) { setCopiedLeftPanelId(true); setTimeout(() => setCopiedLeftPanelId(false), 1200); - } catch { - /* clipboard unavailable in non-secure contexts */ } }; diff --git a/ui/litellm-dashboard/src/components/logs/detail/OutputCard.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/OutputCard.test.tsx index 3dda7c5fa65..7b4c1a987a1 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/OutputCard.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/OutputCard.test.tsx @@ -1,12 +1,15 @@ import React from "react"; -import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { OutputCard } from "./OutputCard"; import { ParsedMessage } from "./prettyMessagesTypes"; +import { copyToClipboard } from "@/utils/dataUtils"; +import { toast } from "@/lib/toast"; + +vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn() })); describe("OutputCard", () => { - const mockWriteText = vi.fn().mockResolvedValue(undefined); const mockMessage: ParsedMessage = { role: "assistant", content: "This is a test response", @@ -14,13 +17,7 @@ describe("OutputCard", () => { beforeEach(() => { vi.clearAllMocks(); - Object.defineProperty(navigator, "clipboard", { - value: { - writeText: mockWriteText, - }, - writable: true, - configurable: true, - }); + vi.mocked(copyToClipboard).mockResolvedValue(true); }); it("should render the OutputCard component", () => { @@ -53,20 +50,16 @@ describe("OutputCard", () => { expect(screen.getByText(/Cost: \$0\.002500/)).toBeInTheDocument(); }); - it("should copy message content when copy button is clicked", async () => { + it.each([true, false])("delegates copying and feedback to the helper (%s)", async (success) => { + const user = userEvent.setup(); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + vi.mocked(copyToClipboard).mockResolvedValue(success); render(); - const copyButton = screen.getByRole("button", { name: /copy/i }); + await user.click(screen.getByRole("button", { name: /copy/i })); - expect(copyButton).toBeInTheDocument(); - - await act(async () => { - fireEvent.click(copyButton); - }); - - await waitFor(() => { - expect(mockWriteText).toHaveBeenCalledWith("This is a test response"); - }); + expect(copyToClipboard).toHaveBeenCalledWith("This is a test response", "Output copied"); + expect(toast.success).not.toHaveBeenCalled(); }); it("should not copy when message is null and copy button is clicked", async () => { @@ -80,7 +73,7 @@ describe("OutputCard", () => { await user.click(copyButton); await waitFor(() => { - expect(mockWriteText).not.toHaveBeenCalled(); + expect(copyToClipboard).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/components/logs/detail/OutputCard.tsx b/ui/litellm-dashboard/src/components/logs/detail/OutputCard.tsx index 7d0ec62ffe2..7daa8b02546 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/OutputCard.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/OutputCard.tsx @@ -4,7 +4,7 @@ */ import { useState } from "react"; -import { toast } from "@/lib/toast"; +import { copyToClipboard } from "@/utils/dataUtils"; import { COLOR_BORDER } from "./constants"; import { ParsedMessage } from "./prettyMessagesTypes"; import { SectionHeader } from "./SectionHeader"; @@ -22,8 +22,7 @@ export function OutputCard({ message, completionTokens, outputCost }: OutputCard const handleCopy = () => { if (!message) return; - navigator.clipboard.writeText(message.content || ""); - toast.success("Output copied"); + void copyToClipboard(message.content || "", "Output copied"); }; return ( diff --git a/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.test.tsx index 201c9e74ce8..d5280c35983 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.test.tsx @@ -1,9 +1,12 @@ import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { copyToClipboard } from "@/utils/dataUtils"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { RealtimePrettyView, isRealtimeResponse } from "./RealtimePrettyView"; +vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn().mockResolvedValue(true) })); + const sampleRealtimeResponse = { usage: { total_tokens: 587, @@ -142,15 +145,20 @@ describe("isRealtimeResponse", () => { }); describe("RealtimePrettyView", () => { - const mockWriteText = vi.fn().mockResolvedValue(undefined); - beforeEach(() => { vi.clearAllMocks(); - Object.defineProperty(navigator, "clipboard", { - value: { writeText: mockWriteText }, - writable: true, - configurable: true, - }); + }); + + it("copies all transcript turns when the Clipboard API is unavailable", async () => { + const user = userEvent.setup(); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + render(); + + await user.click(screen.getByRole("button", { name: /copy/i })); + + expect(copyToClipboard).toHaveBeenCalledWith( + "assistant: Hello! How's your day going?\nassistant: I'm here to help with information and general questions.", + ); }); it("should render the component successfully", () => { diff --git a/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.tsx b/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.tsx index 82f0d3e7fb2..04f0a178462 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.tsx @@ -1,3 +1,4 @@ +import { copyToClipboard } from "@/utils/dataUtils"; /** * RealtimePrettyView - Structured pretty view for OpenAI Realtime API logs * Displays session config, conversation turns, and token usage @@ -276,7 +277,7 @@ function ConversationCard({ (r.output || []).flatMap((o) => (o.content || []).map((c) => `${o.role}: ${c.transcript || c.text || ""}`)), ) .join("\n"); - navigator.clipboard.writeText(transcripts); + void copyToClipboard(transcripts); }; return ( diff --git a/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx b/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx index c2c2ee199b4..311b536d89c 100644 --- a/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ComponentProps } from "react"; import { copyToClipboard } from "@/utils/dataUtils"; -import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; +import { renderWithProviders, screen } from "../../../tests/test-utils"; import CopyButton from "./CopyButton"; vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn() })); @@ -13,13 +13,9 @@ vi.mock("lucide-react", async (importOriginal) => ({ Check: (props: ComponentProps<"svg">) => , })); -const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); - afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); - if (clipboardDescriptor) Object.defineProperty(navigator, "clipboard", clipboardDescriptor); - else Reflect.deleteProperty(navigator, "clipboard"); }); describe("CopyButton", () => { @@ -28,55 +24,37 @@ describe("CopyButton", () => { expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); - it("shows the confirmation checkmark only after a successful write", async () => { + it("shows the icon confirmation only after copying succeeds", async () => { const user = userEvent.setup(); - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + const pendingCopy = Promise.withResolvers(); + vi.mocked(copyToClipboard).mockReturnValue(pendingCopy.promise); + renderWithProviders(); - renderWithProviders(); + await user.click(screen.getByRole("button", { name: "Copy value" })); - const button = screen.getByRole("button", { name: "Copy value" }); + expect(copyToClipboard).toHaveBeenCalledWith("secret-value", "Value copied"); expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument(); - await user.click(button); - - expect(writeText).toHaveBeenCalledWith("secret-value"); - expect(await screen.findByRole("img", { name: "Copied icon" })).toBeInTheDocument(); + await act(async () => pendingCopy.resolve(true)); + expect(screen.getByRole("img", { name: "Copied icon" })).toBeInTheDocument(); }); - it("does not show the checkmark when the clipboard write is rejected", async () => { + it("keeps the icon unchanged when copying fails", async () => { const user = userEvent.setup(); - const writeText = vi.fn().mockRejectedValue(new Error("permission denied")); - Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); - + vi.mocked(copyToClipboard).mockResolvedValue(false); renderWithProviders(); - const button = screen.getByRole("button", { name: "Copy value" }); - await user.click(button); + await user.click(screen.getByRole("button", { name: "Copy value" })); - await waitFor(() => expect(writeText).toHaveBeenCalledWith("secret-value")); + expect(copyToClipboard).toHaveBeenCalledWith("secret-value", "Copied"); expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument(); expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument(); }); - it("does not show the checkmark when the clipboard API is unavailable", async () => { - const user = userEvent.setup(); - Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); - - renderWithProviders(); - - const button = screen.getByRole("button", { name: "Copy value" }); - await user.click(button); - - expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument(); - expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument(); - expect(copyToClipboard).not.toHaveBeenCalled(); - }); - it("keeps the default icon styling, custom classes, title and 1200ms confirmation", async () => { vi.useFakeTimers(); - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + vi.mocked(copyToClipboard).mockResolvedValue(true); renderWithProviders( , ); @@ -87,8 +65,7 @@ describe("CopyButton", () => { expect(screen.getByRole("img", { name: "Copy icon" })).toHaveClass("size-3"); await act(async () => fireEvent.click(button)); - expect(writeText).toHaveBeenCalledWith("request-id"); - expect(copyToClipboard).not.toHaveBeenCalled(); + expect(copyToClipboard).toHaveBeenCalledWith("request-id", "Copied"); act(() => vi.advanceTimersByTime(1199)); expect(screen.getByRole("img", { name: "Copied icon" })).toBeInTheDocument(); act(() => vi.advanceTimersByTime(1)); diff --git a/ui/litellm-dashboard/src/components/shared/CopyButton.tsx b/ui/litellm-dashboard/src/components/shared/CopyButton.tsx index 57ae86a26d0..225154e4bcf 100644 --- a/ui/litellm-dashboard/src/components/shared/CopyButton.tsx +++ b/ui/litellm-dashboard/src/components/shared/CopyButton.tsx @@ -54,17 +54,7 @@ const CopyButton: React.FC = ({ if (value == null || (!value && !isAction)) return null; const handleCopy = async () => { - if (isAction) { - setCopied(await copyToClipboard(value, copiedLabel)); - return; - } - if (!navigator.clipboard) return; - try { - await navigator.clipboard.writeText(value); - setCopied(true); - } catch { - setCopied(false); - } + setCopied(await copyToClipboard(value, copiedLabel)); }; const button = ( diff --git a/ui/litellm-dashboard/src/utils/dataUtils.test.ts b/ui/litellm-dashboard/src/utils/dataUtils.test.ts index af249ca601a..23b425b47e0 100644 --- a/ui/litellm-dashboard/src/utils/dataUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/dataUtils.test.ts @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { copyToClipboard, @@ -16,8 +17,6 @@ const mockToast = vi.mocked(toast); describe("dataUtils", () => { beforeEach(() => { vi.clearAllMocks(); - // Reset document.execCommand mock - delete (document as any).execCommand; }); afterEach(() => { @@ -134,194 +133,114 @@ describe("dataUtils", () => { }); describe("copyToClipboard", () => { - describe("when Clipboard API is available (HTTPS scenario)", () => { - beforeEach(() => { - // Mock modern Clipboard API - Object.assign(navigator, { - clipboard: { - writeText: vi.fn().mockResolvedValue(undefined), - }, - }); - }); - - it("should use navigator.clipboard.writeText when available", async () => { - const result = await copyToClipboard("test text"); - - expect(navigator.clipboard.writeText).toHaveBeenCalledWith("test text"); - expect(mockToast.success).toHaveBeenCalledWith("Copied to clipboard"); - expect(result).toBe(true); - }); - - it("should use custom message when provided", async () => { - await copyToClipboard("test text", "Custom message"); - - expect(mockToast.success).toHaveBeenCalledWith("Custom message"); - }); - - it("should return false for null/undefined text", async () => { - expect(await copyToClipboard(null)).toBe(false); - expect(await copyToClipboard(undefined)).toBe(false); - expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); - }); - - it("should fall back to execCommand when clipboard API fails", async () => { - // Make clipboard API fail - navigator.clipboard.writeText = vi.fn().mockRejectedValue(new Error("Permission denied")); - - // Mock successful execCommand - document.execCommand = vi.fn().mockReturnValue(true); - - // Mock DOM methods - const mockTextArea = { - value: "", - style: {} as Record, - setAttribute: vi.fn(), - focus: vi.fn(), - select: vi.fn(), - }; - document.createElement = vi.fn().mockReturnValue(mockTextArea); - document.body.appendChild = vi.fn(); - document.body.removeChild = vi.fn(); - - const result = await copyToClipboard("test text"); - - expect(navigator.clipboard.writeText).toHaveBeenCalledWith("test text"); - expect(document.execCommand).toHaveBeenCalledWith("copy"); - expect(mockToast.success).toHaveBeenCalledWith("Copied to clipboard"); - expect(result).toBe(true); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, "execCommand"); + const writeText = vi.fn<(text: string) => Promise>(); + const execCommand = vi.fn<(command: string) => boolean>(); + const selectedText = vi.fn(() => { + const element = document.activeElement; + return element instanceof HTMLTextAreaElement + ? element.value.slice(element.selectionStart, element.selectionEnd) + : undefined; + }); + const trigger = document.createElement("button"); + const text = "Request α\nsecond line"; + + beforeEach(() => { + writeText.mockResolvedValue(undefined); + execCommand.mockImplementation(() => { + selectedText(); + return true; }); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand }); + vi.spyOn(console, "error").mockImplementation(() => {}); + document.body.appendChild(trigger); + trigger.focus(); }); - describe("when Clipboard API is not available (HTTP scenario)", () => { - beforeEach(() => { - // Mock HTTP scenario - no clipboard API - Object.assign(navigator, { - clipboard: undefined, - }); - - // Mock successful execCommand - document.execCommand = vi.fn().mockReturnValue(true); - - // Mock DOM methods - const mockTextArea = { - value: "", - style: {} as Record, - setAttribute: vi.fn(), - focus: vi.fn(), - select: vi.fn(), - }; - document.createElement = vi.fn().mockReturnValue(mockTextArea); - document.body.appendChild = vi.fn(); - document.body.removeChild = vi.fn(); - }); - - it("should fall back to execCommand when clipboard API is not available", async () => { - const result = await copyToClipboard("test text"); - - expect(document.createElement).toHaveBeenCalledWith("textarea"); - expect(document.execCommand).toHaveBeenCalledWith("copy"); - expect(mockToast.success).toHaveBeenCalledWith("Copied to clipboard"); - expect(result).toBe(true); - }); - - it("should set textarea properties correctly", async () => { - const mockTextArea = { - value: "", - style: {} as Record, - setAttribute: vi.fn(), - focus: vi.fn(), - select: vi.fn(), - }; - document.createElement = vi.fn().mockReturnValue(mockTextArea); - - await copyToClipboard("test text"); - - expect(mockTextArea.value).toBe("test text"); - /* eslint-disable jest-dom/prefer-to-have-style -- the subject is a plain mock object, not a DOM node, so toHaveStyle cannot read it */ - expect(mockTextArea.style.position).toBe("fixed"); - expect(mockTextArea.style.left).toBe("-999999px"); - expect(mockTextArea.style.top).toBe("-999999px"); - /* eslint-enable jest-dom/prefer-to-have-style */ - expect(mockTextArea.setAttribute).toHaveBeenCalledWith("readonly", ""); - expect(mockTextArea.focus).toHaveBeenCalled(); - expect(mockTextArea.select).toHaveBeenCalled(); - }); - - it("should handle execCommand failure", async () => { - document.execCommand = vi.fn().mockReturnValue(false); - - const result = await copyToClipboard("test text"); - - expect(document.execCommand).toHaveBeenCalledWith("copy"); - expect(mockToast.fromError).toHaveBeenCalledWith("Failed to copy to clipboard"); - expect(result).toBe(false); - }); - - it("should handle DOM manipulation errors", async () => { - document.createElement = vi.fn().mockImplementation(() => { - throw new Error("DOM error"); - }); - - const result = await copyToClipboard("test text"); - - expect(mockToast.fromError).toHaveBeenCalledWith("Failed to copy to clipboard"); - expect(result).toBe(false); - }); - - it("should clean up textarea element after successful copy", async () => { - const mockTextArea = { - value: "", - style: {} as Record, - setAttribute: vi.fn(), - focus: vi.fn(), - select: vi.fn(), - }; - document.createElement = vi.fn().mockReturnValue(mockTextArea); - - await copyToClipboard("test text"); - - expect(document.body.appendChild).toHaveBeenCalledWith(mockTextArea); - expect(document.body.removeChild).toHaveBeenCalledWith(mockTextArea); - }); + afterEach(() => { + document.body.replaceChildren(); + if (clipboardDescriptor) Object.defineProperty(navigator, "clipboard", clipboardDescriptor); + else Reflect.deleteProperty(navigator, "clipboard"); + if (execCommandDescriptor) Object.defineProperty(document, "execCommand", execCommandDescriptor); + else Reflect.deleteProperty(document, "execCommand"); }); - describe("edge cases", () => { - beforeEach(() => { - // Mock scenario where navigator exists but clipboard is null - Object.assign(navigator, { - clipboard: null, - }); + it("confirms a modern clipboard write only after it succeeds", async () => { + const pendingWrite = Promise.withResolvers(); + writeText.mockReturnValue(pendingWrite.promise); + const result = copyToClipboard(text, "Request copied"); - document.execCommand = vi.fn().mockReturnValue(true); - const mockTextArea = { - value: "", - style: {} as Record, - setAttribute: vi.fn(), - focus: vi.fn(), - select: vi.fn(), - }; - document.createElement = vi.fn().mockReturnValue(mockTextArea); - document.body.appendChild = vi.fn(); - document.body.removeChild = vi.fn(); + expect(writeText).toHaveBeenCalledWith(text); + expect(mockToast.success).not.toHaveBeenCalled(); + pendingWrite.resolve(); + + expect(await result).toBe(true); + expect(mockToast.success).toHaveBeenCalledWith("Request copied"); + expect(execCommand).not.toHaveBeenCalled(); + expect(trigger).toHaveFocus(); + }); + + it.each([null, undefined, ""])("does not copy absent content %s", async (value) => { + expect(await copyToClipboard(value)).toBe(false); + expect(writeText).not.toHaveBeenCalled(); + expect(execCommand).not.toHaveBeenCalled(); + expect(mockToast.success).not.toHaveBeenCalled(); + }); + + it.each([undefined, null, {}])("copies the full selection when the Clipboard API is %s", async (clipboard) => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: clipboard }); + + expect(await copyToClipboard(text)).toBe(true); + + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(selectedText).toHaveReturnedWith(text); + expect(mockToast.success).toHaveBeenCalledWith("Copied to clipboard"); + expect(document.querySelector("textarea")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it("falls back after a rejected modern write", async () => { + writeText.mockRejectedValue(new Error("Permission denied")); + + expect(await copyToClipboard(text, "Request copied")).toBe(true); + + expect(writeText).toHaveBeenCalledWith(text); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(selectedText).toHaveReturnedWith(text); + expect(mockToast.success).toHaveBeenCalledExactlyOnceWith("Request copied"); + expect(document.querySelector("textarea")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it.each(["false", "throw"])("cleans up and reports failure when the fallback returns %s", async (outcome) => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + execCommand.mockImplementation(() => { + selectedText(); + if (outcome === "throw") throw new Error("Copy denied"); + return false; }); - it("should handle navigator.clipboard being null", async () => { - const result = await copyToClipboard("test text"); + expect(await copyToClipboard(text)).toBe(false); - expect(document.execCommand).toHaveBeenCalledWith("copy"); - expect(result).toBe(true); + expect(selectedText).toHaveReturnedWith(text); + expect(mockToast.fromError).toHaveBeenCalledWith("Failed to copy to clipboard"); + expect(mockToast.success).not.toHaveBeenCalled(); + expect(document.querySelector("textarea")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it("reports failure when the fallback cannot create its selection", async () => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + vi.spyOn(document, "createElement").mockImplementationOnce(() => { + throw new Error("DOM error"); }); - it("should handle navigator.clipboard.writeText being undefined", async () => { - Object.assign(navigator, { - clipboard: {}, // clipboard exists but writeText doesn't - }); - - const result = await copyToClipboard("test text"); - - expect(document.execCommand).toHaveBeenCalledWith("copy"); - expect(result).toBe(true); - }); + expect(await copyToClipboard(text)).toBe(false); + expect(mockToast.fromError).toHaveBeenCalledWith("Failed to copy to clipboard"); + expect(mockToast.success).not.toHaveBeenCalled(); + expect(trigger).toHaveFocus(); }); }); }); diff --git a/ui/litellm-dashboard/src/utils/dataUtils.ts b/ui/litellm-dashboard/src/utils/dataUtils.ts index c813a69b999..a05b8d6b0c2 100644 --- a/ui/litellm-dashboard/src/utils/dataUtils.ts +++ b/ui/litellm-dashboard/src/utils/dataUtils.ts @@ -103,19 +103,20 @@ const fallbackCopyToClipboard = (text: string, messageText: string): boolean => textArea.setAttribute("readonly", ""); document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); + try { + textArea.focus(); + textArea.select(); - const successful = document.execCommand("copy"); - document.body.removeChild(textArea); - if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus(); - - if (successful) { - toast.success(messageText); - return true; - } else { - throw new Error("execCommand failed"); + if (document.execCommand("copy")) { + toast.success(messageText); + return true; + } + } finally { + textArea.remove(); + if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus(); } + toast.fromError("Failed to copy to clipboard"); + return false; } catch (err) { toast.fromError("Failed to copy to clipboard"); console.error("Failed to copy: ", err);