| 1 | // Run: tsx src/__tests__/external-opener.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React, { act } from "react"; |
| 5 | import { createRoot } from "react-dom/client"; |
| 6 | |
| 7 | import { ExternalOpener, type ExternalOpenerBridge } from "../components/ExternalOpener"; |
| 8 | import { LocaleProvider, t } from "../lib/i18n"; |
| 9 | import { ToastProvider } from "../lib/toast"; |
| 10 | import type { ExternalOpenersView } from "../lib/types"; |
| 11 | |
| 12 | let passed = 0; |
| 13 | let failed = 0; |
| 14 | |
| 15 | function ok(value: boolean, label: string) { |
| 16 | if (value) { |
| 17 | process.stdout.write(` PASS ${label}\n`); |
| 18 | passed += 1; |
| 19 | } else { |
| 20 | process.stdout.write(` FAIL ${label}\n`); |
| 21 | failed += 1; |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | function flush(): Promise<void> { |
| 26 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 27 | } |
| 28 | |
| 29 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 30 | pretendToBeVisual: true, |
| 31 | url: "http://localhost/", |
| 32 | }); |
| 33 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 34 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 35 | globalThis.document = dom.window.document; |
| 36 | globalThis.Node = dom.window.Node; |
| 37 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 38 | globalThis.Event = dom.window.Event; |
| 39 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 40 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 41 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 42 | |
| 43 | const selected: string[] = []; |
| 44 | const opened: Array<[string, string]> = []; |
| 45 | const nativeIcon = "data:image/png;base64,iVBORw0KGgo="; |
| 46 | let discoveryCalls = 0; |
| 47 | const bridge: ExternalOpenerBridge = { |
| 48 | async ExternalOpeners() { |
| 49 | discoveryCalls += 1; |
| 50 | return { |
| 51 | openers: [ |
| 52 | { id: "finder", name: "Finder", kind: "file-manager", iconDataUrl: nativeIcon }, |
| 53 | { id: "ghostty", name: "Ghostty", kind: "terminal" }, |
| 54 | ...(discoveryCalls > 1 ? [{ id: "xcode", name: "Xcode", kind: "editor" as const, iconDataUrl: nativeIcon }] : []), |
| 55 | ], |
| 56 | preferred: "finder", |
| 57 | }; |
| 58 | }, |
| 59 | async SetPreferredExternalOpener(id) { |
| 60 | selected.push(id); |
| 61 | }, |
| 62 | async OpenWorkspaceInExternalOpenerForTab(tabId, id) { |
| 63 | opened.push([tabId, id]); |
| 64 | }, |
| 65 | }; |
| 66 | |
| 67 | console.log("\nexternal opener"); |
| 68 | |
| 69 | const container = document.getElementById("root")!; |
| 70 | const root = createRoot(container); |
| 71 | await act(async () => { |
| 72 | root.render( |
| 73 | <LocaleProvider> |
| 74 | <ToastProvider> |
| 75 | <ExternalOpener tabId="tab-project" dismissSignal={0} bridge={bridge} /> |
| 76 | </ToastProvider> |
| 77 | </LocaleProvider>, |
| 78 | ); |
| 79 | await flush(); |
| 80 | }); |
| 81 | |
| 82 | const choose = container.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]'); |
| 83 | ok(Boolean(choose), "renders a split-button menu trigger after discovery"); |
| 84 | ok(container.querySelector<HTMLImageElement>(`img[src="${nativeIcon}"]`) != null, "renders the native application icon data URL"); |
| 85 | |
| 86 | await act(async () => { |
| 87 | choose?.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 88 | await flush(); |
| 89 | }); |
| 90 | const menu = container.querySelector('[role="menu"]'); |
| 91 | ok(Boolean(menu), "opens the installed-application menu"); |
| 92 | ok(discoveryCalls === 2, "requests an installed-application refresh whenever the menu opens"); |
| 93 | ok(menu?.querySelectorAll('[role="menuitemradio"]').length === 3, "renders applications discovered by the fresh scan"); |
| 94 | |
| 95 | const ghostty = Array.from(menu?.querySelectorAll<HTMLButtonElement>('button[role="menuitemradio"]') ?? []) |
| 96 | .find((button) => button.textContent?.includes("Ghostty")); |
| 97 | await act(async () => { |
| 98 | ghostty?.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 99 | await flush(); |
| 100 | }); |
| 101 | ok(selected.join(",") === "ghostty", "persists the selected application id"); |
| 102 | ok(JSON.stringify(opened) === JSON.stringify([["tab-project", "ghostty"]]), "opens the exact tab workspace with the selection"); |
| 103 | |
| 104 | const primary = container.querySelector<HTMLButtonElement>('button.external-opener__primary'); |
| 105 | await act(async () => { |
| 106 | primary?.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 107 | await flush(); |
| 108 | }); |
| 109 | ok(selected.length === 1, "primary action reuses the preference without another settings write"); |
| 110 | ok(opened.at(-1)?.[1] === "ghostty", "primary action uses the newly selected application"); |
| 111 | |
| 112 | const openedBeforeDoubleClick = opened.length; |
| 113 | await act(async () => { |
| 114 | primary?.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 115 | primary?.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 116 | await flush(); |
| 117 | }); |
| 118 | ok(opened.length === openedBeforeDoubleClick + 1, "rapid primary clicks launch the workspace only once"); |
| 119 | |
| 120 | await act(async () => root.unmount()); |
| 121 | |
| 122 | let staleResolve: ((value: ExternalOpenersView) => void) | undefined; |
| 123 | let raceCalls = 0; |
| 124 | const raceBridge: ExternalOpenerBridge = { |
| 125 | async ExternalOpeners() { |
| 126 | raceCalls += 1; |
| 127 | if (raceCalls === 1) { |
| 128 | return { openers: [{ id: "finder", name: "Finder", kind: "file-manager" }], preferred: "finder" }; |
| 129 | } |
| 130 | if (raceCalls === 2) { |
| 131 | return new Promise<ExternalOpenersView>((resolve) => { |
| 132 | staleResolve = resolve; |
| 133 | }); |
| 134 | } |
| 135 | return { openers: [{ id: "xcode", name: "Xcode", kind: "editor" }], preferred: "xcode" }; |
| 136 | }, |
| 137 | async SetPreferredExternalOpener() {}, |
| 138 | async OpenWorkspaceInExternalOpenerForTab() {}, |
| 139 | }; |
| 140 | const raceContainer = document.createElement("div"); |
| 141 | document.body.append(raceContainer); |
| 142 | const raceRoot = createRoot(raceContainer); |
| 143 | await act(async () => { |
| 144 | raceRoot.render( |
| 145 | <LocaleProvider> |
| 146 | <ToastProvider> |
| 147 | <ExternalOpener tabId="race-tab" dismissSignal={0} bridge={raceBridge} /> |
| 148 | </ToastProvider> |
| 149 | </LocaleProvider>, |
| 150 | ); |
| 151 | await flush(); |
| 152 | }); |
| 153 | const raceChoose = raceContainer.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!; |
| 154 | await act(async () => { |
| 155 | raceChoose.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 156 | await flush(); |
| 157 | }); |
| 158 | ok( |
| 159 | raceContainer.querySelector('[role="menu"]') != null && raceContainer.textContent?.includes("Finder") === true, |
| 160 | "opens immediately with the cached application list while refresh is still running", |
| 161 | ); |
| 162 | await act(async () => { |
| 163 | raceChoose.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 164 | await flush(); |
| 165 | }); |
| 166 | await act(async () => { |
| 167 | raceChoose.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 168 | await flush(); |
| 169 | }); |
| 170 | ok(raceContainer.textContent?.includes("Xcode") === true, "the latest overlapping discovery result wins"); |
| 171 | await act(async () => { |
| 172 | staleResolve?.({ openers: [{ id: "stale", name: "Stale Editor", kind: "editor" }], preferred: "stale" }); |
| 173 | await flush(); |
| 174 | }); |
| 175 | ok(raceContainer.textContent?.includes("Xcode") === true && !raceContainer.textContent?.includes("Stale Editor"), "a stale discovery cannot replace the current menu"); |
| 176 | await act(async () => raceRoot.unmount()); |
| 177 | raceContainer.remove(); |
| 178 | |
| 179 | const failureLog: string[] = []; |
| 180 | let failOpen = true; |
| 181 | let failPersist = false; |
| 182 | const failureBridge: ExternalOpenerBridge = { |
| 183 | async ExternalOpeners() { |
| 184 | return { |
| 185 | openers: [ |
| 186 | { id: "finder", name: "Finder", kind: "file-manager" }, |
| 187 | { id: "xcode", name: "Xcode", kind: "editor" }, |
| 188 | ], |
| 189 | preferred: "finder", |
| 190 | }; |
| 191 | }, |
| 192 | async SetPreferredExternalOpener(id) { |
| 193 | failureLog.push(`persist:${id}`); |
| 194 | if (failPersist) throw new Error("disk full"); |
| 195 | }, |
| 196 | async OpenWorkspaceInExternalOpenerForTab(_tabId, id) { |
| 197 | failureLog.push(`open:${id}`); |
| 198 | if (failOpen) throw new Error("spawn failed"); |
| 199 | }, |
| 200 | }; |
| 201 | const failureContainer = document.createElement("div"); |
| 202 | document.body.append(failureContainer); |
| 203 | const failureRoot = createRoot(failureContainer); |
| 204 | await act(async () => { |
| 205 | failureRoot.render( |
| 206 | <LocaleProvider> |
| 207 | <ToastProvider> |
| 208 | <ExternalOpener tabId="fail-tab" dismissSignal={0} bridge={failureBridge} /> |
| 209 | </ToastProvider> |
| 210 | </LocaleProvider>, |
| 211 | ); |
| 212 | await flush(); |
| 213 | }); |
| 214 | const clickXcodeMenuItem = async () => { |
| 215 | await act(async () => { |
| 216 | failureContainer |
| 217 | .querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]') |
| 218 | ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 219 | await flush(); |
| 220 | }); |
| 221 | await act(async () => { |
| 222 | Array.from(failureContainer.querySelectorAll<HTMLButtonElement>('button[role="menuitemradio"]')) |
| 223 | .find((button) => button.textContent?.includes("Xcode")) |
| 224 | ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 225 | await flush(); |
| 226 | }); |
| 227 | }; |
| 228 | const toastTexts = () => |
| 229 | Array.from(failureContainer.querySelectorAll(".toast--error .toast__text")).map((node) => node.textContent); |
| 230 | |
| 231 | await clickXcodeMenuItem(); |
| 232 | ok(failureLog.join(",") === "open:xcode", "a failed launch never persists the preference"); |
| 233 | ok( |
| 234 | toastTexts().includes(t("externalOpener.failed", { name: "Xcode", error: "spawn failed" })), |
| 235 | "a failed launch reports the launch error", |
| 236 | ); |
| 237 | |
| 238 | failOpen = false; |
| 239 | failPersist = true; |
| 240 | failureLog.length = 0; |
| 241 | await clickXcodeMenuItem(); |
| 242 | ok(failureLog.join(",") === "open:xcode,persist:xcode", "the application launches before the preference write"); |
| 243 | ok( |
| 244 | toastTexts().includes(t("externalOpener.persistFailed", { name: "Xcode", error: "disk full" })), |
| 245 | "a failed preference write reports the save error after opening", |
| 246 | ); |
| 247 | await act(async () => failureRoot.unmount()); |
| 248 | failureContainer.remove(); |
| 249 | |
| 250 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 251 | if (failed > 0) process.exit(1); |
| 252 |