| |
| |
| |
| |
| |
|
|
| import { |
| vi, |
| describe, |
| it, |
| expect, |
| beforeEach, |
| afterEach, |
| type Mock, |
| } from 'vitest'; |
|
|
| import os from 'node:os'; |
| import EventEmitter from 'node:events'; |
| import type { Readable } from 'node:stream'; |
| import { type ChildProcess } from 'node:child_process'; |
| import { |
| ShellExecutionService, |
| type ShellOutputEvent, |
| type ShellExecutionConfig, |
| } from './shellExecutionService.js'; |
| import { NoopSandboxManager } from './sandboxManager.js'; |
| import { ExecutionLifecycleService } from './executionLifecycleService.js'; |
| import type { AnsiOutput, AnsiToken } from '../utils/terminalSerializer.js'; |
|
|
| |
| const mockPtySpawn = vi.hoisted(() => vi.fn()); |
| const mockCpSpawn = vi.hoisted(() => vi.fn()); |
| const mockIsBinary = vi.hoisted(() => vi.fn()); |
| const mockPlatform = vi.hoisted(() => vi.fn()); |
| const mockHomedir = vi.hoisted(() => vi.fn()); |
| const mockMkdirSync = vi.hoisted(() => vi.fn()); |
| const mockCreateWriteStream = vi.hoisted(() => vi.fn()); |
| const mockGetPty = vi.hoisted(() => vi.fn()); |
| const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn()); |
| const mockResolveExecutable = vi.hoisted(() => vi.fn()); |
| const mockDebugLogger = vi.hoisted(() => ({ |
| log: vi.fn(), |
| warn: vi.fn(), |
| error: vi.fn(), |
| debug: vi.fn(), |
| })); |
|
|
| |
| vi.mock('../config/storage.js', () => ({ |
| Storage: { |
| getGlobalTempDir: vi.fn().mockReturnValue('/mock/temp'), |
| }, |
| })); |
| vi.mock('../utils/debugLogger.js', () => ({ |
| debugLogger: mockDebugLogger, |
| })); |
| vi.mock('@lydell/node-pty', () => ({ |
| spawn: mockPtySpawn, |
| })); |
| vi.mock('node:fs', async (importOriginal) => { |
| const actual = await importOriginal<typeof import('node:fs')>(); |
| return { |
| ...actual, |
| default: { |
| ...actual, |
| mkdirSync: mockMkdirSync, |
| createWriteStream: mockCreateWriteStream, |
| }, |
| mkdirSync: mockMkdirSync, |
| createWriteStream: mockCreateWriteStream, |
| }; |
| }); |
| vi.mock('../utils/shell-utils.js', async (importOriginal) => { |
| const actual = |
| await importOriginal<typeof import('../utils/shell-utils.js')>(); |
| return { |
| ...actual, |
| resolveExecutable: mockResolveExecutable, |
| spawnAsync: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), |
| }; |
| }); |
| vi.mock('node:child_process', async (importOriginal) => { |
| const actual = await importOriginal(); |
| return { |
| ...(actual as object), |
| spawn: mockCpSpawn, |
| }; |
| }); |
| vi.mock('../utils/textUtils.js', () => ({ |
| isBinary: mockIsBinary, |
| })); |
| vi.mock('node:os', () => ({ |
| default: { |
| platform: mockPlatform, |
| homedir: mockHomedir, |
| constants: { |
| signals: { |
| SIGTERM: 15, |
| SIGKILL: 9, |
| }, |
| }, |
| }, |
| platform: mockPlatform, |
| homedir: mockHomedir, |
| constants: { |
| signals: { |
| SIGTERM: 15, |
| SIGKILL: 9, |
| }, |
| }, |
| })); |
| vi.mock('../utils/getPty.js', () => ({ |
| getPty: mockGetPty, |
| })); |
| vi.mock('../utils/terminalSerializer.js', () => ({ |
| |
| serializeTerminalToObject: ( |
| _terminal: unknown, |
| ...args: [number | undefined, number | undefined] |
| ) => mockSerializeTerminalToObject(...args), |
| convertColorToHex: () => '#000000', |
| ColorMode: { DEFAULT: 0, PALETTE: 1, RGB: 2 }, |
| })); |
| const mockProcessKill = vi |
| .spyOn(process, 'kill') |
| .mockImplementation(() => true); |
|
|
| const shellExecutionConfig: ShellExecutionConfig = { |
| sessionId: 'default', |
| terminalWidth: 80, |
| terminalHeight: 24, |
| pager: 'cat', |
| showColor: false, |
| disableDynamicLineTrimming: true, |
| sanitizationConfig: { |
| enableEnvironmentVariableRedaction: false, |
| allowedEnvironmentVariables: [], |
| blockedEnvironmentVariables: [], |
| }, |
| sandboxManager: new NoopSandboxManager(), |
| }; |
|
|
| const createMockSerializeTerminalToObjectReturnValue = ( |
| text: string | string[], |
| ): AnsiOutput => { |
| const lines = Array.isArray(text) ? text : text.split('\n'); |
| const len = shellExecutionConfig.terminalHeight ?? 24; |
| const expected: AnsiOutput = Array.from({ length: len }, (_, i) => [ |
| { |
| text: (lines[i] || '').trim(), |
| bold: false, |
| italic: false, |
| underline: false, |
| dim: false, |
| inverse: false, |
| isUninitialized: false, |
| fg: '#ffffff', |
| bg: '#000000', |
| }, |
| ]); |
| return expected; |
| }; |
|
|
| const createExpectedAnsiOutput = (text: string | string[]): AnsiOutput => { |
| const lines = Array.isArray(text) ? text : text.split('\n'); |
| const len = shellExecutionConfig.terminalHeight ?? 24; |
| const expected: AnsiOutput = Array.from({ length: len }, (_, i) => [ |
| { |
| text: expect.stringMatching((lines[i] || '').trim()), |
| bold: false, |
| italic: false, |
| underline: false, |
| dim: false, |
| inverse: false, |
| isUninitialized: false, |
| fg: '', |
| bg: '', |
| } as AnsiToken, |
| ]); |
| return expected; |
| }; |
|
|
| describe('ShellExecutionService', () => { |
| let mockPtyProcess: EventEmitter & { |
| pid: number; |
| kill: Mock; |
| onData: Mock; |
| onExit: Mock; |
| write: Mock; |
| resize: Mock; |
| destroy: Mock; |
| }; |
| let mockHeadlessTerminal: { |
| resize: Mock; |
| scrollLines: Mock; |
| buffer: { |
| active: { |
| viewportY: number; |
| length: number; |
| getLine: Mock; |
| }; |
| }; |
| }; |
| let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>; |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
| ExecutionLifecycleService.resetForTest(); |
| ShellExecutionService.resetForTest(); |
| mockSerializeTerminalToObject.mockReturnValue([]); |
| mockIsBinary.mockReturnValue(false); |
| mockPlatform.mockReturnValue('linux'); |
| mockResolveExecutable.mockImplementation((exe: string) => exe); |
| process.env['PATH'] = '/test/path'; |
| mockGetPty.mockResolvedValue({ |
| module: { spawn: mockPtySpawn }, |
| name: 'mock-pty', |
| }); |
|
|
| onOutputEventMock = vi.fn(); |
|
|
| mockPtyProcess = new EventEmitter() as EventEmitter & { |
| pid: number; |
| kill: Mock; |
| onData: Mock; |
| onExit: Mock; |
| write: Mock; |
| resize: Mock; |
| destroy: Mock; |
| }; |
| mockPtyProcess.pid = 12345; |
| mockPtyProcess.kill = vi.fn(); |
| mockPtyProcess.onData = vi.fn(); |
| mockPtyProcess.onExit = vi.fn(); |
| mockPtyProcess.write = vi.fn(); |
| mockPtyProcess.resize = vi.fn(); |
| mockPtyProcess.destroy = vi.fn(); |
|
|
| mockHeadlessTerminal = { |
| resize: vi.fn(), |
| scrollLines: vi.fn(), |
| buffer: { |
| active: { |
| viewportY: 0, |
| length: 0, |
| getLine: vi.fn(), |
| }, |
| }, |
| }; |
|
|
| mockPtySpawn.mockReturnValue(mockPtyProcess); |
| }); |
|
|
| |
| const simulateExecution = async ( |
| command: string, |
| simulation: ( |
| ptyProcess: typeof mockPtyProcess, |
| ac: AbortController, |
| ) => void | Promise<void>, |
| config = shellExecutionConfig, |
| ) => { |
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| command, |
| '/test/dir', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| config, |
| ); |
|
|
| await new Promise((resolve) => process.nextTick(resolve)); |
| await simulation(mockPtyProcess, abortController); |
| const result = await handle.result; |
| return { result, handle, abortController }; |
| }; |
|
|
| describe('Successful Execution', () => { |
| it('should execute a command and capture output', async () => { |
| mockSerializeTerminalToObject.mockReturnValue( |
| createMockSerializeTerminalToObjectReturnValue('file1.txt'), |
| ); |
| 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', |
| 'shopt -u promptvars nullglob extglob nocaseglob dotglob; 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.trim()).toBe('file1.txt'); |
| expect(handle.pid).toBe(12345); |
|
|
| expect(onOutputEventMock).toHaveBeenCalledWith({ |
| type: 'data', |
| chunk: createExpectedAnsiOutput('file1.txt'), |
| }); |
| }); |
|
|
| it('should strip ANSI color codes from output', async () => { |
| mockSerializeTerminalToObject.mockReturnValue( |
| createMockSerializeTerminalToObjectReturnValue('aredword'), |
| ); |
| 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.trim()).toBe('aredword'); |
| expect(onOutputEventMock).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: 'data', |
| chunk: createExpectedAnsiOutput('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.trim()).toBe('你好'); |
| }); |
|
|
| it('should handle commands with no output', async () => { |
| mockSerializeTerminalToObject.mockReturnValue( |
| createMockSerializeTerminalToObjectReturnValue(''), |
| ); |
| await simulateExecution('touch file', (pty) => { |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }); |
|
|
| expect(onOutputEventMock).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| chunk: createExpectedAnsiOutput(''), |
| }), |
| ); |
| }); |
|
|
| it('should capture large output (10000 lines)', async () => { |
| const lineCount = 10000; |
| const lines = Array.from({ length: lineCount }, (_, i) => `line ${i}`); |
| const expectedOutput = lines.join('\n'); |
|
|
| const { result } = await simulateExecution( |
| 'large-output-command', |
| (pty) => { |
| |
| |
| const chunkSize = 1000; |
| for (let i = 0; i < lineCount; i += chunkSize) { |
| const chunk = lines.slice(i, i + chunkSize).join('\r\n') + '\r\n'; |
| pty.onData.mock.calls[0][0](chunk); |
| } |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }, |
| { ...shellExecutionConfig, maxSerializedLines: 100 }, |
| ); |
|
|
| expect(result.exitCode).toBe(0); |
| |
| |
| const processedOutput = result.output |
| .split('\n') |
| .map((l) => l.trimEnd()) |
| .join('\n') |
| .trim(); |
| expect(processedOutput).toBe(expectedOutput); |
| expect(result.output.split('\n').length).toBeGreaterThanOrEqual( |
| lineCount, |
| ); |
| }); |
|
|
| it('should not wrap long lines in the final output', async () => { |
| |
| const narrowConfig = { ...shellExecutionConfig, terminalWidth: 10 }; |
| const longString = '123456789012345'; |
|
|
| const { result } = await simulateExecution( |
| 'long-line-command', |
| (pty) => { |
| pty.onData.mock.calls[0][0](longString); |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }, |
| narrowConfig, |
| ); |
|
|
| expect(result.exitCode).toBe(0); |
| expect(result.output.trim()).toBe(longString); |
| }); |
|
|
| it('should not add extra padding but preserve explicit trailing whitespace', async () => { |
| const { result } = await simulateExecution('cmd', (pty) => { |
| |
| |
| pty.onData.mock.calls[0][0]('value\r\nvalue2 '); |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }); |
|
|
| expect(result.output).toBe('value\nvalue2 '); |
| }); |
|
|
| it('should truncate output exceeding the scrollback limit', async () => { |
| const scrollbackLimit = 100; |
| const totalLines = 150; |
| |
| const lines = Array.from({ length: totalLines }, (_, i) => `line ${i}`); |
|
|
| const { result } = await simulateExecution( |
| 'overflow-command', |
| (pty) => { |
| const chunk = lines.join('\r\n') + '\r\n'; |
| pty.onData.mock.calls[0][0](chunk); |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }, |
| { ...shellExecutionConfig, scrollback: scrollbackLimit }, |
| ); |
|
|
| expect(result.exitCode).toBe(0); |
|
|
| |
| |
| |
| |
| |
| |
|
|
| const outputLines = result.output |
| .trim() |
| .split('\n') |
| .map((l) => l.trimEnd()); |
|
|
| |
| |
| |
| |
|
|
| |
| expect(outputLines.length).toBeLessThan(totalLines); |
| expect(outputLines[0]).not.toBe('line 0'); |
|
|
| |
| expect(outputLines[outputLines.length - 1]).toBe( |
| `line ${totalLines - 1}`, |
| ); |
| }); |
|
|
| it('should call onPid with the process id', async () => { |
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| 'ls -l', |
| '/test/dir', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| shellExecutionConfig, |
| ); |
| mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| await handle.result; |
| expect(handle.pid).toBe(12345); |
| }); |
| }); |
|
|
| describe('pty interaction', () => { |
| let activePtysGetSpy: { mockRestore: () => void }; |
|
|
| beforeEach(() => { |
| activePtysGetSpy = vi |
| .spyOn(ShellExecutionService['activePtys'], 'get') |
| .mockReturnValue({ |
| |
| ptyProcess: mockPtyProcess as any, |
| |
| headlessTerminal: mockHeadlessTerminal as any, |
| command: 'some-command', |
| }); |
| }); |
|
|
| afterEach(() => { |
| activePtysGetSpy.mockRestore(); |
| }); |
|
|
| it('should write to the pty and trigger a render', async () => { |
| vi.useFakeTimers(); |
| await simulateExecution('interactive-app', (pty) => { |
| ShellExecutionService.writeToPty(pty.pid, 'input'); |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }); |
|
|
| expect(mockPtyProcess.write).toHaveBeenCalledWith('input'); |
| |
| await vi.advanceTimersByTimeAsync(17); |
| |
| expect(onOutputEventMock).toHaveBeenCalled(); |
| vi.useRealTimers(); |
| }); |
|
|
| it('should resize the pty and the headless terminal', async () => { |
| await simulateExecution('ls -l', (pty) => { |
| pty.onData.mock.calls[0][0]('file1.txt\n'); |
| ShellExecutionService.resizePty(pty.pid, 100, 40); |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }); |
|
|
| expect(mockPtyProcess.resize).toHaveBeenCalledWith(100, 40); |
| expect(mockHeadlessTerminal.resize).toHaveBeenCalledWith(100, 40); |
| }); |
|
|
| it('should not resize the pty if it is not active', async () => { |
| const isPtyActiveSpy = vi |
| .spyOn(ShellExecutionService, 'isPtyActive') |
| .mockReturnValue(false); |
|
|
| await simulateExecution('ls -l', (pty) => { |
| ShellExecutionService.resizePty(pty.pid, 100, 40); |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }); |
|
|
| expect(mockPtyProcess.resize).not.toHaveBeenCalled(); |
| expect(mockHeadlessTerminal.resize).not.toHaveBeenCalled(); |
| isPtyActiveSpy.mockRestore(); |
| }); |
|
|
| it('should ignore errors when resizing an exited pty', async () => { |
| const resizeError = new Error( |
| 'Cannot resize a pty that has already exited', |
| ); |
| mockPtyProcess.resize.mockImplementation(() => { |
| throw resizeError; |
| }); |
|
|
| |
| await expect( |
| simulateExecution('ls -l', (pty) => { |
| ShellExecutionService.resizePty(pty.pid, 100, 40); |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }), |
| ).resolves.not.toThrow(); |
|
|
| expect(mockPtyProcess.resize).toHaveBeenCalledWith(100, 40); |
| }); |
|
|
| it('should re-throw other errors during resize', async () => { |
| const otherError = new Error('Some other error'); |
| mockPtyProcess.resize.mockImplementation(() => { |
| throw otherError; |
| }); |
|
|
| await expect( |
| simulateExecution('ls -l', (pty) => { |
| ShellExecutionService.resizePty(pty.pid, 100, 40); |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }), |
| ).rejects.toThrow('Some other error'); |
| }); |
|
|
| it('should scroll the headless terminal', async () => { |
| await simulateExecution('ls -l', (pty) => { |
| pty.onData.mock.calls[0][0]('file1.txt\n'); |
| ShellExecutionService.scrollPty(pty.pid, 10); |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }); |
|
|
| expect(mockHeadlessTerminal.scrollLines).toHaveBeenCalledWith(10); |
| }); |
|
|
| it('should not throw when resizing a pty that has already exited (Windows)', () => { |
| const resizeError = new Error( |
| 'Cannot resize a pty that has already exited', |
| ); |
| mockPtyProcess.resize.mockImplementation(() => { |
| throw resizeError; |
| }); |
|
|
| |
| expect(() => { |
| ShellExecutionService.resizePty(mockPtyProcess.pid, 100, 40); |
| }).not.toThrow(); |
|
|
| expect(mockPtyProcess.resize).toHaveBeenCalledWith(100, 40); |
| expect(mockHeadlessTerminal.resize).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.trim()).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 () => { |
| mockGetPty.mockImplementation(() => null); |
|
|
| mockCpSpawn.mockImplementation(() => { |
| throw new Error('Simulated PTY spawn error'); |
| }); |
|
|
| const handle = await ShellExecutionService.execute( |
| 'any-command', |
| '/test/dir', |
| onOutputEventMock, |
| new AbortController().signal, |
| true, |
| { |
| ...shellExecutionConfig, |
| sanitizationConfig: { |
| enableEnvironmentVariableRedaction: true, |
| allowedEnvironmentVariables: [], |
| blockedEnvironmentVariables: [], |
| }, |
| }, |
| ); |
| const result = await handle.result; |
|
|
| expect(result.error).toBeInstanceOf(Error); |
| expect(result.error?.message).toContain('Simulated PTY spawn error'); |
| 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); |
| |
| }); |
|
|
| it('should send SIGTERM and then SIGKILL on abort', async () => { |
| const sigkillPromise = new Promise<void>((resolve) => { |
| mockProcessKill.mockImplementation((pid, signal) => { |
| if (signal === 'SIGKILL' && pid === -mockPtyProcess.pid) { |
| resolve(); |
| } |
| return true; |
| }); |
| }); |
|
|
| const { result } = await simulateExecution( |
| 'long-running-process', |
| async (pty, abortController) => { |
| abortController.abort(); |
| await sigkillPromise; |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: 9 }); |
| }, |
| ); |
|
|
| expect(result.aborted).toBe(true); |
|
|
| |
| const killCalls = mockProcessKill.mock.calls; |
| const sigtermCallIndex = killCalls.findIndex( |
| (call) => call[0] === -mockPtyProcess.pid && call[1] === 'SIGTERM', |
| ); |
| const sigkillCallIndex = killCalls.findIndex( |
| (call) => call[0] === -mockPtyProcess.pid && call[1] === 'SIGKILL', |
| ); |
|
|
| expect(sigtermCallIndex).toBe(0); |
| expect(sigkillCallIndex).toBeGreaterThan(0); |
| expect(sigtermCallIndex).toBeLessThan(sigkillCallIndex); |
|
|
| expect(result.signal).toBe(9); |
| }); |
|
|
| it('should resolve without waiting for the processing chain on abort', async () => { |
| const { result } = await simulateExecution( |
| 'long-output', |
| (pty, abortController) => { |
| |
| for (let i = 0; i < 1000; i++) { |
| pty.onData.mock.calls[0][0]('some data'); |
| } |
| abortController.abort(); |
| pty.onExit.mock.calls[0][0]({ exitCode: 1, signal: null }); |
| }, |
| ); |
|
|
| |
| |
| |
| |
| expect(result.aborted).toBe(true); |
| }); |
| }); |
|
|
| describe('Backgrounding', () => { |
| let mockWriteStream: { write: Mock; end: Mock; on: Mock }; |
| let mockBgChildProcess: EventEmitter & Partial<ChildProcess>; |
|
|
| beforeEach(async () => { |
| mockWriteStream = { |
| write: vi.fn(), |
| end: vi.fn().mockImplementation((cb) => cb?.()), |
| on: vi.fn(), |
| }; |
|
|
| mockMkdirSync.mockReturnValue(undefined); |
| mockCreateWriteStream.mockReturnValue( |
| |
| mockWriteStream as any, |
| ); |
| mockHomedir.mockReturnValue('/mock/home'); |
|
|
| mockBgChildProcess = new EventEmitter() as EventEmitter & |
| Partial<ChildProcess>; |
| mockBgChildProcess.stdout = new EventEmitter() as Readable; |
| mockBgChildProcess.stderr = new EventEmitter() as Readable; |
| mockBgChildProcess.kill = vi.fn(); |
| Object.defineProperty(mockBgChildProcess, 'pid', { |
| value: 99999, |
| configurable: true, |
| }); |
| mockCpSpawn.mockReturnValue(mockBgChildProcess); |
|
|
| |
| |
| (ShellExecutionService as any).backgroundLogStreams.clear(); |
| |
| (ShellExecutionService as any).activePtys.clear(); |
| |
| (ShellExecutionService as any).activeChildProcesses.clear(); |
| |
| (ShellExecutionService as any).backgroundProcessHistory.clear(); |
| }); |
|
|
| afterEach(() => { |
| |
| (ShellExecutionService as any).backgroundLogStreams.clear(); |
| }); |
|
|
| it('should move a running pty process to the background and start logging', async () => { |
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| 'long-running-pty', |
| '/', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| |
| const onDataListener = mockPtyProcess.onData.mock.calls[0][0]; |
| onDataListener('initial pty output'); |
|
|
| |
| await new Promise((resolve) => setTimeout(resolve, 100)); |
|
|
| mockSerializeTerminalToObject.mockReturnValue([ |
| [{ text: 'initial pty output', fg: '', bg: '' }], |
| ]); |
|
|
| |
| ShellExecutionService.background( |
| handle.pid!, |
| 'default', |
| 'long-running-pty', |
| ); |
|
|
| const result = await handle.result; |
| expect(result.backgrounded).toBe(true); |
| expect(result.output).toContain('initial pty output'); |
|
|
| expect(mockMkdirSync).toHaveBeenCalledWith( |
| expect.stringContaining('background-processes'), |
| { recursive: true, mode: 0o700 }, |
| ); |
|
|
| |
| expect( |
| mockWriteStream.write.mock.calls.some((call) => |
| call[0].includes('initial pty output'), |
| ), |
| ).toBe(true); |
|
|
| await ShellExecutionService.kill(handle.pid!); |
| expect(mockWriteStream.end).toHaveBeenCalled(); |
| }); |
|
|
| it('should continue logging after backgrounding for child_process', async () => { |
| mockGetPty.mockResolvedValue(null); |
|
|
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| 'long-running-cp', |
| '/', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| |
| mockBgChildProcess.stdout?.emit('data', Buffer.from('initial cp output')); |
| await new Promise((resolve) => process.nextTick(resolve)); |
|
|
| ShellExecutionService.background( |
| handle.pid!, |
| 'default', |
| 'long-running-child', |
| ); |
|
|
| const result = await handle.result; |
| expect(result.backgrounded).toBe(true); |
| expect(result.output).toBe('initial cp output'); |
|
|
| expect( |
| mockWriteStream.write.mock.calls.some((call) => |
| call[0].includes('initial cp output'), |
| ), |
| ).toBe(true); |
|
|
| |
| mockBgChildProcess.stdout?.emit('data', Buffer.from('more cp output')); |
| await new Promise((resolve) => process.nextTick(resolve)); |
| expect(mockWriteStream.write).toHaveBeenCalledWith('more cp output'); |
|
|
| await ShellExecutionService.kill(handle.pid!); |
| expect(mockWriteStream.end).toHaveBeenCalled(); |
| }); |
|
|
| it('should log a warning if background log setup fails', async () => { |
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| 'failing-log-setup', |
| '/', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| |
| const error = new Error('Permission denied'); |
| mockMkdirSync.mockImplementationOnce(() => { |
| throw error; |
| }); |
|
|
| |
| ShellExecutionService.background( |
| handle.pid!, |
| 'default', |
| 'failing-log-setup', |
| ); |
|
|
| const result = await handle.result; |
| expect(result.backgrounded).toBe(true); |
| expect(mockDebugLogger.warn).toHaveBeenCalledWith( |
| 'Failed to setup background logging:', |
| error, |
| ); |
|
|
| await ShellExecutionService.kill(handle.pid!); |
| }); |
|
|
| it('should track background process history', async () => { |
| await simulateExecution( |
| 'history-test-cmd', |
| async (pty) => { |
| ShellExecutionService.background( |
| pty.pid, |
| 'default', |
| 'history-test-cmd', |
| ); |
|
|
| const history = |
| ShellExecutionService.listBackgroundProcesses('default'); |
| expect(history).toHaveLength(1); |
| expect(history[0]).toEqual( |
| expect.objectContaining({ |
| pid: pty.pid, |
| command: 'history-test-cmd', |
| status: 'running', |
| }), |
| ); |
|
|
| |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }, |
| { ...shellExecutionConfig, originalCommand: 'history-test-cmd' }, |
| ); |
|
|
| const history = ShellExecutionService.listBackgroundProcesses('default'); |
| expect(history[0]).toEqual( |
| expect.objectContaining({ |
| pid: mockPtyProcess.pid, |
| command: 'history-test-cmd', |
| status: 'exited', |
| exitCode: 0, |
| }), |
| ); |
| }); |
|
|
| it('should evict oldest process history when exceeding max size', () => { |
| const MAX = 100; |
| const history = new Map(); |
| for (let i = 1; i <= MAX; i++) { |
| history.set(i, { |
| command: `cmd-${i}`, |
| status: 'running', |
| startTime: Date.now(), |
| }); |
| } |
| |
| (ShellExecutionService as any).backgroundProcessHistory.set( |
| 'default', |
| history, |
| ); |
|
|
| |
| (ShellExecutionService as any).activeChildProcesses.set(101, { |
| process: {}, |
| state: { output: '' }, |
| command: 'cmd-101', |
| sessionId: 'default', |
| }); |
|
|
| ShellExecutionService.background(101, 'default', 'cmd-101'); |
|
|
| const processes = |
| ShellExecutionService.listBackgroundProcesses('default'); |
| expect(processes).toHaveLength(MAX); |
| expect(processes.some((p) => p.pid === 1)).toBe(false); |
| }); |
|
|
| it('should throw error if sessionId is missing for background operations', () => { |
| expect(() => ShellExecutionService.background(102)).toThrow( |
| 'Session ID is required for background operations', |
| ); |
| }); |
|
|
| it('should throw error if sessionId is missing for listBackgroundProcesses', () => { |
| expect(() => |
| |
| ShellExecutionService.listBackgroundProcesses(undefined as any), |
| ).toThrow('Session ID is required'); |
| }); |
| }); |
|
|
| 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]); |
|
|
| 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(onOutputEventMock).toHaveBeenCalledTimes(4); |
| 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, |
| }); |
| expect(onOutputEventMock.mock.calls[3][0]).toEqual({ |
| type: 'exit', |
| exitCode: 0, |
| signal: null, |
| }); |
| }); |
|
|
| 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([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([ |
| 'binary_detected', |
| 'binary_progress', |
| 'binary_progress', |
| 'exit', |
| ]); |
| }); |
| }); |
|
|
| describe('Platform-Specific Behavior', () => { |
| it('should use powershell.exe on Windows and prefix the command with chcp 65001 for the PTY session', async () => { |
| mockPlatform.mockReturnValue('win32'); |
| await simulateExecution('dir "foo bar"', (pty) => |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), |
| ); |
|
|
| expect(mockPtySpawn).toHaveBeenCalledWith( |
| 'powershell.exe', |
| [ |
| '-NoProfile', |
| '-NonInteractive', |
| '-Command', |
| 'chcp 65001 >$null;dir "foo bar"', |
| ], |
| expect.objectContaining({ |
| handleFlowControl: false, |
| useConpty: true, |
| }), |
| ); |
| }); |
|
|
| 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', |
| 'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls "foo bar"', |
| ], |
| expect.objectContaining({ |
| handleFlowControl: true, |
| }), |
| ); |
| }); |
| }); |
|
|
| describe('AnsiOutput rendering', () => { |
| it('should call onOutputEvent with AnsiOutput when showColor is true', async () => { |
| const coloredShellExecutionConfig = { |
| ...shellExecutionConfig, |
| showColor: true, |
| defaultFg: '#ffffff', |
| defaultBg: '#000000', |
| disableDynamicLineTrimming: true, |
| }; |
| const mockAnsiOutput = [ |
| [{ text: 'hello', fg: '#ffffff', bg: '#000000' }], |
| ]; |
| mockSerializeTerminalToObject.mockReturnValue(mockAnsiOutput); |
|
|
| 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 }); |
| }, |
| coloredShellExecutionConfig, |
| ); |
|
|
| expect(mockSerializeTerminalToObject).toHaveBeenCalled(); |
|
|
| expect(onOutputEventMock).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: 'data', |
| chunk: mockAnsiOutput, |
| }), |
| ); |
| }); |
|
|
| it('should call onOutputEvent with AnsiOutput when showColor is false', async () => { |
| mockSerializeTerminalToObject.mockReturnValue( |
| createMockSerializeTerminalToObjectReturnValue('aredword'), |
| ); |
| 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 }); |
| }, |
| { |
| ...shellExecutionConfig, |
| showColor: false, |
| disableDynamicLineTrimming: true, |
| }, |
| ); |
|
|
| const expected = createExpectedAnsiOutput('aredword'); |
|
|
| expect(onOutputEventMock).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: 'data', |
| chunk: expected, |
| }), |
| ); |
| }); |
|
|
| it('should handle multi-line output correctly when showColor is false', async () => { |
| mockSerializeTerminalToObject.mockReturnValue( |
| createMockSerializeTerminalToObjectReturnValue([ |
| 'line 1', |
| 'line 2', |
| 'line 3', |
| ]), |
| ); |
| await simulateExecution( |
| 'ls --color=auto', |
| (pty) => { |
| pty.onData.mock.calls[0][0]( |
| 'line 1\n\u001b[32mline 2\u001b[0m\nline 3', |
| ); |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }, |
| { |
| ...shellExecutionConfig, |
| showColor: false, |
| disableDynamicLineTrimming: true, |
| }, |
| ); |
|
|
| const expected = createExpectedAnsiOutput(['line 1', 'line 2', 'line 3']); |
|
|
| expect(onOutputEventMock).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: 'data', |
| chunk: expected, |
| }), |
| ); |
| }); |
| }); |
|
|
| describe('Resource Management', () => { |
| it('should destroy the PTY process and clear activePtys on exit', async () => { |
| await simulateExecution('ls -l', (pty) => { |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }); |
|
|
| expect(mockPtyProcess.destroy).toHaveBeenCalled(); |
| expect(ShellExecutionService['activePtys'].size).toBe(0); |
| }); |
|
|
| it('should destroy the PTY process even if destroy throws', async () => { |
| mockPtyProcess.destroy.mockImplementation(() => { |
| throw new Error('Destroy failed'); |
| }); |
|
|
| await expect( |
| simulateExecution('ls -l', (pty) => { |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }), |
| ).resolves.not.toThrow(); |
|
|
| expect(ShellExecutionService['activePtys'].size).toBe(0); |
| }); |
|
|
| it('should destroy the PTY when kill() is called', async () => { |
| |
| const abortController = new AbortController(); |
| await ShellExecutionService.execute( |
| 'long-running', |
| '/test/dir', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| shellExecutionConfig, |
| ); |
| await new Promise((resolve) => process.nextTick(resolve)); |
|
|
| const pid = mockPtyProcess.pid; |
| const activePty = ShellExecutionService['activePtys'].get(pid); |
| expect(activePty).toBeTruthy(); |
|
|
| |
| const storedDestroySpy = vi.spyOn( |
| activePty!.ptyProcess as never as { destroy: () => void }, |
| 'destroy', |
| ); |
|
|
| await ShellExecutionService.kill(pid); |
|
|
| expect(storedDestroySpy).toHaveBeenCalled(); |
| expect(ShellExecutionService['activePtys'].has(pid)).toBe(false); |
| }); |
|
|
| it('should destroy the PTY when an exception occurs after spawn in executeWithPty', async () => { |
| |
| |
| |
| const destroySpy = vi.fn(); |
| const faultyPty = { |
| onData: vi.fn(), |
| onExit: vi.fn(), |
| write: vi.fn(), |
| kill: vi.fn(), |
| resize: vi.fn(), |
| destroy: destroySpy, |
| get pid(): number { |
| throw new Error('Simulated post-spawn failure on pid access'); |
| }, |
| }; |
| mockPtySpawn.mockReturnValueOnce(faultyPty); |
|
|
| const handle = await ShellExecutionService.execute( |
| 'will-fail-after-spawn', |
| '/test/dir', |
| onOutputEventMock, |
| new AbortController().signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| const result = await handle.result; |
| expect(result.exitCode).toBe(1); |
| expect(result.error).toBeTruthy(); |
| |
| expect(destroySpy).toHaveBeenCalled(); |
| }); |
|
|
| it('should dispose of PTY event listeners on process exit', async () => { |
| const dataDisposeSpy = vi.fn(); |
| const exitDisposeSpy = vi.fn(); |
|
|
| mockPtyProcess.onData.mockReturnValue({ dispose: dataDisposeSpy }); |
| mockPtyProcess.onExit.mockReturnValue({ dispose: exitDisposeSpy }); |
|
|
| await simulateExecution('ls -l', (pty) => { |
| pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| }); |
|
|
| expect(dataDisposeSpy).toHaveBeenCalled(); |
| expect(exitDisposeSpy).toHaveBeenCalled(); |
| }); |
|
|
| it('should dispose of PTY event listeners on abort', async () => { |
| const dataDisposeSpy = vi.fn(); |
| const exitDisposeSpy = vi.fn(); |
|
|
| mockPtyProcess.onData.mockReturnValue({ dispose: dataDisposeSpy }); |
| mockPtyProcess.onExit.mockReturnValue({ dispose: exitDisposeSpy }); |
|
|
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| 'long-running', |
| '/test/dir', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| await new Promise((resolve) => process.nextTick(resolve)); |
| abortController.abort(); |
|
|
| |
| mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 1, signal: 9 }); |
| await handle.result; |
|
|
| expect(dataDisposeSpy).toHaveBeenCalled(); |
| expect(exitDisposeSpy).toHaveBeenCalled(); |
| }); |
|
|
| it('should fall back to child_process when PTY creation fails with ENXIO', async () => { |
| const ptyError = new Error('posix_openpt failed: Device not configured'); |
| |
| ptyError.code = 'ENXIO'; |
| mockPtySpawn.mockImplementationOnce(() => { |
| throw ptyError; |
| }); |
|
|
| |
| const mockFallbackChild = new EventEmitter() as unknown as ChildProcess; |
| Object.defineProperty(mockFallbackChild, 'stdout', { |
| value: new EventEmitter(), |
| }); |
| Object.defineProperty(mockFallbackChild, 'stderr', { |
| value: new EventEmitter(), |
| }); |
| Object.defineProperty(mockFallbackChild, 'kill', { |
| value: vi.fn(), |
| }); |
| Object.defineProperty(mockFallbackChild, 'pid', { |
| value: 9999, |
| }); |
| mockCpSpawn.mockReturnValueOnce(mockFallbackChild); |
|
|
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| 'test-fallback', |
| '/test/dir', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| |
| mockFallbackChild.emit('exit', 0, null); |
| mockFallbackChild.emit('close', 0, null); |
|
|
| const result = await handle.result; |
| expect(result.executionMethod).toBe('child_process'); |
| expect(mockCpSpawn).toHaveBeenCalled(); |
| }); |
| }); |
| }); |
|
|
| describe('ShellExecutionService child_process fallback', () => { |
| let mockChildProcess: EventEmitter & Partial<ChildProcess>; |
| let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>; |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
| ExecutionLifecycleService.resetForTest(); |
| ShellExecutionService.resetForTest(); |
|
|
| mockIsBinary.mockReturnValue(false); |
| mockPlatform.mockReturnValue('linux'); |
| mockGetPty.mockResolvedValue(null); |
|
|
| onOutputEventMock = vi.fn(); |
|
|
| mockChildProcess = new EventEmitter() as EventEmitter & |
| Partial<ChildProcess>; |
| mockChildProcess.stdout = new EventEmitter() as Readable; |
| mockChildProcess.stderr = new EventEmitter() as Readable; |
| mockChildProcess.kill = vi.fn(); |
|
|
| Object.defineProperty(mockChildProcess, 'pid', { |
| value: 12345, |
| configurable: true, |
| }); |
|
|
| mockCpSpawn.mockReturnValue(mockChildProcess); |
| }); |
|
|
| |
| const simulateExecution = async ( |
| command: string, |
| simulation: ( |
| cp: typeof mockChildProcess, |
| ac: AbortController, |
| ) => void | Promise<void>, |
| ) => { |
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| command, |
| '/test/dir', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| await new Promise((resolve) => process.nextTick(resolve)); |
| await simulation(mockChildProcess, abortController); |
| const result = await handle.result; |
| return { result, handle, abortController }; |
| }; |
|
|
| describe('Successful Execution', () => { |
| it('should execute a command and capture stdout and stderr', async () => { |
| const { result, handle } = await simulateExecution('ls -l', (cp) => { |
| cp.stdout?.emit('data', Buffer.from('file1.txt\n')); |
| cp.stderr?.emit('data', Buffer.from('a warning')); |
| cp.emit('exit', 0, null); |
| cp.emit('close', 0, null); |
| }); |
|
|
| expect(mockCpSpawn).toHaveBeenCalledWith( |
| 'bash', |
| [ |
| '-c', |
| 'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls -l', |
| ], |
| expect.objectContaining({ shell: false, detached: true }), |
| ); |
| expect(result.exitCode).toBe(0); |
| expect(result.signal).toBeNull(); |
| expect(result.error).toBeNull(); |
| expect(result.aborted).toBe(false); |
| expect(result.output).toBe('file1.txt\na warning'); |
| expect(handle.pid).toBe(12345); |
|
|
| expect(onOutputEventMock).toHaveBeenCalledWith({ |
| type: 'data', |
| chunk: 'file1.txt\n', |
| }); |
| expect(onOutputEventMock).toHaveBeenCalledWith({ |
| type: 'data', |
| chunk: 'a warning', |
| }); |
| expect(onOutputEventMock).toHaveBeenCalledWith({ |
| type: 'exit', |
| exitCode: 0, |
| signal: null, |
| }); |
| }); |
|
|
| it('should strip ANSI color codes from output', async () => { |
| const { result } = await simulateExecution('ls --color=auto', (cp) => { |
| cp.stdout?.emit('data', Buffer.from('a\u001b[31mred\u001b[0mword')); |
| cp.emit('exit', 0, null); |
| cp.emit('close', 0, null); |
| }); |
|
|
| expect(result.output.trim()).toBe('aredword'); |
| expect(onOutputEventMock).toHaveBeenCalledWith({ |
| type: 'data', |
| chunk: 'a\u001b[31mred\u001b[0mword', |
| }); |
| expect(onOutputEventMock).toHaveBeenCalledWith({ |
| type: 'exit', |
| exitCode: 0, |
| signal: null, |
| }); |
| }); |
|
|
| it('should correctly decode multi-byte characters split across chunks', async () => { |
| const { result } = await simulateExecution('echo "你好"', (cp) => { |
| const multiByteChar = Buffer.from('你好', 'utf-8'); |
| cp.stdout?.emit('data', multiByteChar.slice(0, 2)); |
| cp.stdout?.emit('data', multiByteChar.slice(2)); |
| cp.emit('exit', 0, null); |
| cp.emit('close', 0, null); |
| }); |
| expect(result.output.trim()).toBe('你好'); |
| }); |
|
|
| it('should handle commands with no output', async () => { |
| const { result } = await simulateExecution('touch file', (cp) => { |
| cp.emit('exit', 0, null); |
| cp.emit('close', 0, null); |
| }); |
|
|
| expect(result.output.trim()).toBe(''); |
| expect(onOutputEventMock).toHaveBeenCalledWith({ |
| type: 'exit', |
| exitCode: 0, |
| signal: null, |
| }); |
| }); |
|
|
| it('should truncate stdout using a sliding window and show a warning', async () => { |
| const MAX_SIZE = 16 * 1024 * 1024; |
| const chunk1 = 'a'.repeat(MAX_SIZE / 2 - 5); |
| const chunk2 = 'b'.repeat(MAX_SIZE / 2 - 5); |
| const chunk3 = 'c'.repeat(20); |
|
|
| const { result } = await simulateExecution('large-output', (cp) => { |
| cp.stdout?.emit('data', Buffer.from(chunk1)); |
| cp.stdout?.emit('data', Buffer.from(chunk2)); |
| cp.stdout?.emit('data', Buffer.from(chunk3)); |
| cp.emit('exit', 0, null); |
| cp.emit('close', 0, null); |
| }); |
|
|
| const truncationMessage = |
| '[GEMINI_CLI_WARNING: Output truncated. The buffer is limited to 16MB.]'; |
| expect(result.output).toContain(truncationMessage); |
|
|
| const outputWithoutMessage = result.output |
| .substring(0, result.output.indexOf(truncationMessage)) |
| .trimEnd(); |
|
|
| expect(outputWithoutMessage.length).toBe(MAX_SIZE); |
|
|
| const expectedStart = (chunk1 + chunk2 + chunk3).slice(-MAX_SIZE); |
| expect( |
| outputWithoutMessage.startsWith(expectedStart.substring(0, 10)), |
| ).toBe(true); |
| expect(outputWithoutMessage.endsWith('c'.repeat(20))).toBe(true); |
| }, 120000); |
| }); |
|
|
| describe('Failed Execution', () => { |
| it('should capture a non-zero exit code and format output correctly', async () => { |
| const { result } = await simulateExecution('a-bad-command', (cp) => { |
| cp.stderr?.emit('data', Buffer.from('command not found')); |
| cp.emit('exit', 127, null); |
| cp.emit('close', 127, null); |
| }); |
|
|
| expect(result.exitCode).toBe(127); |
| expect(result.output.trim()).toBe('command not found'); |
| expect(result.error).toBeNull(); |
| }); |
|
|
| it('should capture a termination signal', async () => { |
| const { result } = await simulateExecution('long-process', (cp) => { |
| cp.emit('exit', null, 'SIGTERM'); |
| cp.emit('close', null, 'SIGTERM'); |
| }); |
|
|
| expect(result.exitCode).toBeNull(); |
| expect(result.signal).toBe(15); |
| }); |
|
|
| it('should handle a spawn error', async () => { |
| const spawnError = new Error('spawn EACCES'); |
| const { result } = await simulateExecution('protected-cmd', (cp) => { |
| cp.emit('error', spawnError); |
| cp.emit('exit', 1, null); |
| cp.emit('close', 1, null); |
| }); |
|
|
| expect(result.error).toBe(spawnError); |
| expect(result.exitCode).toBe(1); |
| }); |
|
|
| it('handles errors that do not fire the exit event', async () => { |
| const error = new Error('spawn abc ENOENT'); |
| const { result } = await simulateExecution('touch cat.jpg', (cp) => { |
| cp.emit('error', error); |
| cp.emit('close', 1, null); |
| }); |
|
|
| expect(result.error).toBe(error); |
| expect(result.exitCode).toBe(1); |
| }); |
| }); |
|
|
| describe('Aborting Commands', () => { |
| describe.each([ |
| { |
| platform: 'linux', |
| expectedSignal: 'SIGTERM', |
| expectedExit: { signal: 'SIGKILL' as const }, |
| }, |
| { |
| platform: 'win32', |
| expectedCommand: 'taskkill', |
| expectedExit: { code: 1 }, |
| }, |
| ])( |
| 'on $platform', |
| ({ platform, expectedSignal, expectedCommand, expectedExit }) => { |
| it('should abort a running process and set the aborted flag', async () => { |
| mockPlatform.mockReturnValue(platform); |
|
|
| const { result } = await simulateExecution( |
| 'sleep 10', |
| async (cp, abortController) => { |
| abortController.abort(); |
| await new Promise(process.nextTick); |
| await new Promise(process.nextTick); |
| await new Promise(process.nextTick); |
| if (expectedExit.signal) { |
| cp.emit('exit', null, expectedExit.signal); |
| cp.emit('close', null, expectedExit.signal); |
| } |
| if (typeof expectedExit.code === 'number') { |
| cp.emit('exit', expectedExit.code, null); |
| cp.emit('close', expectedExit.code, null); |
| } |
| }, |
| ); |
|
|
| expect(result.aborted).toBe(true); |
|
|
| if (platform === 'linux') { |
| expect(mockProcessKill).toHaveBeenCalledWith( |
| -mockChildProcess.pid!, |
| expectedSignal, |
| ); |
| } else { |
| |
| const { spawnAsync } = await import('../utils/shell-utils.js'); |
| expect(spawnAsync).toHaveBeenCalledWith(expectedCommand, [ |
| '/pid', |
| String(mockChildProcess.pid), |
| '/f', |
| '/t', |
| ]); |
| } |
| }); |
| }, |
| ); |
|
|
| it('should gracefully attempt SIGKILL on linux if SIGTERM fails', async () => { |
| mockPlatform.mockReturnValue('linux'); |
| vi.useFakeTimers(); |
|
|
| |
| |
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| 'unresponsive_process', |
| '/test/dir', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| { |
| ...shellExecutionConfig, |
| sanitizationConfig: { |
| enableEnvironmentVariableRedaction: true, |
| allowedEnvironmentVariables: [], |
| blockedEnvironmentVariables: [], |
| }, |
| }, |
| ); |
|
|
| abortController.abort(); |
| await vi.advanceTimersByTimeAsync(0); |
|
|
| |
| expect(mockProcessKill).toHaveBeenCalledWith( |
| -mockChildProcess.pid!, |
| 'SIGTERM', |
| ); |
|
|
| |
| await vi.advanceTimersByTimeAsync(250); |
|
|
| |
| expect(mockProcessKill).toHaveBeenCalledWith( |
| -mockChildProcess.pid!, |
| 'SIGKILL', |
| ); |
|
|
| |
| mockChildProcess.emit('exit', null, 'SIGKILL'); |
| mockChildProcess.emit('close', null, 'SIGKILL'); |
| const result = await handle.result; |
|
|
| vi.useRealTimers(); |
|
|
| expect(result.aborted).toBe(true); |
| expect(result.signal).toBe(9); |
| }); |
| }); |
|
|
| 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]); |
|
|
| await simulateExecution('cat image.png', (cp) => { |
| cp.stdout?.emit('data', binaryChunk1); |
| cp.stdout?.emit('data', binaryChunk2); |
| cp.emit('exit', 0, null); |
| cp.emit('close', 0, null); |
| }); |
|
|
| expect(onOutputEventMock).toHaveBeenCalledTimes(4); |
| 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, |
| }); |
| expect(onOutputEventMock.mock.calls[3][0]).toEqual({ |
| type: 'exit', |
| exitCode: 0, |
| signal: null, |
| }); |
| }); |
|
|
| it('should not emit data events after binary is detected', async () => { |
| mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00)); |
|
|
| await simulateExecution('cat mixed_file', (cp) => { |
| cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02])); |
| cp.stdout?.emit('data', Buffer.from('more text')); |
| cp.emit('exit', 0, null); |
| cp.emit('close', 0, null); |
| }); |
|
|
| const eventTypes = onOutputEventMock.mock.calls.map( |
| (call: [ShellOutputEvent]) => call[0].type, |
| ); |
| expect(eventTypes).toEqual([ |
| 'binary_detected', |
| 'binary_progress', |
| 'binary_progress', |
| 'exit', |
| ]); |
| }); |
|
|
| it('should correctly measure sniffedBytes with >20 small chunks to prevent OOM (regression #22170)', async () => { |
| mockIsBinary.mockReturnValue(false); |
|
|
| await simulateExecution('cat lots_of_chunks', (cp) => { |
| for (let i = 0; i < 25; i++) { |
| cp.stdout?.emit('data', Buffer.alloc(10, 'a')); |
| } |
| cp.emit('exit', 0, null); |
| cp.emit('close', 0, null); |
| }); |
|
|
| const lastCallBuffer = |
| mockIsBinary.mock.calls[mockIsBinary.mock.calls.length - 1][0]; |
| expect(lastCallBuffer.length).toBe(250); |
| }); |
| }); |
|
|
| describe('Platform-Specific Behavior', () => { |
| it('should use powershell.exe on Windows', async () => { |
| mockPlatform.mockReturnValue('win32'); |
| await simulateExecution('dir "foo bar"', (cp) => { |
| cp.emit('exit', 0, null); |
| cp.emit('close', 0, null); |
| }); |
|
|
| expect(mockCpSpawn).toHaveBeenCalledWith( |
| 'powershell.exe', |
| ['-NoProfile', '-NonInteractive', '-Command', 'dir "foo bar"'], |
| expect.objectContaining({ |
| shell: false, |
| detached: false, |
| windowsVerbatimArguments: false, |
| }), |
| ); |
| }); |
|
|
| it('should use bash and detached process group on Linux', async () => { |
| mockPlatform.mockReturnValue('linux'); |
| await simulateExecution('ls "foo bar"', (cp) => { |
| cp.emit('exit', 0, null); |
| cp.emit('close', 0, null); |
| }); |
|
|
| expect(mockCpSpawn).toHaveBeenCalledWith( |
| 'bash', |
| [ |
| '-c', |
| 'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls "foo bar"', |
| ], |
| expect.objectContaining({ |
| shell: false, |
| detached: true, |
| }), |
| ); |
| }); |
| }); |
| }); |
|
|
| describe('ShellExecutionService execution method selection', () => { |
| let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>; |
| let mockPtyProcess: EventEmitter & { |
| pid: number; |
| kill: Mock; |
| onData: Mock; |
| onExit: Mock; |
| write: Mock; |
| resize: Mock; |
| }; |
| let mockChildProcess: EventEmitter & Partial<ChildProcess>; |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
| ExecutionLifecycleService.resetForTest(); |
| ShellExecutionService.resetForTest(); |
| onOutputEventMock = vi.fn(); |
|
|
| |
| mockPtyProcess = new EventEmitter() as EventEmitter & { |
| pid: number; |
| kill: Mock; |
| onData: Mock; |
| onExit: Mock; |
| write: Mock; |
| resize: Mock; |
| }; |
| mockPtyProcess.pid = 12345; |
| mockPtyProcess.kill = vi.fn(); |
| mockPtyProcess.onData = vi.fn(); |
| mockPtyProcess.onExit = vi.fn(); |
| mockPtyProcess.write = vi.fn(); |
| mockPtyProcess.resize = vi.fn(); |
|
|
| mockPtySpawn.mockReturnValue(mockPtyProcess); |
| mockGetPty.mockResolvedValue({ |
| module: { spawn: mockPtySpawn }, |
| name: 'mock-pty', |
| }); |
|
|
| |
| mockChildProcess = new EventEmitter() as EventEmitter & |
| Partial<ChildProcess>; |
| mockChildProcess.stdout = new EventEmitter() as Readable; |
| mockChildProcess.stderr = new EventEmitter() as Readable; |
| mockChildProcess.kill = vi.fn(); |
| Object.defineProperty(mockChildProcess, 'pid', { |
| value: 54321, |
| configurable: true, |
| }); |
| mockCpSpawn.mockReturnValue(mockChildProcess); |
| }); |
|
|
| it('should use node-pty when shouldUseNodePty is true and pty is available', async () => { |
| mockSerializeTerminalToObject.mockReturnValue([]); |
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| 'test command', |
| '/test/dir', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| |
| if (!mockPtyProcess.onExit.mock.calls[0]) { |
| const res = await handle.result; |
| throw new Error(`Failed early in executeWithPty: ${res.error}`); |
| } |
| mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| const result = await handle.result; |
| expect(mockPtySpawn).toHaveBeenCalled(); |
| expect(mockCpSpawn).not.toHaveBeenCalled(); |
| expect(result.executionMethod).toBe('mock-pty'); |
| }); |
|
|
| it('should use child_process when shouldUseNodePty is false', async () => { |
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| 'test command', |
| '/test/dir', |
| onOutputEventMock, |
| abortController.signal, |
| false, |
| { |
| ...shellExecutionConfig, |
| sanitizationConfig: { |
| enableEnvironmentVariableRedaction: true, |
| allowedEnvironmentVariables: [], |
| blockedEnvironmentVariables: [], |
| }, |
| }, |
| ); |
|
|
| |
| mockChildProcess.emit('exit', 0, null); |
| mockChildProcess.emit('close', 0, null); |
| const result = await handle.result; |
|
|
| expect(mockGetPty).not.toHaveBeenCalled(); |
| expect(mockPtySpawn).not.toHaveBeenCalled(); |
| expect(mockCpSpawn).toHaveBeenCalled(); |
| expect(result.executionMethod).toBe('child_process'); |
| }); |
|
|
| it('should fall back to child_process if pty is not available even if shouldUseNodePty is true', async () => { |
| mockGetPty.mockResolvedValue(null); |
|
|
| const abortController = new AbortController(); |
| const handle = await ShellExecutionService.execute( |
| 'test command', |
| '/test/dir', |
| onOutputEventMock, |
| abortController.signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| |
| mockChildProcess.emit('exit', 0, null); |
| mockChildProcess.emit('close', 0, null); |
| const result = await handle.result; |
|
|
| expect(mockGetPty).toHaveBeenCalled(); |
| expect(mockPtySpawn).not.toHaveBeenCalled(); |
| expect(mockCpSpawn).toHaveBeenCalled(); |
| expect(result.executionMethod).toBe('child_process'); |
| }); |
| }); |
|
|
| describe('ShellExecutionService environment variables', () => { |
| let mockPtyProcess: EventEmitter & { |
| pid: number; |
| kill: Mock; |
| onData: Mock; |
| onExit: Mock; |
| write: Mock; |
| resize: Mock; |
| }; |
| let mockChildProcess: EventEmitter & Partial<ChildProcess>; |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
| ExecutionLifecycleService.resetForTest(); |
| ShellExecutionService.resetForTest(); |
| vi.resetModules(); |
|
|
| |
| mockPtyProcess = new EventEmitter() as EventEmitter & { |
| pid: number; |
| kill: Mock; |
| onData: Mock; |
| onExit: Mock; |
| write: Mock; |
| resize: Mock; |
| }; |
| mockPtyProcess.pid = 12345; |
| mockPtyProcess.kill = vi.fn(); |
| mockPtyProcess.onData = vi.fn(); |
| mockPtyProcess.onExit = vi.fn(); |
| mockPtyProcess.write = vi.fn(); |
| mockPtyProcess.resize = vi.fn(); |
|
|
| mockPtySpawn.mockReturnValue(mockPtyProcess); |
| mockGetPty.mockResolvedValue({ |
| module: { spawn: mockPtySpawn }, |
| name: 'mock-pty', |
| }); |
|
|
| |
| mockChildProcess = new EventEmitter() as EventEmitter & |
| Partial<ChildProcess>; |
| mockChildProcess.stdout = new EventEmitter() as Readable; |
| mockChildProcess.stderr = new EventEmitter() as Readable; |
| mockChildProcess.kill = vi.fn(); |
| Object.defineProperty(mockChildProcess, 'pid', { |
| value: 54321, |
| configurable: true, |
| }); |
| mockCpSpawn.mockReturnValue(mockChildProcess); |
|
|
| |
| mockPtyProcess.onExit.mockImplementationOnce(({ exitCode, signal }) => { |
| |
| setTimeout(() => mockPtyProcess.emit('exit', { exitCode, signal }), 0); |
| }); |
| mockChildProcess.on('exit', (code, signal) => { |
| |
| setTimeout(() => mockChildProcess.emit('close', code, signal), 0); |
| }); |
| }); |
|
|
| afterEach(() => { |
| |
| vi.unstubAllEnvs(); |
| }); |
|
|
| it('should use a sanitized environment when in a GitHub run', async () => { |
| |
| vi.stubEnv('GITHUB_SHA', 'test-sha'); |
| vi.stubEnv('MY_SENSITIVE_VAR', 'secret-value'); |
| vi.stubEnv('PATH', '/test/path'); |
| vi.stubEnv('GEMINI_CLI_TEST_VAR', 'test-value'); |
|
|
| vi.resetModules(); |
| const { ShellExecutionService } = await import( |
| './shellExecutionService.js' |
| ); |
|
|
| |
| await ShellExecutionService.execute( |
| 'test-pty-command', |
| '/', |
| vi.fn(), |
| new AbortController().signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| const ptyEnv = mockPtySpawn.mock.calls[0][2].env; |
| expect(ptyEnv).not.toHaveProperty('MY_SENSITIVE_VAR'); |
| expect(ptyEnv).toHaveProperty('PATH', '/test/path'); |
| expect(ptyEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value'); |
|
|
| |
| mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| await new Promise(process.nextTick); |
|
|
| |
| mockGetPty.mockResolvedValue(null); |
| await ShellExecutionService.execute( |
| 'test-cp-command', |
| '/', |
| vi.fn(), |
| new AbortController().signal, |
| true, |
| { |
| ...shellExecutionConfig, |
| sanitizationConfig: { |
| enableEnvironmentVariableRedaction: false, |
| allowedEnvironmentVariables: [], |
| blockedEnvironmentVariables: [], |
| }, |
| }, |
| ); |
|
|
| const cpEnv = mockCpSpawn.mock.calls[0][2].env; |
| expect(cpEnv).not.toHaveProperty('MY_SENSITIVE_VAR'); |
| expect(cpEnv).toHaveProperty('PATH', '/test/path'); |
| expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value'); |
|
|
| |
| mockChildProcess.emit('exit', 0, null); |
| mockChildProcess.emit('close', 0, null); |
| await new Promise(process.nextTick); |
| }); |
|
|
| it('should use a sanitized environment when in a GitHub run (SURFACE=Github)', async () => { |
| |
| vi.stubEnv('SURFACE', 'Github'); |
| vi.stubEnv('MY_SENSITIVE_VAR', 'secret-value'); |
| vi.stubEnv('PATH', '/test/path'); |
| vi.stubEnv('GEMINI_CLI_TEST_VAR', 'test-value'); |
|
|
| vi.resetModules(); |
| const { ShellExecutionService } = await import( |
| './shellExecutionService.js' |
| ); |
|
|
| |
| await ShellExecutionService.execute( |
| 'test-pty-command-surface', |
| '/', |
| vi.fn(), |
| new AbortController().signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| const ptyEnv = mockPtySpawn.mock.calls[0][2].env; |
| expect(ptyEnv).not.toHaveProperty('MY_SENSITIVE_VAR'); |
| expect(ptyEnv).toHaveProperty('PATH', '/test/path'); |
| expect(ptyEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value'); |
|
|
| |
| mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| await new Promise(process.nextTick); |
|
|
| |
| mockGetPty.mockResolvedValue(null); |
| await ShellExecutionService.execute( |
| 'test-cp-command-surface', |
| '/', |
| vi.fn(), |
| new AbortController().signal, |
| true, |
| { |
| ...shellExecutionConfig, |
| sanitizationConfig: { |
| enableEnvironmentVariableRedaction: false, |
| allowedEnvironmentVariables: [], |
| blockedEnvironmentVariables: [], |
| }, |
| }, |
| ); |
|
|
| const cpEnv = mockCpSpawn.mock.calls[0][2].env; |
| expect(cpEnv).not.toHaveProperty('MY_SENSITIVE_VAR'); |
| expect(cpEnv).toHaveProperty('PATH', '/test/path'); |
| expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value'); |
|
|
| |
| mockChildProcess.emit('exit', 0, null); |
| mockChildProcess.emit('close', 0, null); |
| await new Promise(process.nextTick); |
| }); |
|
|
| it('should include the full process.env when not in a GitHub run', async () => { |
| vi.stubEnv('MY_TEST_VAR', 'test-value'); |
| vi.stubEnv('GITHUB_SHA', ''); |
| vi.stubEnv('SURFACE', ''); |
| vi.resetModules(); |
| const { ShellExecutionService } = await import( |
| './shellExecutionService.js' |
| ); |
|
|
| |
| await ShellExecutionService.execute( |
| 'test-pty-command-no-github', |
| '/', |
| vi.fn(), |
| new AbortController().signal, |
| true, |
| shellExecutionConfig, |
| ); |
| expect(mockPtySpawn).toHaveBeenCalled(); |
| const ptyEnv = mockPtySpawn.mock.calls[0][2].env; |
| expect(ptyEnv).toHaveProperty('MY_TEST_VAR', 'test-value'); |
| expect(ptyEnv).toHaveProperty('GEMINI_CLI', '1'); |
|
|
| |
| mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); |
| await new Promise(process.nextTick); |
|
|
| |
| mockGetPty.mockResolvedValue(null); |
| await ShellExecutionService.execute( |
| 'test-cp-command-no-github', |
| '/', |
| vi.fn(), |
| new AbortController().signal, |
| true, |
| shellExecutionConfig, |
| ); |
| expect(mockCpSpawn).toHaveBeenCalled(); |
| const cpEnv = mockCpSpawn.mock.calls[0][2].env; |
| expect(cpEnv).toHaveProperty('MY_TEST_VAR', 'test-value'); |
| expect(cpEnv).toHaveProperty('GEMINI_CLI', '1'); |
|
|
| |
| mockChildProcess.emit('exit', 0, null); |
| mockChildProcess.emit('close', 0, null); |
| await new Promise(process.nextTick); |
| }); |
|
|
| it('should call prepareCommand on sandboxManager when provided', async () => { |
| const mockSandboxManager = { |
| prepareCommand: vi.fn().mockResolvedValue({ |
| program: 'sandboxed-bash', |
| args: ['-c', 'ls'], |
| env: { SANDBOXED: 'true' }, |
| }), |
| isKnownSafeCommand: vi.fn().mockReturnValue(false), |
| isDangerousCommand: vi.fn().mockReturnValue(false), |
| parseDenials: vi.fn().mockReturnValue(undefined), |
| getWorkspace: vi.fn().mockReturnValue('/workspace'), |
| getOptions: vi.fn().mockReturnValue(undefined), |
| }; |
|
|
| const configWithSandbox: ShellExecutionConfig = { |
| ...shellExecutionConfig, |
| sandboxManager: mockSandboxManager, |
| }; |
|
|
| mockResolveExecutable.mockReturnValue('/bin/bash/resolved'); |
| const mockChild = new EventEmitter() as unknown as ChildProcess; |
| mockChild.stdout = new EventEmitter() as unknown as Readable; |
| mockChild.stderr = new EventEmitter() as unknown as Readable; |
| Object.assign(mockChild, { pid: 123 }); |
| mockCpSpawn.mockReturnValue(mockChild); |
|
|
| const handle = await ShellExecutionService.execute( |
| 'ls', |
| '/test/cwd', |
| () => {}, |
| new AbortController().signal, |
| false, |
| configWithSandbox, |
| ); |
|
|
| expect(mockResolveExecutable).toHaveBeenCalledWith(expect.any(String)); |
| expect(mockSandboxManager.prepareCommand).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| command: '/bin/bash/resolved', |
| args: expect.arrayContaining([expect.stringContaining('ls')]), |
| cwd: '/test/cwd', |
| }), |
| ); |
| expect(mockCpSpawn).toHaveBeenCalledWith( |
| 'sandboxed-bash', |
| ['-c', 'ls'], |
| expect.objectContaining({ |
| env: expect.objectContaining({ SANDBOXED: 'true' }), |
| }), |
| ); |
|
|
| |
| mockChild.emit('exit', 0, null); |
| mockChild.emit('close', 0, null); |
| await handle.result; |
| }); |
|
|
| it('should include headless git and gh environment variables in non-interactive mode and append git config safely', async () => { |
| vi.resetModules(); |
| vi.stubEnv('GIT_CONFIG_COUNT', '2'); |
| vi.stubEnv('GIT_CONFIG_KEY_0', 'core.editor'); |
| vi.stubEnv('GIT_CONFIG_VALUE_0', 'vim'); |
| vi.stubEnv('GIT_CONFIG_KEY_1', 'pull.rebase'); |
| vi.stubEnv('GIT_CONFIG_VALUE_1', 'true'); |
|
|
| const { ShellExecutionService } = await import( |
| './shellExecutionService.js' |
| ); |
|
|
| mockGetPty.mockResolvedValue(null); |
| await ShellExecutionService.execute( |
| 'test-cp-headless-git', |
| '/', |
| vi.fn(), |
| new AbortController().signal, |
| false, |
| shellExecutionConfig, |
| ); |
|
|
| expect(mockCpSpawn).toHaveBeenCalled(); |
| const cpEnv = mockCpSpawn.mock.calls[0][2].env; |
| expect(cpEnv).toHaveProperty('GIT_TERMINAL_PROMPT', '0'); |
| expect(cpEnv).toHaveProperty('GIT_ASKPASS', ''); |
| expect(cpEnv).toHaveProperty('SSH_ASKPASS', ''); |
| expect(cpEnv).toHaveProperty('GH_PROMPT_DISABLED', '1'); |
| expect(cpEnv).toHaveProperty('GCM_INTERACTIVE', 'never'); |
| expect(cpEnv).toHaveProperty('DISPLAY', ''); |
| expect(cpEnv).toHaveProperty('DBUS_SESSION_BUS_ADDRESS', ''); |
|
|
| |
| const devNullPath = os.platform() === 'win32' ? 'NUL' : '/dev/null'; |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_GLOBAL', devNullPath); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_SYSTEM', devNullPath); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_NOSYSTEM', '1'); |
|
|
| |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_0', 'core.editor'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_0', 'vim'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_1', 'pull.rebase'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_1', 'true'); |
|
|
| |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_COUNT', '10'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_2', 'credential.helper'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_2', ''); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_3', 'core.fsmonitor'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_3', ''); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_4', 'core.hooksPath'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_4', ''); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_5', 'core.sshCommand'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_5', ''); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_6', 'core.pager'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_6', 'cat'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_7', 'core.editor'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_7', ''); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_8', 'sequence.editor'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_8', ''); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_9', 'diff.external'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_9', ''); |
|
|
| |
| mockChildProcess.emit('exit', 0, null); |
| mockChildProcess.emit('close', 0, null); |
| await new Promise(process.nextTick); |
|
|
| vi.unstubAllEnvs(); |
| }); |
|
|
| it('should include headless git and gh environment variables in interactive fallback mode', async () => { |
| vi.resetModules(); |
| vi.stubEnv('GIT_TERMINAL_PROMPT', undefined); |
| vi.stubEnv('GIT_ASKPASS', undefined); |
| vi.stubEnv('SSH_ASKPASS', undefined); |
| vi.stubEnv('GH_PROMPT_DISABLED', undefined); |
| vi.stubEnv('GCM_INTERACTIVE', undefined); |
| vi.stubEnv('GIT_CONFIG_COUNT', undefined); |
|
|
| const { ShellExecutionService } = await import( |
| './shellExecutionService.js' |
| ); |
|
|
| mockGetPty.mockResolvedValue(null); |
| await ShellExecutionService.execute( |
| 'test-cp-interactive-fallback', |
| '/', |
| vi.fn(), |
| new AbortController().signal, |
| true, |
| shellExecutionConfig, |
| ); |
|
|
| expect(mockCpSpawn).toHaveBeenCalled(); |
| const cpEnv = mockCpSpawn.mock.calls[0][2].env; |
| expect(cpEnv).toHaveProperty('GIT_TERMINAL_PROMPT', '0'); |
| expect(cpEnv).toHaveProperty('GIT_ASKPASS', ''); |
| expect(cpEnv).toHaveProperty('SSH_ASKPASS', ''); |
| expect(cpEnv).toHaveProperty('GH_PROMPT_DISABLED', '1'); |
| expect(cpEnv).toHaveProperty('GCM_INTERACTIVE', 'never'); |
| expect(cpEnv).toHaveProperty('GIT_CONFIG_COUNT'); |
|
|
| |
| mockChildProcess.emit('exit', 0, null); |
| mockChildProcess.emit('close', 0, null); |
| await new Promise(process.nextTick); |
|
|
| vi.unstubAllEnvs(); |
| }); |
| }); |
|
|