Files
orca/src/main/ipc/shell.test.ts
T
2026-06-19 17:10:43 -07:00

442 lines
16 KiB
TypeScript

/* eslint-disable max-lines -- Why: shell IPC path validation, OS opener fallbacks, and launcher lifecycle tests share one mocked Electron/child_process boundary. */
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', () => ({
getSpawnArgsForWindows: getSpawnArgsForWindowsMock
}))
import { EXTERNAL_EDITOR_CLI_COMMAND, registerShellHandlers } from './shell'
import { resolveExternalEditorLaunchSpec } from '../external-editor-launch'
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
}
describe('registerShellHandlers', () => {
beforeEach(() => {
handleMock.mockReset()
getSpawnArgsForWindowsMock.mockReset()
openPathMock.mockReset()
resolveCliCommandMock.mockReset()
showItemInFolderMock.mockReset()
showOpenDialogMock.mockReset()
spawnMock.mockReset()
statMock.mockReset()
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()
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()
})
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({}, '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({}, 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({}, 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)
])
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({}, workspacePath)).resolves.toEqual({ ok: true })
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
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 () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true })
expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform })
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
})
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({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true })
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/cursor', [
'--new-window',
normalize(workspacePath)
])
resolveCliCommandMock.mockReturnValueOnce('C:\\Cursor\\cursor.cmd')
await expect(handler({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true })
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith('C:\\Cursor\\cursor.cmd', [
'--new-window',
normalize(workspacePath)
])
})
it('falls back to VS Code when command is blank', async () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath, ' ')).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({}, workspacePath)).resolves.toEqual({ ok: true })
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
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({}, filePath, '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
})
})
})
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))
})
})
})