From c12f53a591251ddf53abf2e01c58e15b0932aee3 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Wed, 15 Jul 2026 11:26:16 +0530 Subject: [PATCH 1/2] test: cover CopyButton clipboard states --- web/src/components/copy-button.test.tsx | 160 ++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 web/src/components/copy-button.test.tsx diff --git a/web/src/components/copy-button.test.tsx b/web/src/components/copy-button.test.tsx new file mode 100644 index 00000000..f6af89c1 --- /dev/null +++ b/web/src/components/copy-button.test.tsx @@ -0,0 +1,160 @@ +import "../test/setup-dom"; + +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { CopyButton } from "./copy-button"; +import { browserAnalytics } from "@/lib/browser-analytics"; +import { AnalyticsEvent } from "@/lib/analytics-events"; + +const originalCapture = browserAnalytics.capture; +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); +const originalSetTimeout = window.setTimeout; +const originalClearTimeout = window.clearTimeout; + +let nextTimerId = 1; +let pendingTimers = new Map void>(); + +function installDeterministicTimers(): void { + nextTimerId = 1; + pendingTimers = new Map(); + window.setTimeout = ((callback: TimerHandler) => { + const id = nextTimerId++; + pendingTimers.set(id, callback as () => void); + return id; + }) as unknown as typeof window.setTimeout; + window.clearTimeout = ((id: number) => { + pendingTimers.delete(id); + }) as typeof window.clearTimeout; +} + +function flushPendingTimer(): void { + const [id, callback] = pendingTimers.entries().next().value ?? []; + if (id !== undefined && callback) { + pendingTimers.delete(id); + callback(); + } +} + +function installClipboard(writeText = mock(async (_value: string) => undefined)) { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + return writeText; +} + +beforeEach(() => { + installDeterministicTimers(); + installClipboard(); + browserAnalytics.capture = mock(); +}); + +afterEach(() => { + cleanup(); + window.setTimeout = originalSetTimeout; + window.clearTimeout = originalClearTimeout; + browserAnalytics.capture = originalCapture; + pendingTimers.clear(); + + if (originalClipboard) { + Object.defineProperty(navigator, "clipboard", originalClipboard); + } else { + delete (navigator as unknown as { clipboard?: unknown }).clipboard; + } +}); + +describe("CopyButton", () => { + it("synthesizes a bounded accessible label and preserves custom props", () => { + const longValue = "abcdefghijklmnopqrstuvwxyz0123456789"; + const { getByRole } = render( + , + ); + const button = getByRole("button"); + + expect(button.getAttribute("aria-label")).toBe("Copy code abcdefghijklmnopqrstuvwx"); + expect(button.className).toContain("custom-class"); + + cleanup(); + const custom = render( + , + ).getByRole("button"); + expect(custom.getAttribute("aria-label")).toBe("Copy secret value"); + }); + + it("copies the value, shows the configured label, and emits analytics once", async () => { + const writeText = installClipboard(); + const { getByRole } = render( + , + ); + const button = getByRole("button"); + + fireEvent.click(button); + await waitFor(() => expect(button.textContent).toContain("Copied!")); + + expect(writeText).toHaveBeenCalledWith("hello"); + expect(browserAnalytics.capture).toHaveBeenCalledTimes(1); + expect(browserAnalytics.capture).toHaveBeenCalledWith(AnalyticsEvent.SummaryCopied, { + source: "test", + }); + }); + + it("returns to the normal label after the scheduled reset", async () => { + const { getByRole } = render(); + const button = getByRole("button"); + + fireEvent.click(button); + await waitFor(() => expect(button.textContent).toContain("Copied")); + expect(pendingTimers.size).toBe(1); + + act(() => flushPendingTimer()); + expect(button.textContent).toContain("Copy"); + }); + + it("replaces the pending reset on repeated successful clicks", async () => { + const { getByRole } = render(); + const button = getByRole("button"); + + fireEvent.click(button); + await waitFor(() => expect(button.textContent).toContain("Copied")); + const firstTimer = [...pendingTimers.keys()][0]; + + fireEvent.click(button); + await waitFor(() => expect(button.textContent).toContain("Copied")); + + expect(pendingTimers.size).toBe(1); + expect(pendingTimers.has(firstTimer)).toBe(false); + }); + + it("keeps the normal label and skips analytics when copying fails", async () => { + const writeText = installClipboard(mock(async () => Promise.reject(new Error("blocked")))); + const { getByRole } = render( + , + ); + const button = getByRole("button"); + + fireEvent.click(button); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("hello")); + + expect(button.textContent).toContain("Copy"); + expect(browserAnalytics.capture).not.toHaveBeenCalled(); + expect(pendingTimers.size).toBe(0); + }); + + it("clears a pending reset when unmounted", async () => { + const { getByRole, unmount } = render(); + const button = getByRole("button"); + + fireEvent.click(button); + await waitFor(() => expect(button.textContent).toContain("Copied")); + expect(pendingTimers.size).toBe(1); + + unmount(); + expect(pendingTimers.size).toBe(0); + }); +}); From 028dcc3926c69f6d079670651558acb9fab0bec1 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Fri, 17 Jul 2026 13:05:32 +0530 Subject: [PATCH 2/2] test: assert copy reset delay --- web/src/components/copy-button.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/components/copy-button.test.tsx b/web/src/components/copy-button.test.tsx index f6af89c1..43c0b9ef 100644 --- a/web/src/components/copy-button.test.tsx +++ b/web/src/components/copy-button.test.tsx @@ -13,17 +13,21 @@ const originalClearTimeout = window.clearTimeout; let nextTimerId = 1; let pendingTimers = new Map void>(); +let pendingTimerDelays = new Map(); function installDeterministicTimers(): void { nextTimerId = 1; pendingTimers = new Map(); - window.setTimeout = ((callback: TimerHandler) => { + pendingTimerDelays = new Map(); + window.setTimeout = ((callback: TimerHandler, delay?: number) => { const id = nextTimerId++; pendingTimers.set(id, callback as () => void); + pendingTimerDelays.set(id, delay ?? 0); return id; }) as unknown as typeof window.setTimeout; window.clearTimeout = ((id: number) => { pendingTimers.delete(id); + pendingTimerDelays.delete(id); }) as typeof window.clearTimeout; } @@ -55,6 +59,7 @@ afterEach(() => { window.clearTimeout = originalClearTimeout; browserAnalytics.capture = originalCapture; pendingTimers.clear(); + pendingTimerDelays.clear(); if (originalClipboard) { Object.defineProperty(navigator, "clipboard", originalClipboard); @@ -111,6 +116,7 @@ describe("CopyButton", () => { fireEvent.click(button); await waitFor(() => expect(button.textContent).toContain("Copied")); expect(pendingTimers.size).toBe(1); + expect(pendingTimerDelays.get([...pendingTimers.keys()][0])).toBe(1600); act(() => flushPendingTimer()); expect(button.textContent).toContain("Copy");