503 lines
16 KiB
TypeScript
503 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { resolveCliCommandMock } = vi.hoisted(() => ({
|
|
resolveCliCommandMock: vi.fn((command: string) => command)
|
|
}))
|
|
|
|
vi.mock('./codex-cli/command', () => ({
|
|
resolveCliCommand: resolveCliCommandMock
|
|
}))
|
|
|
|
import { getCmdExePath } from './win32-utils'
|
|
import {
|
|
resolveExternalEditorLaunchSpec,
|
|
resolveVsCodeRemoteSshLaunchSpec
|
|
} from './external-editor-launch'
|
|
|
|
describe('resolveExternalEditorLaunchSpec', () => {
|
|
beforeEach(() => {
|
|
resolveCliCommandMock.mockReset()
|
|
resolveCliCommandMock.mockImplementation((command: string) => command)
|
|
})
|
|
|
|
it('keeps simple CLI commands on the executable launch path', () => {
|
|
const spec = resolveExternalEditorLaunchSpec('cursor', '/tmp/workspace', {
|
|
platform: 'darwin'
|
|
})
|
|
expect(spec).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: expect.any(String),
|
|
spawnArgs: ['--new-window', '/tmp/workspace']
|
|
})
|
|
})
|
|
|
|
it('prefers the JetBrains *64.exe colocated with the resolved idea.cmd on Windows', () => {
|
|
const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin'
|
|
resolveCliCommandMock.mockImplementation((command: string) =>
|
|
command === 'idea' ? `${installBin}\\idea.cmd` : command
|
|
)
|
|
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', {
|
|
platform: 'win32',
|
|
fileExists: (candidate) => candidate === `${installBin}\\idea64.exe`
|
|
})
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: `${installBin}\\idea64.exe`,
|
|
spawnArgs: ['C:\\workspaces\\orca']
|
|
})
|
|
// Why: a bare PATH `idea64` may belong to a different, stale install.
|
|
expect(resolveCliCommandMock).toHaveBeenCalledWith('idea', { platform: 'win32' })
|
|
expect(resolveCliCommandMock).not.toHaveBeenCalledWith('idea64', expect.anything())
|
|
})
|
|
|
|
it('prefers a colocated .exe over an idea64.cmd shim when the user names idea64', () => {
|
|
const installBin = 'C:\\Program Files\\JetBrains\\GoLand\\bin'
|
|
resolveCliCommandMock.mockImplementation((command: string) =>
|
|
command === 'goland64' ? `${installBin}\\goland64.cmd` : command
|
|
)
|
|
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('goland64', 'C:\\workspaces\\orca', {
|
|
platform: 'win32',
|
|
fileExists: (candidate) => candidate === `${installBin}\\goland64.exe`
|
|
}).spawnCmd
|
|
).toBe(`${installBin}\\goland64.exe`)
|
|
})
|
|
|
|
it('keeps the Toolbox idea.cmd shim and detaches it when no GUI exe sits beside it', () => {
|
|
const toolboxShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd'
|
|
resolveCliCommandMock.mockImplementation((command: string) =>
|
|
command === 'idea' ? toolboxShim : command
|
|
)
|
|
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', {
|
|
platform: 'win32',
|
|
fileExists: () => false
|
|
})
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
detachedGui: true,
|
|
spawnCmd: toolboxShim,
|
|
spawnArgs: ['C:\\workspaces\\orca']
|
|
})
|
|
})
|
|
|
|
it('detaches a directly configured JetBrains shim path when no GUI exe is beside it', () => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('C:\\Tools\\WebStorm\\bin\\webstorm.bat', 'C:\\ws', {
|
|
platform: 'win32',
|
|
fileExists: () => false
|
|
})
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
detachedGui: true,
|
|
spawnCmd: 'C:\\Tools\\WebStorm\\bin\\webstorm.bat',
|
|
spawnArgs: ['C:\\ws']
|
|
})
|
|
})
|
|
|
|
it('upgrades a direct JetBrains .cmd path to the colocated *64.exe', () => {
|
|
const installBin = 'C:\\Program Files\\JetBrains\\WebStorm\\bin'
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(`${installBin}\\webstorm.cmd`, 'C:\\ws', {
|
|
platform: 'win32',
|
|
fileExists: (candidate) => candidate === `${installBin}\\webstorm64.exe`
|
|
})
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: `${installBin}\\webstorm64.exe`,
|
|
spawnArgs: ['C:\\ws']
|
|
})
|
|
})
|
|
|
|
it('upgrades a short-name console idea.exe stub to the colocated idea64.exe', () => {
|
|
const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin'
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(`${installBin}\\idea.exe`, 'C:\\ws', {
|
|
platform: 'win32',
|
|
fileExists: (candidate) => candidate === `${installBin}\\idea64.exe`
|
|
})
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: `${installBin}\\idea64.exe`,
|
|
spawnArgs: ['C:\\ws']
|
|
})
|
|
})
|
|
|
|
it('leaves a direct idea64.exe path unchanged even when a sibling exists', () => {
|
|
const guiExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe'
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(guiExe, 'C:\\ws', {
|
|
platform: 'win32',
|
|
fileExists: () => true
|
|
})
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: guiExe,
|
|
spawnArgs: ['C:\\ws']
|
|
})
|
|
})
|
|
|
|
it('does not rewrite non-JetBrains .exe launchers via colocation', () => {
|
|
const codeExe = 'C:\\Program Files\\Microsoft VS Code\\Code.exe'
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(codeExe, 'C:\\ws', {
|
|
platform: 'win32',
|
|
fileExists: () => true
|
|
}).spawnCmd
|
|
).toBe(codeExe)
|
|
})
|
|
|
|
it('keeps idea.exe when no colocated idea64.exe exists', () => {
|
|
const consoleExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea.exe'
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(consoleExe, 'C:\\ws', {
|
|
platform: 'win32',
|
|
fileExists: () => false
|
|
})
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: consoleExe,
|
|
spawnArgs: ['C:\\ws']
|
|
})
|
|
})
|
|
|
|
// Why: `start` re-parses argv, so only JetBrains shims may take that path.
|
|
it.each([
|
|
['code', 'C:\\Tools\\code.cmd', ['C:\\workspaces\\orca']],
|
|
[
|
|
'cursor',
|
|
'C:\\Users\\me\\AppData\\Local\\Programs\\cursor\\bin\\cursor.cmd',
|
|
['--new-window', 'C:\\workspaces\\orca']
|
|
]
|
|
])('does not detach the %s batch shim through start', (command, resolvedCommand, spawnArgs) => {
|
|
resolveCliCommandMock.mockImplementation(() => resolvedCommand)
|
|
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(command, 'C:\\workspaces\\orca', { platform: 'win32' })
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: resolvedCommand,
|
|
spawnArgs
|
|
})
|
|
})
|
|
|
|
it('appends escaped paths to compound macOS open commands', () => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('open -a "Typora"', "/tmp/note's.md", {
|
|
platform: 'darwin'
|
|
})
|
|
).toEqual({
|
|
kind: 'shell',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: '/bin/sh',
|
|
spawnArgs: ['-c', "open -a \"Typora\" '/tmp/note'\\''s.md'"]
|
|
})
|
|
})
|
|
|
|
it('treats an existing POSIX executable path with spaces as an executable launcher', () => {
|
|
const ideaPath = '/Users/me/Library/Application Support/JetBrains/Toolbox/scripts/idea'
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(ideaPath, '/tmp/workspace', {
|
|
platform: 'darwin',
|
|
fileExists: (candidate) => candidate === ideaPath
|
|
})
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: ideaPath,
|
|
spawnArgs: ['/tmp/workspace']
|
|
})
|
|
})
|
|
|
|
it('keeps absolute POSIX commands with arguments on the shell launch path', () => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('/usr/local/bin/code --reuse-window', '/tmp/workspace', {
|
|
platform: 'darwin',
|
|
fileExists: () => false
|
|
})
|
|
).toEqual({
|
|
kind: 'shell',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: '/bin/sh',
|
|
spawnArgs: ['-c', '/usr/local/bin/code --reuse-window /tmp/workspace']
|
|
})
|
|
})
|
|
|
|
it('runs compound Windows commands through cmd.exe', () => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\note.md', { platform: 'win32' })
|
|
).toEqual({
|
|
kind: 'shell',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: getCmdExePath(),
|
|
spawnArgs: ['/d', '/s', '/c', 'start "" notepad C:\\note.md']
|
|
})
|
|
})
|
|
|
|
it('runs GUI compound Windows commands verbatim instead of re-parsing them under start', () => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('code --reuse-window', 'C:\\workspaces\\orca', {
|
|
platform: 'win32'
|
|
})
|
|
).toEqual({
|
|
kind: 'shell',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: getCmdExePath(),
|
|
spawnArgs: ['/d', '/s', '/c', 'code --reuse-window C:\\workspaces\\orca']
|
|
})
|
|
})
|
|
|
|
it('quotes Windows paths with spaces in compound commands', () => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\my notes.md', { platform: 'win32' })
|
|
).toEqual({
|
|
kind: 'shell',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: getCmdExePath(),
|
|
spawnArgs: ['/d', '/s', '/c', 'start "" notepad "C:\\my notes.md"']
|
|
})
|
|
})
|
|
|
|
it('treats unquoted Windows executable paths with spaces as executable launchers', () => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(
|
|
'C:\\Program Files\\Neovim\\bin\\nvim.exe',
|
|
'C:\\workspaces\\orca',
|
|
{ platform: 'win32' }
|
|
)
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: false,
|
|
spawnCmd: 'C:\\Program Files\\Neovim\\bin\\nvim.exe',
|
|
spawnArgs: ['C:\\workspaces\\orca']
|
|
})
|
|
})
|
|
|
|
it('treats quoted Windows executable paths with spaces as executable launchers', () => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(
|
|
'"C:\\Program Files\\Neovim\\bin\\nvim.exe"',
|
|
'C:\\workspaces\\orca',
|
|
{ platform: 'win32' }
|
|
)
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: false,
|
|
spawnCmd: 'C:\\Program Files\\Neovim\\bin\\nvim.exe',
|
|
spawnArgs: ['C:\\workspaces\\orca']
|
|
})
|
|
})
|
|
|
|
it('shows the Windows console for NeoVim shell commands with arguments', () => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('nvim --clean', 'C:\\workspaces\\orca', {
|
|
platform: 'win32'
|
|
})
|
|
).toEqual({
|
|
kind: 'shell',
|
|
hideWindowsConsole: false,
|
|
spawnCmd: getCmdExePath(),
|
|
spawnArgs: ['/d', '/s', '/c', 'nvim --clean C:\\workspaces\\orca']
|
|
})
|
|
})
|
|
|
|
it.each(['code', 'code-insiders'])(
|
|
'opens modern WSL UNC workspaces with %s in the matching VS Code remote',
|
|
(editorCommand) => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(
|
|
editorCommand,
|
|
'\\\\wsl.localhost\\Ubuntu\\home\\aliuq\\project',
|
|
{ platform: 'win32' }
|
|
).spawnArgs
|
|
).toEqual(['--remote', 'wsl+Ubuntu', '/home/aliuq/project'])
|
|
}
|
|
)
|
|
|
|
it.each([
|
|
[
|
|
'legacy WSL UNC workspace',
|
|
'\\\\wsl$\\Debian\\home\\ada\\project',
|
|
'wsl+Debian',
|
|
'/home/ada/project'
|
|
],
|
|
['modern WSL distro root', '\\\\wsl.localhost\\Ubuntu', 'wsl+Ubuntu', '/'],
|
|
['legacy WSL distro root', '\\\\wsl$\\Debian', 'wsl+Debian', '/']
|
|
])('opens a %s in the matching VS Code remote', (_label, pathValue, authority, linuxPath) => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('code', pathValue, { platform: 'win32' }).spawnArgs
|
|
).toEqual(['--remote', authority, linuxPath])
|
|
})
|
|
|
|
it.each([
|
|
'C:\\Program Files\\Microsoft VS Code\\Code.exe',
|
|
'C:\\Program Files\\Microsoft VS Code Insiders\\Code - Insiders.exe',
|
|
'C:\\Tools\\CODE.CMD',
|
|
'C:\\Tools\\code.bat',
|
|
'C:\\Tools\\code-insiders.cmd'
|
|
])('recognizes the direct Windows VS Code launcher %s', (editorCommand) => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(
|
|
editorCommand,
|
|
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\project',
|
|
{ platform: 'win32' }
|
|
).spawnArgs
|
|
).toEqual(['--remote', 'wsl+Ubuntu', '/home/ada/project'])
|
|
})
|
|
|
|
it.each([
|
|
['code', 'C:\\Tools\\CODE.CMD'],
|
|
['code', 'C:\\Tools\\code.bat'],
|
|
['code-insiders', 'C:\\Tools\\code-insiders.cmd']
|
|
])('recognizes the resolved Windows VS Code launcher %s', (editorCommand, resolvedCommand) => {
|
|
resolveCliCommandMock.mockReturnValueOnce(resolvedCommand)
|
|
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(
|
|
editorCommand,
|
|
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\project',
|
|
{ platform: 'win32' }
|
|
)
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: resolvedCommand,
|
|
spawnArgs: ['--remote', 'wsl+Ubuntu', '/home/ada/project']
|
|
})
|
|
})
|
|
|
|
it('preserves spaces in WSL distro and folder arguments', () => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec(
|
|
'code',
|
|
'\\\\wsl.localhost\\Ubuntu Preview\\home\\Ada Lovelace\\project',
|
|
{ platform: 'win32' }
|
|
).spawnArgs
|
|
).toEqual(['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project'])
|
|
})
|
|
|
|
it.each(['darwin', 'linux'] as const)('keeps WSL-looking paths local on %s', (platform) => {
|
|
const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project'
|
|
expect(resolveExternalEditorLaunchSpec('code', pathValue, { platform }).spawnArgs).toEqual([
|
|
pathValue
|
|
])
|
|
})
|
|
|
|
it.each(['C:\\workspaces\\orca', '\\\\server\\share\\project'])(
|
|
'keeps the non-WSL Windows path %s local',
|
|
(pathValue) => {
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('code', pathValue, { platform: 'win32' }).spawnArgs
|
|
).toEqual([pathValue])
|
|
}
|
|
)
|
|
|
|
it('does not add VS Code remote arguments to other editors', () => {
|
|
const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project'
|
|
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('C:\\Tools\\cursor.exe', pathValue, {
|
|
platform: 'win32'
|
|
}).spawnArgs
|
|
).toEqual(['--new-window', pathValue])
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('C:\\Tools\\codium.exe', pathValue, {
|
|
platform: 'win32'
|
|
}).spawnArgs
|
|
).toEqual([pathValue])
|
|
})
|
|
|
|
it('does not rewrite compound VS Code commands into WSL remote args', () => {
|
|
const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project'
|
|
|
|
expect(
|
|
resolveExternalEditorLaunchSpec('code --reuse-window', pathValue, { platform: 'win32' })
|
|
).toEqual({
|
|
kind: 'shell',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: getCmdExePath(),
|
|
spawnArgs: ['/d', '/s', '/c', `code --reuse-window ${pathValue}`]
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('resolveVsCodeRemoteSshLaunchSpec', () => {
|
|
beforeEach(() => {
|
|
resolveCliCommandMock.mockReset()
|
|
resolveCliCommandMock.mockImplementation((command: string) => command)
|
|
})
|
|
|
|
it.each(['code', 'code-insiders'])('builds exact Remote-SSH arguments for %s', (command) => {
|
|
expect(
|
|
resolveVsCodeRemoteSshLaunchSpec(command, '/home/Ada Lovelace/project', 'builder', {
|
|
platform: 'linux'
|
|
})
|
|
).toEqual({
|
|
kind: 'executable',
|
|
hideWindowsConsole: true,
|
|
spawnCmd: command,
|
|
spawnArgs: ['--remote', 'ssh-remote+builder', '/home/Ada Lovelace/project']
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
'C:\\Program Files\\Microsoft VS Code\\Code.exe',
|
|
'C:\\Program Files\\Microsoft VS Code Insiders\\Code - Insiders.exe',
|
|
'C:\\Tools\\code.cmd',
|
|
'C:\\Tools\\code-insiders.bat'
|
|
])('supports the direct Windows launcher %s', (command) => {
|
|
expect(
|
|
resolveVsCodeRemoteSshLaunchSpec(command, 'C:\\Users\\Ada Lovelace\\project', 'builder', {
|
|
platform: 'win32'
|
|
})?.spawnArgs
|
|
).toEqual(['--remote', 'ssh-remote+builder', 'C:\\Users\\Ada Lovelace\\project'])
|
|
})
|
|
|
|
it('supports an existing direct POSIX launcher path containing spaces', () => {
|
|
const command = '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code'
|
|
expect(
|
|
resolveVsCodeRemoteSshLaunchSpec(command, '/srv/project', 'builder', {
|
|
platform: 'darwin',
|
|
fileExists: (candidate) => candidate === command
|
|
})
|
|
).toMatchObject({
|
|
kind: 'executable',
|
|
spawnCmd: command,
|
|
spawnArgs: ['--remote', 'ssh-remote+builder', '/srv/project']
|
|
})
|
|
})
|
|
|
|
it('recognizes a simple CLI name resolved to a Windows shim', () => {
|
|
resolveCliCommandMock.mockReturnValueOnce('C:\\Tools\\Code.CMD')
|
|
expect(
|
|
resolveVsCodeRemoteSshLaunchSpec('code', '/srv/project', 'builder', {
|
|
platform: 'win32'
|
|
})
|
|
).toMatchObject({ spawnCmd: 'C:\\Tools\\Code.CMD' })
|
|
})
|
|
|
|
it.each(['cursor', 'zed', 'code --reuse-window', 'open -a "Visual Studio Code"'])(
|
|
'rejects unsupported and compound SSH commands: %s',
|
|
(command) => {
|
|
expect(
|
|
resolveVsCodeRemoteSshLaunchSpec(command, '/srv/project', 'builder', {
|
|
platform: 'linux'
|
|
})
|
|
).toBeNull()
|
|
}
|
|
)
|
|
})
|