/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; const mockPtySpawn = vi.hoisted(() => vi.fn()); vi.mock('@lydell/node-pty', () => ({ spawn: mockPtySpawn, })); import EventEmitter from 'events'; import { ShellExecutionService, ShellOutputEvent, } from './shellExecutionService.js'; const mockIsBinary = vi.hoisted(() => vi.fn()); vi.mock('../utils/textUtils.js', () => ({ isBinary: mockIsBinary, })); const mockPlatform = vi.hoisted(() => vi.fn()); vi.mock('os', () => ({ default: { platform: mockPlatform, }, platform: mockPlatform, })); describe('ShellExecutionService', () => { let mockPtyProcess: EventEmitter & { pid: number; kill: Mock; onData: Mock; onExit: Mock; }; let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>; beforeEach(() => { vi.clearAllMocks(); mockIsBinary.mockReturnValue(false); mockPlatform.mockReturnValue('linux'); onOutputEventMock = vi.fn(); mockPtyProcess = new EventEmitter() as EventEmitter & { pid: number; kill: Mock; onData: Mock; onExit: Mock; }; mockPtyProcess.pid = 12345; mockPtyProcess.kill = vi.fn(); mockPtyProcess.onData = vi.fn(); mockPtyProcess.onExit = vi.fn(); mockPtySpawn.mockReturnValue(mockPtyProcess); }); // Helper function to run a standard execution simulation const simulateExecution = async ( command: string, simulation: ( ptyProcess: typeof mockPtyProcess, ac: AbortController, ) => void, ) => { const abortController = new AbortController(); const handle = ShellExecutionService.execute( command, '/test/dir', onOutputEventMock, abortController.signal, ); await new Promise((resolve) => setImmediate(resolve)); simulation(mockPtyProcess, abortController); const result = await handle.result; return { result, handle, abortController }; }; describe('Successful Execution', () => { it('should execute a command and capture output', async () => { const { result, handle } = await simulateExecution('ls -l', (pty) => { pty.onData.mock.calls[0][0]('file1.txt\n'); pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }); expect(mockPtySpawn).toHaveBeenCalledWith( 'bash', ['-c', 'ls -l'], expect.any(Object), ); expect(result.exitCode).toBe(0); expect(result.signal).toBeNull(); expect(result.error).toBeNull(); expect(result.aborted).toBe(false); expect(result.output).toBe('file1.txt'); expect(handle.pid).toBe(12345); expect(onOutputEventMock).toHaveBeenCalledWith({ type: 'data', chunk: 'file1.txt', }); }); it('should strip ANSI codes from output', async () => { const { result } = await simulateExecution('ls --color=auto', (pty) => { pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword'); pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }); expect(result.output).toBe('aredword'); expect(onOutputEventMock).toHaveBeenCalledWith({ type: 'data', chunk: 'aredword', }); }); it('should correctly decode multi-byte characters split across chunks', async () => { const { result } = await simulateExecution('echo "你好"', (pty) => { const multiByteChar = '你好'; pty.onData.mock.calls[0][0](multiByteChar.slice(0, 1)); pty.onData.mock.calls[0][0](multiByteChar.slice(1)); pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }); expect(result.output).toBe('你好'); }); it('should handle commands with no output', async () => { const { result } = await simulateExecution('touch file', (pty) => { pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }); expect(result.output).toBe(''); expect(onOutputEventMock).not.toHaveBeenCalled(); }); }); describe('Failed Execution', () => { it('should capture a non-zero exit code', async () => { const { result } = await simulateExecution('a-bad-command', (pty) => { pty.onData.mock.calls[0][0]('command not found'); pty.onExit.mock.calls[0][0]({ exitCode: 127, signal: null }); }); expect(result.exitCode).toBe(127); expect(result.output).toBe('command not found'); expect(result.error).toBeNull(); }); it('should capture a termination signal', async () => { const { result } = await simulateExecution('long-process', (pty) => { pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: 15 }); }); expect(result.exitCode).toBe(0); expect(result.signal).toBe(15); }); it('should handle a synchronous spawn error', async () => { const spawnError = new Error('spawn ENOENT'); mockPtySpawn.mockImplementation(() => { throw spawnError; }); const handle = ShellExecutionService.execute( 'any-command', '/test/dir', onOutputEventMock, new AbortController().signal, ); const result = await handle.result; expect(result.error).toBe(spawnError); expect(result.exitCode).toBe(1); expect(result.output).toBe(''); expect(handle.pid).toBeUndefined(); }); }); describe('Aborting Commands', () => { it('should abort a running process and set the aborted flag', async () => { const { result } = await simulateExecution( 'sleep 10', (pty, abortController) => { abortController.abort(); pty.onExit.mock.calls[0][0]({ exitCode: 1, signal: null }); }, ); expect(result.aborted).toBe(true); expect(mockPtyProcess.kill).toHaveBeenCalled(); }); }); describe('Binary Output', () => { it('should detect binary output and switch to progress events', async () => { mockIsBinary.mockReturnValueOnce(true); const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]); const { result } = await simulateExecution('cat image.png', (pty) => { pty.onData.mock.calls[0][0](binaryChunk1); pty.onData.mock.calls[0][0](binaryChunk2); pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }); expect(result.rawOutput).toEqual( Buffer.concat([binaryChunk1, binaryChunk2]), ); expect(onOutputEventMock).toHaveBeenCalledTimes(3); expect(onOutputEventMock.mock.calls[0][0]).toEqual({ type: 'binary_detected', }); expect(onOutputEventMock.mock.calls[1][0]).toEqual({ type: 'binary_progress', bytesReceived: 4, }); expect(onOutputEventMock.mock.calls[2][0]).toEqual({ type: 'binary_progress', bytesReceived: 8, }); }); it('should not emit data events after binary is detected', async () => { mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00)); await simulateExecution('cat mixed_file', (pty) => { pty.onData.mock.calls[0][0](Buffer.from('some text')); pty.onData.mock.calls[0][0](Buffer.from([0x00, 0x01, 0x02])); pty.onData.mock.calls[0][0](Buffer.from('more text')); pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }); const eventTypes = onOutputEventMock.mock.calls.map( (call: [ShellOutputEvent]) => call[0].type, ); expect(eventTypes).toEqual([ 'data', 'binary_detected', 'binary_progress', 'binary_progress', ]); }); }); describe('Platform-Specific Behavior', () => { it('should use cmd.exe on Windows', async () => { mockPlatform.mockReturnValue('win32'); await simulateExecution('dir "foo bar"', (pty) => pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), ); expect(mockPtySpawn).toHaveBeenCalledWith( 'cmd.exe', ['/c', 'dir "foo bar"'], expect.any(Object), ); }); it('should use bash on Linux', async () => { mockPlatform.mockReturnValue('linux'); await simulateExecution('ls "foo bar"', (pty) => pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), ); expect(mockPtySpawn).toHaveBeenCalledWith( 'bash', ['-c', 'ls "foo bar"'], expect.any(Object), ); }); }); });