mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +00:00
fix(ui): restore clipboard copying in key and log details (#48425)
This commit is contained in:
parent
96792d0913
commit
be3a7be1db
19 changed files with 287 additions and 345 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/utils/dataUtils")>()),
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../common_components/DefaultProxyAdminTag", () => ({
|
||||
default: ({ userId }: { userId: string }) => <span>{userId}</span>,
|
||||
}));
|
||||
|
|
@ -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(<AuditLogDrawer {...defaultProps} />);
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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<stri
|
|||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/utils/dataUtils")>()),
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
const logEntry = (overrides: Partial<LogEntry>): 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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(<InputCard messages={[{ role: "user", content: "Copy this text" }]} />);
|
||||
|
||||
render(<InputCard messages={messages} />);
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 }) => <div data-testid="guardrail-viewer">{JSON.stringify(data)}</div>,
|
||||
|
|
@ -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(<LogDetailContent logEntry={log} />);
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/utils/dataUtils")>()),
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(<OutputCard message={mockMessage} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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(<RealtimePrettyView response={sampleRealtimeResponse} />);
|
||||
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">) => <svg {...props} role="img" aria-label="Copied icon" />,
|
||||
}));
|
||||
|
||||
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<boolean>();
|
||||
vi.mocked(copyToClipboard).mockReturnValue(pendingCopy.promise);
|
||||
renderWithProviders(<CopyButton value="secret-value" label="Copy value" copiedLabel="Value copied" />);
|
||||
|
||||
renderWithProviders(<CopyButton value="secret-value" label="Copy value" />);
|
||||
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(<CopyButton value="secret-value" label="Copy value" />);
|
||||
|
||||
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(<CopyButton value="secret-value" label="Copy value" />);
|
||||
|
||||
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(
|
||||
<CopyButton value="request-id" label="Copy request ID" className="shrink-0" iconClassName="size-3" />,
|
||||
);
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -54,17 +54,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({
|
|||
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 = (
|
||||
|
|
|
|||
|
|
@ -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<string, string>,
|
||||
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<void>>();
|
||||
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<string, string>,
|
||||
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<string, string>,
|
||||
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<string, string>,
|
||||
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<void>();
|
||||
writeText.mockReturnValue(pendingWrite.promise);
|
||||
const result = copyToClipboard(text, "Request copied");
|
||||
|
||||
document.execCommand = vi.fn().mockReturnValue(true);
|
||||
const mockTextArea = {
|
||||
value: "",
|
||||
style: {} as Record<string, string>,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue