Skip to content
171 changes: 170 additions & 1 deletion packages/core/src/services/shellExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
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';
Expand Down Expand Up @@ -1284,3 +1292,164 @@ describe('ShellExecutionService execution method selection', () => {
expect(result.executionMethod).toBe('child_process');
});
});

describe('ShellExecutionService environment variables', () => {
let mockPtyProcess: EventEmitter & {
pid: number;
kill: Mock;
onData: Mock;
onExit: Mock;
write: Mock;
resize: Mock;
};
let mockChildProcess: EventEmitter & Partial<ChildProcess>;

beforeEach(() => {
vi.clearAllMocks();
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<ChildProcess>;
mockChildProcess.stdout = new EventEmitter() as Readable;
mockChildProcess.stderr = new EventEmitter() as Readable;
mockChildProcess.kill = vi.fn();
Object.defineProperty(mockChildProcess, 'pid', {
value: 54321,
configurable: true,
});
mockCpSpawn.mockReturnValue(mockChildProcess);

// 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 not include process.env when GITHUB_SHA is set (pty and child_process)', async () => {
vi.stubEnv('GITHUB_SHA', 'test-sha');
vi.stubEnv('MY_TEST_VAR', 'test-value');
vi.resetModules();
const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);

// Test pty path
await ShellExecutionService.execute(
'test-pty-command',
'/',
vi.fn(),
new AbortController().signal,
true,
shellExecutionConfig,
);
expect(mockPtySpawn).toHaveBeenCalled();
const ptyEnv = mockPtySpawn.mock.calls[0][2].env;
expect(ptyEnv).not.toHaveProperty('MY_TEST_VAR');
expect(ptyEnv).toHaveProperty('GEMINI_CLI', '1');
expect(ptyEnv).toHaveProperty('TERM', 'xterm-256color');

// 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',
'/',
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).not.toHaveProperty('MY_TEST_VAR');
expect(cpEnv).toHaveProperty('GEMINI_CLI', '1');
expect(cpEnv).toHaveProperty('TERM', 'xterm-256color');

// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
});

it('should include process.env when GITHUB_SHA is not set (pty and child_process)', async () => {
vi.stubEnv('MY_TEST_VAR', 'test-value');
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);
});
});
8 changes: 6 additions & 2 deletions packages/core/src/services/shellExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,9 @@ export class ShellExecutionService {
shell: false,
detached: !isWindows,
env: {
...process.env,
...(process.env['GITHUB_SHA'] || process.env['SURFACE'] === 'Github'
? {}
: process.env),
GEMINI_CLI: '1',
TERM: 'xterm-256color',
PAGER: 'cat',
Expand Down Expand Up @@ -463,7 +465,9 @@ export class ShellExecutionService {
cols,
rows,
env: {
...process.env,
...(process.env['GITHUB_SHA'] || process.env['SURFACE'] === 'Github'
? {}
: process.env),
GEMINI_CLI: '1',
TERM: 'xterm-256color',
PAGER: shellExecutionConfig.pager ?? 'cat',
Expand Down
Loading