mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 00:51:25 -07:00
Improve code coverage for cli package (#13724)
This commit is contained in:
32
packages/cli/src/utils/checks.test.ts
Normal file
32
packages/cli/src/utils/checks.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { checkExhaustive, assumeExhaustive } from './checks.js';
|
||||
|
||||
describe('checks', () => {
|
||||
describe('checkExhaustive', () => {
|
||||
it('should throw an error with default message', () => {
|
||||
expect(() => {
|
||||
checkExhaustive('unexpected' as never);
|
||||
}).toThrow('unexpected value unexpected!');
|
||||
});
|
||||
|
||||
it('should throw an error with custom message', () => {
|
||||
expect(() => {
|
||||
checkExhaustive('unexpected' as never, 'custom message');
|
||||
}).toThrow('custom message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assumeExhaustive', () => {
|
||||
it('should do nothing', () => {
|
||||
expect(() => {
|
||||
assumeExhaustive('unexpected' as never);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,61 +5,122 @@
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { registerCleanup, runExitCleanup } from './cleanup.js';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
Storage: vi.fn().mockImplementation(() => ({
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
rm: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('cleanup', () => {
|
||||
let register: typeof registerCleanup;
|
||||
let runExit: typeof runExitCleanup;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
register = cleanupModule.registerCleanup;
|
||||
runExit = cleanupModule.runExitCleanup;
|
||||
}, 30000);
|
||||
vi.clearAllMocks();
|
||||
// No need to re-assign, we can use the imported functions directly
|
||||
// because we are using vi.resetModules() and re-importing if necessary,
|
||||
// but actually, since we are mocking dependencies, we might not need to re-import cleanup.js
|
||||
// unless it has internal state that needs resetting. It does (cleanupFunctions array).
|
||||
// So we DO need to re-import it to get fresh state.
|
||||
});
|
||||
|
||||
it('should run a registered synchronous function', async () => {
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
const cleanupFn = vi.fn();
|
||||
register(cleanupFn);
|
||||
cleanupModule.registerCleanup(cleanupFn);
|
||||
|
||||
await runExit();
|
||||
await cleanupModule.runExitCleanup();
|
||||
|
||||
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should run a registered asynchronous function', async () => {
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
const cleanupFn = vi.fn().mockResolvedValue(undefined);
|
||||
register(cleanupFn);
|
||||
cleanupModule.registerCleanup(cleanupFn);
|
||||
|
||||
await runExit();
|
||||
await cleanupModule.runExitCleanup();
|
||||
|
||||
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should run multiple registered functions', async () => {
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
const syncFn = vi.fn();
|
||||
const asyncFn = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
register(syncFn);
|
||||
register(asyncFn);
|
||||
cleanupModule.registerCleanup(syncFn);
|
||||
cleanupModule.registerCleanup(asyncFn);
|
||||
|
||||
await runExit();
|
||||
await cleanupModule.runExitCleanup();
|
||||
|
||||
expect(syncFn).toHaveBeenCalledTimes(1);
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should continue running cleanup functions even if one throws an error', async () => {
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
const errorFn = vi.fn().mockImplementation(() => {
|
||||
throw new Error('test error');
|
||||
});
|
||||
const successFn = vi.fn();
|
||||
register(errorFn);
|
||||
register(successFn);
|
||||
cleanupModule.registerCleanup(errorFn);
|
||||
cleanupModule.registerCleanup(successFn);
|
||||
|
||||
await expect(runExit()).resolves.not.toThrow();
|
||||
await expect(cleanupModule.runExitCleanup()).resolves.not.toThrow();
|
||||
|
||||
expect(errorFn).toHaveBeenCalledTimes(1);
|
||||
expect(successFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('sync cleanup', () => {
|
||||
it('should run registered sync functions', async () => {
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
const syncFn = vi.fn();
|
||||
cleanupModule.registerSyncCleanup(syncFn);
|
||||
cleanupModule.runSyncCleanup();
|
||||
expect(syncFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should continue running sync cleanup functions even if one throws', async () => {
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
const errorFn = vi.fn().mockImplementation(() => {
|
||||
throw new Error('test error');
|
||||
});
|
||||
const successFn = vi.fn();
|
||||
cleanupModule.registerSyncCleanup(errorFn);
|
||||
cleanupModule.registerSyncCleanup(successFn);
|
||||
|
||||
expect(() => cleanupModule.runSyncCleanup()).not.toThrow();
|
||||
expect(errorFn).toHaveBeenCalledTimes(1);
|
||||
expect(successFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupCheckpoints', () => {
|
||||
it('should remove checkpoints directory', async () => {
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
await cleanupModule.cleanupCheckpoints();
|
||||
expect(fs.rm).toHaveBeenCalledWith(
|
||||
path.join('/tmp/project', 'checkpoints'),
|
||||
{
|
||||
recursive: true,
|
||||
force: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore errors during checkpoint removal', async () => {
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
vi.mocked(fs.rm).mockRejectedValue(new Error('Failed to remove'));
|
||||
await expect(cleanupModule.cleanupCheckpoints()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
108
packages/cli/src/utils/dialogScopeUtils.test.ts
Normal file
108
packages/cli/src/utils/dialogScopeUtils.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SettingScope } from '../config/settings.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import {
|
||||
getScopeItems,
|
||||
getScopeMessageForSetting,
|
||||
} from './dialogScopeUtils.js';
|
||||
import { settingExistsInScope } from './settingsUtils.js';
|
||||
|
||||
vi.mock('../config/settings', () => ({
|
||||
SettingScope: {
|
||||
User: 'user',
|
||||
Workspace: 'workspace',
|
||||
System: 'system',
|
||||
},
|
||||
isLoadableSettingScope: (scope: string) =>
|
||||
['user', 'workspace', 'system'].includes(scope),
|
||||
}));
|
||||
|
||||
vi.mock('./settingsUtils', () => ({
|
||||
settingExistsInScope: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('dialogScopeUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getScopeItems', () => {
|
||||
it('should return scope items with correct labels and values', () => {
|
||||
const items = getScopeItems();
|
||||
expect(items).toEqual([
|
||||
{ label: 'User Settings', value: SettingScope.User },
|
||||
{ label: 'Workspace Settings', value: SettingScope.Workspace },
|
||||
{ label: 'System Settings', value: SettingScope.System },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScopeMessageForSetting', () => {
|
||||
let mockSettings: { forScope: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockSettings = {
|
||||
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
||||
};
|
||||
});
|
||||
|
||||
it('should return empty string if not modified in other scopes', () => {
|
||||
vi.mocked(settingExistsInScope).mockReturnValue(false);
|
||||
const message = getScopeMessageForSetting(
|
||||
'key',
|
||||
SettingScope.User,
|
||||
mockSettings as unknown as LoadedSettings,
|
||||
);
|
||||
expect(message).toBe('');
|
||||
});
|
||||
|
||||
it('should return message indicating modification in other scopes', () => {
|
||||
vi.mocked(settingExistsInScope).mockReturnValue(true);
|
||||
|
||||
const message = getScopeMessageForSetting(
|
||||
'key',
|
||||
SettingScope.User,
|
||||
mockSettings as unknown as LoadedSettings,
|
||||
);
|
||||
expect(message).toMatch(/Also modified in/);
|
||||
expect(message).toMatch(/workspace/);
|
||||
expect(message).toMatch(/system/);
|
||||
});
|
||||
|
||||
it('should return message indicating modification in other scopes but not current', () => {
|
||||
const workspaceSettings = { scope: 'workspace' };
|
||||
const systemSettings = { scope: 'system' };
|
||||
const userSettings = { scope: 'user' };
|
||||
|
||||
mockSettings.forScope.mockImplementation((scope: string) => {
|
||||
if (scope === SettingScope.Workspace)
|
||||
return { settings: workspaceSettings };
|
||||
if (scope === SettingScope.System) return { settings: systemSettings };
|
||||
if (scope === SettingScope.User) return { settings: userSettings };
|
||||
return { settings: {} };
|
||||
});
|
||||
|
||||
vi.mocked(settingExistsInScope).mockImplementation(
|
||||
(_key, settings: unknown) => {
|
||||
if (settings === workspaceSettings) return true;
|
||||
if (settings === systemSettings) return false;
|
||||
if (settings === userSettings) return false;
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const message = getScopeMessageForSetting(
|
||||
'key',
|
||||
SettingScope.User,
|
||||
mockSettings as unknown as LoadedSettings,
|
||||
);
|
||||
expect(message).toBe('(Modified in workspace)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,16 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
),
|
||||
),
|
||||
})),
|
||||
StreamJsonFormatter: vi.fn().mockImplementation(() => ({
|
||||
emitEvent: vi.fn(),
|
||||
convertToStreamStats: vi.fn().mockReturnValue({}),
|
||||
})),
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn().mockReturnValue({}),
|
||||
},
|
||||
JsonStreamEventType: {
|
||||
RESULT: 'result',
|
||||
},
|
||||
FatalToolExecutionError: class extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -248,6 +258,30 @@ describe('errors', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in STREAM_JSON mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||
});
|
||||
|
||||
it('should emit result event and exit', () => {
|
||||
const testError = new Error('Test error');
|
||||
|
||||
expect(() => {
|
||||
handleError(testError, mockConfig);
|
||||
}).toThrow('process.exit called with code: 1');
|
||||
});
|
||||
|
||||
it('should extract exitCode from FatalError instances', () => {
|
||||
const fatalError = new FatalInputError('Fatal error');
|
||||
|
||||
expect(() => {
|
||||
handleError(fatalError, mockConfig);
|
||||
}).toThrow('process.exit called with code: 42');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleToolError', () => {
|
||||
@@ -377,6 +411,28 @@ describe('errors', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('in STREAM_JSON mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||
});
|
||||
|
||||
it('should emit result event and exit for fatal errors', () => {
|
||||
expect(() => {
|
||||
handleToolError(toolName, toolError, mockConfig, 'no_space_left');
|
||||
}).toThrow('process.exit called with code: 54');
|
||||
});
|
||||
|
||||
it('should log to stderr and not exit for non-fatal errors', () => {
|
||||
handleToolError(toolName, toolError, mockConfig, 'invalid_tool_params');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCancellationError', () => {
|
||||
@@ -423,6 +479,20 @@ describe('errors', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in STREAM_JSON mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||
});
|
||||
|
||||
it('should emit result event and exit with 130', () => {
|
||||
expect(() => {
|
||||
handleCancellationError(mockConfig);
|
||||
}).toThrow('process.exit called with code: 130');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMaxTurnsExceededError', () => {
|
||||
@@ -472,5 +542,19 @@ describe('errors', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in STREAM_JSON mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||
});
|
||||
|
||||
it('should emit result event and exit with 53', () => {
|
||||
expect(() => {
|
||||
handleMaxTurnsExceededError(mockConfig);
|
||||
}).toThrow('process.exit called with code: 53');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
32
packages/cli/src/utils/events.test.ts
Normal file
32
packages/cli/src/utils/events.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { appEvents, AppEvent } from './events.js';
|
||||
|
||||
describe('events', () => {
|
||||
it('should allow registering and emitting events', () => {
|
||||
const callback = vi.fn();
|
||||
appEvents.on(AppEvent.OauthDisplayMessage, callback);
|
||||
|
||||
appEvents.emit(AppEvent.OauthDisplayMessage, 'test message');
|
||||
|
||||
expect(callback).toHaveBeenCalledWith('test message');
|
||||
|
||||
appEvents.off(AppEvent.OauthDisplayMessage, callback);
|
||||
});
|
||||
|
||||
it('should work with events without data', () => {
|
||||
const callback = vi.fn();
|
||||
appEvents.on(AppEvent.Flicker, callback);
|
||||
|
||||
appEvents.emit(AppEvent.Flicker);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
|
||||
appEvents.off(AppEvent.Flicker, callback);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,8 @@ import { updateEventEmitter } from './updateEventEmitter.js';
|
||||
import type { UpdateObject } from '../ui/utils/updateCheck.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import EventEmitter from 'node:events';
|
||||
import { handleAutoUpdate } from './handleAutoUpdate.js';
|
||||
import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js';
|
||||
import { MessageType } from '../ui/types.js';
|
||||
|
||||
vi.mock('./installationInfo.js', async () => {
|
||||
const actual = await vi.importActual('./installationInfo.js');
|
||||
@@ -21,16 +22,11 @@ vi.mock('./installationInfo.js', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./updateEventEmitter.js', async () => {
|
||||
const actual = await vi.importActual('./updateEventEmitter.js');
|
||||
return {
|
||||
...actual,
|
||||
updateEventEmitter: {
|
||||
...(actual['updateEventEmitter'] as EventEmitter),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock(
|
||||
'./updateEventEmitter.js',
|
||||
async (importOriginal) =>
|
||||
await importOriginal<typeof import('./updateEventEmitter.js')>(),
|
||||
);
|
||||
|
||||
interface MockChildProcess extends EventEmitter {
|
||||
stdin: EventEmitter & {
|
||||
@@ -41,7 +37,7 @@ interface MockChildProcess extends EventEmitter {
|
||||
}
|
||||
|
||||
const mockGetInstallationInfo = vi.mocked(getInstallationInfo);
|
||||
const mockUpdateEventEmitter = vi.mocked(updateEventEmitter);
|
||||
// updateEventEmitter is now real, but we will spy on it
|
||||
|
||||
describe('handleAutoUpdate', () => {
|
||||
let mockSpawn: Mock;
|
||||
@@ -52,6 +48,7 @@ describe('handleAutoUpdate', () => {
|
||||
beforeEach(() => {
|
||||
mockSpawn = vi.fn();
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(updateEventEmitter, 'emit');
|
||||
mockUpdateInfo = {
|
||||
update: {
|
||||
latest: '2.0.0',
|
||||
@@ -90,7 +87,7 @@ describe('handleAutoUpdate', () => {
|
||||
it('should do nothing if update info is null', () => {
|
||||
handleAutoUpdate(null, mockSettings, '/root', mockSpawn);
|
||||
expect(mockGetInstallationInfo).not.toHaveBeenCalled();
|
||||
expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled();
|
||||
expect(updateEventEmitter.emit).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -98,7 +95,7 @@ describe('handleAutoUpdate', () => {
|
||||
mockSettings.merged.general!.disableUpdateNag = true;
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
expect(mockGetInstallationInfo).not.toHaveBeenCalled();
|
||||
expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled();
|
||||
expect(updateEventEmitter.emit).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -113,13 +110,10 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
|
||||
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith(
|
||||
'update-received',
|
||||
{
|
||||
message: 'An update is available!\nPlease update manually.',
|
||||
},
|
||||
);
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
|
||||
message: 'An update is available!\nPlease update manually.',
|
||||
});
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -135,7 +129,7 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
|
||||
expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled();
|
||||
expect(updateEventEmitter.emit).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
@@ -150,13 +144,10 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
|
||||
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith(
|
||||
'update-received',
|
||||
{
|
||||
message: 'An update is available!\nCannot determine update command.',
|
||||
},
|
||||
);
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
|
||||
message: 'An update is available!\nCannot determine update command.',
|
||||
});
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -170,13 +161,10 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
|
||||
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith(
|
||||
'update-received',
|
||||
{
|
||||
message: 'An update is available!\nThis is an additional message.',
|
||||
},
|
||||
);
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
|
||||
message: 'An update is available!\nThis is an additional message.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should attempt to perform an update when conditions are met', async () => {
|
||||
@@ -216,7 +204,7 @@ describe('handleAutoUpdate', () => {
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
});
|
||||
|
||||
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
|
||||
message:
|
||||
'Automatic update failed. Please try updating manually. (command: npm i -g @google/gemini-cli@2.0.0, stderr: An error occurred)',
|
||||
});
|
||||
@@ -240,7 +228,7 @@ describe('handleAutoUpdate', () => {
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
});
|
||||
|
||||
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
|
||||
message:
|
||||
'Automatic update failed. Please try updating manually. (error: Spawn error)',
|
||||
});
|
||||
@@ -290,9 +278,129 @@ describe('handleAutoUpdate', () => {
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
});
|
||||
|
||||
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith('update-success', {
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-success', {
|
||||
message:
|
||||
'Update successful! The new version will be used on your next run.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setUpdateHandler', () => {
|
||||
let addItem: ReturnType<typeof vi.fn>;
|
||||
let setUpdateInfo: ReturnType<typeof vi.fn>;
|
||||
let unregister: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
addItem = vi.fn();
|
||||
setUpdateInfo = vi.fn();
|
||||
vi.useFakeTimers();
|
||||
unregister = setUpdateHandler(addItem, setUpdateInfo);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregister();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should register event listeners', () => {
|
||||
// We can't easily check if listeners are registered on the real EventEmitter
|
||||
// without mocking it more deeply, but we can check if they respond to events.
|
||||
expect(unregister).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should handle update-received event', () => {
|
||||
const updateInfo: UpdateObject = {
|
||||
update: {
|
||||
latest: '2.0.0',
|
||||
current: '1.0.0',
|
||||
type: 'major',
|
||||
name: '@google/gemini-cli',
|
||||
},
|
||||
message: 'Update available',
|
||||
};
|
||||
|
||||
// Access the actual emitter to emit events
|
||||
updateEventEmitter.emit('update-received', updateInfo);
|
||||
|
||||
expect(setUpdateInfo).toHaveBeenCalledWith(updateInfo);
|
||||
|
||||
// Advance timers to trigger timeout
|
||||
vi.advanceTimersByTime(60000);
|
||||
|
||||
expect(addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Update available',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(setUpdateInfo).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should handle update-failed event', () => {
|
||||
updateEventEmitter.emit('update-failed', { message: 'Failed' });
|
||||
|
||||
expect(setUpdateInfo).toHaveBeenCalledWith(null);
|
||||
expect(addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Automatic update failed. Please try updating manually',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle update-success event', () => {
|
||||
updateEventEmitter.emit('update-success', { message: 'Success' });
|
||||
|
||||
expect(setUpdateInfo).toHaveBeenCalledWith(null);
|
||||
expect(addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Update successful! The new version will be used on your next run.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show update-received message if update-success was called', () => {
|
||||
const updateInfo: UpdateObject = {
|
||||
update: {
|
||||
latest: '2.0.0',
|
||||
current: '1.0.0',
|
||||
type: 'major',
|
||||
name: '@google/gemini-cli',
|
||||
},
|
||||
message: 'Update available',
|
||||
};
|
||||
|
||||
updateEventEmitter.emit('update-received', updateInfo);
|
||||
updateEventEmitter.emit('update-success', { message: 'Success' });
|
||||
|
||||
// Advance timers
|
||||
vi.advanceTimersByTime(60000);
|
||||
|
||||
// Should only have called addItem for success, not for received (after timeout)
|
||||
expect(addItem).toHaveBeenCalledTimes(1);
|
||||
expect(addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Update successful! The new version will be used on your next run.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle update-info event', () => {
|
||||
updateEventEmitter.emit('update-info', { message: 'Info message' });
|
||||
|
||||
expect(addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Info message',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
24
packages/cli/src/utils/math.test.ts
Normal file
24
packages/cli/src/utils/math.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { lerp } from './math.js';
|
||||
|
||||
describe('math', () => {
|
||||
describe('lerp', () => {
|
||||
it.each([
|
||||
[0, 10, 0, 0],
|
||||
[0, 10, 1, 10],
|
||||
[0, 10, 0.5, 5],
|
||||
[10, 20, 0.5, 15],
|
||||
[-10, 10, 0.5, 0],
|
||||
[0, 10, 2, 20],
|
||||
[0, 10, -1, -10],
|
||||
])('lerp(%d, %d, %d) should return %d', (start, end, t, expected) => {
|
||||
expect(lerp(start, end, t)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
packages/cli/src/utils/persistentState.test.ts
Normal file
83
packages/cli/src/utils/persistentState.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { Storage, debugLogger } from '@google/gemini-cli-core';
|
||||
import { PersistentState } from './persistentState.js';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
Storage: {
|
||||
getGlobalGeminiDir: vi.fn(),
|
||||
},
|
||||
debugLogger: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PersistentState', () => {
|
||||
let persistentState: PersistentState;
|
||||
const mockDir = '/mock/dir';
|
||||
const mockFilePath = path.join(mockDir, 'state.json');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue(mockDir);
|
||||
persistentState = new PersistentState();
|
||||
});
|
||||
|
||||
it('should load state from file if it exists', () => {
|
||||
const mockData = { defaultBannerShownCount: { banner1: 1 } };
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData));
|
||||
|
||||
const value = persistentState.get('defaultBannerShownCount');
|
||||
expect(value).toEqual(mockData.defaultBannerShownCount);
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(mockFilePath, 'utf-8');
|
||||
});
|
||||
|
||||
it('should return undefined if key does not exist', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
const value = persistentState.get('defaultBannerShownCount');
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should save state to file', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
persistentState.set('defaultBannerShownCount', { banner1: 1 });
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(path.normalize(mockDir), {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
mockFilePath,
|
||||
JSON.stringify({ defaultBannerShownCount: { banner1: 1 } }, null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle load errors and start fresh', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
||||
throw new Error('Read error');
|
||||
});
|
||||
|
||||
const value = persistentState.get('defaultBannerShownCount');
|
||||
expect(value).toBeUndefined();
|
||||
expect(debugLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle save errors', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {
|
||||
throw new Error('Write error');
|
||||
});
|
||||
|
||||
persistentState.set('defaultBannerShownCount', { banner1: 1 });
|
||||
expect(debugLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,13 @@
|
||||
|
||||
import { vi, describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { readStdin } from './readStdin.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock process.stdin
|
||||
const mockStdin = {
|
||||
@@ -20,6 +27,7 @@ describe('readStdin', () => {
|
||||
let originalStdin: typeof process.stdin;
|
||||
let onReadableHandler: () => void;
|
||||
let onEndHandler: () => void;
|
||||
let onErrorHandler: (err: Error) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -33,10 +41,13 @@ describe('readStdin', () => {
|
||||
});
|
||||
|
||||
// Capture event handlers
|
||||
mockStdin.on.mockImplementation((event: string, handler: () => void) => {
|
||||
if (event === 'readable') onReadableHandler = handler;
|
||||
if (event === 'end') onEndHandler = handler;
|
||||
});
|
||||
mockStdin.on.mockImplementation(
|
||||
(event: string, handler: (...args: unknown[]) => void) => {
|
||||
if (event === 'readable') onReadableHandler = handler as () => void;
|
||||
if (event === 'end') onEndHandler = handler as () => void;
|
||||
if (event === 'error') onErrorHandler = handler as (err: Error) => void;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -109,4 +120,26 @@ describe('readStdin', () => {
|
||||
|
||||
await expect(promise).resolves.toBe('chunk1chunk2');
|
||||
});
|
||||
|
||||
it('should truncate input if it exceeds MAX_STDIN_SIZE', async () => {
|
||||
const MAX_STDIN_SIZE = 8 * 1024 * 1024;
|
||||
const largeChunk = 'a'.repeat(MAX_STDIN_SIZE + 100);
|
||||
mockStdin.read.mockReturnValueOnce(largeChunk).mockReturnValueOnce(null);
|
||||
|
||||
const promise = readStdin();
|
||||
onReadableHandler();
|
||||
|
||||
await expect(promise).resolves.toBe('a'.repeat(MAX_STDIN_SIZE));
|
||||
expect(debugLogger.warn).toHaveBeenCalledWith(
|
||||
`Warning: stdin input truncated to ${MAX_STDIN_SIZE} bytes.`,
|
||||
);
|
||||
expect(mockStdin.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle stdin error', async () => {
|
||||
const promise = readStdin();
|
||||
const error = new Error('stdin error');
|
||||
onErrorHandler(error);
|
||||
await expect(promise).rejects.toThrow('stdin error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function readStdin(): Promise<string> {
|
||||
`Warning: stdin input truncated to ${MAX_STDIN_SIZE} bytes.`,
|
||||
);
|
||||
process.stdin.destroy(); // Stop reading further
|
||||
onEnd();
|
||||
break;
|
||||
}
|
||||
data += chunk;
|
||||
|
||||
35
packages/cli/src/utils/resolvePath.test.ts
Normal file
35
packages/cli/src/utils/resolvePath.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { resolvePath } from './resolvePath.js';
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('resolvePath', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', ''],
|
||||
['/foo/bar', path.normalize('/foo/bar')],
|
||||
['~/foo', path.join('/home/user', 'foo')],
|
||||
['~', path.normalize('/home/user')],
|
||||
['%userprofile%/foo', path.join('/home/user', 'foo')],
|
||||
['%USERPROFILE%/foo', path.join('/home/user', 'foo')],
|
||||
])('resolvePath(%s) should return %s', (input, expected) => {
|
||||
expect(resolvePath(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle path normalization', () => {
|
||||
expect(resolvePath('/foo//bar/../baz')).toBe(path.normalize('/foo/baz'));
|
||||
});
|
||||
});
|
||||
409
packages/cli/src/utils/sandbox.test.ts
Normal file
409
packages/cli/src/utils/sandbox.test.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { spawn, exec, execSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import { start_sandbox } from './sandbox.js';
|
||||
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
vi.mock('../config/settings.js', () => ({
|
||||
USER_SETTINGS_DIR: '/home/user/.gemini',
|
||||
}));
|
||||
vi.mock('node:child_process');
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:util')>();
|
||||
return {
|
||||
...actual,
|
||||
promisify: (fn: (...args: unknown[]) => unknown) => {
|
||||
if (fn === exec) {
|
||||
return async (cmd: string) => {
|
||||
if (cmd === 'id -u' || cmd === 'id -g') {
|
||||
return { stdout: '1000', stderr: '' };
|
||||
}
|
||||
if (cmd.includes('curl')) {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
if (cmd.includes('getconf DARWIN_USER_CACHE_DIR')) {
|
||||
return { stdout: '/tmp/cache', stderr: '' };
|
||||
}
|
||||
if (cmd.includes('ps -a --format')) {
|
||||
return { stdout: 'existing-container', stderr: '' };
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
};
|
||||
}
|
||||
return actual.promisify(fn);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
FatalSandboxError: class extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FatalSandboxError';
|
||||
}
|
||||
},
|
||||
GEMINI_DIR: '.gemini',
|
||||
USER_SETTINGS_DIR: '/home/user/.gemini',
|
||||
};
|
||||
});
|
||||
|
||||
describe('sandbox', () => {
|
||||
const originalEnv = process.env;
|
||||
const originalArgv = process.argv;
|
||||
let mockProcessIn: {
|
||||
pause: ReturnType<typeof vi.fn>;
|
||||
resume: ReturnType<typeof vi.fn>;
|
||||
isTTY: boolean;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.argv = [...originalArgv];
|
||||
mockProcessIn = {
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
isTTY: true,
|
||||
};
|
||||
Object.defineProperty(process, 'stdin', {
|
||||
value: mockProcessIn,
|
||||
writable: true,
|
||||
});
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
describe('start_sandbox', () => {
|
||||
it('should handle macOS seatbelt (sandbox-exec)', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
const config: SandboxConfig = {
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
};
|
||||
|
||||
interface MockProcess extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
||||
mockSpawnProcess.stdout = new EventEmitter();
|
||||
mockSpawnProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockReturnValue(
|
||||
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||
);
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
setTimeout(() => {
|
||||
mockSpawnProcess.emit('close', 0);
|
||||
}, 10);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'sandbox-exec',
|
||||
expect.arrayContaining([
|
||||
'-f',
|
||||
expect.stringContaining('sandbox-macos-permissive-open.sb'),
|
||||
]),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
const config: SandboxConfig = {
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
};
|
||||
|
||||
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
|
||||
});
|
||||
|
||||
it('should handle Docker execution', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
|
||||
// Mock image check to return true (image exists)
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce((_cmd, args) => {
|
||||
if (args && args[0] === 'images') {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
||||
}
|
||||
return new EventEmitter() as unknown as ReturnType<typeof spawn>; // fallback
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockImplementationOnce((cmd, args) => {
|
||||
if (cmd === 'docker' && args && args[0] === 'run') {
|
||||
return mockSpawnProcess;
|
||||
}
|
||||
return new EventEmitter() as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining(['run', '-i', '--rm', '--init']),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pull image if missing', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'missing-image',
|
||||
};
|
||||
|
||||
// 1. Image check fails
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess1 =
|
||||
new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess1.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess1.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
// 2. Pull image succeeds
|
||||
interface MockProcessWithStdoutStderr extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
|
||||
mockPullProcess.stdout = new EventEmitter();
|
||||
mockPullProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockPullProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockPullProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
// 3. Image check succeeds
|
||||
const mockImageCheckProcess2 =
|
||||
new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess2.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess2.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess2.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess2 as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
// 4. Docker run
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining(['pull', 'missing-image']),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if image pull fails', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'missing-image',
|
||||
};
|
||||
|
||||
// 1. Image check fails
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess1 =
|
||||
new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess1.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess1.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
// 2. Pull image fails
|
||||
interface MockProcessWithStdoutStderr extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
|
||||
mockPullProcess.stdout = new EventEmitter();
|
||||
mockPullProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockPullProcess.emit('close', 1);
|
||||
}, 1);
|
||||
return mockPullProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
|
||||
});
|
||||
|
||||
it('should mount volumes correctly', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check
|
||||
|
||||
// Mock image check to return true
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining([
|
||||
'--volume',
|
||||
'/host/path:/container/path:ro',
|
||||
'--volume',
|
||||
expect.stringContaining('/home/user/.gemini'),
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle user creation on Linux if needed', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
process.env['SANDBOX_SET_UID_GID'] = 'true';
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(execSync).mockImplementation((cmd) => {
|
||||
if (cmd === 'id -u') return Buffer.from('1000');
|
||||
if (cmd === 'id -g') return Buffer.from('1000');
|
||||
return Buffer.from('');
|
||||
});
|
||||
|
||||
// Mock image check to return true
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining(['--user', 'root', '--env', 'HOME=/home/user']),
|
||||
expect.any(Object),
|
||||
);
|
||||
// Check that the entrypoint command includes useradd/groupadd
|
||||
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
|
||||
const entrypointCmd = args[args.length - 1];
|
||||
expect(entrypointCmd).toContain('groupadd');
|
||||
expect(entrypointCmd).toContain('useradd');
|
||||
expect(entrypointCmd).toContain('su -p gemini');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { quote, parse } from 'shell-quote';
|
||||
import { USER_SETTINGS_DIR } from '../config/settings.js';
|
||||
@@ -22,166 +21,20 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
getContainerPath,
|
||||
shouldUseCurrentUserInSandbox,
|
||||
parseImageName,
|
||||
ports,
|
||||
entrypoint,
|
||||
LOCAL_DEV_SANDBOX_IMAGE_NAME,
|
||||
SANDBOX_NETWORK_NAME,
|
||||
SANDBOX_PROXY_NAME,
|
||||
BUILTIN_SEATBELT_PROFILES,
|
||||
} from './sandboxUtils.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function getContainerPath(hostPath: string): string {
|
||||
if (os.platform() !== 'win32') {
|
||||
return hostPath;
|
||||
}
|
||||
|
||||
const withForwardSlashes = hostPath.replace(/\\/g, '/');
|
||||
const match = withForwardSlashes.match(/^([A-Z]):\/(.*)/i);
|
||||
if (match) {
|
||||
return `/${match[1].toLowerCase()}/${match[2]}`;
|
||||
}
|
||||
return hostPath;
|
||||
}
|
||||
|
||||
const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox';
|
||||
const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
|
||||
const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
|
||||
const BUILTIN_SEATBELT_PROFILES = [
|
||||
'permissive-open',
|
||||
'permissive-closed',
|
||||
'permissive-proxied',
|
||||
'restrictive-open',
|
||||
'restrictive-closed',
|
||||
'restrictive-proxied',
|
||||
];
|
||||
|
||||
/**
|
||||
* Determines whether the sandbox container should be run with the current user's UID and GID.
|
||||
* This is often necessary on Linux systems (especially Debian/Ubuntu based) when using
|
||||
* rootful Docker without userns-remap configured, to avoid permission issues with
|
||||
* mounted volumes.
|
||||
*
|
||||
* The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable:
|
||||
* - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`.
|
||||
* - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`.
|
||||
* - If `SANDBOX_SET_UID_GID` is not set:
|
||||
* - On Debian/Ubuntu Linux, it defaults to `true`.
|
||||
* - On other OSes, or if OS detection fails, it defaults to `false`.
|
||||
*
|
||||
* For more context on running Docker containers as non-root, see:
|
||||
* https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15
|
||||
*
|
||||
* @returns {Promise<boolean>} A promise that resolves to true if the current user's UID/GID should be used, false otherwise.
|
||||
*/
|
||||
async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
|
||||
const envVar = process.env['SANDBOX_SET_UID_GID']?.toLowerCase().trim();
|
||||
|
||||
if (envVar === '1' || envVar === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (envVar === '0' || envVar === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
|
||||
if (os.platform() === 'linux') {
|
||||
try {
|
||||
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
|
||||
if (
|
||||
osReleaseContent.includes('ID=debian') ||
|
||||
osReleaseContent.includes('ID=ubuntu') ||
|
||||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
|
||||
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
|
||||
) {
|
||||
debugLogger.log(
|
||||
'Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silently ignore if /etc/os-release is not found or unreadable.
|
||||
// The default (false) will be applied in this case.
|
||||
debugLogger.warn(
|
||||
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return false; // Default to false if no other condition is met
|
||||
}
|
||||
|
||||
// docker does not allow container names to contain ':' or '/', so we
|
||||
// parse those out to shorten the name
|
||||
function parseImageName(image: string): string {
|
||||
const [fullName, tag] = image.split(':');
|
||||
const name = fullName.split('/').at(-1) ?? 'unknown-image';
|
||||
return tag ? `${name}-${tag}` : name;
|
||||
}
|
||||
|
||||
function ports(): string[] {
|
||||
return (process.env['SANDBOX_PORTS'] ?? '')
|
||||
.split(',')
|
||||
.filter((p) => p.trim())
|
||||
.map((p) => p.trim());
|
||||
}
|
||||
|
||||
function entrypoint(workdir: string, cliArgs: string[]): string[] {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const containerWorkdir = getContainerPath(workdir);
|
||||
const shellCmds = [];
|
||||
const pathSeparator = isWindows ? ';' : ':';
|
||||
|
||||
let pathSuffix = '';
|
||||
if (process.env['PATH']) {
|
||||
const paths = process.env['PATH'].split(pathSeparator);
|
||||
for (const p of paths) {
|
||||
const containerPath = getContainerPath(p);
|
||||
if (
|
||||
containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase())
|
||||
) {
|
||||
pathSuffix += `:${containerPath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pathSuffix) {
|
||||
shellCmds.push(`export PATH="$PATH${pathSuffix}";`);
|
||||
}
|
||||
|
||||
let pythonPathSuffix = '';
|
||||
if (process.env['PYTHONPATH']) {
|
||||
const paths = process.env['PYTHONPATH'].split(pathSeparator);
|
||||
for (const p of paths) {
|
||||
const containerPath = getContainerPath(p);
|
||||
if (
|
||||
containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase())
|
||||
) {
|
||||
pythonPathSuffix += `:${containerPath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pythonPathSuffix) {
|
||||
shellCmds.push(`export PYTHONPATH="$PYTHONPATH${pythonPathSuffix}";`);
|
||||
}
|
||||
|
||||
const projectSandboxBashrc = path.join(GEMINI_DIR, 'sandbox.bashrc');
|
||||
if (fs.existsSync(projectSandboxBashrc)) {
|
||||
shellCmds.push(`source ${getContainerPath(projectSandboxBashrc)};`);
|
||||
}
|
||||
|
||||
ports().forEach((p) =>
|
||||
shellCmds.push(
|
||||
`socat TCP4-LISTEN:${p},bind=$(hostname -i),fork,reuseaddr TCP4:127.0.0.1:${p} 2> /dev/null &`,
|
||||
),
|
||||
);
|
||||
|
||||
const quotedCliArgs = cliArgs.slice(2).map((arg) => quote([arg]));
|
||||
const cliCmd =
|
||||
process.env['NODE_ENV'] === 'development'
|
||||
? process.env['DEBUG']
|
||||
? 'npm run debug --'
|
||||
: 'npm rebuild && npm run start --'
|
||||
: process.env['DEBUG']
|
||||
? `node --inspect-brk=0.0.0.0:${process.env['DEBUG_PORT'] || '9229'} $(which gemini)`
|
||||
: 'gemini';
|
||||
|
||||
const args = [...shellCmds, cliCmd, ...quotedCliArgs];
|
||||
return ['bash', '-c', args.join(' ')];
|
||||
}
|
||||
|
||||
export async function start_sandbox(
|
||||
config: SandboxConfig,
|
||||
nodeArgs: string[] = [],
|
||||
@@ -231,7 +84,7 @@ export async function start_sandbox(
|
||||
'-D',
|
||||
`HOME_DIR=${fs.realpathSync(os.homedir())}`,
|
||||
'-D',
|
||||
`CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`,
|
||||
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
|
||||
];
|
||||
|
||||
// Add included directories from the workspace context
|
||||
@@ -350,7 +203,7 @@ export async function start_sandbox(
|
||||
debugLogger.log(`hopping into sandbox (command: ${config.command}) ...`);
|
||||
|
||||
// determine full path for gemini-cli to distinguish linked vs installed setting
|
||||
const gcPath = fs.realpathSync(process.argv[1]);
|
||||
const gcPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
|
||||
|
||||
const projectSandboxDockerfile = path.join(
|
||||
GEMINI_DIR,
|
||||
@@ -565,11 +418,9 @@ export async function start_sandbox(
|
||||
debugLogger.log(`ContainerName: ${containerName}`);
|
||||
} else {
|
||||
let index = 0;
|
||||
const containerNameCheck = execSync(
|
||||
`${config.command} ps -a --format "{{.Names}}"`,
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const containerNameCheck = (
|
||||
await execAsync(`${config.command} ps -a --format "{{.Names}}"`)
|
||||
).stdout.trim();
|
||||
while (containerNameCheck.includes(`${imageName}-${index}`)) {
|
||||
index++;
|
||||
}
|
||||
@@ -723,8 +574,8 @@ export async function start_sandbox(
|
||||
// The entrypoint script then handles dropping privileges to the correct user.
|
||||
args.push('--user', 'root');
|
||||
|
||||
const uid = execSync('id -u').toString().trim();
|
||||
const gid = execSync('id -g').toString().trim();
|
||||
const uid = (await execAsync('id -u')).stdout.trim();
|
||||
const gid = (await execAsync('id -g')).stdout.trim();
|
||||
|
||||
// Instead of passing --user to the main sandbox container, we let it
|
||||
// start as root, then create a user with the host's UID/GID, and
|
||||
|
||||
149
packages/cli/src/utils/sandboxUtils.test.ts
Normal file
149
packages/cli/src/utils/sandboxUtils.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import {
|
||||
getContainerPath,
|
||||
parseImageName,
|
||||
ports,
|
||||
entrypoint,
|
||||
shouldUseCurrentUserInSandbox,
|
||||
} from './sandboxUtils.js';
|
||||
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
GEMINI_DIR: '.gemini',
|
||||
}));
|
||||
|
||||
describe('sandboxUtils', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('getContainerPath', () => {
|
||||
it('should return same path on non-Windows', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
expect(getContainerPath('/home/user')).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should convert Windows path to container path', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
expect(getContainerPath('C:\\Users\\user')).toBe('/c/Users/user');
|
||||
});
|
||||
|
||||
it('should handle Windows path without drive letter', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
expect(getContainerPath('\\Users\\user')).toBe('/Users/user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseImageName', () => {
|
||||
it('should parse image name with tag', () => {
|
||||
expect(parseImageName('my-image:latest')).toBe('my-image-latest');
|
||||
});
|
||||
|
||||
it('should parse image name without tag', () => {
|
||||
expect(parseImageName('my-image')).toBe('my-image');
|
||||
});
|
||||
|
||||
it('should handle registry path', () => {
|
||||
expect(parseImageName('gcr.io/my-project/my-image:v1')).toBe(
|
||||
'my-image-v1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ports', () => {
|
||||
it('should return empty array if SANDBOX_PORTS is not set', () => {
|
||||
delete process.env['SANDBOX_PORTS'];
|
||||
expect(ports()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse comma-separated ports', () => {
|
||||
process.env['SANDBOX_PORTS'] = '8080, 3000 , 9000';
|
||||
expect(ports()).toEqual(['8080', '3000', '9000']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entrypoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should generate default entrypoint', () => {
|
||||
const args = entrypoint('/work', ['node', 'gemini', 'arg1']);
|
||||
expect(args).toEqual(['bash', '-c', 'gemini arg1']);
|
||||
});
|
||||
|
||||
it('should include PATH and PYTHONPATH if set', () => {
|
||||
process.env['PATH'] = '/work/bin:/usr/bin';
|
||||
process.env['PYTHONPATH'] = '/work/lib';
|
||||
const args = entrypoint('/work', ['node', 'gemini', 'arg1']);
|
||||
expect(args[2]).toContain('export PATH="$PATH:/work/bin"');
|
||||
expect(args[2]).toContain('export PYTHONPATH="$PYTHONPATH:/work/lib"');
|
||||
});
|
||||
|
||||
it('should source sandbox.bashrc if exists', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
const args = entrypoint('/work', ['node', 'gemini', 'arg1']);
|
||||
expect(args[2]).toContain('source .gemini/sandbox.bashrc');
|
||||
});
|
||||
|
||||
it('should include socat commands for ports', () => {
|
||||
process.env['SANDBOX_PORTS'] = '8080';
|
||||
const args = entrypoint('/work', ['node', 'gemini', 'arg1']);
|
||||
expect(args[2]).toContain('socat TCP4-LISTEN:8080');
|
||||
});
|
||||
|
||||
it('should use development command if NODE_ENV is development', () => {
|
||||
process.env['NODE_ENV'] = 'development';
|
||||
const args = entrypoint('/work', ['node', 'gemini', 'arg1']);
|
||||
expect(args[2]).toContain('npm rebuild && npm run start --');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldUseCurrentUserInSandbox', () => {
|
||||
it('should return true if SANDBOX_SET_UID_GID is 1', async () => {
|
||||
process.env['SANDBOX_SET_UID_GID'] = '1';
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if SANDBOX_SET_UID_GID is 0', async () => {
|
||||
process.env['SANDBOX_SET_UID_GID'] = '0';
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true on Debian Linux', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(readFile).mockResolvedValue('ID=debian\n');
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false on non-Linux', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
148
packages/cli/src/utils/sandboxUtils.ts
Normal file
148
packages/cli/src/utils/sandboxUtils.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { quote } from 'shell-quote';
|
||||
import { debugLogger, GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
|
||||
export const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox';
|
||||
export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
|
||||
export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
|
||||
export const BUILTIN_SEATBELT_PROFILES = [
|
||||
'permissive-open',
|
||||
'permissive-closed',
|
||||
'permissive-proxied',
|
||||
'restrictive-open',
|
||||
'restrictive-closed',
|
||||
'restrictive-proxied',
|
||||
];
|
||||
|
||||
export function getContainerPath(hostPath: string): string {
|
||||
if (os.platform() !== 'win32') {
|
||||
return hostPath;
|
||||
}
|
||||
|
||||
const withForwardSlashes = hostPath.replace(/\\/g, '/');
|
||||
const match = withForwardSlashes.match(/^([A-Z]):\/(.*)/i);
|
||||
if (match) {
|
||||
return `/${match[1].toLowerCase()}/${match[2]}`;
|
||||
}
|
||||
return withForwardSlashes;
|
||||
}
|
||||
|
||||
export async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
|
||||
const envVar = process.env['SANDBOX_SET_UID_GID']?.toLowerCase().trim();
|
||||
|
||||
if (envVar === '1' || envVar === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (envVar === '0' || envVar === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
|
||||
if (os.platform() === 'linux') {
|
||||
try {
|
||||
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
|
||||
if (
|
||||
osReleaseContent.includes('ID=debian') ||
|
||||
osReleaseContent.includes('ID=ubuntu') ||
|
||||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
|
||||
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
|
||||
) {
|
||||
debugLogger.log(
|
||||
'Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silently ignore if /etc/os-release is not found or unreadable.
|
||||
// The default (false) will be applied in this case.
|
||||
debugLogger.warn(
|
||||
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return false; // Default to false if no other condition is met
|
||||
}
|
||||
|
||||
export function parseImageName(image: string): string {
|
||||
const [fullName, tag] = image.split(':');
|
||||
const name = fullName.split('/').at(-1) ?? 'unknown-image';
|
||||
return tag ? `${name}-${tag}` : name;
|
||||
}
|
||||
|
||||
export function ports(): string[] {
|
||||
return (process.env['SANDBOX_PORTS'] ?? '')
|
||||
.split(',')
|
||||
.filter((p) => p.trim())
|
||||
.map((p) => p.trim());
|
||||
}
|
||||
|
||||
export function entrypoint(workdir: string, cliArgs: string[]): string[] {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const containerWorkdir = getContainerPath(workdir);
|
||||
const shellCmds = [];
|
||||
const pathSeparator = isWindows ? ';' : ':';
|
||||
|
||||
let pathSuffix = '';
|
||||
if (process.env['PATH']) {
|
||||
const paths = process.env['PATH'].split(pathSeparator);
|
||||
for (const p of paths) {
|
||||
const containerPath = getContainerPath(p);
|
||||
if (
|
||||
containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase())
|
||||
) {
|
||||
pathSuffix += `:${containerPath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pathSuffix) {
|
||||
shellCmds.push(`export PATH="$PATH${pathSuffix}";`);
|
||||
}
|
||||
|
||||
let pythonPathSuffix = '';
|
||||
if (process.env['PYTHONPATH']) {
|
||||
const paths = process.env['PYTHONPATH'].split(pathSeparator);
|
||||
for (const p of paths) {
|
||||
const containerPath = getContainerPath(p);
|
||||
if (
|
||||
containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase())
|
||||
) {
|
||||
pythonPathSuffix += `:${containerPath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pythonPathSuffix) {
|
||||
shellCmds.push(`export PYTHONPATH="$PYTHONPATH${pythonPathSuffix}";`);
|
||||
}
|
||||
|
||||
const projectSandboxBashrc = `${GEMINI_DIR}/sandbox.bashrc`;
|
||||
if (fs.existsSync(projectSandboxBashrc)) {
|
||||
shellCmds.push(`source ${getContainerPath(projectSandboxBashrc)};`);
|
||||
}
|
||||
|
||||
ports().forEach((p) =>
|
||||
shellCmds.push(
|
||||
`socat TCP4-LISTEN:${p},bind=$(hostname -i),fork,reuseaddr TCP4:127.0.0.1:${p} 2> /dev/null &`,
|
||||
),
|
||||
);
|
||||
|
||||
const quotedCliArgs = cliArgs.slice(2).map((arg) => quote([arg]));
|
||||
const cliCmd =
|
||||
process.env['NODE_ENV'] === 'development'
|
||||
? process.env['DEBUG']
|
||||
? 'npm run debug --'
|
||||
: 'npm rebuild && npm run start --'
|
||||
: process.env['DEBUG']
|
||||
? `node --inspect-brk=0.0.0.0:${process.env['DEBUG_PORT'] || '9229'} $(which gemini)`
|
||||
: 'gemini';
|
||||
|
||||
const args = [...shellCmds, cliCmd, ...quotedCliArgs];
|
||||
return ['bash', '-c', args.join(' ')];
|
||||
}
|
||||
22
packages/cli/src/utils/updateEventEmitter.test.ts
Normal file
22
packages/cli/src/utils/updateEventEmitter.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { updateEventEmitter } from './updateEventEmitter.js';
|
||||
|
||||
describe('updateEventEmitter', () => {
|
||||
it('should allow registering and emitting events', () => {
|
||||
const callback = vi.fn();
|
||||
const eventName = 'test-event';
|
||||
|
||||
updateEventEmitter.on(eventName, callback);
|
||||
updateEventEmitter.emit(eventName, 'test-data');
|
||||
|
||||
expect(callback).toHaveBeenCalledWith('test-data');
|
||||
|
||||
updateEventEmitter.off(eventName, callback);
|
||||
});
|
||||
});
|
||||
46
packages/cli/src/utils/version.test.ts
Normal file
46
packages/cli/src/utils/version.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { getCliVersion } from './version.js';
|
||||
import * as core from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
getPackageJson: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('version', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
vi.mocked(core.getPackageJson).mockResolvedValue({ version: '1.0.0' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should return CLI_VERSION from env if set', async () => {
|
||||
process.env['CLI_VERSION'] = '2.0.0';
|
||||
const version = await getCliVersion();
|
||||
expect(version).toBe('2.0.0');
|
||||
});
|
||||
|
||||
it('should return version from package.json if CLI_VERSION is not set', async () => {
|
||||
delete process.env['CLI_VERSION'];
|
||||
const version = await getCliVersion();
|
||||
expect(version).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('should return "unknown" if package.json is not found and CLI_VERSION is not set', async () => {
|
||||
delete process.env['CLI_VERSION'];
|
||||
vi.mocked(core.getPackageJson).mockResolvedValue(undefined);
|
||||
const version = await getCliVersion();
|
||||
expect(version).toBe('unknown');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user