| ... | @@ -14,6 +14,7 @@ describe("writeTextToClipboard", () => { | ... | @@ -14,6 +14,7 @@ describe("writeTextToClipboard", () => { |
| 14 | it("reports unavailable clipboard support with structural context", async () => { | 14 | it("reports unavailable clipboard support with structural context", async () => { |
| 15 | vi.stubGlobal("window", {}); | 15 | vi.stubGlobal("window", {}); |
| 16 | vi.stubGlobal("navigator", {}); | 16 | vi.stubGlobal("navigator", {}); |
| | 17 | vi.stubGlobal("document", undefined); |
| 17 | | 18 | |
| 18 | const error = await writeTextToClipboard("plan contents", "plan").then( | 19 | const error = await writeTextToClipboard("plan contents", "plan").then( |
| 19 | () => undefined, | 20 | () => undefined, |
| ... | @@ -27,6 +28,72 @@ describe("writeTextToClipboard", () => { | ... | @@ -27,6 +28,72 @@ describe("writeTextToClipboard", () => { |
| 27 | expect((error as Error).message).not.toContain("plan contents"); | 28 | expect((error as Error).message).not.toContain("plan contents"); |
| 28 | }); | 29 | }); |
| 29 | | 30 | |
| | 31 | it.each(["success", "denied", "throws"] as const)( |
| | 32 | "cleans up the Clipboard API fallback when copying %s", |
| | 33 | async (result) => { |
| | 34 | const focus = vi.fn(); |
| | 35 | const restoreFocus = vi.fn(); |
| | 36 | const appendChild = vi.fn(); |
| | 37 | const execCommand = vi.fn(() => { |
| | 38 | if (result === "throws") throw new Error("copy command failed"); |
| | 39 | return result === "success"; |
| | 40 | }); |
| | 41 | const remove = vi.fn(); |
| | 42 | const select = vi.fn(); |
| | 43 | const setAttribute = vi.fn(); |
| | 44 | const setSelectionRange = vi.fn(); |
| | 45 | const textarea = { |
| | 46 | focus, |
| | 47 | remove, |
| | 48 | select, |
| | 49 | setAttribute, |
| | 50 | setSelectionRange, |
| | 51 | style: {}, |
| | 52 | value: "", |
| | 53 | }; |
| | 54 | |
| | 55 | vi.stubGlobal("window", {}); |
| | 56 | vi.stubGlobal("navigator", {}); |
| | 57 | vi.stubGlobal("document", { |
| | 58 | activeElement: { focus: restoreFocus }, |
| | 59 | body: { appendChild }, |
| | 60 | createElement: vi.fn(() => textarea), |
| | 61 | execCommand, |
| | 62 | }); |
| | 63 | |
| | 64 | const pendingCopy = writeTextToClipboard("remote command", "command"); |
| | 65 | // The fallback must run during the original user gesture, before any await. |
| | 66 | expect(execCommand).toHaveBeenCalledWith("copy"); |
| | 67 | if (result === "success") { |
| | 68 | await expect(pendingCopy).resolves.toBe(true); |
| | 69 | } else { |
| | 70 | await expect(pendingCopy).rejects.toBeInstanceOf(ClipboardApiUnavailableError); |
| | 71 | } |
| | 72 | |
| | 73 | expect(textarea.value).toBe("remote command"); |
| | 74 | expect(textarea.style).toMatchObject({ fontSize: "16px" }); |
| | 75 | expect(appendChild).toHaveBeenCalledWith(textarea); |
| | 76 | expect(focus).toHaveBeenCalledWith({ preventScroll: true }); |
| | 77 | expect(select).toHaveBeenCalledOnce(); |
| | 78 | expect(setSelectionRange).toHaveBeenCalledWith(0, "remote command".length); |
| | 79 | expect(remove).toHaveBeenCalledOnce(); |
| | 80 | expect(restoreFocus).toHaveBeenCalledOnce(); |
| | 81 | }, |
| | 82 | ); |
| | 83 | |
| | 84 | it("uses the Clipboard API without touching the fallback when it is available", async () => { |
| | 85 | const writeText = vi.fn().mockResolvedValue(undefined); |
| | 86 | const execCommand = vi.fn(); |
| | 87 | vi.stubGlobal("window", {}); |
| | 88 | vi.stubGlobal("navigator", { clipboard: { writeText } }); |
| | 89 | vi.stubGlobal("document", { execCommand }); |
| | 90 | |
| | 91 | await expect(writeTextToClipboard("remote command", "command")).resolves.toBe(true); |
| | 92 | |
| | 93 | expect(writeText).toHaveBeenCalledWith("remote command"); |
| | 94 | expect(execCommand).not.toHaveBeenCalled(); |
| | 95 | }); |
| | 96 | |
| 30 | it("preserves the exact clipboard failure without exposing copied contents", async () => { | 97 | it("preserves the exact clipboard failure without exposing copied contents", async () => { |
| 31 | const cause = new Error("browser clipboard failure"); | 98 | const cause = new Error("browser clipboard failure"); |
| 32 | const writeText = vi.fn().mockRejectedValue(cause); | 99 | const writeText = vi.fn().mockRejectedValue(cause); |
| ... | @@ -47,12 +114,18 @@ describe("writeTextToClipboard", () => { | ... | @@ -47,12 +114,18 @@ describe("writeTextToClipboard", () => { |
| 47 | expect((error as Error).message).not.toContain("secret clipboard contents"); | 114 | expect((error as Error).message).not.toContain("secret clipboard contents"); |
| 48 | }); | 115 | }); |
| 49 | | 116 | |
| 50 | it("keeps empty values as a no-op when clipboard support is available", async () => { | 117 | it.each([true, false])( |
| 51 | const writeText = vi.fn(); | 118 | "keeps empty values as a no-op with Clipboard API support: %s", |
| 52 | vi.stubGlobal("window", {}); | 119 | async (available) => { |
| 53 | vi.stubGlobal("navigator", { clipboard: { writeText } }); | 120 | const writeText = vi.fn(); |
| | 121 | vi.stubGlobal("window", {}); |
| | 122 | const execCommand = vi.fn(); |
| | 123 | vi.stubGlobal("navigator", available ? { clipboard: { writeText } } : {}); |
| | 124 | vi.stubGlobal("document", { execCommand }); |
| 54 | | 125 | |
| 55 | await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); | 126 | await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); |
| 56 | expect(writeText).not.toHaveBeenCalled(); | 127 | expect(writeText).not.toHaveBeenCalled(); |
| 57 | }); | 128 | expect(execCommand).not.toHaveBeenCalled(); |
| | 129 | }, |
| | 130 | ); |
| 58 | }); | 131 | }); |