/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import { checkHasEditorType, checkHasEditorTypeAsync, getDiffCommand, openDiff, allowEditorTypeInSandbox, isEditorAvailable, isEditorAvailableAsync, resolveEditorAsync, 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 })), })); 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('checkHasEditorType', () => { 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'] }, ]; for (const { editor, commands, win32Commands } of testCases) { describe(`${editor}`, () => { // Non-windows tests 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(checkHasEditorType(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(); // first command not found }) .mockReturnValueOnce(Buffer.from(`/usr/bin/${commands[1]}`)); // second command found expect(checkHasEditorType(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(); // all commands not found }); expect(checkHasEditorType(editor)).toBe(false); expect(execSync).toHaveBeenCalledTimes(commands.length); }); // Windows tests 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(checkHasEditorType(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(); // first command not found }) .mockReturnValueOnce( Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`), ); // second command found expect(checkHasEditorType(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(); // all commands not found }); expect(checkHasEditorType(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'], }, ]; for (const { editor, commands, win32Commands } of guiEditors) { // Non-windows tests 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(); // first command not found }) .mockReturnValueOnce(Buffer.from(`/usr/bin/${commands[1]}`)); // second command found 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(); // all commands not found }); const diffCommand = getDiffCommand('old.txt', 'new.txt', editor); expect(diffCommand).toEqual({ command: commands[commands.length - 1], args: ['--wait', '--diff', 'old.txt', 'new.txt'], }); }); // Windows tests 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(); // first command not found }) .mockReturnValueOnce( Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`), ); // second command found 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(); // all commands not found }); 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', () => { 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 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 an unsupported editor', () => { // @ts-expect-error Testing unsupported editor const command = getDiffCommand('old.txt', 'new.txt', 'foobar'); expect(command).toBeNull(); }); }); describe('openDiff', () => { const guiEditors: EditorType[] = [ 'vscode', 'vscodium', 'windsurf', 'cursor', 'zed', ]; 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 reject if ${editor} exits with non-zero code`, async () => { const mockSpawnOn = vi.fn((event, cb) => { if (event === 'close') { cb(1); } }); (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow( `${editor} exited with code 1`, ); }); } const terminalEditors: EditorType[] = ['vim', 'neovim', 'emacs', 'hx']; 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(() => {}); // @ts-expect-error Testing unsupported editor 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 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', ]; 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); }); }); // Helper to create a mock exec that simulates async behavior 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('checkHasEditorTypeAsync', () => { it('should return true if vim command exists', async () => { Object.defineProperty(process, 'platform', { value: 'linux' }); mockExecAsync((cmd) => cmd.includes('vim')); expect(await checkHasEditorTypeAsync('vim')).toBe(true); }); it('should return false if vim command does not exist', async () => { Object.defineProperty(process, 'platform', { value: 'linux' }); mockExecAsync(() => false); expect(await checkHasEditorTypeAsync('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 checkHasEditorTypeAsync('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); // Simulate UI selection 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); // Simulate UI cancellation (exit dialog) 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); // Simulate UI selection setTimeout( () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }), 0, ); const result = await resolvePromise; expect(result).toBe('vim'); expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection); }); }); });