Skip to content
Open
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
152 changes: 8 additions & 144 deletions src/components/ShareButtons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@ 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;

Object.defineProperty(window, "location", {
Expand All @@ -30,69 +28,13 @@ describe("ShareButtons", () => {
vi.useRealTimers();
vi.restoreAllMocks();
Object.assign(navigator, { clipboard: originalClipboard });
document.execCommand = originalExecCommand;

Object.defineProperty(window, "location", {
value: originalLocation,
writable: true,
});
});

it("uses document.execCommand as fallback when navigator.clipboard.writeText fails", async () => {
// 1. Mock clipboard.writeText to reject
const writeTextMock = vi.fn().mockRejectedValue(new Error("Not allowed"));
Object.assign(navigator, {
clipboard: {
writeText: writeTextMock,
},
});

// 2. Mock execCommand
const execCommandMock = vi.fn().mockReturnValue(true);
document.execCommand = execCommandMock;

// 3. Spy on document.createElement, document.body.appendChild, and document.body.removeChild
// to verify the full fallback flow
const createElementSpy = vi.spyOn(document, "createElement");
const appendChildSpy = vi.spyOn(document.body, "appendChild");
const removeChildSpy = vi.spyOn(document.body, "removeChild");

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(createElementSpy).toHaveBeenCalledWith("textarea");

// Find the appendChild call that appends the textarea (since React might also call appendChild)
const textareaAppendCall = appendChildSpy.mock.calls.find(
(call) => (call[0] as HTMLElement).tagName === "TEXTAREA"
);

expect(textareaAppendCall).toBeDefined();
if (textareaAppendCall) {
const appendedNode = textareaAppendCall[0] as HTMLTextAreaElement;
expect(appendedNode.value).toBe("http://localhost/johndoe");

expect(execCommandMock).toHaveBeenCalledWith("copy");

// Verify removeChild was called with the same element
expect(removeChildSpy).toHaveBeenCalledWith(appendedNode);
}
});

// Clear out React's state updates
await act(async () => {
vi.advanceTimersByTime(2500);
});
});

it("uses navigator.clipboard.writeText when available and successful", async () => {
// Mock clipboard.writeText to succeed
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Expand All @@ -102,9 +44,6 @@ describe("ShareButtons", () => {
},
});

const execCommandMock = vi.fn().mockReturnValue(true);
document.execCommand = execCommandMock;

render(<ShareButtons username="johndoe" />);

const copyButton = screen.getByRole("button", { name: "Copy profile URL" });
Expand All @@ -115,9 +54,6 @@ describe("ShareButtons", () => {
expect(writeTextMock).toHaveBeenCalledWith("http://localhost/johndoe");
});

// Fallback should not be triggered
expect(execCommandMock).not.toHaveBeenCalled();

// Clear out React's state updates
await act(async () => {
vi.advanceTimersByTime(2500);
Expand Down Expand Up @@ -155,7 +91,7 @@ describe("ShareButtons", () => {
});
});

it("logs an error and does not show 'Copied!' feedback when both copy methods fail", async () => {
it("logs an error and does not show 'Copied!' feedback when copy fails", async () => {
// 1. Mock clipboard.writeText to reject
const writeTextMock = vi.fn().mockRejectedValue(new Error("Clipboard API failed"));
Object.assign(navigator, {
Expand All @@ -164,10 +100,6 @@ describe("ShareButtons", () => {
},
});

// 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" });
Expand All @@ -178,15 +110,10 @@ describe("ShareButtons", () => {
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
expect.any(Error) // error from clipboard.writeText
);

// Verify button text remains unchanged
Expand All @@ -209,86 +136,23 @@ describe("ShareButtons", () => {
};
};

it("uses document.execCommand as fallback when navigator.clipboard is undefined", async () => {
it("logs an error and does not show 'Copied!' feedback when clipboard is undefined", async () => {
const restoreClipboard = setupUndefinedClipboard();

// 2. Mock execCommand
const execCommandMock = vi.fn().mockReturnValue(true);
document.execCommand = execCommandMock;

render(<ShareButtons username="johndoe" />);

const copyButton = screen.getByRole("button", { name: "Copy profile URL" });

fireEvent.click(copyButton);

await waitFor(() => {
expect(execCommandMock).toHaveBeenCalledWith("copy");
});

// Check for success feedback
await waitFor(() => {
expect(screen.getByText("Copied!")).toBeDefined();
});

// Clear out React's state updates
await act(async () => {
vi.advanceTimersByTime(2500);
});

restoreClipboard();
});

it("logs an error and does not show 'Copied!' feedback when both copy methods fail and clipboard is undefined", async () => {
const restoreClipboard = setupUndefinedClipboard();

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(execCommandMock).toHaveBeenCalledWith("copy");
});

expect(logger.error).toHaveBeenCalledWith(
"Failed to copy",
expect.any(Error),
expect.any(Error)
);

restoreClipboard();
});

it("uses document.execCommand as fallback and catches its error", async () => {
const restoreClipboard = setupUndefinedClipboard();

const execCommandMock = vi.fn().mockImplementation(() => {
throw new Error("execCommand crashed");
});
document.execCommand = execCommandMock;

render(<ShareButtons username="johndoe" />);

const copyButton = screen.getByRole("button", { name: "Copy profile URL" });

fireEvent.click(copyButton);

await waitFor(() => {
expect(execCommandMock).toHaveBeenCalledWith("copy");
expect(logger.error).toHaveBeenCalledWith(
"Failed to copy",
expect.any(Error)
);
});

expect(logger.error).toHaveBeenCalledWith(
"Failed to copy",
expect.any(Error), // clipboard API missing error
expect.any(Error) // execCommand crash error
);

restoreClipboard();
});

});
});
94 changes: 7 additions & 87 deletions src/hooks/__tests__/useCopyToClipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,12 @@ vi.mock("@/lib/logger", () => ({

describe("useCopyToClipboard", () => {
let originalClipboard: Clipboard | undefined;
let originalExecCommand: typeof document.execCommand;

beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();

originalClipboard = navigator.clipboard;
originalExecCommand = document.execCommand;

// Mock clipboard
Object.defineProperty(navigator, "clipboard", {
Expand All @@ -29,9 +27,6 @@ describe("useCopyToClipboard", () => {
},
configurable: true,
});

// Mock document.execCommand
document.execCommand = vi.fn();
});

afterEach(() => {
Expand All @@ -45,7 +40,6 @@ describe("useCopyToClipboard", () => {
configurable: true,
});
}
document.execCommand = originalExecCommand;
vi.restoreAllMocks();
});

Expand All @@ -62,50 +56,6 @@ describe("useCopyToClipboard", () => {

expect(navigator.clipboard.writeText).toHaveBeenCalledWith("test text");
expect(result.current.copied).toBe(true);
expect(document.execCommand).not.toHaveBeenCalled();

act(() => {
vi.advanceTimersByTime(2000);
});

expect(result.current.copied).toBe(false);
});

it("should use fallback if navigator.clipboard is not available", async () => {
// Remove clipboard
// @ts-expect-error test setup
delete navigator.clipboard;
vi.mocked(document.execCommand).mockReturnValue(true);

const { result } = renderHook(() => useCopyToClipboard());

await act(async () => {
await result.current.copyToClipboard("fallback text");
});

expect(document.execCommand).toHaveBeenCalledWith("copy");
expect(result.current.copied).toBe(true);

act(() => {
vi.advanceTimersByTime(2000);
});

expect(result.current.copied).toBe(false);
});

it("should use fallback if navigator.clipboard.writeText fails", async () => {
vi.mocked(navigator.clipboard.writeText).mockRejectedValue(new Error("Clipboard error"));
vi.mocked(document.execCommand).mockReturnValue(true);

const { result } = renderHook(() => useCopyToClipboard());

await act(async () => {
await result.current.copyToClipboard("fallback text");
});

expect(navigator.clipboard.writeText).toHaveBeenCalledWith("fallback text");
expect(document.execCommand).toHaveBeenCalledWith("copy");
expect(result.current.copied).toBe(true);

act(() => {
vi.advanceTimersByTime(2000);
Expand All @@ -114,54 +64,25 @@ describe("useCopyToClipboard", () => {
expect(result.current.copied).toBe(false);
});

it("should log error if both clipboard and fallback fail", async () => {
vi.mocked(navigator.clipboard.writeText).mockRejectedValue(new Error("Clipboard error"));
vi.mocked(document.execCommand).mockReturnValue(false);

const { result } = renderHook(() => useCopyToClipboard());

await act(async () => {
await result.current.copyToClipboard("failed text");
});

expect(result.current.copied).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
"Failed to copy",
expect.any(Error),
expect.any(Error)
);
});


it("should log error if both clipboard.writeText and fallback throw errors", async () => {
const writeError = new Error("Clipboard write error");
const execError = new Error("execCommand thrown error");

vi.mocked(navigator.clipboard.writeText).mockRejectedValue(writeError);
vi.mocked(document.execCommand).mockImplementation(() => {
throw execError;
});
it("should log error if navigator.clipboard.writeText fails", async () => {
const error = new Error("Clipboard error");
vi.mocked(navigator.clipboard.writeText).mockRejectedValue(error);

const { result } = renderHook(() => useCopyToClipboard());

await act(async () => {
await result.current.copyToClipboard("failed text");
});

expect(navigator.clipboard.writeText).toHaveBeenCalledWith("failed text");
expect(result.current.copied).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
"Failed to copy",
writeError,
execError
);
expect(logger.error).toHaveBeenCalledWith("Failed to copy", error);
});

it("should log error if fallback throws an error", async () => {
it("should log error if navigator.clipboard is not available", async () => {
// Remove clipboard
// @ts-expect-error test setup
delete navigator.clipboard;
vi.mocked(document.execCommand).mockImplementation(() => {
throw new Error("execCommand thrown error");
});

const { result } = renderHook(() => useCopyToClipboard());

Expand All @@ -172,7 +93,6 @@ describe("useCopyToClipboard", () => {
expect(result.current.copied).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
"Failed to copy",
expect.any(Error),
expect.any(Error)
);
});
Expand Down
Loading
Loading