Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions src/lib/dc/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ export async function copyDcHtml(html: string, plainText: string): Promise<void>
const ClipboardItemCtor = getClipboardItemConstructor();

if (ClipboardItemCtor && typeof navigator !== "undefined" && navigator.clipboard?.write) {
const item = new ClipboardItemCtor(createDcClipboardPayload(html, plainText));

await navigator.clipboard.write([item]);
return;
try {
const item = new ClipboardItemCtor(createDcClipboardPayload(html, plainText));
await navigator.clipboard.write([item]);
return;
} catch {
// An exposed async API can still reject; try the existing browser fallback.
}
}

if (typeof window === "undefined" || typeof document === "undefined") {
Expand Down Expand Up @@ -64,8 +67,12 @@ export async function copyDcHtml(html: string, plainText: string): Promise<void>

export async function copyPlainText(text: string): Promise<void> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// The fallback may also be denied; its failure must still reach the caller.
}
}

if (typeof window === "undefined" || typeof document === "undefined") {
Expand Down
97 changes: 97 additions & 0 deletions tests/unit/clipboard-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyDcHtml, copyPlainText } from "../../src/lib/dc/clipboard";

class MockClipboardItem {}

function mockBrowser(copied = true) {
const target = {
value: "",
innerHTML: "",
style: {},
setAttribute: vi.fn<(name: string, value: string) => void>(),
select: vi.fn<() => void>(),
blur: vi.fn<() => void>(),
remove: vi.fn<() => void>(),
};
const write = vi.fn<() => Promise<void>>().mockRejectedValue(new Error("async rejected"));
const writeText = vi.fn<() => Promise<void>>().mockRejectedValue(new Error("async rejected"));
const execCommand = vi.fn<(command: string) => boolean>(() => copied);
vi.stubGlobal("ClipboardItem", MockClipboardItem);
vi.stubGlobal("navigator", { clipboard: { write, writeText } });
vi.stubGlobal("window", {
getSelection: () => ({
removeAllRanges: vi.fn<() => void>(),
addRange: vi.fn<(range: unknown) => void>(),
}),
});
vi.stubGlobal("document", {
createElement: vi.fn<(tagName: string) => typeof target>(() => target),
createRange: () => ({ selectNodeContents: vi.fn<(node: unknown) => void>() }),
body: { append: vi.fn<(node: unknown) => void>() },
execCommand,
});
return { target, write, writeText, execCommand };
}

describe("clipboard fallback after async API failure", () => {
afterEach(() => vi.unstubAllGlobals());

it("tries the browser fallback for rejected HTML writes", async () => {
const { target, execCommand } = mockBrowser();

await copyDcHtml("<p>복사</p>", "복사");

expect(target.innerHTML).toBe("<p>복사</p>");
expect(execCommand).toHaveBeenCalledExactlyOnceWith("copy");
expect(target.remove).toHaveBeenCalledOnce();
});

it("tries the browser fallback for rejected plain text writes", async () => {
const { target, execCommand } = mockBrowser();

await copyPlainText("복사");

expect(target.value).toBe("복사");
expect(execCommand).toHaveBeenCalledExactlyOnceWith("copy");
expect(target.remove).toHaveBeenCalledOnce();
});

it("falls back when ClipboardItem construction rejects the payload", async () => {
const { write, execCommand } = mockBrowser();
vi.stubGlobal(
"ClipboardItem",
class {
constructor() {
throw new Error("unsupported item");
}
},
);

await copyDcHtml("<p>복사</p>", "복사");

expect(write).not.toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledExactlyOnceWith("copy");
});

it("does not duplicate successful async copies", async () => {
const { write, writeText, execCommand } = mockBrowser();
write.mockResolvedValue(undefined);
writeText.mockResolvedValue(undefined);

await copyDcHtml("<p>복사</p>", "복사");
await copyPlainText("복사");

expect(write).toHaveBeenCalledOnce();
expect(writeText).toHaveBeenCalledOnce();
expect(execCommand).not.toHaveBeenCalled();
});

it("still reports failure and cleans up when the fallback is also rejected", async () => {
const { target, execCommand } = mockBrowser(false);

await expect(copyDcHtml("<p>복사</p>", "복사")).rejects.toThrow("Copy command was rejected.");

expect(execCommand).toHaveBeenCalledExactlyOnceWith("copy");
expect(target.remove).toHaveBeenCalledOnce();
});
});