mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
* fix(win32): suppress Command Prompt window on IDE launches - Prefer JetBrains GUI executables (`*64.exe`) over `.cmd` shims to avoid console allocation (STA-3040). - Use `start "" /B` when launching GUI apps via batch scripts; shims chain through console helpers that allocate a visible prompt even with `windowsHide`. `start /B` returns immediately, preventing the lingering window. * fix(win32): suppress Command Prompt window on IDE launches Prevent lingering Command Prompt windows when launching JetBrains IDEs on Windows. Use `start "" /B cmd /d /c` so the nested shell exits with the batch script, but only for JetBrains shims—VS Code and Cursor keep the waiting form because `start` re-parses arguments and breaks remote paths with spaces. Prefer colocated `*64.exe` executables beside the resolved `.cmd` shim over PATH lookups to avoid stale installations. * fix(win32): extend IDE launcher console suppression to direct paths Support IDE paths stored directly in settings (e.g., idea.exe, webstorm.cmd). Detect console idea.exe stubs alongside batch shims for upgrade to GUI *64.exe. Fix start command title escaping: use empty string instead of '""' to prevent libuv re-quoting.
767 lines
27 KiB
TypeScript
767 lines
27 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { normalize, resolve } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
const {
|
|
getSpawnArgsForWindowsMock,
|
|
handleMock,
|
|
openPathMock,
|
|
resolveCliCommandMock,
|
|
showItemInFolderMock,
|
|
showOpenDialogMock,
|
|
spawnMock,
|
|
statMock
|
|
} = vi.hoisted(() => ({
|
|
getSpawnArgsForWindowsMock: vi.fn(),
|
|
handleMock: vi.fn(),
|
|
openPathMock: vi.fn(),
|
|
resolveCliCommandMock: vi.fn(),
|
|
showItemInFolderMock: vi.fn(),
|
|
showOpenDialogMock: vi.fn(),
|
|
spawnMock: vi.fn(),
|
|
statMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('electron', () => ({
|
|
ipcMain: {
|
|
handle: handleMock
|
|
},
|
|
shell: {
|
|
showItemInFolder: showItemInFolderMock,
|
|
openExternal: vi.fn(),
|
|
openPath: openPathMock
|
|
},
|
|
dialog: {
|
|
showOpenDialog: showOpenDialogMock
|
|
}
|
|
}))
|
|
|
|
vi.mock('node:fs/promises', () => ({
|
|
constants: { COPYFILE_EXCL: 1 },
|
|
copyFile: vi.fn(),
|
|
stat: statMock
|
|
}))
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
spawn: spawnMock
|
|
}))
|
|
|
|
vi.mock('../codex-cli/command', () => ({
|
|
resolveCliCommand: resolveCliCommandMock
|
|
}))
|
|
|
|
vi.mock('../win32-utils', () => ({
|
|
getCmdExePath: () => 'C:\\Windows\\System32\\cmd.exe',
|
|
getSpawnArgsForWindows: getSpawnArgsForWindowsMock
|
|
}))
|
|
|
|
import { EXTERNAL_EDITOR_CLI_COMMAND, registerShellHandlers } from './shell'
|
|
import { resolveExternalEditorLaunchSpec } from '../external-editor-launch'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
|
|
function createSpawnedProcess(result: 'spawn' | 'error' = 'spawn'): {
|
|
once: ReturnType<typeof vi.fn>
|
|
off: ReturnType<typeof vi.fn>
|
|
unref: ReturnType<typeof vi.fn>
|
|
} {
|
|
const child = {
|
|
once: vi.fn((eventName: string, callback: (error?: Error) => void) => {
|
|
if (eventName === result) {
|
|
queueMicrotask(() => {
|
|
callback(result === 'error' ? new Error('launcher unavailable') : undefined)
|
|
})
|
|
}
|
|
return child
|
|
}),
|
|
off: vi.fn(() => child),
|
|
unref: vi.fn()
|
|
}
|
|
return child
|
|
}
|
|
|
|
function createSshTarget(overrides: Partial<SshTarget> = {}): SshTarget {
|
|
return {
|
|
id: 'ssh-1',
|
|
label: 'Builder',
|
|
host: 'builder.example.com',
|
|
port: 22,
|
|
username: 'ada',
|
|
source: 'ssh-config',
|
|
configHost: 'builder',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('registerShellHandlers', () => {
|
|
const settings = { activeRuntimeEnvironmentId: null as string | null }
|
|
const sshTargets = new Map<string, SshTarget>()
|
|
const store = {
|
|
getSettings: () => settings,
|
|
getSshTarget: (id: string) => sshTargets.get(id)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
handleMock.mockReset()
|
|
getSpawnArgsForWindowsMock.mockReset()
|
|
openPathMock.mockReset()
|
|
resolveCliCommandMock.mockReset()
|
|
showItemInFolderMock.mockReset()
|
|
showOpenDialogMock.mockReset()
|
|
spawnMock.mockReset()
|
|
statMock.mockReset()
|
|
settings.activeRuntimeEnvironmentId = null
|
|
sshTargets.clear()
|
|
openPathMock.mockResolvedValue('')
|
|
resolveCliCommandMock.mockReturnValue('editor-cli')
|
|
getSpawnArgsForWindowsMock.mockImplementation((command: string, args: string[]) => ({
|
|
spawnCmd: command,
|
|
spawnArgs: args
|
|
}))
|
|
spawnMock.mockReturnValue(createSpawnedProcess())
|
|
statMock.mockResolvedValue({ isDirectory: () => true })
|
|
})
|
|
|
|
function getHandler(channel: string): (event: unknown, ...args: unknown[]) => Promise<unknown> {
|
|
registerShellHandlers(store as never)
|
|
const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === channel)
|
|
if (!call) {
|
|
throw new Error(`${channel} handler not registered`)
|
|
}
|
|
return call[1] as (event: unknown, ...args: unknown[]) => Promise<unknown>
|
|
}
|
|
|
|
it('picks audio files with a constrained native dialog filter', async () => {
|
|
showOpenDialogMock.mockResolvedValue({
|
|
canceled: false,
|
|
filePaths: ['/Users/kaylee/Downloads/Note_block_pling.ogg']
|
|
})
|
|
|
|
const handler = getHandler('shell:pickAudio')
|
|
await expect(handler({})).resolves.toBe('/Users/kaylee/Downloads/Note_block_pling.ogg')
|
|
expect(showOpenDialogMock).toHaveBeenCalledWith({
|
|
properties: ['openFile'],
|
|
filters: [{ name: 'Audio', extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] }]
|
|
})
|
|
})
|
|
|
|
it('returns null when audio picking is canceled', async () => {
|
|
showOpenDialogMock.mockResolvedValue({
|
|
canceled: true,
|
|
filePaths: []
|
|
})
|
|
|
|
const handler = getHandler('shell:pickAudio')
|
|
await expect(handler({})).resolves.toBeNull()
|
|
})
|
|
|
|
it('picks an existing directory without enabling native directory creation', async () => {
|
|
showOpenDialogMock.mockResolvedValue({
|
|
canceled: false,
|
|
filePaths: ['/Users/kaylee/projects']
|
|
})
|
|
|
|
const handler = getHandler('shell:pickDirectory')
|
|
await expect(handler({}, { defaultPath: '/Users/kaylee' })).resolves.toBe(
|
|
'/Users/kaylee/projects'
|
|
)
|
|
expect(showOpenDialogMock).toHaveBeenCalledWith({
|
|
defaultPath: '/Users/kaylee',
|
|
properties: ['openDirectory']
|
|
})
|
|
})
|
|
|
|
describe('shell:openPath', () => {
|
|
it('ignores relative paths', async () => {
|
|
const handler = getHandler('shell:openPath')
|
|
|
|
await expect(handler({}, 'relative/workspace')).resolves.toBeUndefined()
|
|
expect(statMock).not.toHaveBeenCalled()
|
|
expect(showItemInFolderMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('ignores missing paths', async () => {
|
|
statMock.mockRejectedValueOnce(new Error('missing'))
|
|
const workspacePath = resolve('missing-workspace')
|
|
const handler = getHandler('shell:openPath')
|
|
|
|
await expect(handler({}, workspacePath)).resolves.toBeUndefined()
|
|
expect(statMock).toHaveBeenCalledWith(normalize(workspacePath))
|
|
expect(showItemInFolderMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reveals existing absolute paths', async () => {
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openPath')
|
|
|
|
await expect(handler({}, workspacePath)).resolves.toBeUndefined()
|
|
expect(showItemInFolderMock).toHaveBeenCalledWith(normalize(workspacePath))
|
|
})
|
|
|
|
it('swallows launcher failures', async () => {
|
|
showItemInFolderMock.mockImplementationOnce(() => {
|
|
throw new Error('launcher unavailable')
|
|
})
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openPath')
|
|
|
|
await expect(handler({}, workspacePath)).resolves.toBeUndefined()
|
|
expect(showItemInFolderMock).toHaveBeenCalledWith(normalize(workspacePath))
|
|
})
|
|
})
|
|
|
|
describe('shell:openInFileManager', () => {
|
|
it('rejects relative paths', async () => {
|
|
const handler = getHandler('shell:openInFileManager')
|
|
|
|
await expect(handler({}, 'relative/workspace')).resolves.toEqual({
|
|
ok: false,
|
|
reason: 'not-absolute'
|
|
})
|
|
expect(statMock).not.toHaveBeenCalled()
|
|
expect(showItemInFolderMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects missing paths', async () => {
|
|
statMock.mockRejectedValueOnce(new Error('missing'))
|
|
const workspacePath = resolve('missing-workspace')
|
|
const handler = getHandler('shell:openInFileManager')
|
|
|
|
await expect(handler({}, workspacePath)).resolves.toEqual({
|
|
ok: false,
|
|
reason: 'not-found'
|
|
})
|
|
expect(statMock).toHaveBeenCalledWith(normalize(workspacePath))
|
|
expect(showItemInFolderMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('maps launcher errors to launch-failed', async () => {
|
|
showItemInFolderMock.mockImplementationOnce(() => {
|
|
throw new Error('launcher unavailable')
|
|
})
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openInFileManager')
|
|
|
|
await expect(handler({}, workspacePath)).resolves.toEqual({
|
|
ok: false,
|
|
reason: 'launch-failed'
|
|
})
|
|
expect(showItemInFolderMock).toHaveBeenCalledWith(normalize(workspacePath))
|
|
})
|
|
|
|
it('opens existing absolute paths in the OS file manager', async () => {
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openInFileManager')
|
|
|
|
await expect(handler({}, workspacePath)).resolves.toEqual({ ok: true })
|
|
expect(showItemInFolderMock).toHaveBeenCalledWith(normalize(workspacePath))
|
|
})
|
|
})
|
|
|
|
describe('shell:openInExternalEditor', () => {
|
|
it('rejects relative paths', async () => {
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(handler({}, { path: 'relative/workspace' })).resolves.toEqual({
|
|
ok: false,
|
|
reason: 'not-absolute'
|
|
})
|
|
expect(statMock).not.toHaveBeenCalled()
|
|
expect(openPathMock).not.toHaveBeenCalled()
|
|
expect(spawnMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects missing paths', async () => {
|
|
statMock.mockRejectedValueOnce(new Error('missing'))
|
|
const workspacePath = resolve('missing-workspace')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(handler({}, { path: workspacePath })).resolves.toEqual({
|
|
ok: false,
|
|
reason: 'not-found'
|
|
})
|
|
expect(statMock).toHaveBeenCalledWith(normalize(workspacePath))
|
|
expect(openPathMock).not.toHaveBeenCalled()
|
|
expect(spawnMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('maps launcher failures to launch-failed', async () => {
|
|
const child = createSpawnedProcess('error')
|
|
spawnMock.mockReturnValueOnce(child)
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(handler({}, { path: workspacePath })).resolves.toEqual({
|
|
ok: false,
|
|
reason: 'launch-failed'
|
|
})
|
|
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
|
|
platform: process.platform
|
|
})
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
|
'editor-cli',
|
|
[normalize(workspacePath)],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
windowsHide: true
|
|
})
|
|
expect(child.off).toHaveBeenCalledWith('error', expect.any(Function))
|
|
expect(child.off).toHaveBeenCalledWith('spawn', expect.any(Function))
|
|
expect(openPathMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('opens existing absolute paths with the editor launcher', async () => {
|
|
const child = createSpawnedProcess()
|
|
spawnMock.mockReturnValueOnce(child)
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(handler({}, { path: workspacePath })).resolves.toEqual({ ok: true })
|
|
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
|
|
platform: process.platform
|
|
})
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
|
'editor-cli',
|
|
[normalize(workspacePath)],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
windowsHide: true
|
|
})
|
|
expect(child.off).toHaveBeenCalledWith('error', expect.any(Function))
|
|
expect(child.off).toHaveBeenCalledWith('spawn', expect.any(Function))
|
|
expect(openPathMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses a provided launcher command', async () => {
|
|
resolveCliCommandMock.mockReturnValueOnce('custom-editor')
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(handler({}, { path: workspacePath, command: 'custom-editor' })).resolves.toEqual(
|
|
{
|
|
ok: true
|
|
}
|
|
)
|
|
expect(resolveCliCommandMock).toHaveBeenCalledWith('custom-editor', {
|
|
platform: process.platform
|
|
})
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
|
'custom-editor',
|
|
[normalize(workspacePath)],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
})
|
|
|
|
it.runIf(process.platform === 'win32')(
|
|
'forwards WSL remote arguments with spaces through the Windows launcher shim',
|
|
async () => {
|
|
const workspacePath = '\\\\wsl.localhost\\Ubuntu Preview\\home\\Ada Lovelace\\project'
|
|
const codeShim = 'C:\\Tools\\CODE.CMD'
|
|
resolveCliCommandMock.mockReturnValueOnce(codeShim)
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
|
codeShim,
|
|
['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project'],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
}
|
|
)
|
|
|
|
it('shows the Windows console for NeoVim executable launchers on Windows', async () => {
|
|
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
const nvimPath = 'C:\\Program Files\\Neovim\\bin\\nvim.exe'
|
|
|
|
try {
|
|
await expect(handler({}, { path: workspacePath, command: nvimPath })).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
expect(resolveCliCommandMock).not.toHaveBeenCalled()
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
|
nvimPath,
|
|
[normalize(workspacePath)],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
expect(spawnMock).toHaveBeenCalledWith(nvimPath, [normalize(workspacePath)], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
windowsHide: false
|
|
})
|
|
} finally {
|
|
if (platformDescriptor) {
|
|
Object.defineProperty(process, 'platform', platformDescriptor)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('detaches JetBrains batch shims on Windows but leaves other launchers waiting', async () => {
|
|
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
|
const workspacePath = normalize(resolve('workspace'))
|
|
const ideaShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd'
|
|
const codeShim = 'C:\\Tools\\code.cmd'
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
try {
|
|
resolveCliCommandMock.mockReturnValueOnce(ideaShim)
|
|
await expect(handler({}, { path: workspacePath, command: 'idea' })).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(ideaShim, [workspacePath], {
|
|
detachedGui: true
|
|
})
|
|
|
|
resolveCliCommandMock.mockReturnValueOnce(codeShim)
|
|
await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(codeShim, [workspacePath], {
|
|
detachedGui: false
|
|
})
|
|
} finally {
|
|
if (platformDescriptor) {
|
|
Object.defineProperty(process, 'platform', platformDescriptor)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('forces Cursor launcher folders into a new window', async () => {
|
|
resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/cursor')
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
|
'/usr/local/bin/cursor',
|
|
['--new-window', normalize(workspacePath)],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
resolveCliCommandMock.mockReturnValueOnce('C:\\Cursor\\cursor.cmd')
|
|
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(
|
|
'C:\\Cursor\\cursor.cmd',
|
|
['--new-window', normalize(workspacePath)],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
})
|
|
|
|
it('falls back to VS Code when command is blank', async () => {
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(handler({}, { path: workspacePath, command: ' ' })).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
|
|
platform: process.platform
|
|
})
|
|
})
|
|
|
|
it('uses platform-safe launcher command arguments', async () => {
|
|
getSpawnArgsForWindowsMock.mockReturnValueOnce({
|
|
spawnCmd: 'platform-runner',
|
|
spawnArgs: ['platform-arg']
|
|
})
|
|
const workspacePath = resolve('workspace')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(handler({}, { path: workspacePath })).resolves.toEqual({ ok: true })
|
|
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
|
|
platform: process.platform
|
|
})
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
|
'editor-cli',
|
|
[normalize(workspacePath)],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
expect(spawnMock).toHaveBeenCalledWith('platform-runner', ['platform-arg'], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
windowsHide: true
|
|
})
|
|
expect(openPathMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('runs compound shell commands through the platform shell', async () => {
|
|
const filePath = normalize(resolve('note.md'))
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
const launchSpec = resolveExternalEditorLaunchSpec('open -a "Typora"', filePath)
|
|
|
|
await expect(handler({}, { path: filePath, command: 'open -a "Typora"' })).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
expect(resolveCliCommandMock).not.toHaveBeenCalled()
|
|
expect(getSpawnArgsForWindowsMock).not.toHaveBeenCalled()
|
|
expect(launchSpec.kind).toBe('shell')
|
|
expect(spawnMock).toHaveBeenCalledWith(launchSpec.spawnCmd, launchSpec.spawnArgs, {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
windowsHide: true
|
|
})
|
|
})
|
|
|
|
it('rejects local and SSH launches while a remote runtime is active', async () => {
|
|
settings.activeRuntimeEnvironmentId = 'runtime-1'
|
|
sshTargets.set('ssh-1', createSshTarget())
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(handler({}, { path: resolve('workspace') })).resolves.toEqual({
|
|
ok: false,
|
|
reason: 'remote-runtime-unsupported'
|
|
})
|
|
await expect(
|
|
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
|
|
).resolves.toEqual({ ok: false, reason: 'remote-runtime-unsupported' })
|
|
expect(statMock).not.toHaveBeenCalled()
|
|
expect(spawnMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects missing and runtime-owned SSH targets', async () => {
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(
|
|
handler({}, { path: '/srv/project', command: 'code', connectionId: 'missing' })
|
|
).resolves.toEqual({ ok: false, reason: 'ssh-target-not-found' })
|
|
|
|
sshTargets.set(
|
|
'ssh-1',
|
|
createSshTarget({ owner: { type: 'on-demand-runtime', runtimeId: 'runtime-1' } })
|
|
)
|
|
await expect(
|
|
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
|
|
).resolves.toEqual({ ok: false, reason: 'remote-runtime-unsupported' })
|
|
expect(spawnMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('opens POSIX SSH paths through a persisted config alias without local validation', async () => {
|
|
sshTargets.set('ssh-1', createSshTarget())
|
|
resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/code')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
const remotePath = '/home/Ada Lovelace/project'
|
|
|
|
await expect(
|
|
handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' })
|
|
).resolves.toEqual({ ok: true })
|
|
expect(statMock).not.toHaveBeenCalled()
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
|
'/usr/local/bin/code',
|
|
['--remote', 'ssh-remote+builder', remotePath],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
})
|
|
|
|
it('preserves Windows-form SSH paths and uses the manual port-22 authority', async () => {
|
|
sshTargets.set(
|
|
'ssh-1',
|
|
createSshTarget({
|
|
source: 'manual',
|
|
configHost: 'win-builder.example.com',
|
|
host: 'win-builder.example.com',
|
|
username: 'Ada'
|
|
})
|
|
)
|
|
resolveCliCommandMock.mockReturnValueOnce('C:\\Tools\\code.cmd')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
const remotePath = 'C:\\Users\\Ada Lovelace\\project'
|
|
|
|
await expect(
|
|
handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' })
|
|
).resolves.toEqual({ ok: true })
|
|
expect(statMock).not.toHaveBeenCalled()
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
|
'C:\\Tools\\code.cmd',
|
|
['--remote', 'ssh-remote+Ada@win-builder.example.com', remotePath],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
})
|
|
|
|
it('opens a manual port-22 target with a host-only authority when username is blank', async () => {
|
|
sshTargets.set(
|
|
'ssh-1',
|
|
createSshTarget({
|
|
source: 'manual',
|
|
configHost: 'builder.example.com',
|
|
host: 'builder.example.com',
|
|
username: ''
|
|
})
|
|
)
|
|
resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/code')
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(
|
|
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
|
|
).resolves.toEqual({ ok: true })
|
|
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
|
'/usr/local/bin/code',
|
|
['--remote', 'ssh-remote+builder.example.com', '/srv/project'],
|
|
{
|
|
detachedGui: false
|
|
}
|
|
)
|
|
})
|
|
|
|
it('rejects relative SSH paths before resolving or spawning a launcher', async () => {
|
|
sshTargets.set('ssh-1', createSshTarget())
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(
|
|
handler({}, { path: 'relative/project', command: 'code', connectionId: 'ssh-1' })
|
|
).resolves.toEqual({ ok: false, reason: 'not-absolute' })
|
|
expect(statMock).not.toHaveBeenCalled()
|
|
expect(resolveCliCommandMock).not.toHaveBeenCalled()
|
|
expect(spawnMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns alias recovery details for manual custom-port targets', async () => {
|
|
sshTargets.set(
|
|
'ssh-1',
|
|
createSshTarget({
|
|
source: 'manual',
|
|
configHost: 'builder.example.com',
|
|
host: 'builder.example.com',
|
|
port: 2222
|
|
})
|
|
)
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(
|
|
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
|
|
).resolves.toEqual({
|
|
ok: false,
|
|
reason: 'ssh-alias-required',
|
|
host: 'builder.example.com',
|
|
port: 2222
|
|
})
|
|
expect(spawnMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each(['cursor', 'zed', 'code --reuse-window'])(
|
|
'rejects the unsupported SSH launcher %s',
|
|
async (command) => {
|
|
sshTargets.set('ssh-1', createSshTarget())
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(
|
|
handler({}, { path: '/srv/project', command, connectionId: 'ssh-1' })
|
|
).resolves.toEqual({ ok: false, reason: 'remote-editor-unsupported' })
|
|
expect(spawnMock).not.toHaveBeenCalled()
|
|
}
|
|
)
|
|
|
|
it('maps unsafe Windows batch arguments to a closed launch failure', async () => {
|
|
sshTargets.set('ssh-1', createSshTarget())
|
|
resolveCliCommandMock.mockReturnValueOnce('C:\\Tools\\code.cmd')
|
|
getSpawnArgsForWindowsMock.mockImplementationOnce(() => {
|
|
throw new Error('unsafe batch arguments')
|
|
})
|
|
const handler = getHandler('shell:openInExternalEditor')
|
|
|
|
await expect(
|
|
handler({}, { path: '/srv/project&whoami', command: 'code', connectionId: 'ssh-1' })
|
|
).resolves.toEqual({ ok: false, reason: 'launch-failed' })
|
|
expect(spawnMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('legacy file open handlers', () => {
|
|
it('does not open relative file paths', async () => {
|
|
const handler = getHandler('shell:openFilePath')
|
|
|
|
await expect(handler({}, 'relative/file.md')).resolves.toBe(false)
|
|
expect(openPathMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not open missing file paths', async () => {
|
|
statMock.mockRejectedValueOnce(new Error('missing'))
|
|
const handler = getHandler('shell:openFilePath')
|
|
|
|
await expect(handler({}, resolve('missing.md'))).resolves.toBe(false)
|
|
expect(openPathMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns true when the host launcher accepts file paths', async () => {
|
|
const filePath = resolve('note.md')
|
|
const handler = getHandler('shell:openFilePath')
|
|
|
|
await expect(handler({}, filePath)).resolves.toBe(true)
|
|
expect(openPathMock).toHaveBeenCalledWith(normalize(filePath))
|
|
})
|
|
|
|
it('returns false for host launcher failures for file paths', async () => {
|
|
openPathMock.mockRejectedValueOnce(new Error('launcher unavailable'))
|
|
const filePath = resolve('note.md')
|
|
const handler = getHandler('shell:openFilePath')
|
|
|
|
await expect(handler({}, filePath)).resolves.toBe(false)
|
|
expect(openPathMock).toHaveBeenCalledWith(normalize(filePath))
|
|
})
|
|
|
|
it('returns false when the host launcher reports file path errors', async () => {
|
|
openPathMock.mockResolvedValueOnce('no default app')
|
|
const filePath = resolve('note.md')
|
|
const handler = getHandler('shell:openFilePath')
|
|
|
|
await expect(handler({}, filePath)).resolves.toBe(false)
|
|
expect(openPathMock).toHaveBeenCalledWith(normalize(filePath))
|
|
})
|
|
|
|
it('does not open non-file URIs', async () => {
|
|
const handler = getHandler('shell:openFileUri')
|
|
|
|
await expect(handler({}, 'https://example.com/file.md')).resolves.toBeUndefined()
|
|
expect(openPathMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not open remote file URIs', async () => {
|
|
const handler = getHandler('shell:openFileUri')
|
|
|
|
await expect(handler({}, 'file://server/share/file.md')).resolves.toBeUndefined()
|
|
expect(openPathMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('swallows host launcher failures for file URIs', async () => {
|
|
openPathMock.mockRejectedValueOnce(new Error('launcher unavailable'))
|
|
const filePath = resolve('note.md')
|
|
const handler = getHandler('shell:openFileUri')
|
|
|
|
await expect(handler({}, pathToFileURL(filePath).toString())).resolves.toBeUndefined()
|
|
expect(openPathMock).toHaveBeenCalledWith(normalize(filePath))
|
|
})
|
|
})
|
|
})
|