Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/components/ShareButtons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
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;
let originalExecCommand: (commandId: string, showUI?: boolean, value?: string) => boolean;
let originalLocation: Location;

beforeEach(() => {
vi.spyOn(logger, 'error').mockImplementation(() => {});
originalClipboard = navigator.clipboard;
originalExecCommand = document.execCommand;
originalLocation = window.location;
Expand Down Expand Up @@ -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(<ShareButtons username="johndoe" />);

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();
});
});
45 changes: 37 additions & 8 deletions src/components/ShareButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { logger } from "@/lib/logger";

type Props = {
username: string;
Expand Down Expand Up @@ -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(() => {
Expand Down
Loading