diff --git a/frontend/src/components/ai-elements/message.tsx b/frontend/src/components/ai-elements/message.tsx index c0071c219..c260f1f91 100644 --- a/frontend/src/components/ai-elements/message.tsx +++ b/frontend/src/components/ai-elements/message.tsx @@ -18,7 +18,8 @@ import { } from "lucide-react"; import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react"; -import { Streamdown } from "streamdown"; + +import { ClipboardSafeStreamdown } from "./streamdown"; export type MessageProps = HTMLAttributes & { from: UIMessage["role"]; @@ -302,11 +303,13 @@ export const MessageBranchPage = ({ ); }; -export type MessageResponseProps = ComponentProps; +export type MessageResponseProps = ComponentProps< + typeof ClipboardSafeStreamdown +>; export const MessageResponse = memo( ({ className, ...props }: MessageResponseProps) => ( - *:first-child]:mt-0 [&>*:last-child]:mb-0", className, diff --git a/frontend/src/components/ai-elements/reasoning.tsx b/frontend/src/components/ai-elements/reasoning.tsx index b8e0bfcbc..94d4e7a5c 100644 --- a/frontend/src/components/ai-elements/reasoning.tsx +++ b/frontend/src/components/ai-elements/reasoning.tsx @@ -10,9 +10,9 @@ import { cn } from "@/lib/utils"; import { BrainIcon, ChevronDownIcon } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react"; -import { Streamdown } from "streamdown"; import { reasoningPlugins } from "@/core/streamdown/plugins"; import { Shimmer } from "./shimmer"; +import { ClipboardSafeStreamdown } from "./streamdown"; type ReasoningContextValue = { isStreaming: boolean; @@ -178,7 +178,9 @@ export const ReasoningContent = memo( )} {...props} > - {children} + + {children} + ), ); diff --git a/frontend/src/components/ai-elements/streamdown.tsx b/frontend/src/components/ai-elements/streamdown.tsx new file mode 100644 index 000000000..210053d9b --- /dev/null +++ b/frontend/src/components/ai-elements/streamdown.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { type ComponentProps } from "react"; +import { Streamdown } from "streamdown"; + +import { installClipboardFallback } from "@/core/clipboard"; + +export type ClipboardSafeStreamdownProps = ComponentProps; + +// Only patch browser globals in client context; skip during SSR +if (typeof document !== "undefined") { + installClipboardFallback(); +} + +export function ClipboardSafeStreamdown(props: ClipboardSafeStreamdownProps) { + return ; +} diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 70d1d2d88..4d9af67a6 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -10,7 +10,6 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { Streamdown } from "streamdown"; import { Artifact, @@ -20,6 +19,7 @@ import { ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; +import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown"; import { Select, SelectItem } from "@/components/ui/select"; import { SelectContent, @@ -400,13 +400,13 @@ export function ArtifactFilePreview({ if (language === "markdown") { return (
- {content ?? ""} - +
); } diff --git a/frontend/src/components/workspace/messages/subtask-card.tsx b/frontend/src/components/workspace/messages/subtask-card.tsx index b2aa74b34..7113ab480 100644 --- a/frontend/src/components/workspace/messages/subtask-card.tsx +++ b/frontend/src/components/workspace/messages/subtask-card.tsx @@ -6,7 +6,6 @@ import { XCircleIcon, } from "lucide-react"; import { useMemo, useState } from "react"; -import { Streamdown } from "streamdown"; import { ChainOfThought, @@ -14,6 +13,7 @@ import { ChainOfThoughtStep, } from "@/components/ai-elements/chain-of-thought"; import { Shimmer } from "@/components/ai-elements/shimmer"; +import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown"; import { Button } from "@/components/ui/button"; import { ShineBorder } from "@/components/ui/shine-border"; import { useI18n } from "@/core/i18n/hooks"; @@ -126,12 +126,12 @@ export function SubtaskCard({ {task.prompt && ( {task.prompt} -
+ } > )} diff --git a/frontend/src/components/workspace/settings/about-settings-page.tsx b/frontend/src/components/workspace/settings/about-settings-page.tsx index 8635f8dec..d91e60a76 100644 --- a/frontend/src/components/workspace/settings/about-settings-page.tsx +++ b/frontend/src/components/workspace/settings/about-settings-page.tsx @@ -1,9 +1,9 @@ "use client"; -import { Streamdown } from "streamdown"; +import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown"; import { aboutMarkdown } from "./about-content"; export function AboutSettingsPage() { - return {aboutMarkdown}; + return {aboutMarkdown}; } diff --git a/frontend/src/components/workspace/settings/memory-settings-page.tsx b/frontend/src/components/workspace/settings/memory-settings-page.tsx index f7b677445..9c84ff217 100644 --- a/frontend/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend/src/components/workspace/settings/memory-settings-page.tsx @@ -10,8 +10,8 @@ import { import Link from "next/link"; import { useDeferredValue, useId, useRef, useState } from "react"; import { toast } from "sonner"; -import { Streamdown } from "streamdown"; +import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -639,12 +639,12 @@ export function MemorySettingsPage() {
{summaryReadOnly}
- {summariesToMarkdown(memory, filteredSectionGroups, t)} - + ) : null} diff --git a/frontend/src/core/clipboard.ts b/frontend/src/core/clipboard.ts index 1be382833..81bc92726 100644 --- a/frontend/src/core/clipboard.ts +++ b/frontend/src/core/clipboard.ts @@ -1,3 +1,47 @@ +type ClipboardItemLike = { + types?: readonly string[]; + getType?: (type: string) => Promise; + items?: Record; +}; + +function copyTextWithExecCommand(text: string): boolean { + const document = globalThis.document; + if ( + typeof document?.createElement !== "function" || + typeof document.body?.appendChild !== "function" || + typeof document.execCommand !== "function" + ) { + throw new Error("Clipboard DOM fallback not available"); + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-9999px"; + textarea.style.left = "-9999px"; + + let copied = false; + let appended = false; + try { + document.body.appendChild(textarea); + appended = true; + textarea.select(); + copied = document.execCommand("copy"); + } finally { + if (appended) { + const parentNode = textarea.parentNode; + if (typeof textarea.remove === "function") { + textarea.remove(); + } else if (typeof parentNode?.removeChild === "function") { + parentNode.removeChild(textarea); + } + } + } + + return copied; +} + export async function writeTextToClipboard(text: string): Promise { try { const clipboard = globalThis.navigator?.clipboard; @@ -6,26 +50,209 @@ export async function writeTextToClipboard(text: string): Promise { return true; } - const document = globalThis.document; - if (!document?.body?.appendChild || !document.execCommand) { - return false; - } - - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.setAttribute("readonly", ""); - textarea.style.position = "fixed"; - textarea.style.top = "-9999px"; - textarea.style.left = "-9999px"; - document.body.appendChild(textarea); - textarea.select(); - - try { - return document.execCommand("copy"); - } finally { - textarea.remove(); - } + return copyTextWithExecCommand(text); } catch { return false; } } + +function fallbackWriteText(text: string): Promise { + try { + if (!copyTextWithExecCommand(text)) { + return Promise.reject(new Error("Clipboard copy command failed")); + } + } catch (error) { + return Promise.reject( + error instanceof Error ? error : new Error(String(error)), + ); + } + return Promise.resolve(); +} + +function hasUsableClipboardItem(): boolean { + return typeof globalThis.ClipboardItem === "function"; +} + +async function readPlainTextFromClipboardItem( + item: ClipboardItemLike, +): Promise { + const plainText = item.items?.["text/plain"]; + if (typeof plainText === "string") { + return plainText; + } + if (plainText instanceof Blob) { + return await plainText.text(); + } + + if (item.types && !item.types.includes("text/plain")) { + throw new Error("Clipboard item is missing text/plain data"); + } + + if (typeof item.getType !== "function") { + throw new Error("Clipboard item cannot read text/plain data"); + } + + const blob = await item.getType("text/plain"); + if (blob instanceof Blob) { + return await blob.text(); + } + + throw new Error("Clipboard item text/plain data is not a Blob"); +} + +function canDefineNavigatorClipboard( + navigator: Navigator, + descriptor: PropertyDescriptor | undefined, +): boolean { + if (descriptor) { + return descriptor.configurable === true; + } + return Object.isExtensible(navigator); +} + +/** + * Installs browser clipboard fallbacks for Streamdown copy controls by patching + * missing navigator.clipboard methods and ClipboardItem when the host permits it. + */ +export function installClipboardFallback(): void { + const navigator = globalThis.navigator; + if (!navigator) { + return; + } + + const rawClipboard = navigator.clipboard; + const clipboard = + typeof rawClipboard === "object" && rawClipboard !== null + ? (rawClipboard as Partial) + : undefined; + const clipboardDescriptor = Object.getOwnPropertyDescriptor( + navigator, + "clipboard", + ); + const hasWriteText = typeof clipboard?.writeText === "function"; + const hasWrite = typeof clipboard?.write === "function"; + const hasClipboardItem = hasUsableClipboardItem(); + + if (hasWriteText && hasWrite && hasClipboardItem) { + return; + } + + const writeText = hasWriteText + ? clipboard.writeText!.bind(clipboard) + : fallbackWriteText; + const write = hasWrite + ? clipboard.write!.bind(clipboard) + : (items: ClipboardItemLike[]) => { + const firstItem = items[0]; + if (!firstItem) { + return Promise.reject(new Error("Clipboard item not available")); + } + + return readPlainTextFromClipboardItem(firstItem).then(writeText); + }; + + const fallbackClipboard = clipboard ?? {}; + + try { + const missingMethods: PropertyDescriptorMap = {}; + if (!hasWrite) { + missingMethods.write = { + configurable: true, + value: write, + writable: true, + }; + } + if (!hasWriteText) { + missingMethods.writeText = { + configurable: true, + value: writeText, + writable: true, + }; + } + + Object.defineProperties(fallbackClipboard, missingMethods); + + if ( + !clipboard && + canDefineNavigatorClipboard(navigator, clipboardDescriptor) + ) { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: fallbackClipboard, + }); + } + } catch { + if (!canDefineNavigatorClipboard(navigator, clipboardDescriptor)) { + // The ClipboardItem fallback below is independent from navigator.clipboard. + if (hasClipboardItem) { + return; + } + } else { + const replacement = Object.create(clipboard ?? null); + for (const methodName of ["read", "readText"] as const) { + const method = clipboard?.[methodName]; + if (typeof method === "function") { + Object.defineProperty(replacement, methodName, { + configurable: true, + value: method.bind(clipboard), + writable: true, + }); + } + } + Object.defineProperties(replacement, { + write: { + configurable: true, + value: write, + writable: true, + }, + writeText: { + configurable: true, + value: writeText, + writable: true, + }, + }); + try { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: replacement, + }); + } catch { + // The ClipboardItem fallback below is independent from navigator.clipboard. + } + } + } + + if (!hasClipboardItem) { + class ClipboardItemFallback { + items: Record; + types: string[]; + + constructor(items: Record) { + this.items = items; + this.types = Object.keys(items); + } + + getType(type: string): Promise { + const value = this.items[type]; + if (value instanceof Blob) { + return Promise.resolve(value); + } + if (typeof value === "string") { + return Promise.resolve(new Blob([value], { type })); + } + return Promise.reject( + new Error(`Clipboard item is missing ${type} data`), + ); + } + } + + try { + Object.defineProperty(globalThis, "ClipboardItem", { + configurable: true, + value: ClipboardItemFallback, + }); + } catch { + return; + } + } +} diff --git a/frontend/tests/unit/core/clipboard.test.ts b/frontend/tests/unit/core/clipboard.test.ts index 56db47c16..2329a17dc 100644 --- a/frontend/tests/unit/core/clipboard.test.ts +++ b/frontend/tests/unit/core/clipboard.test.ts @@ -1,11 +1,18 @@ import { afterEach, expect, test, vi } from "vitest"; -import { writeTextToClipboard } from "@/core/clipboard"; +import { + installClipboardFallback, + writeTextToClipboard, +} from "@/core/clipboard"; const originalNavigator = globalThis.navigator; const hadOriginalNavigator = "navigator" in globalThis; const originalDocument = globalThis.document; const hadOriginalDocument = "document" in globalThis; +const originalClipboardItemDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "ClipboardItem", +); afterEach(() => { vi.restoreAllMocks(); @@ -26,6 +33,16 @@ afterEach(() => { value: originalDocument, }); } + + if (!originalClipboardItemDescriptor) { + Reflect.deleteProperty(globalThis, "ClipboardItem"); + } else { + Object.defineProperty( + globalThis, + "ClipboardItem", + originalClipboardItemDescriptor, + ); + } }); test("writes text with the Clipboard API when available", async () => { @@ -90,6 +107,95 @@ test("falls back to execCommand when Clipboard API is unavailable", async () => expect(textarea.remove).toHaveBeenCalled(); }); +test("falls back to parent removal when textarea.remove is unavailable", async () => { + const parentNode = { + removeChild: vi.fn(), + }; + const textarea = { + parentNode, + select: vi.fn(), + setAttribute: vi.fn(), + style: {}, + value: "", + }; + const execCommand = vi.fn().mockReturnValue(true); + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + appendChild: vi.fn(), + }, + createElement: vi.fn().mockReturnValue(textarea), + execCommand, + }, + }); + + await expect(writeTextToClipboard("hello")).resolves.toBe(true); + expect(parentNode.removeChild).toHaveBeenCalledWith(textarea); +}); + +test("does not fail cleanup when textarea removal APIs are unavailable", async () => { + const textarea = { + parentNode: {}, + select: vi.fn(), + setAttribute: vi.fn(), + style: {}, + value: "", + }; + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + appendChild: vi.fn(), + }, + createElement: vi.fn().mockReturnValue(textarea), + execCommand: vi.fn().mockReturnValue(true), + }, + }); + + await expect(writeTextToClipboard("hello")).resolves.toBe(true); +}); + +test("cleans up the textarea when selecting text fails", async () => { + const textarea = { + remove: vi.fn(), + select: vi.fn(() => { + throw new Error("selection failed"); + }), + setAttribute: vi.fn(), + style: {}, + value: "", + }; + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + appendChild: vi.fn(), + }, + createElement: vi.fn().mockReturnValue(textarea), + execCommand: vi.fn(), + }, + }); + + await expect(writeTextToClipboard("hello")).resolves.toBe(false); + expect(textarea.remove).toHaveBeenCalled(); +}); + test("returns false when execCommand fallback fails", async () => { const textarea = { remove: vi.fn(), @@ -118,6 +224,24 @@ test("returns false when execCommand fallback fails", async () => { expect(textarea.remove).toHaveBeenCalled(); }); +test("returns false when execCommand fallback cannot create an element", async () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + appendChild: vi.fn(), + }, + execCommand: vi.fn(), + }, + }); + + await expect(writeTextToClipboard("hello")).resolves.toBe(false); +}); + test("returns false when navigator is unavailable", async () => { Object.defineProperty(globalThis, "navigator", { configurable: true, @@ -144,3 +268,495 @@ test("returns false when Clipboard API rejects", async () => { await expect(writeTextToClipboard("hello")).resolves.toBe(false); }); + +test("installs a writeText fallback when Clipboard API is unavailable", async () => { + const textarea = { + remove: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + style: {}, + value: "", + }; + const appendChild = vi.fn(); + const execCommand = vi.fn().mockReturnValue(true); + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + appendChild, + }, + createElement: vi.fn().mockReturnValue(textarea), + execCommand, + }, + }); + + installClipboardFallback(); + + await expect(globalThis.navigator.clipboard.writeText("hello")).resolves.toBe( + undefined, + ); + expect(textarea.value).toBe("hello"); + expect(appendChild).toHaveBeenCalledWith(textarea); + expect(textarea.select).toHaveBeenCalled(); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(textarea.remove).toHaveBeenCalled(); +}); + +test("installed writeText fallback rejects instead of throwing synchronously", async () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: undefined, + }); + + installClipboardFallback(); + + const result = globalThis.navigator.clipboard.writeText("hello"); + expect(result).toBeInstanceOf(Promise); + await expect(result).rejects.toThrow("Clipboard DOM fallback not available"); +}); + +test("installed writeText fallback converts thrown DOM failures to rejections", async () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + appendChild: vi.fn(), + }, + createElement: vi.fn(() => { + throw new Error("dom unavailable"); + }), + execCommand: vi.fn(), + }, + }); + + installClipboardFallback(); + + const result = globalThis.navigator.clipboard.writeText("hello"); + expect(result).toBeInstanceOf(Promise); + await expect(result).rejects.toThrow("dom unavailable"); +}); + +test("installed writeText fallback distinguishes copy command failure", async () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + appendChild: vi.fn(), + }, + createElement: vi.fn().mockReturnValue({ + remove: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + style: {}, + value: "", + }), + execCommand: vi.fn().mockReturnValue(false), + }, + }); + + installClipboardFallback(); + + await expect( + globalThis.navigator.clipboard.writeText("hello"), + ).rejects.toThrow("Clipboard copy command failed"); +}); + +test("installs a write fallback for ClipboardItem text/plain payloads", async () => { + const textarea = { + remove: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + style: {}, + value: "", + }; + const execCommand = vi.fn().mockReturnValue(true); + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + appendChild: vi.fn(), + }, + createElement: vi.fn().mockReturnValue(textarea), + execCommand, + }, + }); + Reflect.deleteProperty(globalThis, "ClipboardItem"); + + installClipboardFallback(); + + const item = new globalThis.ClipboardItem({ + "text/html": new Blob(["
"], { type: "text/html" }), + "text/plain": "| A |\n| B |", + }); + await expect(globalThis.navigator.clipboard.write([item])).resolves.toBe( + undefined, + ); + expect(textarea.value).toBe("| A |\n| B |"); + expect(execCommand).toHaveBeenCalledWith("copy"); +}); + +test("installed write fallback rejects when ClipboardItem lacks text/plain", async () => { + const execCommand = vi.fn().mockReturnValue(true); + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + appendChild: vi.fn(), + }, + createElement: vi.fn().mockReturnValue({ + remove: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + style: {}, + value: "", + }), + execCommand, + }, + }); + Reflect.deleteProperty(globalThis, "ClipboardItem"); + + installClipboardFallback(); + + const item = new globalThis.ClipboardItem({ + "text/html": new Blob(["
"], { type: "text/html" }), + }); + await expect(globalThis.navigator.clipboard.write([item])).rejects.toThrow( + "Clipboard item is missing text/plain data", + ); + expect(execCommand).not.toHaveBeenCalled(); +}); + +test("installed write fallback rejects when getType cannot provide text/plain", async () => { + const execCommand = vi.fn().mockReturnValue(true); + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + appendChild: vi.fn(), + }, + createElement: vi.fn().mockReturnValue({ + remove: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + style: {}, + value: "", + }), + execCommand, + }, + }); + + installClipboardFallback(); + + await expect( + globalThis.navigator.clipboard.write([ + { + getType: vi.fn().mockRejectedValue(new Error("missing")), + types: ["text/plain"], + } as unknown as ClipboardItem, + ]), + ).rejects.toThrow("missing"); + expect(execCommand).not.toHaveBeenCalled(); +}); + +test("installed write fallback rejects before getType when item types exclude text/plain", async () => { + const getType = vi.fn().mockResolvedValue(new Blob(["ignored"])); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: undefined, + }); + + installClipboardFallback(); + + await expect( + globalThis.navigator.clipboard.write([ + { + getType, + types: ["text/html"], + } as unknown as ClipboardItem, + ]), + ).rejects.toThrow("Clipboard item is missing text/plain data"); + expect(getType).not.toHaveBeenCalled(); +}); + +test("installed write fallback rejects when getType is missing", async () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: undefined, + }); + + installClipboardFallback(); + + await expect( + globalThis.navigator.clipboard.write([ + { + types: ["text/plain"], + } as unknown as ClipboardItem, + ]), + ).rejects.toThrow("Clipboard item cannot read text/plain data"); +}); + +test("installed write fallback rejects when getType returns a non-Blob", async () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: undefined, + }); + + installClipboardFallback(); + + await expect( + globalThis.navigator.clipboard.write([ + { + getType: vi.fn().mockResolvedValue("plain text"), + types: ["text/plain"], + } as unknown as ClipboardItem, + ]), + ).rejects.toThrow("Clipboard item text/plain data is not a Blob"); +}); + +test("installed write fallback preserves existing clipboard prototype methods", async () => { + const readText = vi.fn().mockResolvedValue("existing"); + const clipboard = Object.create({ + readText, + }); + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + clipboard, + }, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: undefined, + }); + Reflect.deleteProperty(globalThis, "ClipboardItem"); + + installClipboardFallback(); + + expect(globalThis.navigator.clipboard).toBe(clipboard); + await expect(globalThis.navigator.clipboard.readText()).resolves.toBe( + "existing", + ); + expect(readText).toHaveBeenCalled(); + await expect( + globalThis.navigator.clipboard.writeText("hello"), + ).rejects.toThrow("Clipboard DOM fallback not available"); +}); + +test("installClipboardFallback does not replace existing clipboard methods when only ClipboardItem is missing", async () => { + const write = vi.fn().mockResolvedValue(undefined); + const writeText = vi.fn().mockResolvedValue(undefined); + const clipboard = { + write, + writeText, + }; + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + clipboard, + }, + }); + Reflect.deleteProperty(globalThis, "ClipboardItem"); + + installClipboardFallback(); + + expect(globalThis.navigator.clipboard).toBe(clipboard); + expect(Reflect.get(globalThis.navigator.clipboard, "write")).toBe(write); + expect(Reflect.get(globalThis.navigator.clipboard, "writeText")).toBe( + writeText, + ); + expect(typeof globalThis.ClipboardItem).toBe("function"); +}); + +test("installClipboardFallback is idempotent for the same navigator", async () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: undefined, + }); + Reflect.deleteProperty(globalThis, "ClipboardItem"); + + installClipboardFallback(); + const clipboard = globalThis.navigator.clipboard; + const ClipboardItemFallback = globalThis.ClipboardItem; + + installClipboardFallback(); + + expect(globalThis.navigator.clipboard).toBe(clipboard); + expect(globalThis.ClipboardItem).toBe(ClipboardItemFallback); +}); + +test("installClipboardFallback can recover when the same navigator loses fallback globals", async () => { + const navigator = {}; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: navigator, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: undefined, + }); + Reflect.deleteProperty(globalThis, "ClipboardItem"); + + installClipboardFallback(); + Reflect.deleteProperty(globalThis, "ClipboardItem"); + Reflect.deleteProperty(navigator, "clipboard"); + + installClipboardFallback(); + + expect(typeof globalThis.navigator.clipboard.writeText).toBe("function"); + expect(typeof globalThis.ClipboardItem).toBe("function"); +}); + +test("installClipboardFallback defines writable fallback methods", async () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: undefined, + }); + + installClipboardFallback(); + + expect( + Object.getOwnPropertyDescriptor(globalThis.navigator.clipboard, "write") + ?.writable, + ).toBe(true); + expect( + Object.getOwnPropertyDescriptor(globalThis.navigator.clipboard, "writeText") + ?.writable, + ).toBe(true); +}); + +test("installClipboardFallback skips missing clipboard on non-extensible navigator while installing ClipboardItem", async () => { + const navigator = {}; + Object.preventExtensions(navigator); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: navigator, + }); + Reflect.deleteProperty(globalThis, "ClipboardItem"); + + installClipboardFallback(); + + expect("clipboard" in globalThis.navigator).toBe(false); + expect(typeof globalThis.ClipboardItem).toBe("function"); +}); + +test("installClipboardFallback handles non-object navigator.clipboard values", async () => { + const navigator = {}; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: "locked", + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: navigator, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: undefined, + }); + + installClipboardFallback(); + + expect(typeof globalThis.navigator.clipboard.writeText).toBe("function"); + await expect( + globalThis.navigator.clipboard.writeText("hello"), + ).rejects.toThrow("Clipboard DOM fallback not available"); +}); + +test("installClipboardFallback does not throw when ClipboardItem cannot be defined", async () => { + const originalDefineProperty = Object.defineProperty; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: undefined, + }); + Reflect.deleteProperty(globalThis, "ClipboardItem"); + vi.spyOn(Object, "defineProperty").mockImplementation( + (target, property, descriptor) => { + if (target === globalThis && property === "ClipboardItem") { + throw new Error("locked global"); + } + return originalDefineProperty(target, property, descriptor); + }, + ); + + expect(() => installClipboardFallback()).not.toThrow(); + expect(typeof globalThis.navigator.clipboard.writeText).toBe("function"); + expect("ClipboardItem" in globalThis).toBe(false); +}); + +test("installs ClipboardItem fallback when the global property exists but is unusable", async () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + clipboard: { + write: vi.fn().mockResolvedValue(undefined), + writeText: vi.fn().mockResolvedValue(undefined), + }, + }, + }); + Object.defineProperty(globalThis, "ClipboardItem", { + configurable: true, + value: undefined, + }); + + installClipboardFallback(); + + expect(typeof globalThis.ClipboardItem).toBe("function"); +});