| |
| |
| |
| |
| |
|
|
| import { |
| vi, |
| describe, |
| it, |
| expect, |
| beforeEach, |
| afterEach, |
| type Mock, |
| } from 'vitest'; |
| import { |
| hasValidEditorCommand, |
| hasValidEditorCommandAsync, |
| getDiffCommand, |
| openDiff, |
| allowEditorTypeInSandbox, |
| isEditorAvailable, |
| isEditorAvailableAsync, |
| isValidEditorType, |
| getEditorWaitFlag, |
| getEditorExtraArgs, |
| resolveEditorAsync, |
| resolveEditorTypeFromCommand, |
| type EditorType, |
| } from './editor.js'; |
| import { coreEvents, CoreEvent } from './events.js'; |
| import { exec, execSync, spawn, spawnSync } from 'node:child_process'; |
| import { debugLogger } from './debugLogger.js'; |
|
|
| vi.mock('child_process', () => ({ |
| exec: vi.fn(), |
| execSync: vi.fn(), |
| spawn: vi.fn(), |
| spawnSync: vi.fn(() => ({ error: null, status: 0 })), |
| })); |
|
|
| vi.mock('./headless.js', () => ({ |
| isHeadlessMode: vi.fn(() => false), |
| })); |
|
|
| const originalPlatform = process.platform; |
|
|
| describe('editor utils', () => { |
| beforeEach(() => { |
| vi.clearAllMocks(); |
| vi.unstubAllEnvs(); |
| Object.defineProperty(process, 'platform', { |
| value: originalPlatform, |
| writable: true, |
| }); |
| }); |
|
|
| afterEach(() => { |
| vi.restoreAllMocks(); |
| vi.unstubAllEnvs(); |
| Object.defineProperty(process, 'platform', { |
| value: originalPlatform, |
| writable: true, |
| }); |
| }); |
|
|
| describe('hasValidEditorCommand', () => { |
| const testCases: Array<{ |
| editor: EditorType; |
| commands: string[]; |
| win32Commands: string[]; |
| }> = [ |
| { editor: 'vscode', commands: ['code'], win32Commands: ['code.cmd'] }, |
| { |
| editor: 'vscodium', |
| commands: ['codium'], |
| win32Commands: ['codium.cmd'], |
| }, |
| { |
| editor: 'windsurf', |
| commands: ['windsurf'], |
| win32Commands: ['windsurf'], |
| }, |
| { editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] }, |
| { editor: 'vim', commands: ['vim'], win32Commands: ['vim'] }, |
| { editor: 'neovim', commands: ['nvim'], win32Commands: ['nvim'] }, |
| { editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] }, |
| { editor: 'emacs', commands: ['emacs'], win32Commands: ['emacs.exe'] }, |
| { |
| editor: 'antigravity', |
| commands: ['agy', 'antigravity'], |
| win32Commands: ['agy.cmd', 'antigravity.cmd', 'antigravity'], |
| }, |
| { editor: 'hx', commands: ['hx'], win32Commands: ['hx'] }, |
| { |
| editor: 'sublimetext', |
| commands: ['subl'], |
| win32Commands: ['subl'], |
| }, |
| { editor: 'lapce', commands: ['lapce'], win32Commands: ['lapce'] }, |
| { editor: 'nova', commands: ['nova'], win32Commands: ['nova'] }, |
| { editor: 'bbedit', commands: ['bbedit'], win32Commands: ['bbedit'] }, |
| { |
| editor: 'emacsclient', |
| commands: ['emacsclient'], |
| win32Commands: ['emacsclient'], |
| }, |
| { editor: 'micro', commands: ['micro'], win32Commands: ['micro'] }, |
| ]; |
|
|
| for (const { editor, commands, win32Commands } of testCases) { |
| describe(`${editor}`, () => { |
| |
| it(`should return true if first command "${commands[0]}" exists on non-windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'linux' }); |
| (execSync as Mock).mockReturnValue( |
| Buffer.from(`/usr/bin/${commands[0]}`), |
| ); |
| expect(hasValidEditorCommand(editor)).toBe(true); |
| expect(execSync).toHaveBeenCalledWith(`command -v ${commands[0]}`, { |
| stdio: 'ignore', |
| }); |
| }); |
|
|
| if (commands.length > 1) { |
| it(`should return true if first command doesn't exist but second command "${commands[1]}" exists on non-windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'linux' }); |
| (execSync as Mock) |
| .mockImplementationOnce(() => { |
| throw new Error(); |
| }) |
| .mockReturnValueOnce(Buffer.from(`/usr/bin/${commands[1]}`)); |
| expect(hasValidEditorCommand(editor)).toBe(true); |
| expect(execSync).toHaveBeenCalledTimes(2); |
| }); |
| } |
|
|
| it(`should return false if none of the commands exist on non-windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'linux' }); |
| (execSync as Mock).mockImplementation(() => { |
| throw new Error(); |
| }); |
| expect(hasValidEditorCommand(editor)).toBe(false); |
| expect(execSync).toHaveBeenCalledTimes(commands.length); |
| }); |
|
|
| |
| it(`should return true if first command "${win32Commands[0]}" exists on windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'win32' }); |
| (execSync as Mock).mockReturnValue( |
| Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`), |
| ); |
| expect(hasValidEditorCommand(editor)).toBe(true); |
| expect(execSync).toHaveBeenCalledWith( |
| `where.exe ${win32Commands[0]}`, |
| { |
| stdio: 'ignore', |
| }, |
| ); |
| }); |
|
|
| if (win32Commands.length > 1) { |
| it(`should return true if first command doesn't exist but second command "${win32Commands[1]}" exists on windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'win32' }); |
| (execSync as Mock) |
| .mockImplementationOnce(() => { |
| throw new Error(); |
| }) |
| .mockReturnValueOnce( |
| Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`), |
| ); |
| expect(hasValidEditorCommand(editor)).toBe(true); |
| expect(execSync).toHaveBeenCalledTimes(2); |
| }); |
| } |
|
|
| it(`should return false if none of the commands exist on windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'win32' }); |
| (execSync as Mock).mockImplementation(() => { |
| throw new Error(); |
| }); |
| expect(hasValidEditorCommand(editor)).toBe(false); |
| expect(execSync).toHaveBeenCalledTimes(win32Commands.length); |
| }); |
| }); |
| } |
| }); |
|
|
| describe('getDiffCommand', () => { |
| const guiEditors: Array<{ |
| editor: EditorType; |
| commands: string[]; |
| win32Commands: string[]; |
| }> = [ |
| { editor: 'vscode', commands: ['code'], win32Commands: ['code.cmd'] }, |
| { |
| editor: 'vscodium', |
| commands: ['codium'], |
| win32Commands: ['codium.cmd'], |
| }, |
| { |
| editor: 'windsurf', |
| commands: ['windsurf'], |
| win32Commands: ['windsurf'], |
| }, |
| { editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] }, |
| { editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] }, |
| { |
| editor: 'antigravity', |
| commands: ['agy', 'antigravity'], |
| win32Commands: ['agy.cmd', 'antigravity.cmd', 'antigravity'], |
| }, |
| { editor: 'bbedit', commands: ['bbedit'], win32Commands: ['bbedit'] }, |
| ]; |
|
|
| for (const { editor, commands, win32Commands } of guiEditors) { |
| |
| it(`should use first command "${commands[0]}" when it exists on non-windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'linux' }); |
| (execSync as Mock).mockReturnValue( |
| Buffer.from(`/usr/bin/${commands[0]}`), |
| ); |
| const diffCommand = getDiffCommand('old.txt', 'new.txt', editor); |
| expect(diffCommand).toEqual({ |
| command: commands[0], |
| args: ['--wait', '--diff', 'old.txt', 'new.txt'], |
| }); |
| }); |
|
|
| if (commands.length > 1) { |
| it(`should use second command "${commands[1]}" when first doesn't exist on non-windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'linux' }); |
| (execSync as Mock) |
| .mockImplementationOnce(() => { |
| throw new Error(); |
| }) |
| .mockReturnValueOnce(Buffer.from(`/usr/bin/${commands[1]}`)); |
|
|
| const diffCommand = getDiffCommand('old.txt', 'new.txt', editor); |
| expect(diffCommand).toEqual({ |
| command: commands[1], |
| args: ['--wait', '--diff', 'old.txt', 'new.txt'], |
| }); |
| }); |
| } |
|
|
| it(`should fall back to last command "${commands[commands.length - 1]}" when none exist on non-windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'linux' }); |
| (execSync as Mock).mockImplementation(() => { |
| throw new Error(); |
| }); |
|
|
| const diffCommand = getDiffCommand('old.txt', 'new.txt', editor); |
| expect(diffCommand).toEqual({ |
| command: commands[commands.length - 1], |
| args: ['--wait', '--diff', 'old.txt', 'new.txt'], |
| }); |
| }); |
|
|
| |
| it(`should use first command "${win32Commands[0]}" when it exists on windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'win32' }); |
| (execSync as Mock).mockReturnValue( |
| Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`), |
| ); |
| const diffCommand = getDiffCommand('old.txt', 'new.txt', editor); |
| expect(diffCommand).toEqual({ |
| command: win32Commands[0], |
| args: ['--wait', '--diff', 'old.txt', 'new.txt'], |
| }); |
| }); |
|
|
| if (win32Commands.length > 1) { |
| it(`should use second command "${win32Commands[1]}" when first doesn't exist on windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'win32' }); |
| (execSync as Mock) |
| .mockImplementationOnce(() => { |
| throw new Error(); |
| }) |
| .mockReturnValueOnce( |
| Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`), |
| ); |
|
|
| const diffCommand = getDiffCommand('old.txt', 'new.txt', editor); |
| expect(diffCommand).toEqual({ |
| command: win32Commands[1], |
| args: ['--wait', '--diff', 'old.txt', 'new.txt'], |
| }); |
| }); |
| } |
|
|
| it(`should fall back to last command "${win32Commands[win32Commands.length - 1]}" when none exist on windows`, () => { |
| Object.defineProperty(process, 'platform', { value: 'win32' }); |
| (execSync as Mock).mockImplementation(() => { |
| throw new Error(); |
| }); |
|
|
| const diffCommand = getDiffCommand('old.txt', 'new.txt', editor); |
| expect(diffCommand).toEqual({ |
| command: win32Commands[win32Commands.length - 1], |
| args: ['--wait', '--diff', 'old.txt', 'new.txt'], |
| }); |
| }); |
| } |
|
|
| const terminalEditors: Array<{ |
| editor: EditorType; |
| command: string; |
| }> = [ |
| { editor: 'vim', command: 'vim' }, |
| { editor: 'neovim', command: 'nvim' }, |
| ]; |
|
|
| for (const { editor, command } of terminalEditors) { |
| it(`should return the correct command for ${editor}`, () => { |
| const diffCommand = getDiffCommand('old.txt', 'new.txt', editor); |
| expect(diffCommand).toEqual({ |
| command, |
| args: [ |
| '-d', |
| '-i', |
| 'NONE', |
| '-c', |
| 'wincmd h | set readonly | wincmd l', |
| '-c', |
| 'highlight DiffAdd cterm=bold ctermbg=22 guibg=#005f00 | highlight DiffChange cterm=bold ctermbg=24 guibg=#005f87 | highlight DiffText ctermbg=21 guibg=#0000af | highlight DiffDelete ctermbg=52 guibg=#5f0000', |
| '-c', |
| 'set showtabline=2 | set tabline=[Instructions]\\ :wqa(save\\ &\\ quit)\\ \\|\\ i/esc(toggle\\ edit\\ mode)', |
| '-c', |
| 'wincmd h | setlocal statusline=OLD\\ FILE', |
| '-c', |
| 'wincmd l | setlocal statusline=%#StatusBold#NEW\\ FILE\\ :wqa(save\\ &\\ quit)\\ \\|\\ i/esc(toggle\\ edit\\ mode)', |
| '-c', |
| 'autocmd BufWritePost * wqa', |
| 'old.txt', |
| 'new.txt', |
| ], |
| }); |
| }); |
| } |
|
|
| it('should return the correct command for emacs with escaped paths', () => { |
| Object.defineProperty(process, 'platform', { value: 'linux' }); |
| const command = getDiffCommand( |
| 'old file "quote".txt', |
| 'new file \\back\\slash.txt', |
| 'emacs', |
| ); |
| expect(command).toEqual({ |
| command: 'emacs', |
| args: [ |
| '--eval', |
| '(ediff "old file \\"quote\\".txt" "new file \\\\back\\\\slash.txt")', |
| ], |
| }); |
| }); |
|
|
| it('should return the correct command for emacsclient', () => { |
| const command = getDiffCommand('old.txt', 'new.txt', 'emacsclient'); |
| expect(command).toEqual({ |
| command: 'emacsclient', |
| args: ['-nw', '--eval', '(ediff "old.txt" "new.txt")'], |
| }); |
| }); |
|
|
| it('should return the correct command for emacsclient with escaped paths', () => { |
| const command = getDiffCommand( |
| 'old file "quote".txt', |
| 'new file \\back\\slash.txt', |
| 'emacsclient', |
| ); |
| expect(command).toEqual({ |
| command: 'emacsclient', |
| args: [ |
| '-nw', |
| '--eval', |
| '(ediff "old file \\"quote\\".txt" "new file \\\\back\\\\slash.txt")', |
| ], |
| }); |
| }); |
|
|
| it('should return the correct command for helix', () => { |
| const command = getDiffCommand('old.txt', 'new.txt', 'hx'); |
| expect(command).toEqual({ |
| command: 'hx', |
| args: ['--vsplit', '--', 'old.txt', 'new.txt'], |
| }); |
| }); |
|
|
| it('should return null for sublimetext (no CLI diff support)', () => { |
| expect(getDiffCommand('old.txt', 'new.txt', 'sublimetext')).toBeNull(); |
| }); |
|
|
| it('should return null for lapce (no CLI diff support)', () => { |
| expect(getDiffCommand('old.txt', 'new.txt', 'lapce')).toBeNull(); |
| }); |
|
|
| it('should return null for nova (no CLI diff support)', () => { |
| expect(getDiffCommand('old.txt', 'new.txt', 'nova')).toBeNull(); |
| }); |
|
|
| it('should return null for micro (no CLI diff support)', () => { |
| expect(getDiffCommand('old.txt', 'new.txt', 'micro')).toBeNull(); |
| }); |
|
|
| it('should return null for an unsupported editor', () => { |
| |
| const command = getDiffCommand('old.txt', 'new.txt', 'foobar'); |
| expect(command).toBeNull(); |
| }); |
| }); |
|
|
| describe('openDiff', () => { |
| const guiEditors: EditorType[] = [ |
| 'vscode', |
| 'vscodium', |
| 'windsurf', |
| 'cursor', |
| 'zed', |
| 'bbedit', |
| ]; |
|
|
| for (const editor of guiEditors) { |
| it(`should call spawn for ${editor}`, async () => { |
| const mockSpawnOn = vi.fn((event, cb) => { |
| if (event === 'close') { |
| cb(0); |
| } |
| }); |
| (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); |
|
|
| await openDiff('old.txt', 'new.txt', editor); |
| const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; |
| expect(spawn).toHaveBeenCalledWith( |
| diffCommand.command, |
| diffCommand.args, |
| { |
| stdio: 'inherit', |
| shell: process.platform === 'win32', |
| }, |
| ); |
| expect(mockSpawnOn).toHaveBeenCalledWith('close', expect.any(Function)); |
| expect(mockSpawnOn).toHaveBeenCalledWith('error', expect.any(Function)); |
| }); |
|
|
| it(`should reject if spawn for ${editor} fails`, async () => { |
| const mockError = new Error('spawn error'); |
| const mockSpawnOn = vi.fn((event, cb) => { |
| if (event === 'error') { |
| cb(mockError); |
| } |
| }); |
| (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); |
|
|
| await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow( |
| 'spawn error', |
| ); |
| }); |
|
|
| it(`should resolve and log warning if ${editor} exits with non-zero code`, async () => { |
| const warnSpy = vi |
| .spyOn(debugLogger, 'warn') |
| .mockImplementation(() => {}); |
| const mockSpawnOn = vi.fn((event, cb) => { |
| if (event === 'close') { |
| cb(1); |
| } |
| }); |
| (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); |
|
|
| await openDiff('old.txt', 'new.txt', editor); |
| expect(warnSpy).toHaveBeenCalledWith(`${editor} exited with code 1`); |
| }); |
|
|
| it(`should emit ExternalEditorClosed when ${editor} exits successfully`, async () => { |
| const emitSpy = vi.spyOn(coreEvents, 'emit'); |
| const mockSpawnOn = vi.fn((event, cb) => { |
| if (event === 'close') { |
| cb(0); |
| } |
| }); |
| (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); |
|
|
| await openDiff('old.txt', 'new.txt', editor); |
| expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed); |
| }); |
|
|
| it(`should emit ExternalEditorClosed when ${editor} exits with non-zero code`, async () => { |
| vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); |
| const emitSpy = vi.spyOn(coreEvents, 'emit'); |
| const mockSpawnOn = vi.fn((event, cb) => { |
| if (event === 'close') { |
| cb(1); |
| } |
| }); |
| (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); |
|
|
| await openDiff('old.txt', 'new.txt', editor); |
| expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed); |
| }); |
|
|
| it(`should emit ExternalEditorClosed when ${editor} spawn errors`, async () => { |
| const emitSpy = vi.spyOn(coreEvents, 'emit'); |
| const mockError = new Error('spawn error'); |
| const mockSpawnOn = vi.fn((event, cb) => { |
| if (event === 'error') { |
| cb(mockError); |
| } |
| }); |
| (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); |
|
|
| await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow( |
| 'spawn error', |
| ); |
| expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed); |
| }); |
|
|
| it(`should only emit ExternalEditorClosed once when ${editor} fires both error and close`, async () => { |
| const emitSpy = vi.spyOn(coreEvents, 'emit'); |
| const callbacks: Record<string, (arg: unknown) => void> = {}; |
| const mockSpawnOn = vi.fn( |
| (event: string, cb: (arg: unknown) => void) => { |
| callbacks[event] = cb; |
| }, |
| ); |
| (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); |
|
|
| const promise = openDiff('old.txt', 'new.txt', editor); |
| |
| callbacks['error'](new Error('spawn error')); |
| callbacks['close'](1); |
|
|
| await expect(promise).rejects.toThrow('spawn error'); |
| const editorClosedEmissions = emitSpy.mock.calls.filter( |
| (call) => call[0] === CoreEvent.ExternalEditorClosed, |
| ); |
| expect(editorClosedEmissions).toHaveLength(1); |
| }); |
| } |
|
|
| |
| const terminalEditors: EditorType[] = [ |
| 'vim', |
| 'neovim', |
| 'emacs', |
| 'hx', |
| 'emacsclient', |
| ]; |
|
|
| for (const editor of terminalEditors) { |
| it(`should call spawnSync for ${editor}`, async () => { |
| await openDiff('old.txt', 'new.txt', editor); |
| const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; |
| expect(spawnSync).toHaveBeenCalledWith( |
| diffCommand.command, |
| diffCommand.args, |
| { |
| stdio: 'inherit', |
| }, |
| ); |
| }); |
| } |
|
|
| it('should log an error if diff command is not available', async () => { |
| const consoleErrorSpy = vi |
| .spyOn(debugLogger, 'error') |
| .mockImplementation(() => {}); |
| |
| await openDiff('old.txt', 'new.txt', 'foobar'); |
| expect(consoleErrorSpy).toHaveBeenCalledWith( |
| 'No diff tool available. Install a supported editor.', |
| ); |
| }); |
| }); |
|
|
| describe('allowEditorTypeInSandbox', () => { |
| it('should allow vim in sandbox mode', () => { |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(allowEditorTypeInSandbox('vim')).toBe(true); |
| }); |
|
|
| it('should allow vim when not in sandbox mode', () => { |
| expect(allowEditorTypeInSandbox('vim')).toBe(true); |
| }); |
|
|
| it('should allow emacs in sandbox mode', () => { |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(allowEditorTypeInSandbox('emacs')).toBe(true); |
| }); |
|
|
| it('should allow emacs when not in sandbox mode', () => { |
| expect(allowEditorTypeInSandbox('emacs')).toBe(true); |
| }); |
|
|
| it('should allow emacsclient in sandbox mode', () => { |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(allowEditorTypeInSandbox('emacsclient')).toBe(true); |
| }); |
|
|
| it('should allow emacsclient when not in sandbox mode', () => { |
| expect(allowEditorTypeInSandbox('emacsclient')).toBe(true); |
| }); |
|
|
| it('should allow neovim in sandbox mode', () => { |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(allowEditorTypeInSandbox('neovim')).toBe(true); |
| }); |
|
|
| it('should allow neovim when not in sandbox mode', () => { |
| expect(allowEditorTypeInSandbox('neovim')).toBe(true); |
| }); |
|
|
| it('should allow hx in sandbox mode', () => { |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(allowEditorTypeInSandbox('hx')).toBe(true); |
| }); |
|
|
| it('should allow hx when not in sandbox mode', () => { |
| expect(allowEditorTypeInSandbox('hx')).toBe(true); |
| }); |
|
|
| const guiEditors: EditorType[] = [ |
| 'vscode', |
| 'vscodium', |
| 'windsurf', |
| 'cursor', |
| 'zed', |
| 'sublimetext', |
| 'lapce', |
| 'nova', |
| 'bbedit', |
| ]; |
| for (const editor of guiEditors) { |
| it(`should not allow ${editor} in sandbox mode`, () => { |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(allowEditorTypeInSandbox(editor)).toBe(false); |
| }); |
|
|
| it(`should allow ${editor} when not in sandbox mode`, () => { |
| vi.stubEnv('SANDBOX', ''); |
| expect(allowEditorTypeInSandbox(editor)).toBe(true); |
| }); |
| } |
| }); |
|
|
| describe('isEditorAvailable', () => { |
| it('should return false for undefined editor', () => { |
| expect(isEditorAvailable(undefined)).toBe(false); |
| }); |
|
|
| it('should return false for empty string editor', () => { |
| expect(isEditorAvailable('')).toBe(false); |
| }); |
|
|
| it('should return false for invalid editor type', () => { |
| expect(isEditorAvailable('invalid-editor')).toBe(false); |
| }); |
|
|
| it('should return true for vscode when installed and not in sandbox mode', () => { |
| (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code')); |
| vi.stubEnv('SANDBOX', ''); |
| expect(isEditorAvailable('vscode')).toBe(true); |
| }); |
|
|
| it('should return false for vscode when not installed and not in sandbox mode', () => { |
| (execSync as Mock).mockImplementation(() => { |
| throw new Error(); |
| }); |
| expect(isEditorAvailable('vscode')).toBe(false); |
| }); |
|
|
| it('should return false for vscode when installed and in sandbox mode', () => { |
| (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code')); |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(isEditorAvailable('vscode')).toBe(false); |
| }); |
|
|
| it('should return true for vim when installed and in sandbox mode', () => { |
| (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/vim')); |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(isEditorAvailable('vim')).toBe(true); |
| }); |
|
|
| it('should return true for emacs when installed and in sandbox mode', () => { |
| (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/emacs')); |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(isEditorAvailable('emacs')).toBe(true); |
| }); |
|
|
| it('should return true for hx when installed and in sandbox mode', () => { |
| (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/hx')); |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(isEditorAvailable('hx')).toBe(true); |
| }); |
|
|
| it('should return true for neovim when installed and in sandbox mode', () => { |
| (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/nvim')); |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(isEditorAvailable('neovim')).toBe(true); |
| }); |
| }); |
|
|
| |
| const mockExecAsync = (implementation: (cmd: string) => boolean): void => { |
| (exec as unknown as Mock).mockImplementation( |
| ( |
| cmd: string, |
| callback: (error: Error | null, stdout: string, stderr: string) => void, |
| ) => { |
| if (implementation(cmd)) { |
| callback(null, '/usr/bin/cmd', ''); |
| } else { |
| callback(new Error('Command not found'), '', ''); |
| } |
| }, |
| ); |
| }; |
|
|
| describe('hasValidEditorCommandAsync', () => { |
| it('should return true if vim command exists', async () => { |
| Object.defineProperty(process, 'platform', { value: 'linux' }); |
| mockExecAsync((cmd) => cmd.includes('vim')); |
| expect(await hasValidEditorCommandAsync('vim')).toBe(true); |
| }); |
|
|
| it('should return false if vim command does not exist', async () => { |
| Object.defineProperty(process, 'platform', { value: 'linux' }); |
| mockExecAsync(() => false); |
| expect(await hasValidEditorCommandAsync('vim')).toBe(false); |
| }); |
|
|
| it('should check zed and zeditor commands in order', async () => { |
| Object.defineProperty(process, 'platform', { value: 'linux' }); |
| mockExecAsync((cmd) => cmd.includes('zeditor')); |
| expect(await hasValidEditorCommandAsync('zed')).toBe(true); |
| }); |
| }); |
|
|
| describe('isEditorAvailableAsync', () => { |
| it('should return false for undefined editor', async () => { |
| expect(await isEditorAvailableAsync(undefined)).toBe(false); |
| }); |
|
|
| it('should return false for empty string editor', async () => { |
| expect(await isEditorAvailableAsync('')).toBe(false); |
| }); |
|
|
| it('should return false for invalid editor type', async () => { |
| expect(await isEditorAvailableAsync('invalid-editor')).toBe(false); |
| }); |
|
|
| it('should return true for vscode when installed and not in sandbox mode', async () => { |
| mockExecAsync((cmd) => cmd.includes('code')); |
| vi.stubEnv('SANDBOX', ''); |
| expect(await isEditorAvailableAsync('vscode')).toBe(true); |
| }); |
|
|
| it('should return false for vscode when not installed', async () => { |
| mockExecAsync(() => false); |
| expect(await isEditorAvailableAsync('vscode')).toBe(false); |
| }); |
|
|
| it('should return false for vscode in sandbox mode', async () => { |
| mockExecAsync((cmd) => cmd.includes('code')); |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(await isEditorAvailableAsync('vscode')).toBe(false); |
| }); |
|
|
| it('should return true for vim in sandbox mode', async () => { |
| mockExecAsync((cmd) => cmd.includes('vim')); |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| expect(await isEditorAvailableAsync('vim')).toBe(true); |
| }); |
| }); |
|
|
| describe('resolveEditorAsync', () => { |
| it('should return the preferred editor when available', async () => { |
| mockExecAsync((cmd) => cmd.includes('vim')); |
| vi.stubEnv('SANDBOX', ''); |
| const result = await resolveEditorAsync('vim'); |
| expect(result).toBe('vim'); |
| }); |
|
|
| it('should request editor selection when preferred editor is not installed', async () => { |
| mockExecAsync(() => false); |
| vi.stubEnv('SANDBOX', ''); |
| const resolvePromise = resolveEditorAsync('vim'); |
| setTimeout( |
| () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'neovim' }), |
| 0, |
| ); |
| const result = await resolvePromise; |
| expect(result).toBe('neovim'); |
| }); |
|
|
| it('should request editor selection when preferred GUI editor cannot be used in sandbox mode', async () => { |
| mockExecAsync((cmd) => cmd.includes('code')); |
| vi.stubEnv('SANDBOX', 'sandbox'); |
| const resolvePromise = resolveEditorAsync('vscode'); |
| setTimeout( |
| () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }), |
| 0, |
| ); |
| const result = await resolvePromise; |
| expect(result).toBe('vim'); |
| }); |
|
|
| it('should request editor selection when no preference is set', async () => { |
| const emitSpy = vi.spyOn(coreEvents, 'emit'); |
| vi.stubEnv('SANDBOX', ''); |
|
|
| const resolvePromise = resolveEditorAsync(undefined); |
|
|
| |
| setTimeout( |
| () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }), |
| 0, |
| ); |
|
|
| const result = await resolvePromise; |
| expect(result).toBe('vim'); |
| expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection); |
| }); |
|
|
| it('should return undefined when editor selection is cancelled', async () => { |
| const resolvePromise = resolveEditorAsync(undefined); |
|
|
| |
| setTimeout( |
| () => coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined }), |
| 0, |
| ); |
|
|
| const result = await resolvePromise; |
| expect(result).toBeUndefined(); |
| }); |
|
|
| it('should return undefined when abort signal is triggered', async () => { |
| const controller = new AbortController(); |
| const resolvePromise = resolveEditorAsync(undefined, controller.signal); |
|
|
| setTimeout(() => controller.abort(), 0); |
|
|
| const result = await resolvePromise; |
| expect(result).toBeUndefined(); |
| }); |
|
|
| it('should request editor selection in sandbox mode when no preference is set', async () => { |
| const emitSpy = vi.spyOn(coreEvents, 'emit'); |
| vi.stubEnv('SANDBOX', 'sandbox'); |
|
|
| const resolvePromise = resolveEditorAsync(undefined); |
|
|
| |
| setTimeout( |
| () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }), |
| 0, |
| ); |
|
|
| const result = await resolvePromise; |
| expect(result).toBe('vim'); |
| expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection); |
| }); |
| }); |
|
|
| describe('isValidEditorType', () => { |
| it('should return true for known editor identifiers', () => { |
| expect(isValidEditorType('vscode')).toBe(true); |
| expect(isValidEditorType('vim')).toBe(true); |
| expect(isValidEditorType('sublimetext')).toBe(true); |
| expect(isValidEditorType('emacsclient')).toBe(true); |
| expect(isValidEditorType('micro')).toBe(true); |
| expect(isValidEditorType('lapce')).toBe(true); |
| expect(isValidEditorType('nova')).toBe(true); |
| expect(isValidEditorType('bbedit')).toBe(true); |
| }); |
|
|
| it('should return false for unrecognized strings', () => { |
| expect(isValidEditorType('emacsclient -nw')).toBe(false); |
| expect(isValidEditorType('subl')).toBe(false); |
| expect(isValidEditorType('code')).toBe(false); |
| expect(isValidEditorType('')).toBe(false); |
| expect(isValidEditorType('notepad')).toBe(false); |
| }); |
| }); |
|
|
| describe('getEditorWaitFlag', () => { |
| it('should return -w for sublimetext', () => { |
| expect(getEditorWaitFlag('sublimetext')).toBe('-w'); |
| }); |
|
|
| it('should return --wait for all other GUI editors', () => { |
| const standardGuiEditors: EditorType[] = [ |
| 'vscode', |
| 'vscodium', |
| 'windsurf', |
| 'cursor', |
| 'zed', |
| 'antigravity', |
| 'lapce', |
| 'nova', |
| 'bbedit', |
| ]; |
| for (const editor of standardGuiEditors) { |
| expect(getEditorWaitFlag(editor)).toBe('--wait'); |
| } |
| }); |
| }); |
|
|
| describe('resolveEditorTypeFromCommand', () => { |
| it('should resolve known command names to their editor type', () => { |
| expect(resolveEditorTypeFromCommand('cursor')).toBe('cursor'); |
| expect(resolveEditorTypeFromCommand('code')).toBe('vscode'); |
| expect(resolveEditorTypeFromCommand('codium')).toBe('vscodium'); |
| expect(resolveEditorTypeFromCommand('vim')).toBe('vim'); |
| }); |
|
|
| it('should be case-insensitive', () => { |
| expect(resolveEditorTypeFromCommand('Cursor')).toBe('cursor'); |
| expect(resolveEditorTypeFromCommand('CODE')).toBe('vscode'); |
| }); |
|
|
| it('should return undefined for unknown commands', () => { |
| expect(resolveEditorTypeFromCommand('unknowntool')).toBeUndefined(); |
| expect(resolveEditorTypeFromCommand('')).toBeUndefined(); |
| }); |
| }); |
|
|
| describe('getEditorExtraArgs', () => { |
| it('should return [-nw] for emacsclient', () => { |
| expect(getEditorExtraArgs('emacsclient')).toEqual(['-nw']); |
| }); |
|
|
| it('should return [] for VS Code-family editors by default', () => { |
| const vscodeEditors: EditorType[] = [ |
| 'vscode', |
| 'vscodium', |
| 'cursor', |
| 'windsurf', |
| ]; |
| for (const editor of vscodeEditors) { |
| expect(getEditorExtraArgs(editor)).toEqual([]); |
| } |
| }); |
|
|
| it('should return [--new-window] for VS Code-family editors when newWindow is true', () => { |
| const vscodeEditors: EditorType[] = [ |
| 'vscode', |
| 'vscodium', |
| 'cursor', |
| 'windsurf', |
| ]; |
| for (const editor of vscodeEditors) { |
| expect(getEditorExtraArgs(editor, { newWindow: true })).toEqual([ |
| '--new-window', |
| ]); |
| } |
| }); |
|
|
| it('should return [] for VS Code-family editors when newWindow is false', () => { |
| const vscodeEditors: EditorType[] = [ |
| 'vscode', |
| 'vscodium', |
| 'cursor', |
| 'windsurf', |
| ]; |
| for (const editor of vscodeEditors) { |
| expect(getEditorExtraArgs(editor, { newWindow: false })).toEqual([]); |
| } |
| }); |
|
|
| it('should return [] for all other editors', () => { |
| const otherEditors: EditorType[] = [ |
| 'vim', |
| 'neovim', |
| 'emacs', |
| 'hx', |
| 'sublimetext', |
| 'micro', |
| ]; |
| for (const editor of otherEditors) { |
| expect(getEditorExtraArgs(editor)).toEqual([]); |
| } |
| }); |
| }); |
| }); |
|
|