diff --git a/src/components/AddressDisplay.test.tsx b/src/components/AddressDisplay.test.tsx index 76899cc..a6aa7c9 100644 --- a/src/components/AddressDisplay.test.tsx +++ b/src/components/AddressDisplay.test.tsx @@ -28,7 +28,7 @@ describe("AddressDisplay", () => { expect(copyBtn).not.toHaveAttribute("tabindex", "-1"); await act(async () => { fireEvent.click(copyBtn); }); expect(mockWriteText).toHaveBeenCalledWith(address); - expect(screen.getByRole("button", { name: "Address copied" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument(); await waitFor(() => { expect(screen.getByRole("button", { name: "Copy address" })).toBeInTheDocument(); }, { timeout: 2500 }); @@ -42,6 +42,65 @@ describe("AddressDisplay", () => { }); }); + describe("copy operation & clipboard fallback (#604)", () => { + it("resets aria-label back to 'Copy address' after 2 seconds using fake timers", async () => { + vi.useFakeTimers(); + render(); + const copyBtn = screen.getByRole("button", { name: "Copy address" }); + + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.getByRole("button", { name: "Copy address" })).toBeInTheDocument(); + vi.useRealTimers(); + }); + + it("triggers document.execCommand fallback when navigator.clipboard.writeText rejects", async () => { + mockWriteText.mockRejectedValueOnce(new Error("Clipboard access denied")); + const execCommandSpy = vi.fn(); + const originalExecCommand = document.execCommand; + document.execCommand = execCommandSpy; + + render(); + const copyBtn = screen.getByRole("button", { name: "Copy address" }); + + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(execCommandSpy).toHaveBeenCalledWith("copy"); + expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument(); + + document.execCommand = originalExecCommand; + }); + + it("does not throw an error when clipboard is unavailable and fallback fails", async () => { + mockWriteText.mockRejectedValueOnce(new Error("Clipboard access denied")); + const originalExecCommand = document.execCommand; + document.execCommand = vi.fn().mockImplementation(() => { + throw new Error("execCommand unsupported"); + }); + + render(); + const copyBtn = screen.getByRole("button", { name: "Copy address" }); + + await expect( + act(async () => { + fireEvent.click(copyBtn); + }) + ).resolves.not.toThrow(); + + document.execCommand = originalExecCommand; + }); + }); + describe("showFull prop", () => { it("renders the full address and applies select-all class", () => { render(); diff --git a/src/components/AddressDisplay.tsx b/src/components/AddressDisplay.tsx index 4bb494b..9c62f70 100644 --- a/src/components/AddressDisplay.tsx +++ b/src/components/AddressDisplay.tsx @@ -45,7 +45,17 @@ export function AddressDisplay({ onCopy?.(); setTimeout(() => setCopied(false), 2000); } catch { - /* fallback */ + /* fallback for non-HTTPS or rejected writeText */ + try { + if (typeof document !== "undefined" && typeof document.execCommand === "function") { + document.execCommand("copy"); + } + setCopied(true); + onCopy?.(); + setTimeout(() => setCopied(false), 2000); + } catch { + /* ignore */ + } } } @@ -72,7 +82,7 @@ export function AddressDisplay({