返回 DeepSeek-Reasonix
external-opener.test.tsx
根目录 / desktop / frontend / src / __tests__ / external-opener.test.tsx
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
252 lines Plain Text