1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({
4 impactAsync: vi.fn(),
5 selectionAsync: vi.fn(),
6 setStringAsync: vi.fn(),
7}));
8
9vi.mock("expo-clipboard", () => ({
10 setStringAsync: mocks.setStringAsync,
11}));
12
13vi.mock("expo-haptics", () => ({
14 ImpactFeedbackStyle: {
15 Light: "light",
16 },
17 impactAsync: mocks.impactAsync,
18 selectionAsync: mocks.selectionAsync,
19}));
20
21import { copyTextWithHaptic, tryCopyTextWithHaptic } from "./copyTextWithHaptic";
22
23describe("copyTextWithHaptic", () => {
24 beforeEach(() => {
25 vi.clearAllMocks();
26 mocks.setStringAsync.mockReturnValue(new Promise<void>(() => undefined));
27 mocks.impactAsync.mockResolvedValue(undefined);
28 mocks.selectionAsync.mockResolvedValue(undefined);
29 });
30
31 afterEach(() => {
32 vi.restoreAllMocks();
33 });
34
35 it("triggers haptic feedback without waiting for the clipboard promise", () => {
36 copyTextWithHaptic("trace-123");
37
38 expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123");
39 expect(mocks.impactAsync).toHaveBeenCalledWith("light");
40 });
41
42 it("preserves selection feedback for thread work rows", () => {
43 copyTextWithHaptic("work output", {
44 target: "thread-work-row",
45 feedback: "selection",
46 });
47
48 expect(mocks.setStringAsync).toHaveBeenCalledWith("work output");
49 expect(mocks.selectionAsync).toHaveBeenCalledOnce();
50 expect(mocks.impactAsync).not.toHaveBeenCalled();
51 });
52
53 it("reports whether the clipboard write succeeded", async () => {
54 mocks.setStringAsync.mockResolvedValueOnce(undefined);
55
56 await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(true);
57 });
58
59 it("returns false when the clipboard write fails", async () => {
60 vi.spyOn(console, "error").mockImplementation(() => undefined);
61 mocks.setStringAsync.mockRejectedValueOnce(new Error("native clipboard failure"));
62
63 await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(false);
64 });
65
66 it("reports structured failures without including clipboard contents", async () => {
67 const content = "https://accounts.google.com/auth?state=private-state&code=private-code";
68 const clipboardCause = new Error(`Cannot copy ${content}`);
69 const hapticCause = new Error(`Native failure for ${content}`);
70 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
71 mocks.setStringAsync.mockRejectedValueOnce(clipboardCause);
72 mocks.impactAsync.mockRejectedValueOnce(hapticCause);
73
74 await tryCopyTextWithHaptic(content, { target: "provider-sign-in-link" });
75
76 expect(consoleError).toHaveBeenCalledWith(
77 "Failed to copy provider-sign-in-link to the clipboard.",
78 expect.objectContaining({
79 _tag: "CopyTextClipboardWriteError",
80 target: "provider-sign-in-link",
81 }),
82 );
83 expect(consoleError).toHaveBeenCalledWith(
84 "Failed to trigger light-impact haptic feedback after copying provider-sign-in-link.",
85 expect.objectContaining({ _tag: "CopyTextHapticFeedbackError", feedback: "light-impact" }),
86 );
87 const diagnostics = JSON.stringify(consoleError.mock.calls);
88 expect(diagnostics).not.toContain("private-state");
89 expect(diagnostics).not.toContain("private-code");
90 expect(diagnostics).not.toContain("cause");
91 });
92});