返回 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 { readFileSync } from "node:fs";
4 import { JSDOM } from "jsdom";
5 import React, { act } from "react";
6 import { createRoot } from "react-dom/client";
7
8 import { ExternalOpener, shouldMountExternalOpener, type ExternalOpenerBridge } from "../components/ExternalOpener";
9 import { LocaleProvider, t } from "../lib/i18n";
10 import { ToastProvider } from "../lib/toast";
11 import type { ExternalOpenersView } from "../lib/types";
12
13 let passed = 0;
14 let failed = 0;
15
16 function ok(value: boolean, label: string) {
17 if (value) {
18 process.stdout.write(` PASS ${label}\n`);
19 passed += 1;
20 } else {
21 process.stdout.write(` FAIL ${label}\n`);
22 failed += 1;
23 }
24 }
25
26 function flush(): Promise<void> {
27 return new Promise((resolve) => setTimeout(resolve, 0));
28 }
29
30 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
31 pretendToBeVisual: true,
32 url: "http://localhost/",
33 });
34 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
35 globalThis.window = dom.window as unknown as Window & typeof globalThis;
36 globalThis.document = dom.window.document;
37 globalThis.Node = dom.window.Node;
38 globalThis.HTMLElement = dom.window.HTMLElement;
39 globalThis.Event = dom.window.Event;
40 globalThis.MouseEvent = dom.window.MouseEvent;
41 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
42 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
43
44 const selected: string[] = [];
45 const opened: Array<[string, string]> = [];
46 const nativeIcon = "data:image/png;base64,iVBORw0KGgo=";
47 let discoveryCalls = 0;
48 const bridge: ExternalOpenerBridge = {
49 async ExternalOpenersForTab() {
50 discoveryCalls += 1;
51 return {
52 openers: [
53 { id: "finder", name: "Finder", kind: "file-manager", iconDataUrl: nativeIcon },
54 { id: "ghostty", name: "Ghostty", kind: "terminal" },
55 ...(discoveryCalls > 1 ? [{ id: "xcode", name: "Xcode", kind: "editor" as const, iconDataUrl: nativeIcon }] : []),
56 ],
57 preferred: "finder",
58 workspaceOpenable: true,
59 };
60 },
61 async SetPreferredExternalOpener(id) {
62 selected.push(id);
63 },
64 async OpenWorkspaceInExternalOpenerForTab(tabId, id) {
65 opened.push([tabId, id]);
66 },
67 };
68
69 console.log("\nexternal opener");
70
71 const stylesSource = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
72 const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
73 // The App layering split (#9777) renders these actions inside the topicbar
74 // actions region; the key namespace contract lives there.
75 const topicbarActionsSource = readFileSync(new URL("../app-shell/TopicbarActionsRegion.tsx", import.meta.url), "utf8");
76 const sharedControlRule = stylesSource.match(/(?:^|\n)\.external-opener\s*\{([^}]*)\}/)?.[1] ?? "";
77 const sharedSegmentRule = stylesSource.match(
78 /\.external-opener__primary,\s*\.external-opener__menu-trigger\s*\{([^}]*)\}/,
79 )?.[1] ?? "";
80 const creationControlRule = stylesSource.match(
81 /\.app--creation \.topicbar__actions \.external-opener,\s*:root\[data-theme-style\] \.app--creation \.topicbar__actions \.external-opener\s*\{([^}]*)\}/,
82 )?.[1] ?? "";
83 const sharedIconRule = stylesSource.match(/(?:^|\n)\.external-opener__app-icon\s*\{([^}]*)\}/)?.[1] ?? "";
84 const creationIconRule = stylesSource.match(
85 /\.app--creation \.topicbar__actions \.external-opener__app-icon,\s*:root\[data-theme-style\] \.app--creation \.topicbar__actions \.external-opener__app-icon\s*\{([^}]*)\}/,
86 )?.[1] ?? "";
87 ok(
88 /height:\s*28px;/.test(sharedControlRule)
89 && /border-radius:\s*7px;/.test(sharedControlRule)
90 && /box-shadow:\s*none;/.test(sharedControlRule),
91 "uses the shared 28px / 7px topic-bar control geometry without an extra shadow",
92 );
93 ok(/height:\s*100%;/.test(sharedSegmentRule), "keeps both split-button segments within the shared control height");
94 ok(
95 /height:\s*28px;/.test(creationControlRule) && /border-radius:\s*7px;/.test(creationControlRule),
96 "keeps Creation topic bars on the same 28px / 7px geometry",
97 );
98 ok(
99 /width:\s*18px;/.test(sharedIconRule) && /height:\s*18px;/.test(sharedIconRule),
100 "fits Workbench application artwork to the compact topic-bar control",
101 );
102 ok(
103 /width:\s*15px;/.test(creationIconRule) && /height:\s*15px;/.test(creationIconRule),
104 "preserves the slimmer Creation application artwork size",
105 );
106
107 ok(topicbarActionsSource.includes('key="external-opener"') && topicbarActionsSource.includes("key={external.tabId}"), "external opener has a distinct React key namespace");
108 ok(topicbarActionsSource.includes('key="session-actions"') && topicbarActionsSource.includes("key={sessionIdentity}"), "session actions have a distinct React key namespace");
109 ok(shouldMountExternalOpener({ id: "tab-project", scope: "project" }, false), "mounts for a Project tab");
110 ok(shouldMountExternalOpener({ id: "tab-global", scope: "global" }, false), "mounts for a Global tab without guessing from scope");
111 ok(!shouldMountExternalOpener({ id: "tab-global", scope: "global" }, true), "stays hidden while an IM detail surface owns the header");
112
113 const container = document.getElementById("root")!;
114 const root = createRoot(container);
115 await act(async () => {
116 root.render(
117 <LocaleProvider>
118 <ToastProvider>
119 <ExternalOpener tabId="tab-global" dismissSignal={0} bridge={bridge} />
120 </ToastProvider>
121 </LocaleProvider>,
122 );
123 await flush();
124 });
125
126 const choose = container.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
127 ok(Boolean(choose), "renders a split-button menu trigger after discovery");
128 ok(container.querySelector<HTMLImageElement>(`img[src="${nativeIcon}"]`) != null, "renders the native application icon data URL");
129
130 await act(async () => {
131 choose?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
132 await flush();
133 });
134 const menu = container.querySelector('[role="menu"]');
135 ok(Boolean(menu), "opens the installed-application menu");
136 ok(discoveryCalls === 2, "requests an installed-application refresh whenever the menu opens");
137 ok(menu?.querySelectorAll('[role="menuitemradio"]').length === 3, "renders applications discovered by the fresh scan");
138
139 const ghostty = Array.from(menu?.querySelectorAll<HTMLButtonElement>('button[role="menuitemradio"]') ?? [])
140 .find((button) => button.textContent?.includes("Ghostty"));
141 await act(async () => {
142 ghostty?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
143 await flush();
144 });
145 ok(selected.join(",") === "ghostty", "persists the selected application id");
146 ok(JSON.stringify(opened) === JSON.stringify([["tab-global", "ghostty"]]), "opens the exact Global workspace with the selection");
147
148 const primary = container.querySelector<HTMLButtonElement>('button.external-opener__primary');
149 await act(async () => {
150 primary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
151 await flush();
152 });
153 ok(selected.length === 1, "primary action reuses the preference without another settings write");
154 ok(opened.at(-1)?.[1] === "ghostty", "primary action uses the newly selected application");
155
156 const openedBeforeDoubleClick = opened.length;
157 await act(async () => {
158 primary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
159 primary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
160 await flush();
161 });
162 ok(opened.length === openedBeforeDoubleClick + 1, "rapid primary clicks launch the workspace only once");
163
164 await act(async () => root.unmount());
165
166 let staleResolve: ((value: ExternalOpenersView) => void) | undefined;
167 let raceCalls = 0;
168 const raceBridge: ExternalOpenerBridge = {
169 async ExternalOpenersForTab() {
170 raceCalls += 1;
171 if (raceCalls === 1) {
172 return { openers: [{ id: "finder", name: "Finder", kind: "file-manager" }], preferred: "finder", workspaceOpenable: true };
173 }
174 if (raceCalls === 2) {
175 return new Promise<ExternalOpenersView>((resolve) => {
176 staleResolve = resolve;
177 });
178 }
179 return { openers: [{ id: "xcode", name: "Xcode", kind: "editor" }], preferred: "xcode", workspaceOpenable: true };
180 },
181 async SetPreferredExternalOpener() {},
182 async OpenWorkspaceInExternalOpenerForTab() {},
183 };
184 const raceContainer = document.createElement("div");
185 document.body.append(raceContainer);
186 const raceRoot = createRoot(raceContainer);
187 await act(async () => {
188 raceRoot.render(
189 <LocaleProvider>
190 <ToastProvider>
191 <ExternalOpener tabId="race-tab" dismissSignal={0} bridge={raceBridge} />
192 </ToastProvider>
193 </LocaleProvider>,
194 );
195 await flush();
196 });
197 const raceChoose = raceContainer.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!;
198 await act(async () => {
199 raceChoose.dispatchEvent(new MouseEvent("click", { bubbles: true }));
200 await flush();
201 });
202 ok(
203 raceContainer.querySelector('[role="menu"]') != null && raceContainer.textContent?.includes("Finder") === true,
204 "opens immediately with the cached application list while refresh is still running",
205 );
206 await act(async () => {
207 raceChoose.dispatchEvent(new MouseEvent("click", { bubbles: true }));
208 await flush();
209 });
210 await act(async () => {
211 raceChoose.dispatchEvent(new MouseEvent("click", { bubbles: true }));
212 await flush();
213 });
214 ok(raceContainer.textContent?.includes("Xcode") === true, "the latest overlapping discovery result wins");
215 await act(async () => {
216 staleResolve?.({ openers: [{ id: "stale", name: "Stale Editor", kind: "editor" }], preferred: "stale", workspaceOpenable: true });
217 await flush();
218 });
219 ok(raceContainer.textContent?.includes("Xcode") === true && !raceContainer.textContent?.includes("Stale Editor"), "a stale discovery cannot replace the current menu");
220 await act(async () => raceRoot.unmount());
221 raceContainer.remove();
222
223 const preferenceWrites: string[] = [];
224 const preferenceLaunches: string[] = [];
225 let resolveOlderLaunch: (() => void) | undefined;
226 const preferenceRaceBridge: ExternalOpenerBridge = {
227 async ExternalOpenersForTab() {
228 return {
229 openers: [
230 { id: "finder", name: "Finder", kind: "file-manager" },
231 { id: "xcode", name: "Xcode", kind: "editor" },
232 ],
233 preferred: "finder",
234 workspaceOpenable: true,
235 };
236 },
237 async SetPreferredExternalOpener(id) {
238 preferenceWrites.push(id);
239 },
240 async OpenWorkspaceInExternalOpenerForTab(tabId, id) {
241 preferenceLaunches.push(`${tabId}:${id}`);
242 if (tabId === "older-tab") {
243 await new Promise<void>((resolve) => {
244 resolveOlderLaunch = resolve;
245 });
246 }
247 },
248 };
249 const preferenceContainer = document.createElement("div");
250 document.body.append(preferenceContainer);
251 const preferenceRoot = createRoot(preferenceContainer);
252 const renderPreferenceTab = async (
253 root: ReturnType<typeof createRoot>,
254 targetBridge: ExternalOpenerBridge,
255 tabId: string,
256 ) => {
257 await act(async () => {
258 root.render(
259 <LocaleProvider>
260 <ToastProvider>
261 <ExternalOpener key={tabId} tabId={tabId} dismissSignal={0} bridge={targetBridge} />
262 </ToastProvider>
263 </LocaleProvider>,
264 );
265 await flush();
266 });
267 };
268 const choosePreference = async (targetContainer: HTMLElement, name: string) => {
269 await act(async () => {
270 targetContainer.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')
271 ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
272 await flush();
273 });
274 await act(async () => {
275 Array.from(targetContainer.querySelectorAll<HTMLButtonElement>('button[role="menuitemradio"]'))
276 .find((button) => button.textContent?.includes(name))
277 ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
278 await flush();
279 });
280 };
281 await renderPreferenceTab(preferenceRoot, preferenceRaceBridge, "older-tab");
282 await choosePreference(preferenceContainer, "Xcode");
283 await renderPreferenceTab(preferenceRoot, preferenceRaceBridge, "newer-tab");
284 await choosePreference(preferenceContainer, "Finder");
285 ok(
286 preferenceLaunches.join(",") === "older-tab:xcode,newer-tab:finder" && preferenceWrites.join(",") === "finder",
287 "a newer tab selection persists even when it matches the discovered preference",
288 );
289 await act(async () => {
290 resolveOlderLaunch?.();
291 await flush();
292 });
293 ok(preferenceWrites.join(",") === "finder", "an older tab completion cannot overwrite the latest opener preference");
294 await act(async () => preferenceRoot.unmount());
295 preferenceContainer.remove();
296
297 const failedNewerWrites: string[] = [];
298 let resolveOlderSuccessfulLaunch: (() => void) | undefined;
299 const failedNewerBridge: ExternalOpenerBridge = {
300 async ExternalOpenersForTab() {
301 return {
302 openers: [
303 { id: "finder", name: "Finder", kind: "file-manager" },
304 { id: "xcode", name: "Xcode", kind: "editor" },
305 ],
306 preferred: "finder",
307 workspaceOpenable: true,
308 };
309 },
310 async SetPreferredExternalOpener(id) {
311 failedNewerWrites.push(id);
312 },
313 async OpenWorkspaceInExternalOpenerForTab(tabId) {
314 if (tabId === "older-success") {
315 await new Promise<void>((resolve) => {
316 resolveOlderSuccessfulLaunch = resolve;
317 });
318 return;
319 }
320 throw new Error("newer launch failed");
321 },
322 };
323 const failedNewerContainer = document.createElement("div");
324 document.body.append(failedNewerContainer);
325 const failedNewerRoot = createRoot(failedNewerContainer);
326 await renderPreferenceTab(failedNewerRoot, failedNewerBridge, "older-success");
327 await choosePreference(failedNewerContainer, "Xcode");
328 await renderPreferenceTab(failedNewerRoot, failedNewerBridge, "newer-failure");
329 await choosePreference(failedNewerContainer, "Finder");
330 await act(async () => {
331 resolveOlderSuccessfulLaunch?.();
332 await flush();
333 });
334 ok(
335 failedNewerWrites.join(",") === "xcode",
336 "a newer failed launch does not cancel an older successful selection",
337 );
338 await act(async () => failedNewerRoot.unmount());
339 failedNewerContainer.remove();
340
341 const inFlightWrites: string[] = [];
342 let markOlderWriteStarted: (() => void) | undefined;
343 const olderWriteStarted = new Promise<void>((resolve) => {
344 markOlderWriteStarted = resolve;
345 });
346 let resolveOlderWrite: (() => void) | undefined;
347 const inFlightWriteBridge: ExternalOpenerBridge = {
348 async ExternalOpenersForTab() {
349 return {
350 openers: [
351 { id: "finder", name: "Finder", kind: "file-manager" },
352 { id: "xcode", name: "Xcode", kind: "editor" },
353 ],
354 preferred: "finder",
355 workspaceOpenable: true,
356 };
357 },
358 async SetPreferredExternalOpener(id) {
359 inFlightWrites.push(id);
360 markOlderWriteStarted?.();
361 await new Promise<void>((resolve) => {
362 resolveOlderWrite = resolve;
363 });
364 },
365 async OpenWorkspaceInExternalOpenerForTab(tabId) {
366 if (tabId === "newer-failure") throw new Error("newer launch failed");
367 },
368 };
369 const inFlightContainer = document.createElement("div");
370 document.body.append(inFlightContainer);
371 const inFlightRoot = createRoot(inFlightContainer);
372 await renderPreferenceTab(inFlightRoot, inFlightWriteBridge, "older-write");
373 await choosePreference(inFlightContainer, "Xcode");
374 await olderWriteStarted;
375 await renderPreferenceTab(inFlightRoot, inFlightWriteBridge, "newer-failure");
376 await choosePreference(inFlightContainer, "Finder");
377 await act(async () => {
378 resolveOlderWrite?.();
379 await flush();
380 });
381 ok(
382 inFlightWrites.join(",") === "xcode",
383 "an in-flight older preference write has the same result when a newer launch fails",
384 );
385 await act(async () => inFlightRoot.unmount());
386 inFlightContainer.remove();
387
388 const failureLog: string[] = [];
389 let failOpen = true;
390 let failPersist = false;
391 const failureBridge: ExternalOpenerBridge = {
392 async ExternalOpenersForTab() {
393 return {
394 openers: [
395 { id: "finder", name: "Finder", kind: "file-manager" },
396 { id: "xcode", name: "Xcode", kind: "editor" },
397 ],
398 preferred: "finder",
399 workspaceOpenable: true,
400 };
401 },
402 async SetPreferredExternalOpener(id) {
403 failureLog.push(`persist:${id}`);
404 if (failPersist) throw new Error("disk full");
405 },
406 async OpenWorkspaceInExternalOpenerForTab(_tabId, id) {
407 failureLog.push(`open:${id}`);
408 if (failOpen) throw new Error("spawn failed");
409 },
410 };
411 const failureContainer = document.createElement("div");
412 document.body.append(failureContainer);
413 const failureRoot = createRoot(failureContainer);
414 await act(async () => {
415 failureRoot.render(
416 <LocaleProvider>
417 <ToastProvider>
418 <ExternalOpener tabId="fail-tab" dismissSignal={0} bridge={failureBridge} />
419 </ToastProvider>
420 </LocaleProvider>,
421 );
422 await flush();
423 });
424 const clickXcodeMenuItem = async () => {
425 await act(async () => {
426 failureContainer
427 .querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')
428 ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
429 await flush();
430 });
431 await act(async () => {
432 Array.from(failureContainer.querySelectorAll<HTMLButtonElement>('button[role="menuitemradio"]'))
433 .find((button) => button.textContent?.includes("Xcode"))
434 ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
435 await flush();
436 });
437 };
438 const toastTexts = () =>
439 Array.from(failureContainer.querySelectorAll(".toast--error .toast__text")).map((node) => node.textContent);
440
441 await clickXcodeMenuItem();
442 ok(failureLog.join(",") === "open:xcode", "a failed launch never persists the preference");
443 ok(
444 toastTexts().includes(t("externalOpener.failed", { name: "Xcode", error: "spawn failed" })),
445 "a failed launch reports the launch error",
446 );
447
448 failOpen = false;
449 failPersist = true;
450 failureLog.length = 0;
451 await clickXcodeMenuItem();
452 ok(failureLog.join(",") === "open:xcode,persist:xcode", "the application launches before the preference write");
453 ok(
454 toastTexts().includes(t("externalOpener.persistFailed", { name: "Xcode", error: "disk full" })),
455 "a failed preference write reports the save error after opening",
456 );
457 await act(async () => failureRoot.unmount());
458 failureContainer.remove();
459
460 const unavailableContainer = document.createElement("div");
461 document.body.append(unavailableContainer);
462 const unavailableRoot = createRoot(unavailableContainer);
463 const unavailableBridge: ExternalOpenerBridge = {
464 async ExternalOpenersForTab() {
465 return {
466 openers: [{ id: "finder", name: "Finder", kind: "file-manager" }],
467 preferred: "finder",
468 workspaceOpenable: false,
469 };
470 },
471 async SetPreferredExternalOpener() {},
472 async OpenWorkspaceInExternalOpenerForTab() {},
473 };
474 await act(async () => {
475 unavailableRoot.render(
476 <LocaleProvider>
477 <ToastProvider>
478 <ExternalOpener tabId="tab-missing-workspace" dismissSignal={0} bridge={unavailableBridge} />
479 </ToastProvider>
480 </LocaleProvider>,
481 );
482 await flush();
483 });
484 ok(unavailableContainer.querySelector(".external-opener") == null, "hides when the backend reports no local workspace capability");
485 await act(async () => unavailableRoot.unmount());
486 unavailableContainer.remove();
487
488 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
489 if (failed > 0) process.exit(1);
490
490 lines Plain Text