mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-10 09:25:57 +00:00
fix(frontend): fallback Streamdown clipboard copy (#3397)
* fix(frontend): fallback streamdown clipboard copy * fix(frontend): address clipboard fallback review * fix(frontend): normalize clipboard fallback rejection * fix(frontend): harden clipboard fallback install * fix(frontend): clarify clipboard fallback errors * fix(frontend): cover clipboard fallback edge cases * fix(frontend): tighten clipboard fallback cleanup * fix(frontend): reduce clipboard fallback copy window * fix(frontend): guard clipboard item fallback install * fix(frontend): clean up clipboard fallback on selection errors * Address clipboard fallback review feedback * fix(frontend): guard clipboard fallback install during SSR
This commit is contained in:
@@ -18,7 +18,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
|
||||||
|
import { ClipboardSafeStreamdown } from "./streamdown";
|
||||||
|
|
||||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
from: UIMessage["role"];
|
from: UIMessage["role"];
|
||||||
@@ -302,11 +303,13 @@ export const MessageBranchPage = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
export type MessageResponseProps = ComponentProps<
|
||||||
|
typeof ClipboardSafeStreamdown
|
||||||
|
>;
|
||||||
|
|
||||||
export const MessageResponse = memo(
|
export const MessageResponse = memo(
|
||||||
({ className, ...props }: MessageResponseProps) => (
|
({ className, ...props }: MessageResponseProps) => (
|
||||||
<Streamdown
|
<ClipboardSafeStreamdown
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { cn } from "@/lib/utils";
|
|||||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
|
||||||
import { reasoningPlugins } from "@/core/streamdown/plugins";
|
import { reasoningPlugins } from "@/core/streamdown/plugins";
|
||||||
import { Shimmer } from "./shimmer";
|
import { Shimmer } from "./shimmer";
|
||||||
|
import { ClipboardSafeStreamdown } from "./streamdown";
|
||||||
|
|
||||||
type ReasoningContextValue = {
|
type ReasoningContextValue = {
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
@@ -178,7 +178,9 @@ export const ReasoningContent = memo(
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
<ClipboardSafeStreamdown {...reasoningPlugins}>
|
||||||
|
{children}
|
||||||
|
</ClipboardSafeStreamdown>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ComponentProps } from "react";
|
||||||
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
|
import { installClipboardFallback } from "@/core/clipboard";
|
||||||
|
|
||||||
|
export type ClipboardSafeStreamdownProps = ComponentProps<typeof Streamdown>;
|
||||||
|
|
||||||
|
// Only patch browser globals in client context; skip during SSR
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
installClipboardFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClipboardSafeStreamdown(props: ClipboardSafeStreamdownProps) {
|
||||||
|
return <Streamdown {...props} />;
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Streamdown } from "streamdown";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Artifact,
|
Artifact,
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
ArtifactHeader,
|
ArtifactHeader,
|
||||||
ArtifactTitle,
|
ArtifactTitle,
|
||||||
} from "@/components/ai-elements/artifact";
|
} from "@/components/ai-elements/artifact";
|
||||||
|
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||||
import { Select, SelectItem } from "@/components/ui/select";
|
import { Select, SelectItem } from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -400,13 +400,13 @@ export function ArtifactFilePreview({
|
|||||||
if (language === "markdown") {
|
if (language === "markdown") {
|
||||||
return (
|
return (
|
||||||
<div className="size-full px-4">
|
<div className="size-full px-4">
|
||||||
<Streamdown
|
<ClipboardSafeStreamdown
|
||||||
className="size-full"
|
className="size-full"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
components={{ a: ArtifactLink }}
|
components={{ a: ArtifactLink }}
|
||||||
>
|
>
|
||||||
{content ?? ""}
|
{content ?? ""}
|
||||||
</Streamdown>
|
</ClipboardSafeStreamdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChainOfThought,
|
ChainOfThought,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
ChainOfThoughtStep,
|
ChainOfThoughtStep,
|
||||||
} from "@/components/ai-elements/chain-of-thought";
|
} from "@/components/ai-elements/chain-of-thought";
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||||
|
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ShineBorder } from "@/components/ui/shine-border";
|
import { ShineBorder } from "@/components/ui/shine-border";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -126,12 +126,12 @@ export function SubtaskCard({
|
|||||||
{task.prompt && (
|
{task.prompt && (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
label={
|
label={
|
||||||
<Streamdown
|
<ClipboardSafeStreamdown
|
||||||
{...streamdownPluginsWithWordAnimation}
|
{...streamdownPluginsWithWordAnimation}
|
||||||
components={{ a: CitationLink }}
|
components={{ a: CitationLink }}
|
||||||
>
|
>
|
||||||
{task.prompt}
|
{task.prompt}
|
||||||
</Streamdown>
|
</ClipboardSafeStreamdown>
|
||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Streamdown } from "streamdown";
|
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||||
|
|
||||||
import { aboutMarkdown } from "./about-content";
|
import { aboutMarkdown } from "./about-content";
|
||||||
|
|
||||||
export function AboutSettingsPage() {
|
export function AboutSettingsPage() {
|
||||||
return <Streamdown>{aboutMarkdown}</Streamdown>;
|
return <ClipboardSafeStreamdown>{aboutMarkdown}</ClipboardSafeStreamdown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useDeferredValue, useId, useRef, useState } from "react";
|
import { useDeferredValue, useId, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Streamdown } from "streamdown";
|
|
||||||
|
|
||||||
|
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -639,12 +639,12 @@ export function MemorySettingsPage() {
|
|||||||
<div className="text-muted-foreground mb-4 text-sm">
|
<div className="text-muted-foreground mb-4 text-sm">
|
||||||
{summaryReadOnly}
|
{summaryReadOnly}
|
||||||
</div>
|
</div>
|
||||||
<Streamdown
|
<ClipboardSafeStreamdown
|
||||||
className="size-full min-w-0 [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
className="size-full min-w-0 [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
>
|
>
|
||||||
{summariesToMarkdown(memory, filteredSectionGroups, t)}
|
{summariesToMarkdown(memory, filteredSectionGroups, t)}
|
||||||
</Streamdown>
|
</ClipboardSafeStreamdown>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
+239
-12
@@ -1,14 +1,17 @@
|
|||||||
export async function writeTextToClipboard(text: string): Promise<boolean> {
|
type ClipboardItemLike = {
|
||||||
try {
|
types?: readonly string[];
|
||||||
const clipboard = globalThis.navigator?.clipboard;
|
getType?: (type: string) => Promise<Blob>;
|
||||||
if (clipboard?.writeText) {
|
items?: Record<string, Blob | string>;
|
||||||
await clipboard.writeText(text);
|
};
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function copyTextWithExecCommand(text: string): boolean {
|
||||||
const document = globalThis.document;
|
const document = globalThis.document;
|
||||||
if (!document?.body?.appendChild || !document.execCommand) {
|
if (
|
||||||
return false;
|
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");
|
const textarea = document.createElement("textarea");
|
||||||
@@ -17,15 +20,239 @@ export async function writeTextToClipboard(text: string): Promise<boolean> {
|
|||||||
textarea.style.position = "fixed";
|
textarea.style.position = "fixed";
|
||||||
textarea.style.top = "-9999px";
|
textarea.style.top = "-9999px";
|
||||||
textarea.style.left = "-9999px";
|
textarea.style.left = "-9999px";
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
let appended = false;
|
||||||
try {
|
try {
|
||||||
return document.execCommand("copy");
|
document.body.appendChild(textarea);
|
||||||
|
appended = true;
|
||||||
|
textarea.select();
|
||||||
|
copied = document.execCommand("copy");
|
||||||
} finally {
|
} finally {
|
||||||
|
if (appended) {
|
||||||
|
const parentNode = textarea.parentNode;
|
||||||
|
if (typeof textarea.remove === "function") {
|
||||||
textarea.remove();
|
textarea.remove();
|
||||||
|
} else if (typeof parentNode?.removeChild === "function") {
|
||||||
|
parentNode.removeChild(textarea);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const clipboard = globalThis.navigator?.clipboard;
|
||||||
|
if (clipboard?.writeText) {
|
||||||
|
await clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copyTextWithExecCommand(text);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackWriteText(text: string): Promise<void> {
|
||||||
|
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<string> {
|
||||||
|
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<Clipboard>)
|
||||||
|
: 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<string, Blob | string>;
|
||||||
|
types: string[];
|
||||||
|
|
||||||
|
constructor(items: Record<string, Blob | string>) {
|
||||||
|
this.items = items;
|
||||||
|
this.types = Object.keys(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
getType(type: string): Promise<Blob> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { writeTextToClipboard } from "@/core/clipboard";
|
import {
|
||||||
|
installClipboardFallback,
|
||||||
|
writeTextToClipboard,
|
||||||
|
} from "@/core/clipboard";
|
||||||
|
|
||||||
const originalNavigator = globalThis.navigator;
|
const originalNavigator = globalThis.navigator;
|
||||||
const hadOriginalNavigator = "navigator" in globalThis;
|
const hadOriginalNavigator = "navigator" in globalThis;
|
||||||
const originalDocument = globalThis.document;
|
const originalDocument = globalThis.document;
|
||||||
const hadOriginalDocument = "document" in globalThis;
|
const hadOriginalDocument = "document" in globalThis;
|
||||||
|
const originalClipboardItemDescriptor = Object.getOwnPropertyDescriptor(
|
||||||
|
globalThis,
|
||||||
|
"ClipboardItem",
|
||||||
|
);
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -26,6 +33,16 @@ afterEach(() => {
|
|||||||
value: originalDocument,
|
value: originalDocument,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!originalClipboardItemDescriptor) {
|
||||||
|
Reflect.deleteProperty(globalThis, "ClipboardItem");
|
||||||
|
} else {
|
||||||
|
Object.defineProperty(
|
||||||
|
globalThis,
|
||||||
|
"ClipboardItem",
|
||||||
|
originalClipboardItemDescriptor,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("writes text with the Clipboard API when available", async () => {
|
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();
|
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 () => {
|
test("returns false when execCommand fallback fails", async () => {
|
||||||
const textarea = {
|
const textarea = {
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
@@ -118,6 +224,24 @@ test("returns false when execCommand fallback fails", async () => {
|
|||||||
expect(textarea.remove).toHaveBeenCalled();
|
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 () => {
|
test("returns false when navigator is unavailable", async () => {
|
||||||
Object.defineProperty(globalThis, "navigator", {
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -144,3 +268,495 @@ test("returns false when Clipboard API rejects", async () => {
|
|||||||
|
|
||||||
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
|
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(["<table></table>"], { 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(["<table></table>"], { 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");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user