resolver.spec.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. /**
  2. * Resolver behavior over a deterministic command runner and an in-process
  3. * PATH-resolution fake: per-platform locator chains, the one-pass catalog
  4. * resolution map, registry/desktop parsing, and launch-outcome
  5. * classification. Filesystem-facing locators use real temp directories; no
  6. * host application is touched.
  7. */
  8. import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { afterEach, describe, expect, it, vi } from 'vitest'
  12. import type { NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
  13. import { OPEN_IN_APP_CATALOG, type OpenInAppApp } from '../src/catalog.ts'
  14. import {
  15. execCommand, launchDetachedApp, launchResolved, parseDesktopEntry, parseRegistryDump, resolveInternals,
  16. resolveLaunch, resolveOpenInAppApps, xdgDataDirectories,
  17. type OpenInAppInternals, type OpenInAppLauncher, type OpenInAppResolvedLaunch,
  18. } from '../src/resolver.ts'
  19. const TIMEOUT_MS = 5_000
  20. const roots: string[] = []
  21. afterEach(async () => {
  22. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
  23. })
  24. async function tempRoot(): Promise<string> {
  25. const root = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-spec-'))
  26. roots.push(root)
  27. return root
  28. }
  29. /** Runner resolving for the allowed argv prefixes and rejecting the rest. */
  30. function runner(allow: (command: string, args: readonly string[]) => string | null): NativeCommandRunner {
  31. return (command, args) => {
  32. const stdout = allow(command, [...args])
  33. return stdout === null
  34. ? Promise.reject(new Error(`fixture rejects: ${command} ${args.join(' ')}`))
  35. : Promise.resolve({ stdout, stderr: '' })
  36. }
  37. }
  38. /** PATH-resolution fake answering from a fixed name-to-path table. */
  39. function pathTable(entries: Record<string, string> = {}): (name: string) => Promise<string | null> {
  40. return name => Promise.resolve(entries[name] ?? null)
  41. }
  42. function byId(id: string): OpenInAppApp {
  43. const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === id)
  44. if (app === undefined) throw new Error(`missing catalog id: ${id}`)
  45. return app
  46. }
  47. /** Internals baseline every call completes: a rejecting runner and an empty PATH. */
  48. function bare(overrides: OpenInAppInternals): OpenInAppInternals {
  49. return { env: {}, run: runner(() => null), resolveExecutable: pathTable(), ...overrides }
  50. }
  51. /** Hermetic Linux environment: XDG lookups stay inside the temp home. */
  52. function linuxEnv(home: string): Readonly<Record<string, string>> {
  53. return { XDG_DATA_DIRS: join(home, 'xdg-empty') }
  54. }
  55. describe('resolveOpenInAppApps', () => {
  56. it.each(['darwin', 'win32', 'linux'] as const)('offers no applications over SSH on %s without probing', async (platform) => {
  57. const run = vi.fn<NativeCommandRunner>()
  58. const resolveExecutable = vi.fn(pathTable({ code: '/usr/bin/code' }))
  59. const facts = {
  60. platform, ssh: true, env: { DISPLAY: ':0', VSCODE_IPC_HOOK_CLI: '/tmp/vscode.sock' },
  61. run, resolveExecutable,
  62. }
  63. await expect(resolveOpenInAppApps(TIMEOUT_MS, facts)).resolves.toEqual(new Map())
  64. await expect(resolveLaunch(byId('vscode'), TIMEOUT_MS, facts)).resolves.toBeNull()
  65. expect(run).not.toHaveBeenCalled()
  66. expect(resolveExecutable).not.toHaveBeenCalled()
  67. })
  68. it('keeps local applications available regardless of flattened SSH markers', async () => {
  69. const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({
  70. platform: 'darwin', applicationRoots: [], env: { SSH_CONNECTION: 'stale-value' },
  71. }))
  72. expect([...map.keys()]).toEqual(['finder', 'terminal'])
  73. })
  74. it('fails loud when the PATH resolver is not supplied', async () => {
  75. await expect(resolveOpenInAppApps(TIMEOUT_MS, { platform: 'linux' }))
  76. .rejects.toThrow(/resolveExecutable is required/)
  77. })
  78. it('resolves as empty on a platform without entries, touching no command or PATH lookup', async () => {
  79. const run = vi.fn<NativeCommandRunner>()
  80. const resolveExecutable = vi.fn(pathTable())
  81. await expect(resolveOpenInAppApps(TIMEOUT_MS, { platform: 'aix', run, resolveExecutable }))
  82. .resolves.toEqual(new Map())
  83. expect(run).not.toHaveBeenCalled()
  84. expect(resolveExecutable).not.toHaveBeenCalled()
  85. })
  86. it('resolves macOS entries from the known application directories, in menu order', async () => {
  87. const home = await tempRoot()
  88. const applications = join(home, 'Applications')
  89. const cursor = join(applications, 'Cursor.app')
  90. const zed = join(applications, 'Zed Preview.app')
  91. await mkdir(cursor, { recursive: true })
  92. await mkdir(zed, { recursive: true })
  93. const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({
  94. platform: 'darwin', applicationRoots: [applications],
  95. }))
  96. // finder and terminal ship with the OS (fixed); cursor and the Zed
  97. // Preview spelling resolve from the injected application root.
  98. expect([...map.keys()]).toEqual(['finder', 'cursor', 'zed', 'terminal'])
  99. expect(map.get('cursor')).toEqual({
  100. launch: { kind: 'argv', command: 'open', args: ['-a', cursor] },
  101. icon: { kind: 'app-bundle', path: cursor },
  102. })
  103. expect(map.get('zed')?.launch).toEqual({ kind: 'argv', command: 'open', args: ['-a', zed] })
  104. })
  105. it('resolves Linux entries in-process through the PATH resolver, never spawning a lookup', async () => {
  106. const home = await tempRoot()
  107. const run = vi.fn<NativeCommandRunner>()
  108. const map = await resolveOpenInAppApps(TIMEOUT_MS, {
  109. platform: 'linux', home, env: { ...linuxEnv(home), DISPLAY: ':0' }, run,
  110. resolveExecutable: pathTable({ 'xdg-open': '/usr/bin/xdg-open', code: '/usr/bin/code', ghostty: '/usr/bin/ghostty' }),
  111. })
  112. expect([...map.keys()]).toEqual(['filemanager', 'vscode', 'ghostty'])
  113. expect(map.get('ghostty')?.launch).toEqual({ kind: 'argv', command: '/usr/bin/ghostty', args: ['--working-directory={path}'] })
  114. expect(run).not.toHaveBeenCalled()
  115. })
  116. it('does not offer the Linux file manager without a desktop session', async () => {
  117. const home = await tempRoot()
  118. const resolveExecutable = pathTable({ 'xdg-open': '/usr/bin/xdg-open' })
  119. await expect(resolveLaunch(byId('filemanager'), TIMEOUT_MS, bare({
  120. platform: 'linux', home, env: linuxEnv(home), resolveExecutable,
  121. }))).resolves.toBeNull()
  122. await expect(resolveLaunch(byId('filemanager'), TIMEOUT_MS, bare({
  123. platform: 'linux', home, env: { ...linuxEnv(home), WAYLAND_DISPLAY: 'wayland-0' }, resolveExecutable,
  124. }))).resolves.toEqual({ launch: { kind: 'argv', command: '/usr/bin/xdg-open', args: [] }, icon: undefined })
  125. })
  126. it('reads the Windows registry at most once per pass, sharing the view across entries', async () => {
  127. const root = await tempRoot()
  128. const code = join(root, 'apps', 'Code.exe')
  129. const sublime = join(root, 'apps', 'sublime_text.exe')
  130. await mkdir(join(root, 'apps'), { recursive: true })
  131. await writeFile(code, 'exe')
  132. await writeFile(sublime, 'exe')
  133. const regQueries: string[] = []
  134. const run = runner((command, args) => {
  135. if (command !== 'reg.exe') return null
  136. const key = String(args[1])
  137. regQueries.push(key)
  138. if (key.includes('App Paths')) {
  139. return [
  140. `${key}\\Code.exe`,
  141. ` (Default) REG_SZ ${code}`,
  142. `${key}\\sublime_text.exe`,
  143. ` (Default) REG_SZ "${sublime}"`,
  144. '',
  145. ].join('\r\n')
  146. }
  147. return ''
  148. })
  149. const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({ platform: 'win32', env: {}, run }))
  150. expect(map.get('vscode')).toEqual({
  151. launch: { kind: 'argv', command: code, args: [] },
  152. icon: { kind: 'executable', path: code },
  153. })
  154. expect(map.get('sublimetext')?.launch).toMatchObject({ kind: 'argv', command: sublime })
  155. // One pass reads each registry root once: two App Paths roots and, for
  156. // the entries whose earlier locators all missed, three Uninstall roots.
  157. expect(regQueries.filter(key => key.includes('App Paths'))).toHaveLength(2)
  158. expect(regQueries.filter(key => key.includes('Uninstall'))).toHaveLength(3)
  159. })
  160. })
  161. describe('resolveLaunch locators', () => {
  162. it('fixed entries expand their icon source and survive without one', async () => {
  163. const systemRoot = 'C:/Windows'
  164. await expect(resolveLaunch(byId('explorer'), TIMEOUT_MS, bare({
  165. platform: 'win32', env: { SystemRoot: systemRoot },
  166. }))).resolves.toEqual({
  167. launch: { kind: 'shell-open' },
  168. icon: { kind: 'executable', path: `${systemRoot}/explorer.exe` },
  169. })
  170. // An unset ${SystemRoot} drops the icon claim, not the entry.
  171. await expect(resolveLaunch(byId('explorer'), TIMEOUT_MS, bare({ platform: 'win32', env: {} })))
  172. .resolves.toMatchObject({ launch: { kind: 'shell-open' }, icon: undefined })
  173. // macOS fixed entries trust their OS-shipped bundle path.
  174. await expect(resolveLaunch(byId('finder'), TIMEOUT_MS, bare({ platform: 'darwin', env: {} })))
  175. .resolves.toEqual({
  176. launch: { kind: 'shell-open' },
  177. icon: { kind: 'app-bundle', path: '/System/Library/CoreServices/Finder.app' },
  178. })
  179. })
  180. it('derives the Xcode bundle from xcode-select with the open -a fallback, rejecting non-bundle answers', async () => {
  181. const home = await tempRoot()
  182. const bundle = join(home, 'Xcode-beta.app')
  183. await mkdir(join(bundle, 'Contents', 'Developer'), { recursive: true })
  184. const run = runner(command => command === 'xcode-select' ? `${join(bundle, 'Contents', 'Developer')}\n` : null)
  185. await expect(resolveLaunch(byId('xcode'), TIMEOUT_MS, bare({ platform: 'darwin', run })))
  186. .resolves.toEqual({
  187. launch: { kind: 'argv', command: 'xed', args: [] },
  188. fallbackLaunch: { kind: 'argv', command: 'open', args: ['-a', bundle] },
  189. icon: { kind: 'app-bundle', path: bundle },
  190. })
  191. const rootAnswer = runner(command => command === 'xcode-select' ? '/\n' : null)
  192. await expect(resolveLaunch(byId('xcode'), TIMEOUT_MS, bare({ platform: 'darwin', run: rootAnswer })))
  193. .resolves.toBeNull()
  194. await expect(resolveLaunch(byId('xcode'), TIMEOUT_MS, bare({ platform: 'darwin' }))).resolves.toBeNull()
  195. })
  196. it('marks a resolved Windows CLI as its own icon source', async () => {
  197. await expect(resolveLaunch(byId('windowsterminal'), TIMEOUT_MS, bare({
  198. platform: 'win32', env: {}, resolveExecutable: pathTable({ wt: 'C:\\WA\\wt.exe' }),
  199. }))).resolves.toEqual({
  200. launch: { kind: 'argv', command: 'C:\\WA\\wt.exe', args: ['-d'] },
  201. icon: { kind: 'executable', path: 'C:\\WA\\wt.exe' },
  202. })
  203. })
  204. it('skips file candidates with unset variables and missing files, taking the first existing one', async () => {
  205. const root = await tempRoot()
  206. const local = join(root, 'local')
  207. const programFiles = join(root, 'pf')
  208. await mkdir(local, { recursive: true })
  209. // Candidate expansion is string substitution, so the resolved command
  210. // keeps the template's '/' separators after the expanded prefix.
  211. const code = `${programFiles}/Microsoft VS Code/Code.exe`
  212. await mkdir(join(programFiles, 'Microsoft VS Code'), { recursive: true })
  213. await writeFile(code, 'exe')
  214. // LOCALAPPDATA is set but holds no install; the ProgramFiles candidate wins.
  215. const found = await resolveLaunch(byId('vscode'), TIMEOUT_MS, bare({
  216. platform: 'win32', env: { LOCALAPPDATA: local, ProgramFiles: programFiles }, run: runner(() => ''),
  217. }))
  218. expect(found?.launch).toEqual({ kind: 'argv', command: code, args: [] })
  219. expect(found?.icon).toEqual({ kind: 'executable', path: code })
  220. // An unset ${LOCALAPPDATA} skips Cursor's only file candidate entirely.
  221. await expect(resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({ platform: 'win32', env: {}, run: runner(() => '') })))
  222. .resolves.toBeNull()
  223. })
  224. it('expands ~/ against the injected home for Toolbox scripts, with no Windows icon claim on Linux', async () => {
  225. const home = await tempRoot()
  226. const script = join(home, '.local', 'share', 'JetBrains', 'Toolbox', 'scripts', 'idea')
  227. await mkdir(join(home, '.local', 'share', 'JetBrains', 'Toolbox', 'scripts'), { recursive: true })
  228. await writeFile(script, '#!/bin/sh')
  229. await expect(resolveLaunch(byId('intellij'), TIMEOUT_MS, bare({ platform: 'linux', home, env: linuxEnv(home) })))
  230. .resolves.toEqual({ launch: { kind: 'argv', command: script, args: [] }, icon: undefined })
  231. })
  232. it('scans versioned installs newest-first, skipping versions without the launcher', async () => {
  233. const root = await tempRoot()
  234. const programFiles = join(root, 'pf')
  235. const kept = join(programFiles, 'JetBrains', 'PyCharm 2023.3', 'bin', 'pycharm64.exe')
  236. await mkdir(join(programFiles, 'JetBrains', 'PyCharm 2024.1'), { recursive: true })
  237. await mkdir(join(programFiles, 'JetBrains', 'PyCharm 2023.3', 'bin'), { recursive: true })
  238. await writeFile(kept, 'exe')
  239. const internals = bare({ platform: 'win32', env: { ProgramFiles: programFiles }, run: runner(() => '') })
  240. const found = await resolveLaunch(byId('pycharm'), TIMEOUT_MS, internals)
  241. expect(found?.launch).toEqual({ kind: 'argv', command: kept, args: [] })
  242. // A scan root that does not exist resolves nothing.
  243. await expect(resolveLaunch(byId('webstorm'), TIMEOUT_MS, {
  244. ...internals, env: { ProgramFiles: join(root, 'nonesuch') },
  245. })).resolves.toBeNull()
  246. // An unset scan-root variable resolves nothing.
  247. await expect(resolveLaunch(byId('webstorm'), TIMEOUT_MS, { ...internals, env: {} })).resolves.toBeNull()
  248. // Numeric-aware ordering: '2024.1.10' outranks '2024.1.9'.
  249. const ten = join(programFiles, 'JetBrains', 'WebStorm 2024.1.10', 'bin', 'webstorm64.exe')
  250. await mkdir(join(programFiles, 'JetBrains', 'WebStorm 2024.1.9', 'bin'), { recursive: true })
  251. await writeFile(join(programFiles, 'JetBrains', 'WebStorm 2024.1.9', 'bin', 'webstorm64.exe'), 'exe')
  252. await mkdir(join(programFiles, 'JetBrains', 'WebStorm 2024.1.10', 'bin'), { recursive: true })
  253. await writeFile(ten, 'exe')
  254. const newest = await resolveLaunch(byId('webstorm'), TIMEOUT_MS, internals)
  255. expect(newest?.launch).toEqual({ kind: 'argv', command: ten, args: [] })
  256. // A root whose matching versions all lack the launcher resolves nothing
  257. // (goland's Uninstall records and file candidates also miss here).
  258. await mkdir(join(programFiles, 'JetBrains', 'GoLand 2024.2'), { recursive: true })
  259. await expect(resolveLaunch(byId('goland'), TIMEOUT_MS, internals)).resolves.toBeNull()
  260. })
  261. it('resolves App Paths hits only when the registered target exists on disk', async () => {
  262. const root = await tempRoot()
  263. const cursor = join(root, 'Cursor.exe')
  264. await writeFile(cursor, 'exe')
  265. const run = runner((command, args) => {
  266. if (command !== 'reg.exe') return null
  267. const key = String(args[1])
  268. if (!key.includes('App Paths')) return ''
  269. // The fixture value uses '/' so the expanded path exists on the POSIX
  270. // test host; expansion is string substitution either way.
  271. return [
  272. `${key}\\Cursor.exe`,
  273. ' (Default) REG_EXPAND_SZ %INSTALL_BASE%/Cursor.exe',
  274. '',
  275. ].join('\r\n')
  276. })
  277. // %INSTALL_BASE% expands against the injected environment.
  278. const found = await resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({
  279. platform: 'win32', env: { INSTALL_BASE: root }, run,
  280. }))
  281. expect(found?.launch).toMatchObject({ kind: 'argv', command: `${root}/Cursor.exe` })
  282. expect(found?.icon).toEqual({ kind: 'executable', path: `${root}/Cursor.exe` })
  283. // An unexpandable registered target falls through, and the remaining
  284. // locators (Uninstall records, file candidates) also miss here.
  285. await expect(resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({
  286. platform: 'win32', env: {}, run,
  287. }))).resolves.toBeNull()
  288. // Unreadable registry roots (reg.exe rejects) contribute nothing.
  289. await expect(resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({ platform: 'win32', env: {} })))
  290. .resolves.toBeNull()
  291. })
  292. it('verifies Uninstall records through InstallLocation and falls back to the DisplayIcon executable', async () => {
  293. const root = await tempRoot()
  294. const git = join(root, 'Git')
  295. await mkdir(git, { recursive: true })
  296. await writeFile(join(git, 'git-bash.exe'), 'exe')
  297. const fork = join(root, 'Fork.exe')
  298. await writeFile(fork, 'exe')
  299. await mkdir(join(root, 'empty-install'), { recursive: true })
  300. const run = runner((command, args) => {
  301. if (command !== 'reg.exe') return null
  302. const key = String(args[1])
  303. if (key.includes('App Paths')) return ''
  304. return [
  305. // Git records that prove nothing come first: an unexpandable
  306. // location, then a location without the launcher.
  307. `${key}\\Git_stale`,
  308. ' DisplayName REG_SZ Git version 0.1',
  309. ' InstallLocation REG_SZ %UNSET_BASE%/git',
  310. `${key}\\Git_hollow`,
  311. ' DisplayName REG_SZ Git version 0.2',
  312. ` InstallLocation REG_SZ ${join(root, 'empty-install')}`,
  313. `${key}\\Git_is1`,
  314. ' DisplayName REG_SZ Git version 2.44.0',
  315. ` InstallLocation REG_SZ "${git}"`,
  316. `${key}\\ForkUnexpandable`,
  317. ' DisplayName REG_SZ Fork Beta',
  318. ' DisplayIcon REG_SZ %UNSET_ICON%/Fork.exe',
  319. `${key}\\Fork`,
  320. ' DisplayName REG_SZ Fork',
  321. ` DisplayIcon REG_SZ "${fork}",0`,
  322. `${key}\\NoUseableLauncher`,
  323. ' DisplayName REG_SZ Fork Legacy Notes',
  324. `${key}\\Nameless`,
  325. ` InstallLocation REG_SZ ${root}`,
  326. '',
  327. ].join('\r\n')
  328. })
  329. const internals = bare({ platform: 'win32', env: {}, run })
  330. const gitBash = await resolveLaunch(byId('gitbash'), TIMEOUT_MS, internals)
  331. expect(gitBash?.launch).toEqual({ kind: 'argv', command: join(git, 'git-bash.exe'), args: ['--cd={path}'] })
  332. const forkFound = await resolveLaunch(byId('fork'), TIMEOUT_MS, internals)
  333. expect(forkFound?.launch).toMatchObject({ kind: 'argv', command: fork })
  334. })
  335. it('resolves GitHub Desktop through its packaged CLI, skipping incomplete newer installs', async () => {
  336. const localAppData = await tempRoot()
  337. const installRoot = join(localAppData, 'GitHubDesktop')
  338. const complete = join(installRoot, 'app-3.3.6')
  339. const executable = join(complete, 'GitHubDesktop.exe')
  340. const cli = join(complete, 'resources', 'app', 'cli.js')
  341. await mkdir(join(installRoot, 'app-3.4.0', 'resources', 'app'), { recursive: true })
  342. await writeFile(join(installRoot, 'app-3.4.0', 'GitHubDesktop.exe'), 'incomplete')
  343. await mkdir(join(complete, 'resources', 'app'), { recursive: true })
  344. await writeFile(executable, 'exe')
  345. await writeFile(cli, 'cli')
  346. await expect(resolveLaunch(byId('github'), TIMEOUT_MS, bare({
  347. platform: 'win32', env: { LOCALAPPDATA: localAppData },
  348. }))).resolves.toEqual({
  349. launch: {
  350. kind: 'argv',
  351. command: executable,
  352. args: [cli, 'open'],
  353. env: { ELECTRON_RUN_AS_NODE: '1' },
  354. windowsHide: true,
  355. },
  356. icon: { kind: 'executable', path: executable },
  357. })
  358. await rm(cli)
  359. await expect(resolveLaunch(byId('github'), TIMEOUT_MS, bare({
  360. platform: 'win32', env: { LOCALAPPDATA: localAppData },
  361. }))).resolves.toBeNull()
  362. await expect(resolveLaunch(byId('github'), TIMEOUT_MS, bare({
  363. platform: 'win32', env: { LOCALAPPDATA: join(localAppData, 'missing') },
  364. }))).resolves.toBeNull()
  365. })
  366. it('falls back to the desktop entry when the CLI is off PATH, honoring TryExec and quoted Exec', async () => {
  367. const home = await tempRoot()
  368. const applications = join(home, '.local', 'share', 'applications')
  369. await mkdir(applications, { recursive: true })
  370. const kittyBin = join(home, 'bin', 'kitty')
  371. await mkdir(join(home, 'bin'), { recursive: true })
  372. await writeFile(kittyBin, 'bin')
  373. await writeFile(join(applications, 'kitty.desktop'), [
  374. '[Desktop Entry]',
  375. `TryExec=${kittyBin}`,
  376. 'Exec=kitty --start-as normal %U',
  377. 'Icon=kitty',
  378. '',
  379. ].join('\n'))
  380. const found = await resolveLaunch(byId('kitty'), TIMEOUT_MS, bare({ platform: 'linux', home, env: linuxEnv(home) }))
  381. expect(found?.launch).toEqual({ kind: 'argv', command: kittyBin, args: ['--directory'] })
  382. // A quoted absolute Exec command verifies on disk through its first token.
  383. const gnomeBin = join(home, 'bin', 'gnome-terminal-bin')
  384. await writeFile(gnomeBin, 'bin')
  385. await writeFile(join(applications, 'org.gnome.Terminal.desktop'), [
  386. '[Desktop Entry]',
  387. `Exec="${gnomeBin}" --window %U`,
  388. '',
  389. ].join('\n'))
  390. const viaExec = await resolveLaunch(byId('gnometerminal'), TIMEOUT_MS, bare({
  391. platform: 'linux', home, env: linuxEnv(home),
  392. }))
  393. expect(viaExec?.launch).toEqual({ kind: 'argv', command: gnomeBin, args: ['--working-directory={path}'] })
  394. // A bare Exec name resolves through the in-process PATH resolver;
  395. // XDG_DATA_HOME takes precedence over the home-derived default.
  396. const dataHome = join(home, 'xdg-data')
  397. await mkdir(join(dataHome, 'applications'), { recursive: true })
  398. await writeFile(join(dataHome, 'applications', 'org.kde.konsole.desktop'), [
  399. '[Desktop Entry]',
  400. 'Exec=konsole-launcher --hold',
  401. '',
  402. ].join('\n'))
  403. const viaPath = await resolveLaunch(byId('konsole'), TIMEOUT_MS, bare({
  404. platform: 'linux', home, env: { ...linuxEnv(home), XDG_DATA_HOME: dataHome },
  405. resolveExecutable: pathTable({ 'konsole-launcher': '/usr/bin/konsole-launcher' }),
  406. }))
  407. expect(viaPath?.launch).toEqual({ kind: 'argv', command: '/usr/bin/konsole-launcher', args: ['--workdir'] })
  408. })
  409. it('resolves nothing from missing or unusable desktop entries', async () => {
  410. const home = await tempRoot()
  411. const applications = join(home, '.local', 'share', 'applications')
  412. await mkdir(applications, { recursive: true })
  413. const internals = bare({ platform: 'linux', home, env: linuxEnv(home) })
  414. // No desktop entry at all.
  415. await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
  416. // A TryExec absent from disk.
  417. await writeFile(join(applications, 'org.kde.konsole.desktop'), [
  418. '[Desktop Entry]',
  419. `TryExec=${join(home, 'gone')}`,
  420. '',
  421. ].join('\n'))
  422. await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
  423. // An empty TryExec with no Exec proves nothing.
  424. await writeFile(join(applications, 'org.kde.konsole.desktop'), '[Desktop Entry]\nTryExec=\n')
  425. await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
  426. // No Exec/TryExec keys at all.
  427. await writeFile(join(applications, 'org.kde.konsole.desktop'), '[Desktop Entry]\nIcon=konsole\n')
  428. await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
  429. // A bare Exec name off PATH.
  430. await writeFile(join(applications, 'org.kde.konsole.desktop'), '[Desktop Entry]\nExec=konsole-launcher\n')
  431. await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
  432. })
  433. })
  434. describe('registry and desktop parsing', () => {
  435. it('parses localized default-value markers and ignores lines outside a key block', () => {
  436. const dump = [
  437. 'ignored preamble',
  438. 'HKEY_CURRENT_USER\\...\\App Paths\\Code.exe',
  439. ' (默认) REG_SZ C:\\Code.exe',
  440. ' Path REG_EXPAND_SZ %LOCALAPPDATA%\\Code',
  441. ' Flags REG_DWORD 0x1',
  442. '',
  443. ].join('\r\n')
  444. const parsed = parseRegistryDump(dump)
  445. const values = parsed.get('HKEY_CURRENT_USER\\...\\App Paths\\Code.exe')
  446. expect(values?.get('(Default)')).toBe('C:\\Code.exe')
  447. expect(values?.get('Path')).toBe('%LOCALAPPDATA%\\Code')
  448. expect(values?.has('Flags')).toBe(false)
  449. })
  450. it('reads only the [Desktop Entry] section and tolerates comment and malformed lines', () => {
  451. expect(parseDesktopEntry([
  452. '# comment',
  453. '[Desktop Action new-window]',
  454. 'Exec=ignored --new-window',
  455. '[Desktop Entry]',
  456. 'no separator line',
  457. 'Name=Kitty',
  458. 'Exec=kitty %U',
  459. 'TryExec=/usr/bin/kitty',
  460. 'Icon=kitty',
  461. '',
  462. ].join('\n'))).toEqual({ exec: 'kitty %U', tryExec: '/usr/bin/kitty', icon: 'kitty' })
  463. })
  464. it('takes an Exec command as its quoted or bare first token, and none from blank text', () => {
  465. expect(execCommand(undefined)).toBeNull()
  466. expect(execCommand('"/opt/App Name/bin" --flag')).toBe('/opt/App Name/bin')
  467. expect(execCommand('kitty --directory %U')).toBe('kitty')
  468. expect(execCommand(' ')).toBeNull()
  469. })
  470. it('orders XDG data directories home-first with the freedesktop defaults', () => {
  471. const completed = { env: {}, home: '/h', resolveExecutable: pathTable() }
  472. // The home default goes through join(), so the expectation does too —
  473. // the Windows lane runs this unit over win32 separators.
  474. expect(xdgDataDirectories(resolveInternals(completed)))
  475. .toEqual([join('/h', '.local', 'share'), '/usr/local/share', '/usr/share'])
  476. expect(xdgDataDirectories(resolveInternals({ ...completed, env: { XDG_DATA_HOME: '/x', XDG_DATA_DIRS: '/a::/b' } })))
  477. .toEqual(['/x', '/a', '/b'])
  478. })
  479. })
  480. describe('launchResolved', () => {
  481. /** Launcher recording calls; entries in `outcomes` control each command's fate. */
  482. function launcher(
  483. calls: unknown[][], outcomes: Readonly<Record<string, 'ok' | 'fail' | 'enoent'>> = {},
  484. ): OpenInAppLauncher {
  485. return (command, args, options) => {
  486. calls.push([command, ...args, options])
  487. const outcome = outcomes[command] ?? 'ok'
  488. if (outcome === 'ok') return Promise.resolve()
  489. if (outcome === 'enoent') return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
  490. return Promise.reject(new Error('launch fails'))
  491. }
  492. }
  493. const resolved: OpenInAppResolvedLaunch = { launch: { kind: 'argv', command: 'primary', args: [] } }
  494. const withFallback: OpenInAppResolvedLaunch = {
  495. launch: { kind: 'argv', command: 'primary', args: [] },
  496. fallbackLaunch: { kind: 'argv', command: 'fallback', args: [] },
  497. }
  498. it('appends the directory or substitutes {path} in place', async () => {
  499. const calls: unknown[][] = []
  500. await expect(launchResolved(
  501. { launch: { kind: 'argv', command: 'git-bash', args: ['--cd={path}'] } }, 'C:\\w\\dir', TIMEOUT_MS,
  502. bare({ launch: launcher(calls) }),
  503. )).resolves.toBe('launched')
  504. await expect(launchResolved(
  505. { launch: { kind: 'argv', command: 'code', args: [] } }, '/w/dir', TIMEOUT_MS,
  506. bare({ launch: launcher(calls) }),
  507. )).resolves.toBe('launched')
  508. expect(calls).toEqual([
  509. ['git-bash', '--cd=C:\\w\\dir', { watchMs: TIMEOUT_MS }],
  510. ['code', '/w/dir', { watchMs: TIMEOUT_MS }],
  511. ])
  512. })
  513. it('passes adapter-specific environment and Windows visibility policy', async () => {
  514. const calls: unknown[][] = []
  515. await expect(launchResolved({
  516. launch: {
  517. kind: 'argv',
  518. command: 'GitHubDesktop.exe',
  519. args: ['cli.js', 'open'],
  520. env: { ELECTRON_RUN_AS_NODE: '1' },
  521. windowsHide: true,
  522. },
  523. }, 'C:\\w\\repo', TIMEOUT_MS, bare({ launch: launcher(calls) }))).resolves.toBe('launched')
  524. expect(calls).toEqual([[
  525. 'GitHubDesktop.exe', 'cli.js', 'open', 'C:\\w\\repo',
  526. { watchMs: TIMEOUT_MS, env: { ELECTRON_RUN_AS_NODE: '1' }, windowsHide: true },
  527. ]])
  528. })
  529. it('opens a shell-open launch through the OS path opener, not a detached spawn', async () => {
  530. const spawns: unknown[][] = []
  531. const commands: string[][] = []
  532. await expect(launchResolved(
  533. { launch: { kind: 'shell-open' } }, 'C:\\w\\dir', TIMEOUT_MS,
  534. bare({
  535. platform: 'win32',
  536. launch: launcher(spawns),
  537. run: async (command, args) => {
  538. commands.push([command, ...args])
  539. return { stdout: '', stderr: '' }
  540. },
  541. }),
  542. )).resolves.toBe('launched')
  543. // The opener is the shipped Invoke-Item channel; the detached spawner never runs.
  544. expect(spawns).toEqual([])
  545. expect(commands).toEqual([
  546. ['powershell.exe', '-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\w\\dir'"],
  547. ])
  548. })
  549. it('counts a shell-open opener that outlives the watch window as launched, and a fast failure as failed', async () => {
  550. // A cold powershell start can outlive the window: still-running counts launched.
  551. await expect(launchResolved(
  552. { launch: { kind: 'shell-open' } }, '/w/dir', 25,
  553. bare({ platform: 'darwin', run: () => new Promise(() => {}) }),
  554. )).resolves.toBe('launched')
  555. // A failure inside the window is the outcome.
  556. await expect(launchResolved(
  557. { launch: { kind: 'shell-open' } }, '/w/dir', TIMEOUT_MS,
  558. bare({ platform: 'darwin', run: async () => { throw new Error('opener failed') } }),
  559. )).resolves.toBe('failed')
  560. // A vanished opener marks the resolution stale, like an argv launcher.
  561. await expect(launchResolved(
  562. { launch: { kind: 'shell-open' } }, '/w/dir', TIMEOUT_MS,
  563. bare({ platform: 'darwin', run: async () => {
  564. throw Object.assign(new Error('spawn open ENOENT'), { code: 'ENOENT' })
  565. } }),
  566. )).resolves.toBe('missing')
  567. // A late failure after the window settles nothing (already launched).
  568. let rejectLate: ((error: Error) => void) | undefined
  569. await expect(launchResolved(
  570. { launch: { kind: 'shell-open' } }, '/w/dir', 25,
  571. bare({ platform: 'darwin', run: () => new Promise((_resolve, reject) => { rejectLate = reject }) }),
  572. )).resolves.toBe('launched')
  573. rejectLate?.(new Error('late opener failure'))
  574. })
  575. it('tries the fallback when the primary fails and classifies the ways an attempt ends', async () => {
  576. const calls: unknown[][] = []
  577. await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
  578. launch: launcher(calls, { primary: 'fail' }),
  579. }))).resolves.toBe('launched')
  580. expect(calls.map(call => call[0])).toEqual(['primary', 'fallback'])
  581. await expect(launchResolved(resolved, '/w/dir', TIMEOUT_MS, bare({ launch: launcher([], { primary: 'fail' }) })))
  582. .resolves.toBe('failed')
  583. await expect(launchResolved(resolved, '/w/dir', TIMEOUT_MS, bare({ launch: launcher([], { primary: 'enoent' }) })))
  584. .resolves.toBe('missing')
  585. // Either tried launcher having vanished reports missing.
  586. await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
  587. launch: launcher([], { primary: 'enoent', fallback: 'fail' }),
  588. }))).resolves.toBe('missing')
  589. await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
  590. launch: launcher([], { primary: 'fail', fallback: 'enoent' }),
  591. }))).resolves.toBe('missing')
  592. await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
  593. launch: launcher([], { primary: 'fail', fallback: 'fail' }),
  594. }))).resolves.toBe('failed')
  595. })
  596. })
  597. describe('launchDetachedApp', () => {
  598. const node = process.execPath
  599. it('resolves when the child exits 0 inside the watch window', async () => {
  600. await expect(launchDetachedApp(node, ['-e', ''], { watchMs: TIMEOUT_MS }))
  601. .resolves.toBeUndefined()
  602. })
  603. it('rejects a nonzero exit inside the window', async () => {
  604. await expect(launchDetachedApp(node, ['-e', 'process.exit(3)'], { watchMs: TIMEOUT_MS }))
  605. .rejects.toThrow(/launcher exited with/)
  606. })
  607. it('rejects a signal-terminated child with the signal name', async () => {
  608. await expect(launchDetachedApp(
  609. node, ['-e', 'process.kill(process.pid, "SIGKILL"); setTimeout(() => {}, 5000)'],
  610. { watchMs: TIMEOUT_MS },
  611. )).rejects.toThrow(/launcher exited with/)
  612. })
  613. it('rejects a spawn failure, carrying the ENOENT code', async () => {
  614. await expect(launchDetachedApp('dsh-definitely-missing-launcher', [], { watchMs: TIMEOUT_MS }))
  615. .rejects.toMatchObject({ code: 'ENOENT' })
  616. })
  617. it('hands the child a credential-scrubbed environment with explicit adapter entries', async () => {
  618. const root = await tempRoot()
  619. const witness = join(root, 'env.json')
  620. process.env.OPEN_IN_APP_SPEC_API_KEY = 'leak'
  621. process.env.OPEN_IN_APP_SPEC_PLAIN = 'visible'
  622. try {
  623. await launchDetachedApp(node, [
  624. '-e',
  625. 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify(['
  626. + 'process.env.OPEN_IN_APP_SPEC_API_KEY ?? null, process.env.OPEN_IN_APP_SPEC_PLAIN ?? null, '
  627. + 'process.env.ELECTRON_RUN_AS_NODE ?? null]))',
  628. witness,
  629. ], { watchMs: TIMEOUT_MS, env: { OPEN_IN_APP_SPEC_PLAIN: 'overridden', ELECTRON_RUN_AS_NODE: '1' } })
  630. } finally {
  631. delete process.env.OPEN_IN_APP_SPEC_API_KEY
  632. delete process.env.OPEN_IN_APP_SPEC_PLAIN
  633. }
  634. expect(JSON.parse(await readFile(witness, 'utf8'))).toEqual([null, 'overridden', '1'])
  635. })
  636. })