From 9789d259d8ffa9f7e2552dfafdac050cd8100de3 Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Wed, 23 Sep 2026 03:53:28 +0900 Subject: [PATCH 1/2] fix(clipboard): use browser fallback after rejected async writes --- src/lib/dc/clipboard.ts | 19 ++++-- tests/unit/clipboard-fallback.test.ts | 89 +++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 tests/unit/clipboard-fallback.test.ts diff --git a/src/lib/dc/clipboard.ts b/src/lib/dc/clipboard.ts index 70e05eb..3c68be5 100644 --- a/src/lib/dc/clipboard.ts +++ b/src/lib/dc/clipboard.ts @@ -27,10 +27,13 @@ export async function copyDcHtml(html: string, plainText: string): Promise 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") { @@ -64,8 +67,12 @@ export async function copyDcHtml(html: string, plainText: string): Promise export async function copyPlainText(text: string): Promise { 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") { diff --git a/tests/unit/clipboard-fallback.test.ts b/tests/unit/clipboard-fallback.test.ts new file mode 100644 index 0000000..e338adf --- /dev/null +++ b/tests/unit/clipboard-fallback.test.ts @@ -0,0 +1,89 @@ +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(), + select: vi.fn(), + blur: vi.fn(), + remove: vi.fn(), + }; + const write = vi.fn<() => Promise>().mockRejectedValue(new Error("async rejected")); + const writeText = vi.fn<() => Promise>().mockRejectedValue(new Error("async rejected")); + const execCommand = vi.fn(() => copied); + vi.stubGlobal("ClipboardItem", MockClipboardItem); + vi.stubGlobal("navigator", { clipboard: { write, writeText } }); + vi.stubGlobal("window", { + getSelection: () => ({ removeAllRanges: vi.fn(), addRange: vi.fn() }), + }); + vi.stubGlobal("document", { + createElement: vi.fn(() => target), + createRange: () => ({ selectNodeContents: vi.fn() }), + body: { append: vi.fn() }, + execCommand, + }); + return { target, write, writeText, execCommand }; +} + +describe("clipboard fallback after async API failure", () => { + afterEach(() => vi.unstubAllGlobals()); + + it.each(["html", "text"])("tries the browser fallback for rejected %s writes", async (kind) => { + const { target, execCommand } = mockBrowser(); + + if (kind === "html") { + await copyDcHtml("

복사

", "복사"); + expect(target.innerHTML).toBe("

복사

"); + } else { + 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("

복사

", "복사"); + + 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("

복사

", "복사"); + 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("

복사

", "복사")).rejects.toThrow("Copy command was rejected."); + + expect(execCommand).toHaveBeenCalledExactlyOnceWith("copy"); + expect(target.remove).toHaveBeenCalledOnce(); + }); +}); From 3baf11f4d258af62422e92b6a5bc360781b10651 Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Wed, 23 Sep 2026 04:00:38 +0900 Subject: [PATCH 2/2] test(clipboard): use typed mocks and unconditional assertions --- tests/unit/clipboard-fallback.test.ts | 42 ++++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/tests/unit/clipboard-fallback.test.ts b/tests/unit/clipboard-fallback.test.ts index e338adf..59c4b79 100644 --- a/tests/unit/clipboard-fallback.test.ts +++ b/tests/unit/clipboard-fallback.test.ts @@ -8,23 +8,26 @@ function mockBrowser(copied = true) { value: "", innerHTML: "", style: {}, - setAttribute: vi.fn(), - select: vi.fn(), - blur: vi.fn(), - remove: vi.fn(), + 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>().mockRejectedValue(new Error("async rejected")); const writeText = vi.fn<() => Promise>().mockRejectedValue(new Error("async rejected")); - const execCommand = vi.fn(() => copied); + 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(), addRange: vi.fn() }), + getSelection: () => ({ + removeAllRanges: vi.fn<() => void>(), + addRange: vi.fn<(range: unknown) => void>(), + }), }); vi.stubGlobal("document", { - createElement: vi.fn(() => target), - createRange: () => ({ selectNodeContents: vi.fn() }), - body: { append: vi.fn() }, + 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 }; @@ -33,17 +36,22 @@ function mockBrowser(copied = true) { describe("clipboard fallback after async API failure", () => { afterEach(() => vi.unstubAllGlobals()); - it.each(["html", "text"])("tries the browser fallback for rejected %s writes", async (kind) => { + it("tries the browser fallback for rejected HTML writes", async () => { const { target, execCommand } = mockBrowser(); - if (kind === "html") { - await copyDcHtml("

복사

", "복사"); - expect(target.innerHTML).toBe("

복사

"); - } else { - await copyPlainText("복사"); - expect(target.value).toBe("복사"); - } + await copyDcHtml("

복사

", "복사"); + + expect(target.innerHTML).toBe("

복사

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