1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { COMPOSER_CONTEXT_CLIPBOARD_MIME } from "@t3tools/shared/composerContextClipboard";
4
5import {
6 ClipboardApiUnavailableError,
7 ClipboardWriteError,
8 writeTextToClipboard,
9} from "./useCopyToClipboard";
10
11describe("writeTextToClipboard", () => {
12 it("reserves plain text even when an extra flavor attempts to replace it", async () => {
13 const write = vi.fn().mockResolvedValue(undefined);
14 vi.stubGlobal("window", {});
15 vi.stubGlobal("navigator", { clipboard: { write, writeText: vi.fn() } });
16 vi.stubGlobal(
17 "ClipboardItem",
18 class {
19 constructor(readonly data: Record<string, Blob>) {}
20 },
21 );
22 await expect(
23 writeTextToClipboard("original", "message", {
24 "text/plain": "replacement",
25 "text/html": "<b>original</b>",
26 }),
27 ).resolves.toBe(true);
28 const items = write.mock.calls[0]![0] as Array<{ data: Record<string, Blob> }>;
29 expect(await items[0]!.data["text/plain"]!.text()).toBe("original");
30 });
31 afterEach(() => {
32 vi.unstubAllGlobals();
33 });
34
35 it("keeps caller-built rich HTML when attaching a context fragment", async () => {
36 const write = vi.fn().mockResolvedValue(undefined);
37 vi.stubGlobal("window", {});
38 vi.stubGlobal("navigator", { clipboard: { write, writeText: vi.fn() } });
39 vi.stubGlobal(
40 "ClipboardItem",
41 class {
42 constructor(readonly data: Record<string, Blob>) {}
43 },
44 );
45 const fragment = JSON.stringify({ version: 1, source: {}, records: [] });
46 const rich = "<p><strong>Message</strong></p>";
47
48 await expect(
49 writeTextToClipboard("Message", "message", {
50 [COMPOSER_CONTEXT_CLIPBOARD_MIME]: fragment,
51 "text/html": rich,
52 }),
53 ).resolves.toBe(true);
54
55 const items = write.mock.calls[0]![0] as Array<{ data: Record<string, Blob> }>;
56 const html = await items[0]!.data["text/html"]!.text();
57 expect(html).toContain(rich);
58 expect(html).toContain("data-t3-context-fragment=");
59 });
60
61 it("reports unavailable clipboard support with structural context", async () => {
62 vi.stubGlobal("window", {});
63 vi.stubGlobal("navigator", {});
64 vi.stubGlobal("document", undefined);
65
66 const error = await writeTextToClipboard("plan contents", "plan").then(
67 () => undefined,
68 (cause: unknown) => cause,
69 );
70
71 expect(error).toBeInstanceOf(ClipboardApiUnavailableError);
72 expect(error).toMatchObject({
73 target: "plan",
74 });
75 expect((error as Error).message).not.toContain("plan contents");
76 });
77
78 it.each(["success", "denied", "throws"] as const)(
79 "cleans up the Clipboard API fallback when copying %s",
80 async (result) => {
81 const focus = vi.fn();
82 const restoreFocus = vi.fn();
83 const appendChild = vi.fn();
84 const execCommand = vi.fn(() => {
85 if (result === "throws") throw new Error("copy command failed");
86 return result === "success";
87 });
88 const remove = vi.fn();
89 const select = vi.fn();
90 const setAttribute = vi.fn();
91 const setSelectionRange = vi.fn();
92 const textarea = {
93 addEventListener: vi.fn(),
94 removeEventListener: vi.fn(),
95 focus,
96 remove,
97 select,
98 setAttribute,
99 setSelectionRange,
100 style: {},
101 value: "",
102 };
103
104 vi.stubGlobal("window", {});
105 vi.stubGlobal("navigator", {});
106 vi.stubGlobal("document", {
107 activeElement: { focus: restoreFocus },
108 body: { appendChild },
109 createElement: vi.fn(() => textarea),
110 execCommand,
111 });
112
113 const pendingCopy = writeTextToClipboard("remote command", "command");
114 // The fallback must run during the original user gesture, before any await.
115 expect(execCommand).toHaveBeenCalledWith("copy");
116 if (result === "success") {
117 await expect(pendingCopy).resolves.toBe(true);
118 } else {
119 await expect(pendingCopy).rejects.toBeInstanceOf(ClipboardApiUnavailableError);
120 }
121
122 expect(textarea.value).toBe("remote command");
123 expect(textarea.style).toMatchObject({ fontSize: "16px" });
124 expect(appendChild).toHaveBeenCalledWith(textarea);
125 expect(focus).toHaveBeenCalledWith({ preventScroll: true });
126 expect(select).toHaveBeenCalledOnce();
127 expect(setSelectionRange).toHaveBeenCalledWith(0, "remote command".length);
128 expect(remove).toHaveBeenCalledOnce();
129 expect(restoreFocus).toHaveBeenCalledOnce();
130 },
131 );
132
133 it("uses the Clipboard API without touching the fallback when it is available", async () => {
134 const writeText = vi.fn().mockResolvedValue(undefined);
135 const execCommand = vi.fn();
136 vi.stubGlobal("window", {});
137 vi.stubGlobal("navigator", { clipboard: { writeText } });
138 vi.stubGlobal("document", { execCommand });
139
140 await expect(writeTextToClipboard("remote command", "command")).resolves.toBe(true);
141
142 expect(writeText).toHaveBeenCalledWith("remote command");
143 expect(execCommand).not.toHaveBeenCalled();
144 });
145
146 it("preserves the exact clipboard failure without exposing copied contents", async () => {
147 const cause = new Error("browser clipboard failure");
148 const writeText = vi.fn().mockRejectedValue(cause);
149 vi.stubGlobal("window", {});
150 vi.stubGlobal("navigator", { clipboard: { writeText } });
151
152 const error = await writeTextToClipboard("secret clipboard contents", "error-message").then(
153 () => undefined,
154 (failure: unknown) => failure,
155 );
156
157 expect(writeText).toHaveBeenCalledWith("secret clipboard contents");
158 expect(error).toBeInstanceOf(ClipboardWriteError);
159 expect(error).toMatchObject({
160 target: "error-message",
161 cause,
162 });
163 expect((error as Error).message).not.toContain("secret clipboard contents");
164 });
165
166 it.each([true, false])(
167 "keeps empty values as a no-op with Clipboard API support: %s",
168 async (available) => {
169 const writeText = vi.fn();
170 vi.stubGlobal("window", {});
171 const execCommand = vi.fn();
172 vi.stubGlobal("navigator", available ? { clipboard: { writeText } } : {});
173 vi.stubGlobal("document", { execCommand });
174
175 await expect(writeTextToClipboard("", "plan")).resolves.toBe(false);
176 expect(writeText).not.toHaveBeenCalled();
177 expect(execCommand).not.toHaveBeenCalled();
178 },
179 );
180});