| 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import { |
| 4 | resolveExternalWebLinkHost, |
| 5 | resolveExternalWebLinkHref, |
| 6 | showExternalLinkContextMenu, |
| 7 | } from "./externalLinkContextMenu"; |
| 8 | |
| 9 | function 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 | |
| 35 | describe("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 | }); |