1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 resolveExternalWebLinkHost,
5 resolveExternalWebLinkHref,
6 showExternalLinkContextMenu,
7} from "./externalLinkContextMenu";
8
9function createHarness(
10 selection:
11 | "open-in-preview"
12 | "open-external"
13 | "copy-link"
14 | "link-to-thread"
15 | "unlink-from-thread"
16 | null,
17) {
18 const showContextMenu = vi.fn().mockResolvedValue(selection);
19 const openInPreview = vi.fn().mockResolvedValue(undefined);
20 const openExternal = vi.fn().mockResolvedValue(undefined);
21 const copyLink = vi.fn().mockResolvedValue(undefined);
22 const updateThreadLink = vi.fn().mockResolvedValue(undefined);
23 const reportFailure = vi.fn();
24
25 return {
26 showContextMenu,
27 openInPreview,
28 openExternal,
29 copyLink,
30 updateThreadLink,
31 reportFailure,
32 };
33}
34
35describe("external chat link context menu", () => {
36 it("offers both open actions and Copy Link", async () => {
37 const harness = createHarness(null);
38
39 await showExternalLinkContextMenu({
40 href: "https://example.com/docs?topic=menus#copy",
41 position: { x: 12, y: 24 },
42 ...harness,
43 });
44
45 expect(harness.showContextMenu).toHaveBeenCalledWith(
46 [
47 { id: "open-in-preview", label: "Open in integrated browser" },
48 { id: "open-external", label: "Open in system browser" },
49 { id: "copy-link", label: "Copy Link" },
50 ],
51 { x: 12, y: 24 },
52 );
53 expect(harness.openInPreview).not.toHaveBeenCalled();
54 expect(harness.openExternal).not.toHaveBeenCalled();
55 expect(harness.copyLink).not.toHaveBeenCalled();
56 });
57
58 it("still offers the link's own actions where the integrated browser cannot be opened", async () => {
59 const harness = createHarness(null);
60
61 await showExternalLinkContextMenu({
62 href: "https://github.com/pingdotgg/t3code/pull/6169",
63 canOpenInPreview: false,
64 position: { x: 4, y: 8 },
65 ...harness,
66 });
67
68 expect(harness.showContextMenu).toHaveBeenCalledWith(
69 [
70 { id: "open-external", label: "Open in system browser" },
71 { id: "copy-link", label: "Copy Link" },
72 ],
73 { x: 4, y: 8 },
74 );
75 });
76
77 it("copies the exact destination without opening it", async () => {
78 const harness = createHarness("copy-link");
79 const href = "https://example.com/docs?topic=menus#copy";
80
81 await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness });
82
83 expect(harness.copyLink).toHaveBeenCalledWith(href);
84 expect(harness.openInPreview).not.toHaveBeenCalled();
85 expect(harness.openExternal).not.toHaveBeenCalled();
86 });
87
88 it.each([
89 ["link-to-thread", "Link to thread", true],
90 ["unlink-from-thread", "Unlink from thread", false],
91 ] as const)("offers and runs the %s action", async (action, label, linked) => {
92 const harness = createHarness(action);
93 const href = "https://github.com/pingdotgg/t3code/pull/42";
94
95 await showExternalLinkContextMenu({
96 href,
97 threadLinkAction: action,
98 position: { x: 1, y: 2 },
99 ...harness,
100 });
101
102 expect(harness.showContextMenu).toHaveBeenCalledWith(
103 expect.arrayContaining([{ id: action, label }]),
104 { x: 1, y: 2 },
105 );
106 expect(harness.updateThreadLink).toHaveBeenCalledWith(href, linked);
107 });
108
109 it.each([
110 ["open-in-preview" as const, "openInPreview" as const],
111 ["open-external" as const, "openExternal" as const],
112 ])("preserves the %s action", async (selection, expectedCallback) => {
113 const harness = createHarness(selection);
114 const href = "https://example.com/docs";
115
116 await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness });
117
118 expect(harness[expectedCallback]).toHaveBeenCalledWith(href);
119 expect(harness.copyLink).not.toHaveBeenCalled();
120 });
121
122 it("reports the selected action when it fails", async () => {
123 const harness = createHarness("copy-link");
124 const cause = new Error("clipboard denied");
125 harness.copyLink.mockRejectedValue(cause);
126
127 await showExternalLinkContextMenu({
128 href: "https://example.com/docs",
129 position: { x: 1, y: 2 },
130 ...harness,
131 });
132
133 expect(harness.reportFailure).toHaveBeenCalledWith("copy-link", cause);
134 });
135
136 it("reports the menu operation when the native menu cannot be shown", async () => {
137 const harness = createHarness(null);
138 const cause = new Error("menu unavailable");
139 harness.showContextMenu.mockRejectedValue(cause);
140
141 await showExternalLinkContextMenu({
142 href: "https://example.com/docs",
143 position: { x: 1, y: 2 },
144 ...harness,
145 });
146
147 expect(harness.reportFailure).toHaveBeenCalledWith("show-link-context-menu", cause);
148 expect(harness.openInPreview).not.toHaveBeenCalled();
149 expect(harness.openExternal).not.toHaveBeenCalled();
150 expect(harness.copyLink).not.toHaveBeenCalled();
151 });
152
153 it.each([
154 ["open-in-preview" as const, "openInPreview" as const, "open-link-in-preview"],
155 ["open-external" as const, "openExternal" as const, "open-link-external"],
156 ])("reports a failed %s action", async (selection, callback, operation) => {
157 const harness = createHarness(selection);
158 const cause = new Error("open failed");
159 harness[callback].mockRejectedValue(cause);
160
161 await showExternalLinkContextMenu({
162 href: "https://example.com/docs",
163 position: { x: 1, y: 2 },
164 ...harness,
165 });
166
167 expect(harness.reportFailure).toHaveBeenCalledWith(operation, cause);
168 });
169
170 it("reports a failed thread link action", async () => {
171 const harness = createHarness("link-to-thread");
172 const cause = new Error("thread update failed");
173 harness.updateThreadLink.mockRejectedValue(cause);
174
175 await showExternalLinkContextMenu({
176 href: "https://github.com/pingdotgg/t3code/pull/42",
177 threadLinkAction: "link-to-thread",
178 position: { x: 1, y: 2 },
179 ...harness,
180 });
181
182 expect(harness.reportFailure).toHaveBeenCalledWith("link-pull-request-to-thread", cause);
183 });
184
185 it.each([
186 ["https://example.com", "example.com"],
187 ["http://localhost:3000/path", "localhost"],
188 ["//cdn.example.com/clip.mp4?signature=abc#t=2", "cdn.example.com"],
189 ["//", null],
190 ["#details", null],
191 ["mailto:hello@example.com", null],
192 ["file:///tmp/example.txt", null],
193 ["javascript:void(0)", null],
194 ["not a URL", null],
195 [undefined, null],
196 ])("resolves the external web-link host for %s as %s", (href, expected) => {
197 expect(resolveExternalWebLinkHost(href)).toBe(expected);
198 });
199
200 it.each([
201 [
202 "https://example.com/docs?topic=security#links",
203 "https://example.com/docs?topic=security#links",
204 ],
205 ["HTTP://EXAMPLE.COM", "http://example.com/"],
206 ["//example.com/path", "https://example.com/path"],
207 ])("resolves the safe external web-link href for %s as %s", (href, expected) => {
208 expect(resolveExternalWebLinkHref(href)).toBe(expected);
209 });
210
211 it.each([
212 "javascript:alert(document.domain)",
213 " \nJaVaScRiPt:alert(document.domain)",
214 "data:text/html,<script>alert(document.domain)</script>",
215 "file:///tmp/example.txt",
216 "mailto:hello@example.com",
217 "/relative/path",
218 "not a URL",
219 "",
220 ])("rejects unsafe external web-link href %s", (href) => {
221 expect(resolveExternalWebLinkHref(href)).toBeNull();
222 });
223});