diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts index ccac333cd488..ec6b5566bea5 100644 --- a/apps/web/src/hooks/useCopyToClipboard.test.ts +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -14,6 +14,7 @@ describe("writeTextToClipboard", () => { it("reports unavailable clipboard support with structural context", async () => { vi.stubGlobal("window", {}); vi.stubGlobal("navigator", {}); + vi.stubGlobal("document", undefined); const error = await writeTextToClipboard("plan contents", "plan").then( () => undefined, @@ -27,6 +28,47 @@ describe("writeTextToClipboard", () => { expect((error as Error).message).not.toContain("plan contents"); }); + it("falls back to a selected textarea when the Clipboard API is unavailable", async () => { + const focus = vi.fn(); + const restoreFocus = vi.fn(); + const appendChild = vi.fn(); + const execCommand = vi.fn(() => true); + const remove = vi.fn(); + const select = vi.fn(); + const setAttribute = vi.fn(); + const setSelectionRange = vi.fn(); + const textarea = { + focus, + remove, + select, + setAttribute, + setSelectionRange, + style: {}, + value: "", + }; + + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", {}); + vi.stubGlobal("document", { + activeElement: { focus: restoreFocus }, + body: { appendChild }, + createElement: vi.fn(() => textarea), + execCommand, + }); + + await expect(writeTextToClipboard("remote command", "command")).resolves.toBe(true); + + expect(textarea.value).toBe("remote command"); + expect(textarea.style).toMatchObject({ fontSize: "16px" }); + expect(appendChild).toHaveBeenCalledWith(textarea); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + expect(select).toHaveBeenCalledOnce(); + expect(setSelectionRange).toHaveBeenCalledWith(0, "remote command".length); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(remove).toHaveBeenCalledOnce(); + expect(restoreFocus).toHaveBeenCalledOnce(); + }); + it("preserves the exact clipboard failure without exposing copied contents", async () => { const cause = new Error("browser clipboard failure"); const writeText = vi.fn().mockRejectedValue(cause); diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index ef66410f7db4..8723aaeb941a 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -47,12 +47,40 @@ export class ClipboardReadError extends Schema.TaggedErrorClass