diff --git a/src/components/ShareButtons.test.tsx b/src/components/ShareButtons.test.tsx index 3067c63..7a1c47c 100644 --- a/src/components/ShareButtons.test.tsx +++ b/src/components/ShareButtons.test.tsx @@ -4,6 +4,7 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import ShareButtons from "./ShareButtons"; +import { logger } from "@/lib/logger"; describe("ShareButtons", () => { let originalClipboard: Navigator["clipboard"] | undefined; @@ -11,6 +12,7 @@ describe("ShareButtons", () => { let originalLocation: Location; beforeEach(() => { + vi.spyOn(logger, 'error').mockImplementation(() => {}); originalClipboard = navigator.clipboard; originalExecCommand = document.execCommand; originalLocation = window.location; @@ -152,4 +154,43 @@ describe("ShareButtons", () => { expect(screen.getByText("Copy URL")).toBeDefined(); }); }); + + it("logs an error and does not show 'Copied!' feedback when both copy methods fail", async () => { + // 1. Mock clipboard.writeText to reject + const writeTextMock = vi.fn().mockRejectedValue(new Error("Clipboard API failed")); + Object.assign(navigator, { + clipboard: { + writeText: writeTextMock, + }, + }); + + // 2. Mock execCommand to return false (failure) + const execCommandMock = vi.fn().mockReturnValue(false); + document.execCommand = execCommandMock; + + render(); + + const copyButton = screen.getByRole("button", { name: "Copy profile URL" }); + + fireEvent.click(copyButton); + + await waitFor(() => { + expect(writeTextMock).toHaveBeenCalledWith("http://localhost/johndoe"); + }); + + await waitFor(() => { + expect(execCommandMock).toHaveBeenCalledWith("copy"); + }); + + // Verify logger.error was called + expect(logger.error).toHaveBeenCalledWith( + "Failed to copy", + expect.any(Error), // error from clipboard.writeText + expect.any(Error) // error from execCommand fallback failing + ); + + // Verify button text remains unchanged + expect(screen.getByText("Copy URL")).toBeDefined(); + expect(screen.queryByText("Copied!")).toBeNull(); + }); }); diff --git a/src/components/ShareButtons.tsx b/src/components/ShareButtons.tsx index c326238..c9688bf 100644 --- a/src/components/ShareButtons.tsx +++ b/src/components/ShareButtons.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { logger } from "@/lib/logger"; type Props = { username: string; @@ -28,18 +29,46 @@ export default function ShareButtons({ username }: Props) { }, [username]); const handleCopy = useCallback(async () => { + let clipboardError: unknown = null; + + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(getShareUrl()); + showCopiedFeedback(); + return; + } catch (err) { + clipboardError = err; + } + } else { + clipboardError = new Error("Clipboard API not available"); + } + + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = getShareUrl(); + document.body.appendChild(textArea); + + let successful = false; + let fallbackError: unknown = null; + try { - await navigator.clipboard.writeText(getShareUrl()); - } catch { - // Fallback for older browsers - const textArea = document.createElement("textarea"); - textArea.value = getShareUrl(); - document.body.appendChild(textArea); textArea.select(); - document.execCommand("copy"); + successful = document.execCommand("copy"); + if (!successful) { + fallbackError = new Error("document.execCommand('copy') failed"); + } + } catch (err) { + successful = false; + fallbackError = err; + } finally { document.body.removeChild(textArea); } - showCopiedFeedback(); + + if (successful) { + showCopiedFeedback(); + } else { + logger.error("Failed to copy", clipboardError, fallbackError); + } }, [getShareUrl, showCopiedFeedback]); const handleTwitterShare = useCallback(() => {