/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; 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 type { AnsiOutput, AnsiToken } from '../utils/terminalSerializer.js'; // Hoisted Mocks 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 mockGetPty = vi.hoisted(() => vi.fn()); const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn()); const mockResolveExecutable = vi.hoisted(() => vi.fn()); // Top-level Mocks vi.mock('@lydell/node-pty', () => ({ spawn: mockPtySpawn, })); vi.mock('../utils/shell-utils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, resolveExecutable: mockResolveExecutable, }; }); 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, constants: { signals: { SIGTERM: 15, SIGKILL: 9, }, }, }, platform: mockPlatform, constants: { signals: { SIGTERM: 15, SIGKILL: 9, }, }, })); vi.mock('../utils/getPty.js', () => ({ getPty: mockGetPty, })); vi.mock('../utils/terminalSerializer.js', () => ({ // Avoid passing the heavy Terminal object to the spy to prevent OOM serializeTerminalToObject: ( _terminal: unknown, ...args: [number | undefined, number | undefined] ) => mockSerializeTerminalToObject(...args), convertColorToHex: () => '#000000', ColorMode: { DEFAULT: 0, PALETTE: 1, RGB: 2 }, })); vi.mock('../utils/systemEncoding.js', () => ({ getCachedEncodingForBuffer: vi.fn().mockReturnValue('utf-8'), })); const mockProcessKill = vi .spyOn(process, 'kill') .mockImplementation(() => true); const shellExecutionConfig: ShellExecutionConfig = { terminalWidth: 80, terminalHeight: 24, pager: 'cat', showColor: false, disableDynamicLineTrimming: true, sanitizationConfig: { enableEnvironmentVariableRedaction: false, allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, }; 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, 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, 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; }; }; }; let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>; beforeEach(() => { vi.clearAllMocks(); mockSerializeTerminalToObject.mockReturnValue([]); mockIsBinary.mockReturnValue(false); mockPlatform.mockReturnValue('linux'); mockResolveExecutable.mockImplementation(async (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, }, }, }; mockPtySpawn.mockReturnValue(mockPtyProcess); }); // Helper function to run a standard execution simulation const simulateExecution = async ( command: string, simulation: ( ptyProcess: typeof mockPtyProcess, ac: AbortController, ) => void | Promise, 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) => { // Send data in chunks to simulate realistic streaming // Use \r\n to ensure the terminal moves the cursor to the start of the line 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); // The terminal buffer output includes trailing spaces for each line (up to terminal width). // We trim each line to match our expected simple string. 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 () => { // Set a small width to force wrapping const narrowConfig = { ...shellExecutionConfig, terminalWidth: 10 }; const longString = '123456789012345'; // 15 chars, should wrap at 10 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) => { // "value" should not get terminal-width padding // "value2 " should keep its spaces 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; // Generate lines: "line 0", "line 1", ... 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); // The terminal should keep the *last* 'scrollbackLimit' lines + lines in the viewport. // xterm.js scrollback is the number of lines *above* the viewport. // So total lines retained = scrollback + rows. // However, our `getFullBufferText` implementation iterates the *active* buffer. // In headless xterm, the buffer length grows. // Let's verify that we have fewer lines than totalLines. const outputLines = result.output .trim() .split('\n') .map((l) => l.trimEnd()); // We expect the *start* of the output to be truncated. // The first retained line should be > "line 0". // Specifically, if we sent 150 lines and have space for roughly 100 + viewport(24), // we should miss the first ~26 lines. // Check that we lost some lines from the beginning expect(outputLines.length).toBeLessThan(totalLines); expect(outputLines[0]).not.toBe('line 0'); // Check that we have the *last* lines 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', () => { beforeEach(() => { vi.spyOn(ShellExecutionService['activePtys'], 'get').mockReturnValue({ // eslint-disable-next-line @typescript-eslint/no-explicit-any ptyProcess: mockPtyProcess as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any headlessTerminal: mockHeadlessTerminal as any, }); }); 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'); // Use fake timers to check for the delayed render await vi.advanceTimersByTimeAsync(17); // The render will cause an output event 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; }); // We don't expect this test to throw an error 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; }); // This should catch the specific error and not re-throw it. 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, { 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); // The process kill is mocked, so we just check that the flag is set. }); it('should send SIGTERM and then SIGKILL on abort', async () => { const sigkillPromise = new Promise((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; // Wait for SIGKILL to be sent before exiting. pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: 9 }); }, ); expect(result.aborted).toBe(true); // Verify the calls were made in the correct order. 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).toBe(1); 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) => { // Simulate a lot of data being in the queue to be processed 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 }); }, ); // The main assertion here is implicit: the `await` for the result above // should complete without timing out. This proves that the resolution // was not blocked by the long chain of data processing promises, // which is the desired behavior on abort. expect(result.aborted).toBe(true); }); }); 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(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', 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', '-Command', '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', 'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls "foo bar"', ], expect.any(Object), ); }); }); 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 () => { // Execute a command to populate activePtys 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(); // Spy on the actual stored object's destroy const storedDestroySpy = vi.spyOn( activePty!.ptyProcess as never as { destroy: () => void }, 'destroy', ); 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 () => { // Simulate: spawn succeeds, Promise executor runs fine (pid accesses 1-2), // but the return statement `{ pid: ptyProcess.pid }` (access 3) throws. // The catch block should call spawnedPty.destroy() to release the fd. const destroySpy = vi.fn(); let pidAccessCount = 0; const faultyPty = { onData: vi.fn(), onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), resize: vi.fn(), destroy: destroySpy, get pid() { pidAccessCount++; // Accesses 1-2 are inside the Promise executor (setup). // Access 3 is at `return { pid: ptyProcess.pid, result }`, // outside the Promise — caught by the outer try/catch. if (pidAccessCount > 2) { throw new Error('Simulated post-spawn failure on pid access'); } return 77777; }, }; 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(); // The catch block must call destroy() on spawnedPty to prevent fd leak expect(destroySpy).toHaveBeenCalled(); }); }); }); describe('ShellExecutionService child_process fallback', () => { let mockChildProcess: EventEmitter & Partial; let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>; beforeEach(() => { vi.clearAllMocks(); mockIsBinary.mockReturnValue(false); mockPlatform.mockReturnValue('linux'); mockGetPty.mockResolvedValue(null); onOutputEventMock = vi.fn(); mockChildProcess = new EventEmitter() as EventEmitter & Partial; 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); }); // Helper function to run a standard execution simulation const simulateExecution = async ( command: string, simulation: (cp: typeof mockChildProcess, ac: AbortController) => 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)); 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); }); 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); // No exit event is fired. 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', (cp, abortController) => { abortController.abort(); 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 { expect(mockCpSpawn).toHaveBeenCalledWith(expectedCommand, [ '/pid', String(mockChildProcess.pid), '/f', '/t', ]); } }); }, ); it('should gracefully attempt SIGKILL on linux if SIGTERM fails', async () => { mockPlatform.mockReturnValue('linux'); vi.useFakeTimers(); // Don't await the result inside the simulation block for this specific test. // We need to control the timeline manually. const abortController = new AbortController(); const handle = await ShellExecutionService.execute( 'unresponsive_process', '/test/dir', onOutputEventMock, abortController.signal, true, { sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, }, ); abortController.abort(); // Check the first kill signal expect(mockProcessKill).toHaveBeenCalledWith( -mockChildProcess.pid!, 'SIGTERM', ); // Now, advance time past the timeout await vi.advanceTimersByTimeAsync(250); // Check the second kill signal expect(mockProcessKill).toHaveBeenCalledWith( -mockChildProcess.pid!, 'SIGKILL', ); // Finally, simulate the process exiting and await the result 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]); const { result } = await simulateExecution('cat image.png', (cp) => { cp.stdout?.emit('data', binaryChunk1); cp.stdout?.emit('data', binaryChunk2); cp.emit('exit', 0, null); }); expect(result.rawOutput).toEqual( Buffer.concat([binaryChunk1, binaryChunk2]), ); 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', ]); }); }); 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), ); expect(mockCpSpawn).toHaveBeenCalledWith( 'powershell.exe', ['-NoProfile', '-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)); 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; beforeEach(() => { vi.clearAllMocks(); onOutputEventMock = vi.fn(); // Mock for pty 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', }); // Mock for child_process mockChildProcess = new EventEmitter() as EventEmitter & Partial; 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, // shouldUseNodePty shellExecutionConfig, ); // Simulate exit to allow promise to resolve mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); const result = await handle.result; expect(mockGetPty).toHaveBeenCalled(); 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, // shouldUseNodePty { sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, }, ); // Simulate exit to allow promise to resolve mockChildProcess.emit('exit', 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, // shouldUseNodePty shellExecutionConfig, ); // Simulate exit to allow promise to resolve mockChildProcess.emit('exit', 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; beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); // Reset modules to ensure process.env changes are fresh // Mock for pty 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', }); // Mock for child_process mockChildProcess = new EventEmitter() as EventEmitter & Partial; 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); // Default exit behavior for mocks mockPtyProcess.onExit.mockImplementationOnce(({ exitCode, signal }) => { // Small delay to allow async ops to complete setTimeout(() => mockPtyProcess.emit('exit', { exitCode, signal }), 0); }); mockChildProcess.on('exit', (code, signal) => { // Small delay to allow async ops to complete setTimeout(() => mockChildProcess.emit('close', code, signal), 0); }); }); afterEach(() => { // Clean up process.env after each test vi.unstubAllEnvs(); }); it('should use a sanitized environment when in a GitHub run', async () => { // Mock the environment to simulate a GitHub Actions run vi.stubEnv('GITHUB_SHA', 'test-sha'); vi.stubEnv('MY_SENSITIVE_VAR', 'secret-value'); // This should be stripped out vi.stubEnv('PATH', '/test/path'); // An essential var that should be kept vi.stubEnv('GEMINI_CLI_TEST_VAR', 'test-value'); // A test var that should be kept vi.resetModules(); const { ShellExecutionService } = await import( './shellExecutionService.js' ); // Test pty path 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'); // Ensure pty process exits for next test mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); await new Promise(process.nextTick); // Test child_process path mockGetPty.mockResolvedValue(null); // Force fallback await ShellExecutionService.execute( 'test-cp-command', '/', vi.fn(), new AbortController().signal, true, { 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'); // Ensure child_process exits 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 () => { // Mock the environment to simulate a GitHub Actions run via SURFACE variable vi.stubEnv('SURFACE', 'Github'); vi.stubEnv('MY_SENSITIVE_VAR', 'secret-value'); // This should be stripped out vi.stubEnv('PATH', '/test/path'); // An essential var that should be kept vi.stubEnv('GEMINI_CLI_TEST_VAR', 'test-value'); // A test var that should be kept vi.resetModules(); const { ShellExecutionService } = await import( './shellExecutionService.js' ); // Test pty path 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'); // Ensure pty process exits for next test mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); await new Promise(process.nextTick); // Test child_process path mockGetPty.mockResolvedValue(null); // Force fallback await ShellExecutionService.execute( 'test-cp-command-surface', '/', vi.fn(), new AbortController().signal, true, { 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'); // Ensure child_process exits 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' ); // Test pty path 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'); // Ensure pty process exits mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); await new Promise(process.nextTick); // Test child_process path (forcing fallback by making pty unavailable) mockGetPty.mockResolvedValue(null); await ShellExecutionService.execute( 'test-cp-command-no-github', '/', vi.fn(), new AbortController().signal, true, // Still tries pty, but it will fall back 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'); // Ensure child_process exits mockChildProcess.emit('exit', 0, null); mockChildProcess.emit('close', 0, null); await new Promise(process.nextTick); }); 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); // Force child_process fallback await ShellExecutionService.execute( 'test-cp-headless-git', '/', vi.fn(), new AbortController().signal, false, // non-interactive 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', ''); // Existing values should be preserved 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'); // The new credential.helper override should be appended at index 2 expect(cpEnv).toHaveProperty('GIT_CONFIG_COUNT', '3'); expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_2', 'credential.helper'); expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_2', ''); // Ensure child_process exits mockChildProcess.emit('exit', 0, null); mockChildProcess.emit('close', 0, null); await new Promise(process.nextTick); vi.unstubAllEnvs(); }); });