import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; import { CopyLinkButton } from "../../src/components/CopyLinkButton"; const writeText = vi.fn(); beforeEach(() => { Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true, }); writeText.mockClear(); }); afterEach(() => { window.history.replaceState({}, "", "/"); }); describe("CopyLinkButton", () => { it("shows the default 'Copy link' label", () => { render(); expect(screen.getByRole("button")).toHaveTextContent("Copy link"); }); it("copies a URL carrying the given params on click", () => { render(); fireEvent.click(screen.getByRole("button")); expect(writeText).toHaveBeenCalledTimes(1); const copied = new URL(writeText.mock.calls[0][0]); expect(copied.searchParams.get("tab")).toBe("Nodes"); expect(copied.searchParams.get("node")).toBe("abc123"); }); it("swaps to 'Copied' after clicking, then reverts", async () => { vi.useFakeTimers(); try { render(); const button = screen.getByRole("button"); fireEvent.click(button); await act(async () => {}); expect(button).toHaveTextContent("Copied"); act(() => { vi.advanceTimersByTime(1500); }); expect(button).toHaveTextContent("Copy link"); } finally { vi.useRealTimers(); } }); it("uses the provided aria-label for the accessible name", () => { render(); expect(screen.getByRole("button", { name: "Copy node link" })).toBeInTheDocument(); }); it("evaluates a params thunk at click time, capturing live values", () => { let zoom = "5"; render( ({ tab: "Map", zoom })} />); zoom = "11"; // changes after render, before the click fireEvent.click(screen.getByRole("button")); const copied = new URL(writeText.mock.calls[0][0]); expect(copied.searchParams.get("tab")).toBe("Map"); expect(copied.searchParams.get("zoom")).toBe("11"); }); it("deletes params whose value is null, preserving unrelated ones", () => { window.history.replaceState({}, "", "/?node_type=repeater&keep=1"); render( ({ tab: "Map", node_type: null })} />); fireEvent.click(screen.getByRole("button")); const copied = new URL(writeText.mock.calls[0][0]); expect(copied.searchParams.has("node_type")).toBe(false); expect(copied.searchParams.get("keep")).toBe("1"); expect(copied.searchParams.get("tab")).toBe("Map"); }); it("doesn't claim success when the clipboard API is missing (plain http)", async () => { Object.defineProperty(navigator, "clipboard", { value: undefined, writable: true, configurable: true }); render(); const button = screen.getByRole("button"); fireEvent.click(button); await act(async () => {}); expect(button).not.toHaveTextContent("Copied"); expect(button).toHaveTextContent("Copy failed"); }); it("shows 'Copied' only once the write resolves, and a failure when it rejects", async () => { let reject!: (e: Error) => void; writeText.mockReturnValueOnce(new Promise((_, r) => (reject = r))); render(); const button = screen.getByRole("button"); fireEvent.click(button); expect(button).not.toHaveTextContent("Copied"); await act(async () => reject(new Error("denied"))); expect(button).toHaveTextContent("Copy failed"); }); }); it("retains only an explicit allowlist when building a focused investigation link", () => { window.history.replaceState({}, "", "/?regions=ontario&futureFilter=wrong&observer=old&tab=Routes"); render(); fireEvent.click(screen.getByRole("button")); const copied = new URL(writeText.mock.calls[0][0]); expect(Object.fromEntries(copied.searchParams)).toEqual({ regions: "ontario", tab: "Packets", hash: "abc", path: "all" }); });