fix(ui): restore clipboard copying in key and log details (#48425)

This commit is contained in:
tin-berri 2026-10-26 14:12:11 -04:00 • committed by GitHub
parent 96792d0913
commit be3a7be1db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 287 additions and 345 deletions

View file

@ -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

View file

@ -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", () => {

View file

@ -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]);

View file

@ -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");
});
});

View file

@ -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 */
}
};

View file

@ -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 () => {

View file

@ -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 (

View file

@ -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) => {

View file

@ -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 */
}
};

View file

@ -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);
});
});

View file

@ -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 */
}
};

View file

@ -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();
});
});

View file

@ -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 (

View file

@ -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", () => {

View file

@ -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 (

View file

@ -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));

View file

@ -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 = (

View file

@ -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();
});
});
});

View file

@ -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);