| 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 |