| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686 |
- /**
- * Resolver behavior over a deterministic command runner and an in-process
- * PATH-resolution fake: per-platform locator chains, the one-pass catalog
- * resolution map, registry/desktop parsing, and launch-outcome
- * classification. Filesystem-facing locators use real temp directories; no
- * host application is touched.
- */
- import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import type { NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
- import { OPEN_IN_APP_CATALOG, type OpenInAppApp } from '../src/catalog.ts'
- import {
- execCommand, launchDetachedApp, launchResolved, parseDesktopEntry, parseRegistryDump, resolveInternals,
- resolveLaunch, resolveOpenInAppApps, xdgDataDirectories,
- type OpenInAppInternals, type OpenInAppLauncher, type OpenInAppResolvedLaunch,
- } from '../src/resolver.ts'
- const TIMEOUT_MS = 5_000
- const roots: string[] = []
- afterEach(async () => {
- await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
- })
- async function tempRoot(): Promise<string> {
- const root = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-spec-'))
- roots.push(root)
- return root
- }
- /** Runner resolving for the allowed argv prefixes and rejecting the rest. */
- function runner(allow: (command: string, args: readonly string[]) => string | null): NativeCommandRunner {
- return (command, args) => {
- const stdout = allow(command, [...args])
- return stdout === null
- ? Promise.reject(new Error(`fixture rejects: ${command} ${args.join(' ')}`))
- : Promise.resolve({ stdout, stderr: '' })
- }
- }
- /** PATH-resolution fake answering from a fixed name-to-path table. */
- function pathTable(entries: Record<string, string> = {}): (name: string) => Promise<string | null> {
- return name => Promise.resolve(entries[name] ?? null)
- }
- function byId(id: string): OpenInAppApp {
- const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === id)
- if (app === undefined) throw new Error(`missing catalog id: ${id}`)
- return app
- }
- /** Internals baseline every call completes: a rejecting runner and an empty PATH. */
- function bare(overrides: OpenInAppInternals): OpenInAppInternals {
- return { env: {}, run: runner(() => null), resolveExecutable: pathTable(), ...overrides }
- }
- /** Hermetic Linux environment: XDG lookups stay inside the temp home. */
- function linuxEnv(home: string): Readonly<Record<string, string>> {
- return { XDG_DATA_DIRS: join(home, 'xdg-empty') }
- }
- describe('resolveOpenInAppApps', () => {
- it.each(['darwin', 'win32', 'linux'] as const)('offers no applications over SSH on %s without probing', async (platform) => {
- const run = vi.fn<NativeCommandRunner>()
- const resolveExecutable = vi.fn(pathTable({ code: '/usr/bin/code' }))
- const facts = {
- platform, ssh: true, env: { DISPLAY: ':0', VSCODE_IPC_HOOK_CLI: '/tmp/vscode.sock' },
- run, resolveExecutable,
- }
- await expect(resolveOpenInAppApps(TIMEOUT_MS, facts)).resolves.toEqual(new Map())
- await expect(resolveLaunch(byId('vscode'), TIMEOUT_MS, facts)).resolves.toBeNull()
- expect(run).not.toHaveBeenCalled()
- expect(resolveExecutable).not.toHaveBeenCalled()
- })
- it('keeps local applications available regardless of flattened SSH markers', async () => {
- const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({
- platform: 'darwin', applicationRoots: [], env: { SSH_CONNECTION: 'stale-value' },
- }))
- expect([...map.keys()]).toEqual(['finder', 'terminal'])
- })
- it('fails loud when the PATH resolver is not supplied', async () => {
- await expect(resolveOpenInAppApps(TIMEOUT_MS, { platform: 'linux' }))
- .rejects.toThrow(/resolveExecutable is required/)
- })
- it('resolves as empty on a platform without entries, touching no command or PATH lookup', async () => {
- const run = vi.fn<NativeCommandRunner>()
- const resolveExecutable = vi.fn(pathTable())
- await expect(resolveOpenInAppApps(TIMEOUT_MS, { platform: 'aix', run, resolveExecutable }))
- .resolves.toEqual(new Map())
- expect(run).not.toHaveBeenCalled()
- expect(resolveExecutable).not.toHaveBeenCalled()
- })
- it('resolves macOS entries from the known application directories, in menu order', async () => {
- const home = await tempRoot()
- const applications = join(home, 'Applications')
- const cursor = join(applications, 'Cursor.app')
- const zed = join(applications, 'Zed Preview.app')
- await mkdir(cursor, { recursive: true })
- await mkdir(zed, { recursive: true })
- const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({
- platform: 'darwin', applicationRoots: [applications],
- }))
- // finder and terminal ship with the OS (fixed); cursor and the Zed
- // Preview spelling resolve from the injected application root.
- expect([...map.keys()]).toEqual(['finder', 'cursor', 'zed', 'terminal'])
- expect(map.get('cursor')).toEqual({
- launch: { kind: 'argv', command: 'open', args: ['-a', cursor] },
- icon: { kind: 'app-bundle', path: cursor },
- })
- expect(map.get('zed')?.launch).toEqual({ kind: 'argv', command: 'open', args: ['-a', zed] })
- })
- it('resolves Linux entries in-process through the PATH resolver, never spawning a lookup', async () => {
- const home = await tempRoot()
- const run = vi.fn<NativeCommandRunner>()
- const map = await resolveOpenInAppApps(TIMEOUT_MS, {
- platform: 'linux', home, env: { ...linuxEnv(home), DISPLAY: ':0' }, run,
- resolveExecutable: pathTable({ 'xdg-open': '/usr/bin/xdg-open', code: '/usr/bin/code', ghostty: '/usr/bin/ghostty' }),
- })
- expect([...map.keys()]).toEqual(['filemanager', 'vscode', 'ghostty'])
- expect(map.get('ghostty')?.launch).toEqual({ kind: 'argv', command: '/usr/bin/ghostty', args: ['--working-directory={path}'] })
- expect(run).not.toHaveBeenCalled()
- })
- it('does not offer the Linux file manager without a desktop session', async () => {
- const home = await tempRoot()
- const resolveExecutable = pathTable({ 'xdg-open': '/usr/bin/xdg-open' })
- await expect(resolveLaunch(byId('filemanager'), TIMEOUT_MS, bare({
- platform: 'linux', home, env: linuxEnv(home), resolveExecutable,
- }))).resolves.toBeNull()
- await expect(resolveLaunch(byId('filemanager'), TIMEOUT_MS, bare({
- platform: 'linux', home, env: { ...linuxEnv(home), WAYLAND_DISPLAY: 'wayland-0' }, resolveExecutable,
- }))).resolves.toEqual({ launch: { kind: 'argv', command: '/usr/bin/xdg-open', args: [] }, icon: undefined })
- })
- it('reads the Windows registry at most once per pass, sharing the view across entries', async () => {
- const root = await tempRoot()
- const code = join(root, 'apps', 'Code.exe')
- const sublime = join(root, 'apps', 'sublime_text.exe')
- await mkdir(join(root, 'apps'), { recursive: true })
- await writeFile(code, 'exe')
- await writeFile(sublime, 'exe')
- const regQueries: string[] = []
- const run = runner((command, args) => {
- if (command !== 'reg.exe') return null
- const key = String(args[1])
- regQueries.push(key)
- if (key.includes('App Paths')) {
- return [
- `${key}\\Code.exe`,
- ` (Default) REG_SZ ${code}`,
- `${key}\\sublime_text.exe`,
- ` (Default) REG_SZ "${sublime}"`,
- '',
- ].join('\r\n')
- }
- return ''
- })
- const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({ platform: 'win32', env: {}, run }))
- expect(map.get('vscode')).toEqual({
- launch: { kind: 'argv', command: code, args: [] },
- icon: { kind: 'executable', path: code },
- })
- expect(map.get('sublimetext')?.launch).toMatchObject({ kind: 'argv', command: sublime })
- // One pass reads each registry root once: two App Paths roots and, for
- // the entries whose earlier locators all missed, three Uninstall roots.
- expect(regQueries.filter(key => key.includes('App Paths'))).toHaveLength(2)
- expect(regQueries.filter(key => key.includes('Uninstall'))).toHaveLength(3)
- })
- })
- describe('resolveLaunch locators', () => {
- it('fixed entries expand their icon source and survive without one', async () => {
- const systemRoot = 'C:/Windows'
- await expect(resolveLaunch(byId('explorer'), TIMEOUT_MS, bare({
- platform: 'win32', env: { SystemRoot: systemRoot },
- }))).resolves.toEqual({
- launch: { kind: 'shell-open' },
- icon: { kind: 'executable', path: `${systemRoot}/explorer.exe` },
- })
- // An unset ${SystemRoot} drops the icon claim, not the entry.
- await expect(resolveLaunch(byId('explorer'), TIMEOUT_MS, bare({ platform: 'win32', env: {} })))
- .resolves.toMatchObject({ launch: { kind: 'shell-open' }, icon: undefined })
- // macOS fixed entries trust their OS-shipped bundle path.
- await expect(resolveLaunch(byId('finder'), TIMEOUT_MS, bare({ platform: 'darwin', env: {} })))
- .resolves.toEqual({
- launch: { kind: 'shell-open' },
- icon: { kind: 'app-bundle', path: '/System/Library/CoreServices/Finder.app' },
- })
- })
- it('derives the Xcode bundle from xcode-select with the open -a fallback, rejecting non-bundle answers', async () => {
- const home = await tempRoot()
- const bundle = join(home, 'Xcode-beta.app')
- await mkdir(join(bundle, 'Contents', 'Developer'), { recursive: true })
- const run = runner(command => command === 'xcode-select' ? `${join(bundle, 'Contents', 'Developer')}\n` : null)
- await expect(resolveLaunch(byId('xcode'), TIMEOUT_MS, bare({ platform: 'darwin', run })))
- .resolves.toEqual({
- launch: { kind: 'argv', command: 'xed', args: [] },
- fallbackLaunch: { kind: 'argv', command: 'open', args: ['-a', bundle] },
- icon: { kind: 'app-bundle', path: bundle },
- })
- const rootAnswer = runner(command => command === 'xcode-select' ? '/\n' : null)
- await expect(resolveLaunch(byId('xcode'), TIMEOUT_MS, bare({ platform: 'darwin', run: rootAnswer })))
- .resolves.toBeNull()
- await expect(resolveLaunch(byId('xcode'), TIMEOUT_MS, bare({ platform: 'darwin' }))).resolves.toBeNull()
- })
- it('marks a resolved Windows CLI as its own icon source', async () => {
- await expect(resolveLaunch(byId('windowsterminal'), TIMEOUT_MS, bare({
- platform: 'win32', env: {}, resolveExecutable: pathTable({ wt: 'C:\\WA\\wt.exe' }),
- }))).resolves.toEqual({
- launch: { kind: 'argv', command: 'C:\\WA\\wt.exe', args: ['-d'] },
- icon: { kind: 'executable', path: 'C:\\WA\\wt.exe' },
- })
- })
- it('skips file candidates with unset variables and missing files, taking the first existing one', async () => {
- const root = await tempRoot()
- const local = join(root, 'local')
- const programFiles = join(root, 'pf')
- await mkdir(local, { recursive: true })
- // Candidate expansion is string substitution, so the resolved command
- // keeps the template's '/' separators after the expanded prefix.
- const code = `${programFiles}/Microsoft VS Code/Code.exe`
- await mkdir(join(programFiles, 'Microsoft VS Code'), { recursive: true })
- await writeFile(code, 'exe')
- // LOCALAPPDATA is set but holds no install; the ProgramFiles candidate wins.
- const found = await resolveLaunch(byId('vscode'), TIMEOUT_MS, bare({
- platform: 'win32', env: { LOCALAPPDATA: local, ProgramFiles: programFiles }, run: runner(() => ''),
- }))
- expect(found?.launch).toEqual({ kind: 'argv', command: code, args: [] })
- expect(found?.icon).toEqual({ kind: 'executable', path: code })
- // An unset ${LOCALAPPDATA} skips Cursor's only file candidate entirely.
- await expect(resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({ platform: 'win32', env: {}, run: runner(() => '') })))
- .resolves.toBeNull()
- })
- it('expands ~/ against the injected home for Toolbox scripts, with no Windows icon claim on Linux', async () => {
- const home = await tempRoot()
- const script = join(home, '.local', 'share', 'JetBrains', 'Toolbox', 'scripts', 'idea')
- await mkdir(join(home, '.local', 'share', 'JetBrains', 'Toolbox', 'scripts'), { recursive: true })
- await writeFile(script, '#!/bin/sh')
- await expect(resolveLaunch(byId('intellij'), TIMEOUT_MS, bare({ platform: 'linux', home, env: linuxEnv(home) })))
- .resolves.toEqual({ launch: { kind: 'argv', command: script, args: [] }, icon: undefined })
- })
- it('scans versioned installs newest-first, skipping versions without the launcher', async () => {
- const root = await tempRoot()
- const programFiles = join(root, 'pf')
- const kept = join(programFiles, 'JetBrains', 'PyCharm 2023.3', 'bin', 'pycharm64.exe')
- await mkdir(join(programFiles, 'JetBrains', 'PyCharm 2024.1'), { recursive: true })
- await mkdir(join(programFiles, 'JetBrains', 'PyCharm 2023.3', 'bin'), { recursive: true })
- await writeFile(kept, 'exe')
- const internals = bare({ platform: 'win32', env: { ProgramFiles: programFiles }, run: runner(() => '') })
- const found = await resolveLaunch(byId('pycharm'), TIMEOUT_MS, internals)
- expect(found?.launch).toEqual({ kind: 'argv', command: kept, args: [] })
- // A scan root that does not exist resolves nothing.
- await expect(resolveLaunch(byId('webstorm'), TIMEOUT_MS, {
- ...internals, env: { ProgramFiles: join(root, 'nonesuch') },
- })).resolves.toBeNull()
- // An unset scan-root variable resolves nothing.
- await expect(resolveLaunch(byId('webstorm'), TIMEOUT_MS, { ...internals, env: {} })).resolves.toBeNull()
- // Numeric-aware ordering: '2024.1.10' outranks '2024.1.9'.
- const ten = join(programFiles, 'JetBrains', 'WebStorm 2024.1.10', 'bin', 'webstorm64.exe')
- await mkdir(join(programFiles, 'JetBrains', 'WebStorm 2024.1.9', 'bin'), { recursive: true })
- await writeFile(join(programFiles, 'JetBrains', 'WebStorm 2024.1.9', 'bin', 'webstorm64.exe'), 'exe')
- await mkdir(join(programFiles, 'JetBrains', 'WebStorm 2024.1.10', 'bin'), { recursive: true })
- await writeFile(ten, 'exe')
- const newest = await resolveLaunch(byId('webstorm'), TIMEOUT_MS, internals)
- expect(newest?.launch).toEqual({ kind: 'argv', command: ten, args: [] })
- // A root whose matching versions all lack the launcher resolves nothing
- // (goland's Uninstall records and file candidates also miss here).
- await mkdir(join(programFiles, 'JetBrains', 'GoLand 2024.2'), { recursive: true })
- await expect(resolveLaunch(byId('goland'), TIMEOUT_MS, internals)).resolves.toBeNull()
- })
- it('resolves App Paths hits only when the registered target exists on disk', async () => {
- const root = await tempRoot()
- const cursor = join(root, 'Cursor.exe')
- await writeFile(cursor, 'exe')
- const run = runner((command, args) => {
- if (command !== 'reg.exe') return null
- const key = String(args[1])
- if (!key.includes('App Paths')) return ''
- // The fixture value uses '/' so the expanded path exists on the POSIX
- // test host; expansion is string substitution either way.
- return [
- `${key}\\Cursor.exe`,
- ' (Default) REG_EXPAND_SZ %INSTALL_BASE%/Cursor.exe',
- '',
- ].join('\r\n')
- })
- // %INSTALL_BASE% expands against the injected environment.
- const found = await resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({
- platform: 'win32', env: { INSTALL_BASE: root }, run,
- }))
- expect(found?.launch).toMatchObject({ kind: 'argv', command: `${root}/Cursor.exe` })
- expect(found?.icon).toEqual({ kind: 'executable', path: `${root}/Cursor.exe` })
- // An unexpandable registered target falls through, and the remaining
- // locators (Uninstall records, file candidates) also miss here.
- await expect(resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({
- platform: 'win32', env: {}, run,
- }))).resolves.toBeNull()
- // Unreadable registry roots (reg.exe rejects) contribute nothing.
- await expect(resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({ platform: 'win32', env: {} })))
- .resolves.toBeNull()
- })
- it('verifies Uninstall records through InstallLocation and falls back to the DisplayIcon executable', async () => {
- const root = await tempRoot()
- const git = join(root, 'Git')
- await mkdir(git, { recursive: true })
- await writeFile(join(git, 'git-bash.exe'), 'exe')
- const fork = join(root, 'Fork.exe')
- await writeFile(fork, 'exe')
- await mkdir(join(root, 'empty-install'), { recursive: true })
- const run = runner((command, args) => {
- if (command !== 'reg.exe') return null
- const key = String(args[1])
- if (key.includes('App Paths')) return ''
- return [
- // Git records that prove nothing come first: an unexpandable
- // location, then a location without the launcher.
- `${key}\\Git_stale`,
- ' DisplayName REG_SZ Git version 0.1',
- ' InstallLocation REG_SZ %UNSET_BASE%/git',
- `${key}\\Git_hollow`,
- ' DisplayName REG_SZ Git version 0.2',
- ` InstallLocation REG_SZ ${join(root, 'empty-install')}`,
- `${key}\\Git_is1`,
- ' DisplayName REG_SZ Git version 2.44.0',
- ` InstallLocation REG_SZ "${git}"`,
- `${key}\\ForkUnexpandable`,
- ' DisplayName REG_SZ Fork Beta',
- ' DisplayIcon REG_SZ %UNSET_ICON%/Fork.exe',
- `${key}\\Fork`,
- ' DisplayName REG_SZ Fork',
- ` DisplayIcon REG_SZ "${fork}",0`,
- `${key}\\NoUseableLauncher`,
- ' DisplayName REG_SZ Fork Legacy Notes',
- `${key}\\Nameless`,
- ` InstallLocation REG_SZ ${root}`,
- '',
- ].join('\r\n')
- })
- const internals = bare({ platform: 'win32', env: {}, run })
- const gitBash = await resolveLaunch(byId('gitbash'), TIMEOUT_MS, internals)
- expect(gitBash?.launch).toEqual({ kind: 'argv', command: join(git, 'git-bash.exe'), args: ['--cd={path}'] })
- const forkFound = await resolveLaunch(byId('fork'), TIMEOUT_MS, internals)
- expect(forkFound?.launch).toMatchObject({ kind: 'argv', command: fork })
- })
- it('resolves GitHub Desktop through its packaged CLI, skipping incomplete newer installs', async () => {
- const localAppData = await tempRoot()
- const installRoot = join(localAppData, 'GitHubDesktop')
- const complete = join(installRoot, 'app-3.3.6')
- const executable = join(complete, 'GitHubDesktop.exe')
- const cli = join(complete, 'resources', 'app', 'cli.js')
- await mkdir(join(installRoot, 'app-3.4.0', 'resources', 'app'), { recursive: true })
- await writeFile(join(installRoot, 'app-3.4.0', 'GitHubDesktop.exe'), 'incomplete')
- await mkdir(join(complete, 'resources', 'app'), { recursive: true })
- await writeFile(executable, 'exe')
- await writeFile(cli, 'cli')
- await expect(resolveLaunch(byId('github'), TIMEOUT_MS, bare({
- platform: 'win32', env: { LOCALAPPDATA: localAppData },
- }))).resolves.toEqual({
- launch: {
- kind: 'argv',
- command: executable,
- args: [cli, 'open'],
- env: { ELECTRON_RUN_AS_NODE: '1' },
- windowsHide: true,
- },
- icon: { kind: 'executable', path: executable },
- })
- await rm(cli)
- await expect(resolveLaunch(byId('github'), TIMEOUT_MS, bare({
- platform: 'win32', env: { LOCALAPPDATA: localAppData },
- }))).resolves.toBeNull()
- await expect(resolveLaunch(byId('github'), TIMEOUT_MS, bare({
- platform: 'win32', env: { LOCALAPPDATA: join(localAppData, 'missing') },
- }))).resolves.toBeNull()
- })
- it('falls back to the desktop entry when the CLI is off PATH, honoring TryExec and quoted Exec', async () => {
- const home = await tempRoot()
- const applications = join(home, '.local', 'share', 'applications')
- await mkdir(applications, { recursive: true })
- const kittyBin = join(home, 'bin', 'kitty')
- await mkdir(join(home, 'bin'), { recursive: true })
- await writeFile(kittyBin, 'bin')
- await writeFile(join(applications, 'kitty.desktop'), [
- '[Desktop Entry]',
- `TryExec=${kittyBin}`,
- 'Exec=kitty --start-as normal %U',
- 'Icon=kitty',
- '',
- ].join('\n'))
- const found = await resolveLaunch(byId('kitty'), TIMEOUT_MS, bare({ platform: 'linux', home, env: linuxEnv(home) }))
- expect(found?.launch).toEqual({ kind: 'argv', command: kittyBin, args: ['--directory'] })
- // A quoted absolute Exec command verifies on disk through its first token.
- const gnomeBin = join(home, 'bin', 'gnome-terminal-bin')
- await writeFile(gnomeBin, 'bin')
- await writeFile(join(applications, 'org.gnome.Terminal.desktop'), [
- '[Desktop Entry]',
- `Exec="${gnomeBin}" --window %U`,
- '',
- ].join('\n'))
- const viaExec = await resolveLaunch(byId('gnometerminal'), TIMEOUT_MS, bare({
- platform: 'linux', home, env: linuxEnv(home),
- }))
- expect(viaExec?.launch).toEqual({ kind: 'argv', command: gnomeBin, args: ['--working-directory={path}'] })
- // A bare Exec name resolves through the in-process PATH resolver;
- // XDG_DATA_HOME takes precedence over the home-derived default.
- const dataHome = join(home, 'xdg-data')
- await mkdir(join(dataHome, 'applications'), { recursive: true })
- await writeFile(join(dataHome, 'applications', 'org.kde.konsole.desktop'), [
- '[Desktop Entry]',
- 'Exec=konsole-launcher --hold',
- '',
- ].join('\n'))
- const viaPath = await resolveLaunch(byId('konsole'), TIMEOUT_MS, bare({
- platform: 'linux', home, env: { ...linuxEnv(home), XDG_DATA_HOME: dataHome },
- resolveExecutable: pathTable({ 'konsole-launcher': '/usr/bin/konsole-launcher' }),
- }))
- expect(viaPath?.launch).toEqual({ kind: 'argv', command: '/usr/bin/konsole-launcher', args: ['--workdir'] })
- })
- it('resolves nothing from missing or unusable desktop entries', async () => {
- const home = await tempRoot()
- const applications = join(home, '.local', 'share', 'applications')
- await mkdir(applications, { recursive: true })
- const internals = bare({ platform: 'linux', home, env: linuxEnv(home) })
- // No desktop entry at all.
- await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
- // A TryExec absent from disk.
- await writeFile(join(applications, 'org.kde.konsole.desktop'), [
- '[Desktop Entry]',
- `TryExec=${join(home, 'gone')}`,
- '',
- ].join('\n'))
- await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
- // An empty TryExec with no Exec proves nothing.
- await writeFile(join(applications, 'org.kde.konsole.desktop'), '[Desktop Entry]\nTryExec=\n')
- await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
- // No Exec/TryExec keys at all.
- await writeFile(join(applications, 'org.kde.konsole.desktop'), '[Desktop Entry]\nIcon=konsole\n')
- await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
- // A bare Exec name off PATH.
- await writeFile(join(applications, 'org.kde.konsole.desktop'), '[Desktop Entry]\nExec=konsole-launcher\n')
- await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
- })
- })
- describe('registry and desktop parsing', () => {
- it('parses localized default-value markers and ignores lines outside a key block', () => {
- const dump = [
- 'ignored preamble',
- 'HKEY_CURRENT_USER\\...\\App Paths\\Code.exe',
- ' (默认) REG_SZ C:\\Code.exe',
- ' Path REG_EXPAND_SZ %LOCALAPPDATA%\\Code',
- ' Flags REG_DWORD 0x1',
- '',
- ].join('\r\n')
- const parsed = parseRegistryDump(dump)
- const values = parsed.get('HKEY_CURRENT_USER\\...\\App Paths\\Code.exe')
- expect(values?.get('(Default)')).toBe('C:\\Code.exe')
- expect(values?.get('Path')).toBe('%LOCALAPPDATA%\\Code')
- expect(values?.has('Flags')).toBe(false)
- })
- it('reads only the [Desktop Entry] section and tolerates comment and malformed lines', () => {
- expect(parseDesktopEntry([
- '# comment',
- '[Desktop Action new-window]',
- 'Exec=ignored --new-window',
- '[Desktop Entry]',
- 'no separator line',
- 'Name=Kitty',
- 'Exec=kitty %U',
- 'TryExec=/usr/bin/kitty',
- 'Icon=kitty',
- '',
- ].join('\n'))).toEqual({ exec: 'kitty %U', tryExec: '/usr/bin/kitty', icon: 'kitty' })
- })
- it('takes an Exec command as its quoted or bare first token, and none from blank text', () => {
- expect(execCommand(undefined)).toBeNull()
- expect(execCommand('"/opt/App Name/bin" --flag')).toBe('/opt/App Name/bin')
- expect(execCommand('kitty --directory %U')).toBe('kitty')
- expect(execCommand(' ')).toBeNull()
- })
- it('orders XDG data directories home-first with the freedesktop defaults', () => {
- const completed = { env: {}, home: '/h', resolveExecutable: pathTable() }
- // The home default goes through join(), so the expectation does too —
- // the Windows lane runs this unit over win32 separators.
- expect(xdgDataDirectories(resolveInternals(completed)))
- .toEqual([join('/h', '.local', 'share'), '/usr/local/share', '/usr/share'])
- expect(xdgDataDirectories(resolveInternals({ ...completed, env: { XDG_DATA_HOME: '/x', XDG_DATA_DIRS: '/a::/b' } })))
- .toEqual(['/x', '/a', '/b'])
- })
- })
- describe('launchResolved', () => {
- /** Launcher recording calls; entries in `outcomes` control each command's fate. */
- function launcher(
- calls: unknown[][], outcomes: Readonly<Record<string, 'ok' | 'fail' | 'enoent'>> = {},
- ): OpenInAppLauncher {
- return (command, args, options) => {
- calls.push([command, ...args, options])
- const outcome = outcomes[command] ?? 'ok'
- if (outcome === 'ok') return Promise.resolve()
- if (outcome === 'enoent') return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
- return Promise.reject(new Error('launch fails'))
- }
- }
- const resolved: OpenInAppResolvedLaunch = { launch: { kind: 'argv', command: 'primary', args: [] } }
- const withFallback: OpenInAppResolvedLaunch = {
- launch: { kind: 'argv', command: 'primary', args: [] },
- fallbackLaunch: { kind: 'argv', command: 'fallback', args: [] },
- }
- it('appends the directory or substitutes {path} in place', async () => {
- const calls: unknown[][] = []
- await expect(launchResolved(
- { launch: { kind: 'argv', command: 'git-bash', args: ['--cd={path}'] } }, 'C:\\w\\dir', TIMEOUT_MS,
- bare({ launch: launcher(calls) }),
- )).resolves.toBe('launched')
- await expect(launchResolved(
- { launch: { kind: 'argv', command: 'code', args: [] } }, '/w/dir', TIMEOUT_MS,
- bare({ launch: launcher(calls) }),
- )).resolves.toBe('launched')
- expect(calls).toEqual([
- ['git-bash', '--cd=C:\\w\\dir', { watchMs: TIMEOUT_MS }],
- ['code', '/w/dir', { watchMs: TIMEOUT_MS }],
- ])
- })
- it('passes adapter-specific environment and Windows visibility policy', async () => {
- const calls: unknown[][] = []
- await expect(launchResolved({
- launch: {
- kind: 'argv',
- command: 'GitHubDesktop.exe',
- args: ['cli.js', 'open'],
- env: { ELECTRON_RUN_AS_NODE: '1' },
- windowsHide: true,
- },
- }, 'C:\\w\\repo', TIMEOUT_MS, bare({ launch: launcher(calls) }))).resolves.toBe('launched')
- expect(calls).toEqual([[
- 'GitHubDesktop.exe', 'cli.js', 'open', 'C:\\w\\repo',
- { watchMs: TIMEOUT_MS, env: { ELECTRON_RUN_AS_NODE: '1' }, windowsHide: true },
- ]])
- })
- it('opens a shell-open launch through the OS path opener, not a detached spawn', async () => {
- const spawns: unknown[][] = []
- const commands: string[][] = []
- await expect(launchResolved(
- { launch: { kind: 'shell-open' } }, 'C:\\w\\dir', TIMEOUT_MS,
- bare({
- platform: 'win32',
- launch: launcher(spawns),
- run: async (command, args) => {
- commands.push([command, ...args])
- return { stdout: '', stderr: '' }
- },
- }),
- )).resolves.toBe('launched')
- // The opener is the shipped Invoke-Item channel; the detached spawner never runs.
- expect(spawns).toEqual([])
- expect(commands).toEqual([
- ['powershell.exe', '-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\w\\dir'"],
- ])
- })
- it('counts a shell-open opener that outlives the watch window as launched, and a fast failure as failed', async () => {
- // A cold powershell start can outlive the window: still-running counts launched.
- await expect(launchResolved(
- { launch: { kind: 'shell-open' } }, '/w/dir', 25,
- bare({ platform: 'darwin', run: () => new Promise(() => {}) }),
- )).resolves.toBe('launched')
- // A failure inside the window is the outcome.
- await expect(launchResolved(
- { launch: { kind: 'shell-open' } }, '/w/dir', TIMEOUT_MS,
- bare({ platform: 'darwin', run: async () => { throw new Error('opener failed') } }),
- )).resolves.toBe('failed')
- // A vanished opener marks the resolution stale, like an argv launcher.
- await expect(launchResolved(
- { launch: { kind: 'shell-open' } }, '/w/dir', TIMEOUT_MS,
- bare({ platform: 'darwin', run: async () => {
- throw Object.assign(new Error('spawn open ENOENT'), { code: 'ENOENT' })
- } }),
- )).resolves.toBe('missing')
- // A late failure after the window settles nothing (already launched).
- let rejectLate: ((error: Error) => void) | undefined
- await expect(launchResolved(
- { launch: { kind: 'shell-open' } }, '/w/dir', 25,
- bare({ platform: 'darwin', run: () => new Promise((_resolve, reject) => { rejectLate = reject }) }),
- )).resolves.toBe('launched')
- rejectLate?.(new Error('late opener failure'))
- })
- it('tries the fallback when the primary fails and classifies the ways an attempt ends', async () => {
- const calls: unknown[][] = []
- await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
- launch: launcher(calls, { primary: 'fail' }),
- }))).resolves.toBe('launched')
- expect(calls.map(call => call[0])).toEqual(['primary', 'fallback'])
- await expect(launchResolved(resolved, '/w/dir', TIMEOUT_MS, bare({ launch: launcher([], { primary: 'fail' }) })))
- .resolves.toBe('failed')
- await expect(launchResolved(resolved, '/w/dir', TIMEOUT_MS, bare({ launch: launcher([], { primary: 'enoent' }) })))
- .resolves.toBe('missing')
- // Either tried launcher having vanished reports missing.
- await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
- launch: launcher([], { primary: 'enoent', fallback: 'fail' }),
- }))).resolves.toBe('missing')
- await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
- launch: launcher([], { primary: 'fail', fallback: 'enoent' }),
- }))).resolves.toBe('missing')
- await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
- launch: launcher([], { primary: 'fail', fallback: 'fail' }),
- }))).resolves.toBe('failed')
- })
- })
- describe('launchDetachedApp', () => {
- const node = process.execPath
- it('resolves when the child exits 0 inside the watch window', async () => {
- await expect(launchDetachedApp(node, ['-e', ''], { watchMs: TIMEOUT_MS }))
- .resolves.toBeUndefined()
- })
- it('rejects a nonzero exit inside the window', async () => {
- await expect(launchDetachedApp(node, ['-e', 'process.exit(3)'], { watchMs: TIMEOUT_MS }))
- .rejects.toThrow(/launcher exited with/)
- })
- it('rejects a signal-terminated child with the signal name', async () => {
- await expect(launchDetachedApp(
- node, ['-e', 'process.kill(process.pid, "SIGKILL"); setTimeout(() => {}, 5000)'],
- { watchMs: TIMEOUT_MS },
- )).rejects.toThrow(/launcher exited with/)
- })
- it('rejects a spawn failure, carrying the ENOENT code', async () => {
- await expect(launchDetachedApp('dsh-definitely-missing-launcher', [], { watchMs: TIMEOUT_MS }))
- .rejects.toMatchObject({ code: 'ENOENT' })
- })
- it('hands the child a credential-scrubbed environment with explicit adapter entries', async () => {
- const root = await tempRoot()
- const witness = join(root, 'env.json')
- process.env.OPEN_IN_APP_SPEC_API_KEY = 'leak'
- process.env.OPEN_IN_APP_SPEC_PLAIN = 'visible'
- try {
- await launchDetachedApp(node, [
- '-e',
- 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify(['
- + 'process.env.OPEN_IN_APP_SPEC_API_KEY ?? null, process.env.OPEN_IN_APP_SPEC_PLAIN ?? null, '
- + 'process.env.ELECTRON_RUN_AS_NODE ?? null]))',
- witness,
- ], { watchMs: TIMEOUT_MS, env: { OPEN_IN_APP_SPEC_PLAIN: 'overridden', ELECTRON_RUN_AS_NODE: '1' } })
- } finally {
- delete process.env.OPEN_IN_APP_SPEC_API_KEY
- delete process.env.OPEN_IN_APP_SPEC_PLAIN
- }
- expect(JSON.parse(await readFile(witness, 'utf8'))).toEqual([null, 'overridden', '1'])
- })
- })
|