/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import { vi, describe, it, expect, beforeEach, afterEach, } from 'vitest';
import EventEmitter from 'node:events';
import {} from 'node:child_process';
import { ShellExecutionService, } from './shellExecutionService.js';
import { NoopSandboxManager } from './sandboxManager.js';
import { ExecutionLifecycleService } from './executionLifecycleService.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 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(),
}));
// Top-level Mocks
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();
    return {
        ...actual,
        default: {
            ...actual,
            mkdirSync: mockMkdirSync,
            createWriteStream: mockCreateWriteStream,
        },
        mkdirSync: mockMkdirSync,
        createWriteStream: mockCreateWriteStream,
    };
});
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,
        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', () => ({
    // Avoid passing the heavy Terminal object to the spy to prevent OOM
    serializeTerminalToObject: (_terminal, ...args) => 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 = {
    terminalWidth: 80,
    terminalHeight: 24,
    pager: 'cat',
    showColor: false,
    disableDynamicLineTrimming: true,
    sanitizationConfig: {
        enableEnvironmentVariableRedaction: false,
        allowedEnvironmentVariables: [],
        blockedEnvironmentVariables: [],
    },
    sandboxManager: new NoopSandboxManager(),
};
const createMockSerializeTerminalToObjectReturnValue = (text) => {
    const lines = Array.isArray(text) ? text : text.split('\n');
    const len = shellExecutionConfig.terminalHeight ?? 24;
    const expected = 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) => {
    const lines = Array.isArray(text) ? text : text.split('\n');
    const len = shellExecutionConfig.terminalHeight ?? 24;
    const expected = Array.from({ length: len }, (_, i) => [
        {
            text: expect.stringMatching((lines[i] || '').trim()),
            bold: false,
            italic: false,
            underline: false,
            dim: false,
            inverse: false,
            fg: '',
            bg: '',
        },
    ]);
    return expected;
};
describe('ShellExecutionService', () => {
    let mockPtyProcess;
    let mockHeadlessTerminal;
    let onOutputEventMock;
    beforeEach(() => {
        vi.clearAllMocks();
        ExecutionLifecycleService.resetForTest();
        mockSerializeTerminalToObject.mockReturnValue([]);
        mockIsBinary.mockReturnValue(false);
        mockPlatform.mockReturnValue('linux');
        mockResolveExecutable.mockImplementation(async (exe) => exe);
        process.env['PATH'] = '/test/path';
        mockGetPty.mockResolvedValue({
            module: { spawn: mockPtySpawn },
            name: 'mock-pty',
        });
        onOutputEventMock = vi.fn();
        mockPtyProcess = new EventEmitter();
        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);
    });
    // Helper function to run a standard execution simulation
    const simulateExecution = async (command, simulation, 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', () => {
        let activePtysGetSpy;
        beforeEach(() => {
            activePtysGetSpy = vi
                .spyOn(ShellExecutionService['activePtys'], 'get')
                .mockReturnValue({
                // eslint-disable-next-line @typescript-eslint/no-explicit-any
                ptyProcess: mockPtyProcess,
                // eslint-disable-next-line @typescript-eslint/no-explicit-any
                headlessTerminal: mockHeadlessTerminal,
            });
        });
        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');
            // 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, {
                ...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);
            // 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('Backgrounding', () => {
        let mockWriteStream;
        let mockBgChildProcess;
        beforeEach(async () => {
            mockWriteStream = {
                write: vi.fn(),
                end: vi.fn().mockImplementation((cb) => cb?.()),
                on: vi.fn(),
            };
            mockMkdirSync.mockReturnValue(undefined);
            mockCreateWriteStream.mockReturnValue(
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            mockWriteStream);
            mockHomedir.mockReturnValue('/mock/home');
            mockBgChildProcess = new EventEmitter();
            mockBgChildProcess.stdout = new EventEmitter();
            mockBgChildProcess.stderr = new EventEmitter();
            mockBgChildProcess.kill = vi.fn();
            Object.defineProperty(mockBgChildProcess, 'pid', {
                value: 99999,
                configurable: true,
            });
            mockCpSpawn.mockReturnValue(mockBgChildProcess);
            // Explicitly clear state between runs
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            ShellExecutionService.backgroundLogStreams.clear();
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            ShellExecutionService.activePtys.clear();
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            ShellExecutionService.activeChildProcesses.clear();
        });
        afterEach(() => {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            ShellExecutionService.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);
            // Use the registered onData listener
            const onDataListener = mockPtyProcess.onData.mock.calls[0][0];
            onDataListener('initial pty output');
            // Wait for async write to headless terminal
            await new Promise((resolve) => setTimeout(resolve, 100));
            mockSerializeTerminalToObject.mockReturnValue([
                [{ text: 'initial pty output', fg: '', bg: '' }],
            ]);
            // Background the process
            ShellExecutionService.background(handle.pid);
            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 });
            // Verify initial output was written
            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); // Force child_process fallback
            const abortController = new AbortController();
            const handle = await ShellExecutionService.execute('long-running-cp', '/', onOutputEventMock, abortController.signal, true, shellExecutionConfig);
            // Trigger data before backgrounding
            mockBgChildProcess.stdout?.emit('data', Buffer.from('initial cp output'));
            await new Promise((resolve) => process.nextTick(resolve));
            ShellExecutionService.background(handle.pid);
            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);
            // Subsequent output
            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);
            // Mock mkdirSync to fail
            const error = new Error('Permission denied');
            mockMkdirSync.mockImplementationOnce(() => {
                throw error;
            });
            // Background the process
            ShellExecutionService.background(handle.pid);
            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);
        });
    });
    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) => 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, '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 () => {
            // Simulate: spawn succeeds, but accessing ptyProcess.pid throws.
            // spawnedPty is set before the pid access, so the catch block should
            // call spawnedPty.destroy() to release the fd.
            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() {
                    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();
            // The catch block must call destroy() on spawnedPty to prevent fd leak
            expect(destroySpy).toHaveBeenCalled();
        });
    });
});
describe('ShellExecutionService child_process fallback', () => {
    let mockChildProcess;
    let onOutputEventMock;
    beforeEach(() => {
        vi.clearAllMocks();
        mockIsBinary.mockReturnValue(false);
        mockPlatform.mockReturnValue('linux');
        mockGetPty.mockResolvedValue(null);
        onOutputEventMock = vi.fn();
        mockChildProcess = new EventEmitter();
        mockChildProcess.stdout = new EventEmitter();
        mockChildProcess.stderr = new EventEmitter();
        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, simulation) => {
        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);
            });
            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' },
            },
            {
                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'], expect.anything());
                }
            });
        });
        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, {
                ...shellExecutionConfig,
                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) => 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;
    let mockPtyProcess;
    let mockChildProcess;
    beforeEach(() => {
        vi.clearAllMocks();
        onOutputEventMock = vi.fn();
        // Mock for pty
        mockPtyProcess = new EventEmitter();
        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();
        mockChildProcess.stdout = new EventEmitter();
        mockChildProcess.stderr = new EventEmitter();
        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
        {
            ...shellExecutionConfig,
            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;
    let mockChildProcess;
    beforeEach(() => {
        vi.clearAllMocks();
        vi.resetModules(); // Reset modules to ensure process.env changes are fresh
        // Mock for pty
        mockPtyProcess = new EventEmitter();
        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();
        mockChildProcess.stdout = new EventEmitter();
        mockChildProcess.stderr = new EventEmitter();
        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, {
            ...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');
        // 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, {
            ...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');
        // 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 call prepareCommand on sandboxManager when provided', async () => {
        const mockSandboxManager = {
            prepareCommand: vi.fn().mockResolvedValue({
                program: 'sandboxed-bash',
                args: ['-c', 'ls'],
                env: { SANDBOXED: 'true' },
            }),
        };
        const configWithSandbox = {
            ...shellExecutionConfig,
            sandboxManager: mockSandboxManager,
        };
        mockResolveExecutable.mockResolvedValue('/bin/bash/resolved');
        const mockChild = new EventEmitter();
        mockChild.stdout = new EventEmitter();
        mockChild.stderr = new EventEmitter();
        Object.assign(mockChild, { pid: 123 });
        mockCpSpawn.mockReturnValue(mockChild);
        const handle = await ShellExecutionService.execute('ls', '/test/cwd', () => { }, new AbortController().signal, false, // child_process path
        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' }),
        }));
        // Clean up
        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); // 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();
    });
    it('should NOT 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); // Force child_process fallback
        await ShellExecutionService.execute('test-cp-interactive-fallback', '/', vi.fn(), new AbortController().signal, true, // isInteractive (shouldUseNodePty)
        shellExecutionConfig);
        expect(mockCpSpawn).toHaveBeenCalled();
        const cpEnv = mockCpSpawn.mock.calls[0][2].env;
        expect(cpEnv).not.toHaveProperty('GIT_TERMINAL_PROMPT');
        expect(cpEnv).not.toHaveProperty('GIT_ASKPASS');
        expect(cpEnv).not.toHaveProperty('SSH_ASKPASS');
        expect(cpEnv).not.toHaveProperty('GH_PROMPT_DISABLED');
        expect(cpEnv).not.toHaveProperty('GCM_INTERACTIVE');
        expect(cpEnv).not.toHaveProperty('GIT_CONFIG_COUNT');
        // Ensure child_process exits
        mockChildProcess.emit('exit', 0, null);
        mockChildProcess.emit('close', 0, null);
        await new Promise(process.nextTick);
        vi.unstubAllEnvs();
    });
});
//# sourceMappingURL=shellExecutionService.test.js.map