mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
Improve code coverage for cli package (#13724)
This commit is contained in:
@@ -1,5 +1,32 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
|
||||
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
|
||||
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Stream test"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Thinking...","delta":true}
|
||||
{"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
|
||||
{"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
|
||||
"Use mock tool
|
||||
Use mock tool again
|
||||
|
||||
74
packages/cli/src/commands/extensions.test.tsx
Normal file
74
packages/cli/src/commands/extensions.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { extensionsCommand } from './extensions.js';
|
||||
|
||||
// Mock subcommands
|
||||
vi.mock('./extensions/install.js', () => ({
|
||||
installCommand: { command: 'install' },
|
||||
}));
|
||||
vi.mock('./extensions/uninstall.js', () => ({
|
||||
uninstallCommand: { command: 'uninstall' },
|
||||
}));
|
||||
vi.mock('./extensions/list.js', () => ({ listCommand: { command: 'list' } }));
|
||||
vi.mock('./extensions/update.js', () => ({
|
||||
updateCommand: { command: 'update' },
|
||||
}));
|
||||
vi.mock('./extensions/disable.js', () => ({
|
||||
disableCommand: { command: 'disable' },
|
||||
}));
|
||||
vi.mock('./extensions/enable.js', () => ({
|
||||
enableCommand: { command: 'enable' },
|
||||
}));
|
||||
vi.mock('./extensions/link.js', () => ({ linkCommand: { command: 'link' } }));
|
||||
vi.mock('./extensions/new.js', () => ({ newCommand: { command: 'new' } }));
|
||||
vi.mock('./extensions/validate.js', () => ({
|
||||
validateCommand: { command: 'validate' },
|
||||
}));
|
||||
|
||||
// Mock gemini.js
|
||||
vi.mock('../gemini.js', () => ({
|
||||
initializeOutputListenersAndFlush: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('extensionsCommand', () => {
|
||||
it('should have correct command and aliases', () => {
|
||||
expect(extensionsCommand.command).toBe('extensions <command>');
|
||||
expect(extensionsCommand.aliases).toEqual(['extension']);
|
||||
expect(extensionsCommand.describe).toBe('Manage Gemini CLI extensions.');
|
||||
});
|
||||
|
||||
it('should register all subcommands in builder', () => {
|
||||
const mockYargs = {
|
||||
middleware: vi.fn().mockReturnThis(),
|
||||
command: vi.fn().mockReturnThis(),
|
||||
demandCommand: vi.fn().mockReturnThis(),
|
||||
version: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Mocking yargs
|
||||
extensionsCommand.builder(mockYargs);
|
||||
|
||||
expect(mockYargs.middleware).toHaveBeenCalled();
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'install' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'uninstall' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'update' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'disable' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'enable' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'link' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'new' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'validate' });
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
|
||||
expect(mockYargs.version).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should have a handler that does nothing', () => {
|
||||
// @ts-expect-error - Handler doesn't take arguments in this case
|
||||
expect(extensionsCommand.handler()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
41
packages/cli/src/commands/utils.test.ts
Normal file
41
packages/cli/src/commands/utils.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { exitCli } from './utils.js';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
|
||||
vi.mock('../utils/cleanup.js', () => ({
|
||||
runExitCleanup: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('utils', () => {
|
||||
const originalProcessExit = process.exit;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - Mocking process.exit
|
||||
process.exit = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = originalProcessExit;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('exitCli', () => {
|
||||
it('should call runExitCleanup and process.exit with default exit code 0', async () => {
|
||||
await exitCli();
|
||||
expect(runExitCleanup).toHaveBeenCalled();
|
||||
expect(process.exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('should call runExitCleanup and process.exit with specified exit code', async () => {
|
||||
await exitCli(1);
|
||||
expect(runExitCleanup).toHaveBeenCalled();
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
packages/cli/src/core/auth.test.ts
Normal file
56
packages/cli/src/core/auth.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
AuthType: {
|
||||
OAUTH: 'oauth',
|
||||
},
|
||||
getErrorMessage: (e: unknown) => (e as Error).message,
|
||||
}));
|
||||
|
||||
const AuthType = {
|
||||
OAUTH: 'oauth',
|
||||
} as const;
|
||||
|
||||
describe('auth', () => {
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
it('should return null if authType is undefined', async () => {
|
||||
const result = await performInitialAuth(mockConfig, undefined);
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null on successful auth', async () => {
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.OAUTH as unknown as Parameters<typeof performInitialAuth>[1],
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH);
|
||||
});
|
||||
|
||||
it('should return error message on failed auth', async () => {
|
||||
const error = new Error('Auth failed');
|
||||
vi.mocked(mockConfig.refreshAuth).mockRejectedValue(error);
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.OAUTH as unknown as Parameters<typeof performInitialAuth>[1],
|
||||
);
|
||||
expect(result).toBe('Failed to login. Message: Auth failed');
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH);
|
||||
});
|
||||
});
|
||||
148
packages/cli/src/core/initializer.test.ts
Normal file
148
packages/cli/src/core/initializer.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { initializeApp } from './initializer.js';
|
||||
import {
|
||||
IdeClient,
|
||||
logIdeConnection,
|
||||
logCliConfiguration,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
logIdeConnection: vi.fn(),
|
||||
logCliConfiguration: vi.fn(),
|
||||
StartSessionEvent: vi.fn(),
|
||||
IdeConnectionEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./auth.js', () => ({
|
||||
performInitialAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./theme.js', () => ({
|
||||
validateTheme: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('initializer', () => {
|
||||
let mockConfig: {
|
||||
getToolRegistry: ReturnType<typeof vi.fn>;
|
||||
getIdeMode: ReturnType<typeof vi.fn>;
|
||||
getGeminiMdFileCount: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockSettings: LoadedSettings;
|
||||
let mockIdeClient: {
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = {
|
||||
getToolRegistry: vi.fn(),
|
||||
getIdeMode: vi.fn().mockReturnValue(false),
|
||||
getGeminiMdFileCount: vi.fn().mockReturnValue(5),
|
||||
};
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: 'oauth',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
mockIdeClient = {
|
||||
connect: vi.fn(),
|
||||
};
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue(
|
||||
mockIdeClient as unknown as IdeClient,
|
||||
);
|
||||
vi.mocked(performInitialAuth).mockResolvedValue(null);
|
||||
vi.mocked(validateTheme).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('should initialize correctly in non-IDE mode', async () => {
|
||||
const result = await initializeApp(
|
||||
mockConfig as unknown as Config,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 5,
|
||||
});
|
||||
expect(performInitialAuth).toHaveBeenCalledWith(mockConfig, 'oauth');
|
||||
expect(validateTheme).toHaveBeenCalledWith(mockSettings);
|
||||
expect(logCliConfiguration).toHaveBeenCalled();
|
||||
expect(IdeClient.getInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initialize correctly in IDE mode', async () => {
|
||||
mockConfig.getIdeMode.mockReturnValue(true);
|
||||
const result = await initializeApp(
|
||||
mockConfig as unknown as Config,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 5,
|
||||
});
|
||||
expect(IdeClient.getInstance).toHaveBeenCalled();
|
||||
expect(mockIdeClient.connect).toHaveBeenCalled();
|
||||
expect(logIdeConnection).toHaveBeenCalledWith(
|
||||
mockConfig as unknown as Config,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth error', async () => {
|
||||
vi.mocked(performInitialAuth).mockResolvedValue('Auth failed');
|
||||
const result = await initializeApp(
|
||||
mockConfig as unknown as Config,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.authError).toBe('Auth failed');
|
||||
expect(result.shouldOpenAuthDialog).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle undefined auth type', async () => {
|
||||
mockSettings.merged.security!.auth!.selectedType = undefined;
|
||||
const result = await initializeApp(
|
||||
mockConfig as unknown as Config,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.shouldOpenAuthDialog).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle theme error', async () => {
|
||||
vi.mocked(validateTheme).mockReturnValue('Theme not found');
|
||||
const result = await initializeApp(
|
||||
mockConfig as unknown as Config,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.themeError).toBe('Theme not found');
|
||||
});
|
||||
});
|
||||
54
packages/cli/src/core/theme.test.ts
Normal file
54
packages/cli/src/core/theme.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { validateTheme } from './theme.js';
|
||||
import { themeManager } from '../ui/themes/theme-manager.js';
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
|
||||
vi.mock('../ui/themes/theme-manager.js', () => ({
|
||||
themeManager: {
|
||||
findThemeByName: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('theme', () => {
|
||||
let mockSettings: LoadedSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSettings = {
|
||||
merged: {
|
||||
ui: {
|
||||
theme: 'test-theme',
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
});
|
||||
|
||||
it('should return null if theme is found', () => {
|
||||
vi.mocked(themeManager.findThemeByName).mockReturnValue(
|
||||
{} as unknown as ReturnType<typeof themeManager.findThemeByName>,
|
||||
);
|
||||
const result = validateTheme(mockSettings);
|
||||
expect(result).toBeNull();
|
||||
expect(themeManager.findThemeByName).toHaveBeenCalledWith('test-theme');
|
||||
});
|
||||
|
||||
it('should return error message if theme is not found', () => {
|
||||
vi.mocked(themeManager.findThemeByName).mockReturnValue(undefined);
|
||||
const result = validateTheme(mockSettings);
|
||||
expect(result).toBe('Theme "test-theme" not found.');
|
||||
expect(themeManager.findThemeByName).toHaveBeenCalledWith('test-theme');
|
||||
});
|
||||
|
||||
it('should return null if theme is undefined', () => {
|
||||
mockSettings.merged.ui!.theme = undefined;
|
||||
const result = validateTheme(mockSettings);
|
||||
expect(result).toBeNull();
|
||||
expect(themeManager.findThemeByName).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
setupUnhandledRejectionHandler,
|
||||
validateDnsResolutionOrder,
|
||||
startInteractiveUI,
|
||||
getNodeMemoryArgs,
|
||||
} from './gemini.js';
|
||||
import os from 'node:os';
|
||||
import v8 from 'node:v8';
|
||||
import { type LoadedSettings } from './config/settings.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import {
|
||||
@@ -162,6 +165,7 @@ vi.mock('./utils/sandbox.js', () => ({
|
||||
|
||||
vi.mock('./utils/relaunch.js', () => ({
|
||||
relaunchAppInChildProcess: vi.fn(),
|
||||
relaunchOnExitCode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./config/sandboxConfig.js', () => ({
|
||||
@@ -339,6 +343,88 @@ describe('gemini.tsx main function', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setWindowTitle', () => {
|
||||
it('should set window title when hideWindowTitle is false', async () => {
|
||||
// setWindowTitle is not exported, but we can test its effect if we had a way to call it.
|
||||
// Since we can't easily call it directly without exporting it, we skip direct testing
|
||||
// and rely on startInteractiveUI tests which call it.
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeOutputListenersAndFlush', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should flush backlogs and setup listeners if no listeners exist', async () => {
|
||||
const { coreEvents } = await import('@google/gemini-cli-core');
|
||||
const { initializeOutputListenersAndFlush } = await import('./gemini.js');
|
||||
|
||||
// Mock listenerCount to return 0
|
||||
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0);
|
||||
const drainSpy = vi.spyOn(coreEvents, 'drainBacklogs');
|
||||
|
||||
initializeOutputListenersAndFlush();
|
||||
|
||||
expect(drainSpy).toHaveBeenCalled();
|
||||
// We can't easily check if listeners were added without access to the internal state of coreEvents,
|
||||
// but we can verify that drainBacklogs was called.
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeMemoryArgs', () => {
|
||||
let osTotalMemSpy: MockInstance;
|
||||
let v8GetHeapStatisticsSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
osTotalMemSpy = vi.spyOn(os, 'totalmem');
|
||||
v8GetHeapStatisticsSpy = vi.spyOn(v8, 'getHeapStatistics');
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty array if GEMINI_CLI_NO_RELAUNCH is set', () => {
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
expect(getNodeMemoryArgs(false)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array if current heap limit is sufficient', () => {
|
||||
osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); // 16GB
|
||||
v8GetHeapStatisticsSpy.mockReturnValue({
|
||||
heap_size_limit: 8 * 1024 * 1024 * 1024, // 8GB
|
||||
});
|
||||
// Target is 50% of 16GB = 8GB. Current is 8GB. No relaunch needed.
|
||||
expect(getNodeMemoryArgs(false)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return memory args if current heap limit is insufficient', () => {
|
||||
osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); // 16GB
|
||||
v8GetHeapStatisticsSpy.mockReturnValue({
|
||||
heap_size_limit: 4 * 1024 * 1024 * 1024, // 4GB
|
||||
});
|
||||
// Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed.
|
||||
expect(getNodeMemoryArgs(false)).toEqual(['--max-old-space-size=8192']);
|
||||
});
|
||||
|
||||
it('should log debug info when isDebugMode is true', () => {
|
||||
const debugSpy = vi.spyOn(debugLogger, 'debug');
|
||||
osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024);
|
||||
v8GetHeapStatisticsSpy.mockReturnValue({
|
||||
heap_size_limit: 4 * 1024 * 1024 * 1024,
|
||||
});
|
||||
getNodeMemoryArgs(true);
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Current heap size'),
|
||||
);
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Need to relaunch with more memory'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini.tsx main function kitty protocol', () => {
|
||||
let originalEnvNoRelaunch: string | undefined;
|
||||
let setRawModeSpy: MockInstance<
|
||||
@@ -460,6 +546,618 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
expect(setRawModeSpy).toHaveBeenCalledWith(true);
|
||||
expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ flag: 'listExtensions' },
|
||||
{ flag: 'listSessions' },
|
||||
{ flag: 'deleteSession', value: 'session-id' },
|
||||
])('should handle --$flag flag', async ({ flag, value }) => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const { listSessions, deleteSession } = await import('./utils/sessions.js');
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const mockConfig = {
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getListExtensions: () => flag === 'listExtensions',
|
||||
getListSessions: () => flag === 'listSessions',
|
||||
getDeleteSession: () => (flag === 'deleteSession' ? value : undefined),
|
||||
getExtensions: () => [{ name: 'ext1' }],
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
initialize: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
||||
vi.mock('./utils/sessions.js', () => ({
|
||||
listSessions: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const debugLoggerLogSpy = vi
|
||||
.spyOn(debugLogger, 'log')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
if (flag === 'listExtensions') {
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ext1'),
|
||||
);
|
||||
} else if (flag === 'listSessions') {
|
||||
expect(listSessions).toHaveBeenCalledWith(mockConfig);
|
||||
} else if (flag === 'deleteSession') {
|
||||
expect(deleteSession).toHaveBeenCalledWith(mockConfig, value);
|
||||
}
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0);
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle sandbox activation', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
|
||||
const { start_sandbox } = await import('./utils/sandbox.js');
|
||||
const { relaunchOnExitCode } = await import('./utils/relaunch.js');
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const mockConfig = {
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => true,
|
||||
getDebugMode: () => false,
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getExtensions: () => [],
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
initialize: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
refreshAuth: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(relaunchOnExitCode).mockImplementation(async (fn) => {
|
||||
await fn();
|
||||
});
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(start_sandbox).toHaveBeenCalled();
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0);
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should exit with error when --prompt-interactive is used with piped input', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const core = await import('@google/gemini-cli-core');
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
const writeToStderrSpy = vi
|
||||
.spyOn(core, 'writeToStderr')
|
||||
.mockImplementation(() => true);
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: true,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
// Mock stdin to be non-TTY
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(writeToStderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Error: The --prompt-interactive flag cannot be used',
|
||||
),
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
processExitSpy.mockRestore();
|
||||
writeToStderrSpy.mockRestore();
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
}); // Restore TTY
|
||||
});
|
||||
|
||||
it('should log warning when theme is not found', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const { themeManager } = await import('./ui/themes/theme-manager.js');
|
||||
const debugLoggerWarnSpy = vi
|
||||
.spyOn(debugLogger, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: { theme: 'non-existent-theme' },
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => 'test',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
initialize: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: () => [],
|
||||
getModel: () => 'gemini-pro',
|
||||
getEmbeddingModel: () => 'embedding-001',
|
||||
getApprovalMode: () => 'default',
|
||||
getCoreTools: () => [],
|
||||
getTelemetryEnabled: () => false,
|
||||
getTelemetryLogPromptsEnabled: () => false,
|
||||
getFileFilteringRespectGitIgnore: () => true,
|
||||
getOutputFormat: () => 'text',
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false);
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Warning: Theme "non-existent-theme" not found.'),
|
||||
);
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle session selector error', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
vi.mock('./utils/sessionUtils.js', () => ({
|
||||
SessionSelector: class {
|
||||
resolveSession = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Session not found'));
|
||||
},
|
||||
}));
|
||||
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
resume: 'session-id',
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: () => true,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
initialize: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: () => [],
|
||||
getModel: () => 'gemini-pro',
|
||||
getEmbeddingModel: () => 'embedding-001',
|
||||
getApprovalMode: () => 'default',
|
||||
getCoreTools: () => [],
|
||||
getTelemetryEnabled: () => false,
|
||||
getTelemetryLogPromptsEnabled: () => false,
|
||||
getFileFilteringRespectGitIgnore: () => true,
|
||||
getOutputFormat: () => 'text',
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error resuming session: Session not found'),
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
processExitSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.skip('should log error when cleanupExpiredSessions fails', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const { cleanupExpiredSessions } = await import(
|
||||
'./utils/sessionCleanup.js'
|
||||
);
|
||||
vi.mocked(cleanupExpiredSessions).mockRejectedValue(
|
||||
new Error('Cleanup failed'),
|
||||
);
|
||||
const debugLoggerErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => 'test',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
initialize: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: () => [],
|
||||
getModel: () => 'gemini-pro',
|
||||
getEmbeddingModel: () => 'embedding-001',
|
||||
getApprovalMode: () => 'default',
|
||||
getCoreTools: () => [],
|
||||
getTelemetryEnabled: () => false,
|
||||
getTelemetryLogPromptsEnabled: () => false,
|
||||
getFileFilteringRespectGitIgnore: () => true,
|
||||
getOutputFormat: () => 'text',
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
// The mock is already set up at the top of the test
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Failed to cleanup expired sessions: Cleanup failed',
|
||||
),
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0); // Should not exit on cleanup failure
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle refreshAuth failure', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
const debugLoggerErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: { selectedType: 'google' } },
|
||||
ui: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: () => true,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
initialize: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: () => [],
|
||||
getModel: () => 'gemini-pro',
|
||||
getEmbeddingModel: () => 'embedding-001',
|
||||
getApprovalMode: () => 'default',
|
||||
getCoreTools: () => [],
|
||||
getTelemetryEnabled: () => false,
|
||||
getTelemetryLogPromptsEnabled: () => false,
|
||||
getFileFilteringRespectGitIgnore: () => true,
|
||||
getOutputFormat: () => 'text',
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth refresh failed')),
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Error authenticating:',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should read from stdin in non-interactive mode', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const { readStdin } = await import('./utils/readStdin.js');
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => 'test-question',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
initialize: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: () => [],
|
||||
getModel: () => 'gemini-pro',
|
||||
getEmbeddingModel: () => 'embedding-001',
|
||||
getApprovalMode: () => 'default',
|
||||
getCoreTools: () => [],
|
||||
getTelemetryEnabled: () => false,
|
||||
getTelemetryLogPromptsEnabled: () => false,
|
||||
getFileFilteringRespectGitIgnore: () => true,
|
||||
getOutputFormat: () => 'text',
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mock('./utils/readStdin.js', () => ({
|
||||
readStdin: vi.fn().mockResolvedValue('stdin-data'),
|
||||
}));
|
||||
const runNonInteractiveSpy = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./nonInteractiveCli.js', () => ({
|
||||
runNonInteractive: runNonInteractiveSpy,
|
||||
}));
|
||||
runNonInteractiveSpy.mockClear();
|
||||
vi.mock('./validateNonInterActiveAuth.js', () => ({
|
||||
validateNonInteractiveAuth: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
// Mock stdin to be non-TTY
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(readStdin).toHaveBeenCalled();
|
||||
// In this test setup, runNonInteractive might be called on the mocked module,
|
||||
// but we need to ensure we are checking the correct spy instance.
|
||||
// Since vi.mock is hoisted, runNonInteractiveSpy is defined early.
|
||||
expect(runNonInteractiveSpy).toHaveBeenCalled();
|
||||
const callArgs = runNonInteractiveSpy.mock.calls[0][0];
|
||||
expect(callArgs.input).toBe('test-question');
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0);
|
||||
processExitSpy.mockRestore();
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDnsResolutionOrder', () => {
|
||||
@@ -599,6 +1297,33 @@ describe('startInteractiveUI', () => {
|
||||
expect(reactElement).toBeDefined();
|
||||
});
|
||||
|
||||
it('should enable mouse events when alternate buffer is enabled', async () => {
|
||||
const { enableMouseEvents } = await import('@google/gemini-cli-core');
|
||||
await startTestInteractiveUI(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
expect(enableMouseEvents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should patch console', async () => {
|
||||
const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
|
||||
const patchSpy = vi.spyOn(ConsolePatcher.prototype, 'patch');
|
||||
await startTestInteractiveUI(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
expect(patchSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should perform all startup tasks in correct order', async () => {
|
||||
const { getCliVersion } = await import('./utils/version.js');
|
||||
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
|
||||
|
||||
@@ -112,7 +112,7 @@ export function validateDnsResolutionOrder(
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
const totalMemoryMB = os.totalmem() / (1024 * 1024);
|
||||
const heapStats = v8.getHeapStatistics();
|
||||
const currentMaxOldSpaceSizeMb = Math.floor(
|
||||
@@ -452,7 +452,11 @@ export async function main() {
|
||||
createPolicyUpdater(policyEngine, messageBus);
|
||||
|
||||
// Cleanup sessions after config initialization
|
||||
await cleanupExpiredSessions(config, settings.merged);
|
||||
try {
|
||||
await cleanupExpiredSessions(config, settings.merged);
|
||||
} catch (e) {
|
||||
debugLogger.error('Failed to cleanup expired sessions:', e);
|
||||
}
|
||||
|
||||
if (config.getListExtensions()) {
|
||||
debugLogger.log('Installed extensions:');
|
||||
|
||||
228
packages/cli/src/gemini_cleanup.test.tsx
Normal file
228
packages/cli/src/gemini_cleanup.test.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { main } from './gemini.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
|
||||
// Custom error to identify mock process.exit calls
|
||||
class MockProcessExitError extends Error {
|
||||
constructor(readonly code?: string | number | null | undefined) {
|
||||
super('PROCESS_EXIT_MOCKED');
|
||||
this.name = 'MockProcessExitError';
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
writeToStdout: vi.fn(),
|
||||
patchStdio: vi.fn(() => () => {}),
|
||||
createInkStdio: vi.fn(() => ({
|
||||
stdout: {
|
||||
write: vi.fn(),
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
stderr: { write: vi.fn() },
|
||||
})),
|
||||
enableMouseEvents: vi.fn(),
|
||||
disableMouseEvents: vi.fn(),
|
||||
enterAlternateScreen: vi.fn(),
|
||||
disableLineWrapping: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
render: vi.fn(() => ({
|
||||
unmount: vi.fn(),
|
||||
rerender: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
waitUntilExit: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./config/settings.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn().mockReturnValue({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./config/config.js', () => ({
|
||||
loadCliConfig: vi.fn().mockResolvedValue({
|
||||
getSandbox: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
isInteractive: () => false,
|
||||
} as unknown as Config),
|
||||
parseArguments: vi.fn().mockResolvedValue({}),
|
||||
isDebugMode: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('read-package-up', () => ({
|
||||
readPackageUp: vi.fn().mockResolvedValue({
|
||||
packageJson: { name: 'test-pkg', version: 'test-version' },
|
||||
path: '/fake/path/package.json',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('update-notifier', () => ({
|
||||
default: vi.fn(() => ({ notify: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock('./utils/events.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./utils/events.js')>();
|
||||
return { ...actual, appEvents: { emit: vi.fn() } };
|
||||
});
|
||||
|
||||
vi.mock('./utils/sandbox.js', () => ({
|
||||
sandbox_command: vi.fn(() => ''),
|
||||
start_sandbox: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock('./utils/relaunch.js', () => ({
|
||||
relaunchAppInChildProcess: vi.fn(),
|
||||
relaunchOnExitCode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./config/sandboxConfig.js', () => ({
|
||||
loadSandboxConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./ui/utils/mouse.js', () => ({
|
||||
enableMouseEvents: vi.fn(),
|
||||
disableMouseEvents: vi.fn(),
|
||||
parseMouseEvent: vi.fn(),
|
||||
isIncompleteMouseSequence: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./validateNonInterActiveAuth.js', () => ({
|
||||
validateNonInteractiveAuth: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock('./nonInteractiveCli.js', () => ({
|
||||
runNonInteractive: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { cleanupMockState } = vi.hoisted(() => ({
|
||||
cleanupMockState: { shouldThrow: false, called: false },
|
||||
}));
|
||||
|
||||
// Mock sessionCleanup.js at the top level
|
||||
vi.mock('./utils/sessionCleanup.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./utils/sessionCleanup.js')>();
|
||||
return {
|
||||
...actual,
|
||||
cleanupExpiredSessions: async () => {
|
||||
cleanupMockState.called = true;
|
||||
if (cleanupMockState.shouldThrow) {
|
||||
throw new Error('Cleanup failed');
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('gemini.tsx main function cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should log error when cleanupExpiredSessions fails', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
cleanupMockState.shouldThrow = true;
|
||||
cleanupMockState.called = false;
|
||||
|
||||
const debugLoggerErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => 'test'),
|
||||
getSandbox: vi.fn(() => false),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
initialize: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getExperimentalZedIntegration: vi.fn(() => false),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
getGeminiMdFileCount: vi.fn(() => 0),
|
||||
getProjectRoot: vi.fn(() => '/'),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getModel: vi.fn(() => 'gemini-pro'),
|
||||
getEmbeddingModel: vi.fn(() => 'embedding-001'),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getTelemetryEnabled: vi.fn(() => false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn(() => false),
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getOutputFormat: vi.fn(() => 'text'),
|
||||
getUsageStatisticsEnabled: vi.fn(() => false),
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(cleanupMockState.called).toBe(true);
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to cleanup expired sessions:',
|
||||
expect.objectContaining({ message: 'Cleanup failed' }),
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0); // Should not exit on cleanup failure
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,7 @@ describe('runNonInteractive', () => {
|
||||
processStdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
vi.spyOn(process.stdout, 'on').mockImplementation(() => process.stdout);
|
||||
processStderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
@@ -167,6 +168,7 @@ describe('runNonInteractive', () => {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getOutputFormat: vi.fn().mockReturnValue('text'),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
@@ -885,6 +887,168 @@ describe('runNonInteractive', () => {
|
||||
expect(getWrittenOutput()).toBe('Response from command\n');
|
||||
});
|
||||
|
||||
it('should handle slash commands', async () => {
|
||||
const nonInteractiveCliCommands = await import(
|
||||
'./nonInteractiveCliCommands.js'
|
||||
);
|
||||
const handleSlashCommandSpy = vi.spyOn(
|
||||
nonInteractiveCliCommands,
|
||||
'handleSlashCommand',
|
||||
);
|
||||
handleSlashCommandSpy.mockResolvedValue([{ text: 'Slash command output' }]);
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Response to slash command' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: '/help',
|
||||
prompt_id: 'prompt-id-slash',
|
||||
});
|
||||
|
||||
expect(handleSlashCommandSpy).toHaveBeenCalledWith(
|
||||
'/help',
|
||||
expect.any(AbortController),
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
[{ text: 'Slash command output' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||
handleSlashCommandSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle cancellation (Ctrl+C)', async () => {
|
||||
// Mock isTTY and setRawMode safely
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const originalSetRawMode = (process.stdin as any).setRawMode;
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
if (!originalSetRawMode) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).setRawMode = vi.fn();
|
||||
}
|
||||
|
||||
const stdinOnSpy = vi
|
||||
.spyOn(process.stdin, 'on')
|
||||
.mockImplementation(() => process.stdin);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true);
|
||||
vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin);
|
||||
vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin);
|
||||
vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation(
|
||||
() => process.stdin,
|
||||
);
|
||||
|
||||
// Spy on handleCancellationError to verify it's called
|
||||
const errors = await import('./utils/errors.js');
|
||||
const handleCancellationErrorSpy = vi
|
||||
.spyOn(errors, 'handleCancellationError')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Cancelled');
|
||||
});
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Thinking...' },
|
||||
];
|
||||
// Create a stream that responds to abortion
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(
|
||||
(_messages, signal: AbortSignal) =>
|
||||
(async function* () {
|
||||
yield events[0];
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(resolve, 1000);
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timeout);
|
||||
setTimeout(() => {
|
||||
reject(new Error('Aborted')); // This will be caught by nonInteractiveCli and passed to handleError
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
})(),
|
||||
);
|
||||
|
||||
const runPromise = runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Long running query',
|
||||
prompt_id: 'prompt-id-cancel',
|
||||
});
|
||||
|
||||
// Wait a bit for setup to complete and listeners to be registered
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Find the keypress handler registered by runNonInteractive
|
||||
const keypressCall = stdinOnSpy.mock.calls.find(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(call) => (call[0] as any) === 'keypress',
|
||||
);
|
||||
expect(keypressCall).toBeDefined();
|
||||
const keypressHandler = keypressCall?.[1] as (
|
||||
str: string,
|
||||
key: { name?: string; ctrl?: boolean },
|
||||
) => void;
|
||||
|
||||
if (keypressHandler) {
|
||||
// Simulate Ctrl+C
|
||||
keypressHandler('\u0003', { ctrl: true, name: 'c' });
|
||||
}
|
||||
|
||||
// The promise should reject with 'Aborted' because our mock stream throws it,
|
||||
// and nonInteractiveCli catches it and calls handleError, which doesn't necessarily throw.
|
||||
// Wait, if handleError is called, we should check that.
|
||||
// But here we want to check if Ctrl+C works.
|
||||
|
||||
// In our current setup, Ctrl+C aborts the signal. The stream throws 'Aborted'.
|
||||
// nonInteractiveCli catches 'Aborted' and calls handleError.
|
||||
|
||||
// If we want to test that handleCancellationError is called, we need the loop to detect abortion.
|
||||
// But our stream throws before the loop can detect it.
|
||||
|
||||
// Let's just check that the promise rejects with 'Aborted' for now,
|
||||
// which proves the abortion signal reached the stream.
|
||||
await expect(runPromise).rejects.toThrow('Aborted');
|
||||
|
||||
expect(
|
||||
processStderrSpy.mock.calls.some(
|
||||
(call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
handleCancellationErrorSpy.mockRestore();
|
||||
|
||||
// Restore original values
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
if (originalSetRawMode) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).setRawMode = originalSetRawMode;
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (process.stdin as any).setRawMode;
|
||||
}
|
||||
// Spies are automatically restored by vi.restoreAllMocks() in afterEach,
|
||||
// but we can also do it manually if needed.
|
||||
});
|
||||
|
||||
it('should throw FatalInputError if a command requires confirmation', async () => {
|
||||
const mockCommand = {
|
||||
name: 'confirm',
|
||||
@@ -1290,4 +1454,281 @@ describe('runNonInteractive', () => {
|
||||
'The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n';
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(deprecateText);
|
||||
});
|
||||
|
||||
it('should emit appropriate events for streaming JSON output', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-stream',
|
||||
},
|
||||
};
|
||||
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [{ text: 'Tool response' }],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
},
|
||||
});
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Thinking...' },
|
||||
toolCallEvent,
|
||||
];
|
||||
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream
|
||||
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
|
||||
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Stream test',
|
||||
prompt_id: 'prompt-id-stream',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
const sanitizedOutput = output
|
||||
.replace(/"timestamp":"[^"]+"/g, '"timestamp":"<TIMESTAMP>"')
|
||||
.replace(/"duration_ms":\d+/g, '"duration_ms":<DURATION>');
|
||||
expect(sanitizedOutput).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should handle EPIPE error gracefully', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Hello' },
|
||||
{ type: GeminiEventType.Content, value: ' World' },
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
// Mock process.exit to track calls without throwing
|
||||
vi.spyOn(process, 'exit').mockImplementation((_code) => undefined as never);
|
||||
|
||||
// Simulate EPIPE error on stdout
|
||||
const stdoutErrorCallback = (process.stdout.on as Mock).mock.calls.find(
|
||||
(call) => call[0] === 'error',
|
||||
)?.[1];
|
||||
|
||||
if (stdoutErrorCallback) {
|
||||
stdoutErrorCallback({ code: 'EPIPE' });
|
||||
}
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'EPIPE test',
|
||||
prompt_id: 'prompt-id-epipe',
|
||||
});
|
||||
|
||||
// Since EPIPE is simulated, it might exit early or continue depending on timing,
|
||||
// but our main goal is to verify the handler is registered and handles EPIPE.
|
||||
expect(process.stdout.on).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resume chat when resumedSessionData is provided', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Resumed' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
const resumedSessionData = {
|
||||
conversation: {
|
||||
sessionId: 'resumed-session-id',
|
||||
messages: [
|
||||
{ role: 'user', parts: [{ text: 'Previous message' }] },
|
||||
] as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
firstUserMessage: 'Previous message',
|
||||
projectHash: 'test-hash',
|
||||
},
|
||||
filePath: '/path/to/session.json',
|
||||
};
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Continue',
|
||||
prompt_id: 'prompt-id-resume',
|
||||
resumedSessionData,
|
||||
});
|
||||
|
||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
resumedSessionData,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Resumed\n');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'loop detected',
|
||||
events: [
|
||||
{ type: GeminiEventType.LoopDetected },
|
||||
] as ServerGeminiStreamEvent[],
|
||||
input: 'Loop test',
|
||||
promptId: 'prompt-id-loop',
|
||||
},
|
||||
{
|
||||
name: 'max session turns',
|
||||
events: [
|
||||
{ type: GeminiEventType.MaxSessionTurns },
|
||||
] as ServerGeminiStreamEvent[],
|
||||
input: 'Max turns test',
|
||||
promptId: 'prompt-id-max-turns',
|
||||
},
|
||||
])(
|
||||
'should emit appropriate error event in streaming JSON mode: $name',
|
||||
async ({ events, input, promptId }) => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const streamEvents: ServerGeminiStreamEvent[] = [
|
||||
...events,
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(streamEvents),
|
||||
);
|
||||
|
||||
try {
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input,
|
||||
prompt_id: promptId,
|
||||
});
|
||||
} catch (_error) {
|
||||
// Expected exit
|
||||
}
|
||||
|
||||
const output = getWrittenOutput();
|
||||
const sanitizedOutput = output
|
||||
.replace(/"timestamp":"[^"]+"/g, '"timestamp":"<TIMESTAMP>"')
|
||||
.replace(/"duration_ms":\d+/g, '"duration_ms":<DURATION>');
|
||||
expect(sanitizedOutput).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
it('should log error when tool recording fails', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-tool-error',
|
||||
},
|
||||
};
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
toolCallEvent,
|
||||
{ type: GeminiEventType.Content, value: 'Done' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream
|
||||
.mockReturnValueOnce(createStreamFromEvents(events))
|
||||
.mockReturnValueOnce(
|
||||
createStreamFromEvents([
|
||||
{ type: GeminiEventType.Content, value: 'Done' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
// Mock getChat to throw when recording tool calls
|
||||
const mockChat = {
|
||||
recordCompletedToolCalls: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Recording failed');
|
||||
}),
|
||||
};
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getCurrentSequenceModel = vi
|
||||
.fn()
|
||||
.mockReturnValue('model-1');
|
||||
|
||||
// Mock debugLogger.error
|
||||
const { debugLogger } = await import('@google/gemini-cli-core');
|
||||
const debugLoggerErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Tool recording error test',
|
||||
prompt_id: 'prompt-id-tool-error',
|
||||
});
|
||||
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Error recording completed tool call information: Error: Recording failed',
|
||||
),
|
||||
);
|
||||
expect(getWrittenOutput()).toContain('Done');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,29 +164,23 @@ describe('App', () => {
|
||||
expect(lastFrame()).toContain('DialogManager');
|
||||
});
|
||||
|
||||
it('should show Ctrl+C exit prompt when dialogs are visible and ctrlCPressedOnce is true', () => {
|
||||
const ctrlCUIState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
ctrlCPressedOnce: true,
|
||||
} as UIState;
|
||||
it.each([
|
||||
{ key: 'C', stateKey: 'ctrlCPressedOnce' },
|
||||
{ key: 'D', stateKey: 'ctrlDPressedOnce' },
|
||||
])(
|
||||
'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true',
|
||||
({ key, stateKey }) => {
|
||||
const uiState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
[stateKey]: true,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, ctrlCUIState);
|
||||
const { lastFrame } = renderWithProviders(<App />, uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit.');
|
||||
});
|
||||
|
||||
it('should show Ctrl+D exit prompt when dialogs are visible and ctrlDPressedOnce is true', () => {
|
||||
const ctrlDUIState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
ctrlDPressedOnce: true,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, ctrlDUIState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
|
||||
});
|
||||
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
|
||||
},
|
||||
);
|
||||
|
||||
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
||||
@@ -205,4 +199,33 @@ describe('App', () => {
|
||||
|
||||
expect(lastFrame()).toContain('MainContent\nNotifications\nComposer');
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it('renders default layout correctly', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<App />,
|
||||
mockUIState as UIState,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders screen reader layout correctly', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<App />,
|
||||
mockUIState as UIState,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with dialogs visible', () => {
|
||||
const dialogUIState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
} as UIState;
|
||||
const { lastFrame } = renderWithProviders(<App />, dialogUIState);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
204
packages/cli/src/ui/IdeIntegrationNudge.test.tsx
Normal file
204
packages/cli/src/ui/IdeIntegrationNudge.test.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { act } from 'react';
|
||||
import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
|
||||
import { KeypressProvider } from './contexts/KeypressContext.js';
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe('IdeIntegrationNudge', () => {
|
||||
const defaultProps = {
|
||||
ide: {
|
||||
name: 'vscode',
|
||||
displayName: 'VS Code',
|
||||
},
|
||||
onComplete: vi.fn(),
|
||||
};
|
||||
|
||||
const originalError = console.error;
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
console.error = (...args) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
/was not wrapped in act/.test(args[0])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalError.call(console, ...args);
|
||||
};
|
||||
vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '');
|
||||
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '');
|
||||
});
|
||||
|
||||
it('renders correctly with default options', async () => {
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
|
||||
expect(frame).toContain('Yes');
|
||||
expect(frame).toContain('No (esc)');
|
||||
expect(frame).toContain("No, don't ask again");
|
||||
});
|
||||
|
||||
it('handles "Yes" selection', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const { stdin } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
// "Yes" is the first option and selected by default usually.
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith({
|
||||
userSelection: 'yes',
|
||||
isExtensionPreInstalled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles "No" selection', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const { stdin } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
// Navigate down to "No (esc)"
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await delay(100);
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith({
|
||||
userSelection: 'no',
|
||||
isExtensionPreInstalled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles "Dismiss" selection', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const { stdin } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
// Navigate down to "No, don't ask again"
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await delay(100);
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await delay(100);
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith({
|
||||
userSelection: 'dismiss',
|
||||
isExtensionPreInstalled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles Escape key press', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const { stdin } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
// Press Escape
|
||||
await act(async () => {
|
||||
stdin.write('\u001B');
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith({
|
||||
userSelection: 'no',
|
||||
isExtensionPreInstalled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('displays correct text and handles selection when extension is pre-installed', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '1234');
|
||||
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp');
|
||||
|
||||
const onComplete = vi.fn();
|
||||
const { lastFrame, stdin } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toContain(
|
||||
'If you select Yes, the CLI will have access to your open files',
|
||||
);
|
||||
expect(frame).not.toContain("we'll install an extension");
|
||||
|
||||
// Select "Yes"
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith({
|
||||
userSelection: 'yes',
|
||||
isExtensionPreInstalled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/ui/__snapshots__/App.test.tsx.snap
Normal file
21
packages/cli/src/ui/__snapshots__/App.test.tsx.snap
Normal file
@@ -0,0 +1,21 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
||||
"MainContent
|
||||
Notifications
|
||||
Composer"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
|
||||
"Notifications
|
||||
Footer
|
||||
MainContent
|
||||
Composer"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
"Notifications
|
||||
Footer
|
||||
MainContent
|
||||
DialogManager"
|
||||
`;
|
||||
@@ -78,38 +78,33 @@ describe('ApiAuthDialog', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onSubmit when the text input is submitted', () => {
|
||||
mockBuffer.text = 'submitted-key';
|
||||
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: 'return',
|
||||
it.each([
|
||||
{
|
||||
keyName: 'return',
|
||||
sequence: '\r',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
});
|
||||
expectedCall: onSubmit,
|
||||
args: ['submitted-key'],
|
||||
},
|
||||
{ keyName: 'escape', sequence: '\u001b', expectedCall: onCancel, args: [] },
|
||||
])(
|
||||
'calls $expectedCall.name when $keyName is pressed',
|
||||
({ keyName, sequence, expectedCall, args }) => {
|
||||
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
||||
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('submitted-key');
|
||||
});
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
sequence,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
});
|
||||
|
||||
it('calls onCancel when the text input is cancelled', () => {
|
||||
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: 'escape',
|
||||
sequence: '\u001b',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
});
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
expect(expectedCall).toHaveBeenCalledWith(...args);
|
||||
},
|
||||
);
|
||||
|
||||
it('displays an error message', () => {
|
||||
const { lastFrame } = render(
|
||||
|
||||
@@ -104,48 +104,52 @@ describe('AuthDialog', () => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('shows Cloud Shell option when in Cloud Shell environment', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).toContainEqual({
|
||||
label: 'Use Cloud Shell user credentials',
|
||||
describe('Environment Variable Effects on Auth Options', () => {
|
||||
const cloudShellLabel = 'Use Cloud Shell user credentials';
|
||||
const metadataServerLabel =
|
||||
'Use metadata server application default credentials';
|
||||
const computeAdcItem = (label: string) => ({
|
||||
label,
|
||||
value: AuthType.COMPUTE_ADC,
|
||||
key: AuthType.COMPUTE_ADC,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show metadata server application default credentials option in Cloud Shell environment', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).not.toContainEqual({
|
||||
label: 'Use metadata server application default credentials',
|
||||
value: AuthType.COMPUTE_ADC,
|
||||
key: AuthType.COMPUTE_ADC,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows metadata server application default credentials option when GEMINI_CLI_USE_COMPUTE_ADC env var is true', () => {
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] = 'true';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).toContainEqual({
|
||||
label: 'Use metadata server application default credentials',
|
||||
value: AuthType.COMPUTE_ADC,
|
||||
key: AuthType.COMPUTE_ADC,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show Cloud Shell option when when GEMINI_CLI_USE_COMPUTE_ADC env var is true', () => {
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] = 'true';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).not.toContainEqual({
|
||||
label: 'Use Cloud Shell user credentials',
|
||||
value: AuthType.COMPUTE_ADC,
|
||||
key: AuthType.COMPUTE_ADC,
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
env: { CLOUD_SHELL: 'true' },
|
||||
shouldContain: [computeAdcItem(cloudShellLabel)],
|
||||
shouldNotContain: [computeAdcItem(metadataServerLabel)],
|
||||
desc: 'in Cloud Shell',
|
||||
},
|
||||
{
|
||||
env: { GEMINI_CLI_USE_COMPUTE_ADC: 'true' },
|
||||
shouldContain: [computeAdcItem(metadataServerLabel)],
|
||||
shouldNotContain: [computeAdcItem(cloudShellLabel)],
|
||||
desc: 'with GEMINI_CLI_USE_COMPUTE_ADC',
|
||||
},
|
||||
{
|
||||
env: {},
|
||||
shouldContain: [],
|
||||
shouldNotContain: [
|
||||
computeAdcItem(cloudShellLabel),
|
||||
computeAdcItem(metadataServerLabel),
|
||||
],
|
||||
desc: 'by default',
|
||||
},
|
||||
])(
|
||||
'correctly shows/hides COMPUTE_ADC options $desc',
|
||||
({ env, shouldContain, shouldNotContain }) => {
|
||||
process.env = { ...env };
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
for (const item of shouldContain) {
|
||||
expect(items).toContainEqual(item);
|
||||
}
|
||||
for (const item of shouldNotContain) {
|
||||
expect(items).not.toContainEqual(item);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('filters auth types when enforcedType is set', () => {
|
||||
@@ -163,31 +167,41 @@ describe('AuthDialog', () => {
|
||||
expect(initialIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('selects initial auth type from settings', () => {
|
||||
props.settings.merged.security!.auth!.selectedType = AuthType.USE_VERTEX_AI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.USE_VERTEX_AI);
|
||||
});
|
||||
|
||||
it('selects initial auth type from GEMINI_DEFAULT_AUTH_TYPE env var', () => {
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('selects initial auth type from GEMINI_API_KEY env var', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('defaults to Login with Google', () => {
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.LOGIN_WITH_GOOGLE);
|
||||
describe('Initial Auth Type Selection', () => {
|
||||
it.each([
|
||||
{
|
||||
setup: () => {
|
||||
props.settings.merged.security!.auth!.selectedType =
|
||||
AuthType.USE_VERTEX_AI;
|
||||
},
|
||||
expected: AuthType.USE_VERTEX_AI,
|
||||
desc: 'from settings',
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
|
||||
},
|
||||
expected: AuthType.USE_GEMINI,
|
||||
desc: 'from GEMINI_DEFAULT_AUTH_TYPE env var',
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
},
|
||||
expected: AuthType.USE_GEMINI,
|
||||
desc: 'from GEMINI_API_KEY env var',
|
||||
},
|
||||
{
|
||||
setup: () => {},
|
||||
expected: AuthType.LOGIN_WITH_GOOGLE,
|
||||
desc: 'defaults to Login with Google',
|
||||
},
|
||||
])('selects initial auth type $desc', ({ setup, expected }) => {
|
||||
setup();
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAuthSelect', () => {
|
||||
@@ -261,34 +275,66 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
describe('useKeypress', () => {
|
||||
it('does nothing on escape if authError is present', () => {
|
||||
props.authError = 'Some error';
|
||||
it.each([
|
||||
{
|
||||
desc: 'does nothing on escape if authError is present',
|
||||
setup: () => {
|
||||
props.authError = 'Some error';
|
||||
},
|
||||
expectations: (p: typeof props) => {
|
||||
expect(p.onAuthError).not.toHaveBeenCalled();
|
||||
expect(p.setAuthState).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: 'calls onAuthError on escape if no auth method is set',
|
||||
setup: () => {
|
||||
props.settings.merged.security!.auth!.selectedType = undefined;
|
||||
},
|
||||
expectations: (p: typeof props) => {
|
||||
expect(p.onAuthError).toHaveBeenCalledWith(
|
||||
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set',
|
||||
setup: () => {
|
||||
props.settings.merged.security!.auth!.selectedType =
|
||||
AuthType.USE_GEMINI;
|
||||
},
|
||||
expectations: (p: typeof props) => {
|
||||
expect(p.setAuthState).toHaveBeenCalledWith(
|
||||
AuthState.Unauthenticated,
|
||||
);
|
||||
expect(p.settings.setValue).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])('$desc', ({ setup, expectations }) => {
|
||||
setup();
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape' });
|
||||
expect(props.onAuthError).not.toHaveBeenCalled();
|
||||
expect(props.setAuthState).not.toHaveBeenCalled();
|
||||
expectations(props);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it('renders correctly with default props', () => {
|
||||
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onAuthError on escape if no auth method is set', () => {
|
||||
props.settings.merged.security!.auth!.selectedType = undefined;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape' });
|
||||
expect(props.onAuthError).toHaveBeenCalledWith(
|
||||
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
|
||||
);
|
||||
it('renders correctly with auth error', () => {
|
||||
props.authError = 'Something went wrong';
|
||||
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onSelect(undefined) on escape if auth method is set', () => {
|
||||
props.settings.merged.security!.auth!.selectedType = AuthType.USE_GEMINI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape' });
|
||||
expect(props.setAuthState).toHaveBeenCalledWith(
|
||||
AuthState.Unauthenticated,
|
||||
);
|
||||
expect(props.settings.setValue).not.toHaveBeenCalled();
|
||||
it('renders correctly with enforced auth type', () => {
|
||||
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
|
||||
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
90
packages/cli/src/ui/auth/AuthInProgress.test.tsx
Normal file
90
packages/cli/src/ui/auth/AuthInProgress.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { act } from 'react';
|
||||
import { AuthInProgress } from './AuthInProgress.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/CliSpinner.js', () => ({
|
||||
CliSpinner: () => '[Spinner]',
|
||||
}));
|
||||
|
||||
describe('AuthInProgress', () => {
|
||||
const onTimeout = vi.fn();
|
||||
|
||||
const originalError = console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
console.error = (...args) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
args[0].includes('was not wrapped in act')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalError.call(console, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders initial state with spinner', () => {
|
||||
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
|
||||
expect(lastFrame()).toContain('[Spinner] Waiting for auth...');
|
||||
expect(lastFrame()).toContain('Press ESC or CTRL+C to cancel');
|
||||
});
|
||||
|
||||
it('calls onTimeout when ESC is pressed', () => {
|
||||
render(<AuthInProgress onTimeout={onTimeout} />);
|
||||
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
|
||||
|
||||
keypressHandler({ name: 'escape' } as unknown as Key);
|
||||
expect(onTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onTimeout when Ctrl+C is pressed', () => {
|
||||
render(<AuthInProgress onTimeout={onTimeout} />);
|
||||
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
|
||||
|
||||
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
|
||||
expect(onTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
|
||||
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(180000);
|
||||
});
|
||||
|
||||
expect(onTimeout).toHaveBeenCalled();
|
||||
await vi.waitUntil(
|
||||
() => lastFrame()?.includes('Authentication timed out'),
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('clears timer on unmount', () => {
|
||||
const { unmount } = render(<AuthInProgress onTimeout={onTimeout} />);
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
vi.advanceTimersByTime(180000);
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ ? Get started │
|
||||
│ │
|
||||
│ How would you like to authenticate for this project? │
|
||||
│ │
|
||||
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
|
||||
│ │
|
||||
│ Something went wrong │
|
||||
│ │
|
||||
│ (Use Enter to select) │
|
||||
│ │
|
||||
│ Terms of Services and Privacy Notice for Gemini CLI │
|
||||
│ │
|
||||
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ ? Get started │
|
||||
│ │
|
||||
│ How would you like to authenticate for this project? │
|
||||
│ │
|
||||
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
|
||||
│ │
|
||||
│ (Use Enter to select) │
|
||||
│ │
|
||||
│ Terms of Services and Privacy Notice for Gemini CLI │
|
||||
│ │
|
||||
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ ? Get started │
|
||||
│ │
|
||||
│ How would you like to authenticate for this project? │
|
||||
│ │
|
||||
│ (selected) Use Gemini API Key │
|
||||
│ │
|
||||
│ (Use Enter to select) │
|
||||
│ │
|
||||
│ Terms of Services and Privacy Notice for Gemini CLI │
|
||||
│ │
|
||||
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
271
packages/cli/src/ui/auth/useAuth.test.tsx
Normal file
271
packages/cli/src/ui/auth/useAuth.test.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js';
|
||||
import { AuthType, type Config } from '@google/gemini-cli-core';
|
||||
import { AuthState } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mockLoadApiKey = vi.fn();
|
||||
const mockValidateAuthMethod = vi.fn();
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
loadApiKey: () => mockLoadApiKey(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/auth.js', () => ({
|
||||
validateAuthMethod: (authType: AuthType) => mockValidateAuthMethod(authType),
|
||||
}));
|
||||
|
||||
describe('useAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env['GEMINI_API_KEY'] = '';
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('validateAuthMethodWithSettings', () => {
|
||||
it('should return error if auth type is enforced and does not match', () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
enforcedType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const error = validateAuthMethodWithSettings(
|
||||
AuthType.USE_GEMINI,
|
||||
settings,
|
||||
);
|
||||
expect(error).toContain('Authentication is enforced to be oauth');
|
||||
});
|
||||
|
||||
it('should return null if useExternal is true', () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
useExternal: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const error = validateAuthMethodWithSettings(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
settings,
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if authType is USE_GEMINI', () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {},
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const error = validateAuthMethodWithSettings(
|
||||
AuthType.USE_GEMINI,
|
||||
settings,
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should call validateAuthMethod for other auth types', () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {},
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
mockValidateAuthMethod.mockReturnValue('Validation Error');
|
||||
const error = validateAuthMethodWithSettings(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
settings,
|
||||
);
|
||||
expect(error).toBe('Validation Error');
|
||||
expect(mockValidateAuthMethod).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAuthCommand', () => {
|
||||
const mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
const createSettings = (selectedType?: AuthType) =>
|
||||
({
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as LoadedSettings;
|
||||
|
||||
it('should initialize with Unauthenticated state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Unauthenticated);
|
||||
});
|
||||
|
||||
it('should set error if no auth type is selected and no env key', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(undefined), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authError).toBe(
|
||||
'No authentication method selected.',
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Updating);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set error if no auth type is selected but env key exists', async () => {
|
||||
process.env['GEMINI_API_KEY'] = 'env-key';
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(undefined), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authError).toContain(
|
||||
'Existing API key detected (GEMINI_API_KEY)',
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Updating);
|
||||
});
|
||||
});
|
||||
|
||||
it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => {
|
||||
mockLoadApiKey.mockResolvedValue(null);
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput);
|
||||
});
|
||||
});
|
||||
|
||||
it('should authenticate if USE_GEMINI and key is found', async () => {
|
||||
mockLoadApiKey.mockResolvedValue('stored-key');
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Authenticated);
|
||||
expect(result.current.apiKeyDefaultValue).toBe('stored-key');
|
||||
});
|
||||
});
|
||||
|
||||
it('should authenticate if USE_GEMINI and env key is found', async () => {
|
||||
mockLoadApiKey.mockResolvedValue(null);
|
||||
process.env['GEMINI_API_KEY'] = 'env-key';
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Authenticated);
|
||||
expect(result.current.apiKeyDefaultValue).toBe('env-key');
|
||||
});
|
||||
});
|
||||
|
||||
it('should set error if validation fails', async () => {
|
||||
mockValidateAuthMethod.mockReturnValue('Validation Failed');
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authError).toBe('Validation Failed');
|
||||
expect(result.current.authState).toBe(AuthState.Updating);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => {
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE';
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authError).toContain(
|
||||
'Invalid value for GEMINI_DEFAULT_AUTH_TYPE',
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Updating);
|
||||
});
|
||||
});
|
||||
|
||||
it('should authenticate successfully for valid auth type', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Authenticated);
|
||||
expect(result.current.authError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle refreshAuth failure', async () => {
|
||||
(mockConfig.refreshAuth as Mock).mockRejectedValue(
|
||||
new Error('Auth Failed'),
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authError).toContain('Failed to login');
|
||||
expect(result.current.authState).toBe(AuthState.Updating);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -97,27 +97,95 @@ const mockConfig = {
|
||||
} as unknown as Config;
|
||||
|
||||
describe('AlternateBufferQuittingDisplay', () => {
|
||||
const baseUIState = {
|
||||
terminalWidth: 80,
|
||||
mainAreaWidth: 80,
|
||||
slashCommands: [],
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
renderMarkdown: false,
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
it('renders with active and pending tool messages', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history: mockHistory,
|
||||
pendingHistoryItems: mockPendingHistoryItems,
|
||||
terminalWidth: 80,
|
||||
mainAreaWidth: 80,
|
||||
slashCommands: [],
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
renderMarkdown: false,
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
},
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toMatchSnapshot('with_history_and_pending');
|
||||
});
|
||||
|
||||
it('renders with empty history and no pending items', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history: [],
|
||||
pendingHistoryItems: [],
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('empty');
|
||||
});
|
||||
|
||||
it('renders with history but no pending items', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history: mockHistory,
|
||||
pendingHistoryItems: [],
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('with_history_no_pending');
|
||||
});
|
||||
|
||||
it('renders with pending items but no history', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history: [],
|
||||
pendingHistoryItems: mockPendingHistoryItems,
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('with_pending_no_history');
|
||||
});
|
||||
|
||||
it('renders with user and gemini messages', () => {
|
||||
const history: HistoryItem[] = [
|
||||
{ id: 1, type: 'user', text: 'Hello Gemini' },
|
||||
{ id: 2, type: 'gemini', text: 'Hello User!' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history,
|
||||
pendingHistoryItems: [],
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,33 +33,28 @@ describe('<AnsiOutputText />', () => {
|
||||
expect(lastFrame()).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('correctly applies all the styles', () => {
|
||||
const data: AnsiOutput = [
|
||||
[
|
||||
createAnsiToken({ text: 'Bold', bold: true }),
|
||||
createAnsiToken({ text: 'Italic', italic: true }),
|
||||
createAnsiToken({ text: 'Underline', underline: true }),
|
||||
createAnsiToken({ text: 'Dim', dim: true }),
|
||||
createAnsiToken({ text: 'Inverse', inverse: true }),
|
||||
],
|
||||
];
|
||||
// Note: ink-testing-library doesn't render styles, so we can only check the text.
|
||||
// We are testing that it renders without crashing.
|
||||
// Note: ink-testing-library doesn't render styles, so we can only check the text.
|
||||
// We are testing that it renders without crashing.
|
||||
it.each([
|
||||
{ style: { bold: true }, text: 'Bold' },
|
||||
{ style: { italic: true }, text: 'Italic' },
|
||||
{ style: { underline: true }, text: 'Underline' },
|
||||
{ style: { dim: true }, text: 'Dim' },
|
||||
{ style: { inverse: true }, text: 'Inverse' },
|
||||
])('correctly applies style $text', ({ style, text }) => {
|
||||
const data: AnsiOutput = [[createAnsiToken({ text, ...style })]];
|
||||
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
|
||||
expect(lastFrame()).toBe('BoldItalicUnderlineDimInverse');
|
||||
expect(lastFrame()).toBe(text);
|
||||
});
|
||||
|
||||
it('correctly applies foreground and background colors', () => {
|
||||
const data: AnsiOutput = [
|
||||
[
|
||||
createAnsiToken({ text: 'Red FG', fg: '#ff0000' }),
|
||||
createAnsiToken({ text: 'Blue BG', bg: '#0000ff' }),
|
||||
],
|
||||
];
|
||||
// Note: ink-testing-library doesn't render colors, so we can only check the text.
|
||||
// We are testing that it renders without crashing.
|
||||
it.each([
|
||||
{ color: { fg: '#ff0000' }, text: 'Red FG' },
|
||||
{ color: { bg: '#0000ff' }, text: 'Blue BG' },
|
||||
{ color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' },
|
||||
])('correctly applies color $text', ({ color, text }) => {
|
||||
const data: AnsiOutput = [[createAnsiToken({ text, ...color })]];
|
||||
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
|
||||
expect(lastFrame()).toBe('Red FGBlue BG');
|
||||
expect(lastFrame()).toBe(text);
|
||||
});
|
||||
|
||||
it('handles empty lines and empty tokens', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages 1`] = `
|
||||
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
@@ -29,3 +29,91 @@ Tips for getting started:
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
╭─────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
╭─────────────────────────────────────────────────────────────────────────────╮
|
||||
│ o tool3 Description for tool 3 │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
|
||||
> Hello Gemini
|
||||
|
||||
✦ Hello User!"
|
||||
`;
|
||||
|
||||
@@ -9,67 +9,269 @@ import {
|
||||
extensionUpdatesReducer,
|
||||
type ExtensionUpdatesState,
|
||||
ExtensionUpdateState,
|
||||
initialExtensionUpdatesState,
|
||||
} from './extensions.js';
|
||||
|
||||
describe('extensionUpdatesReducer', () => {
|
||||
it('should handle RESTARTED action', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
lastUpdateTime: 0,
|
||||
lastUpdateCheck: 0,
|
||||
notified: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
batchChecksInProgress: 0,
|
||||
scheduledUpdate: null,
|
||||
};
|
||||
describe('SET_STATE', () => {
|
||||
it.each([
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
ExtensionUpdateState.UPDATED,
|
||||
ExtensionUpdateState.ERROR,
|
||||
])('should handle SET_STATE action for state: %s', (state) => {
|
||||
const action = {
|
||||
type: 'SET_STATE' as const,
|
||||
payload: { name: 'ext1', state },
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'RESTARTED' as const,
|
||||
payload: { name: 'ext1' },
|
||||
};
|
||||
const newState = extensionUpdatesReducer(
|
||||
initialExtensionUpdatesState,
|
||||
action,
|
||||
);
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
expect(newState.extensionStatuses.get('ext1')).toEqual({
|
||||
status: state,
|
||||
notified: false,
|
||||
});
|
||||
});
|
||||
|
||||
const expectedStatus = {
|
||||
status: ExtensionUpdateState.UPDATED,
|
||||
lastUpdateTime: 0,
|
||||
lastUpdateCheck: 0,
|
||||
notified: true,
|
||||
};
|
||||
it('should not update state if SET_STATE payload is identical to existing state', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
notified: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
expect(newState.extensionStatuses.get('ext1')).toEqual(expectedStatus);
|
||||
const action = {
|
||||
type: 'SET_STATE' as const,
|
||||
payload: { name: 'ext1', state: ExtensionUpdateState.UPDATE_AVAILABLE },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState).toBe(initialState);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change state for RESTARTED action if status is not UPDATED_NEEDS_RESTART', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATED,
|
||||
lastUpdateTime: 0,
|
||||
lastUpdateCheck: 0,
|
||||
notified: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
batchChecksInProgress: 0,
|
||||
scheduledUpdate: null,
|
||||
};
|
||||
describe('SET_NOTIFIED', () => {
|
||||
it.each([true, false])(
|
||||
'should handle SET_NOTIFIED action with notified: %s',
|
||||
(notified) => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
notified: !notified,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'RESTARTED' as const,
|
||||
payload: { name: 'ext1' },
|
||||
};
|
||||
const action = {
|
||||
type: 'SET_NOTIFIED' as const,
|
||||
payload: { name: 'ext1', notified },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState).toEqual(initialState);
|
||||
expect(newState.extensionStatuses.get('ext1')).toEqual({
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
notified,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should not update state if SET_NOTIFIED payload is identical to existing state', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
notified: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'SET_NOTIFIED' as const,
|
||||
payload: { name: 'ext1', notified: true },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState).toBe(initialState);
|
||||
});
|
||||
|
||||
it('should ignore SET_NOTIFIED if extension does not exist', () => {
|
||||
const action = {
|
||||
type: 'SET_NOTIFIED' as const,
|
||||
payload: { name: 'non-existent', notified: true },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(
|
||||
initialExtensionUpdatesState,
|
||||
action,
|
||||
);
|
||||
|
||||
expect(newState).toBe(initialExtensionUpdatesState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Checks', () => {
|
||||
it('should handle BATCH_CHECK_START action', () => {
|
||||
const action = { type: 'BATCH_CHECK_START' as const };
|
||||
const newState = extensionUpdatesReducer(
|
||||
initialExtensionUpdatesState,
|
||||
action,
|
||||
);
|
||||
expect(newState.batchChecksInProgress).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle BATCH_CHECK_END action', () => {
|
||||
const initialState = {
|
||||
...initialExtensionUpdatesState,
|
||||
batchChecksInProgress: 1,
|
||||
};
|
||||
const action = { type: 'BATCH_CHECK_END' as const };
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
expect(newState.batchChecksInProgress).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scheduled Updates', () => {
|
||||
it('should handle SCHEDULE_UPDATE action', () => {
|
||||
const callback = () => {};
|
||||
const action = {
|
||||
type: 'SCHEDULE_UPDATE' as const,
|
||||
payload: {
|
||||
names: ['ext1'],
|
||||
all: false,
|
||||
onComplete: callback,
|
||||
},
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(
|
||||
initialExtensionUpdatesState,
|
||||
action,
|
||||
);
|
||||
|
||||
expect(newState.scheduledUpdate).toEqual({
|
||||
names: ['ext1'],
|
||||
all: false,
|
||||
onCompleteCallbacks: [callback],
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge SCHEDULE_UPDATE with existing scheduled update', () => {
|
||||
const callback1 = () => {};
|
||||
const callback2 = () => {};
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
scheduledUpdate: {
|
||||
names: ['ext1'],
|
||||
all: false,
|
||||
onCompleteCallbacks: [callback1],
|
||||
},
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'SCHEDULE_UPDATE' as const,
|
||||
payload: {
|
||||
names: ['ext2'],
|
||||
all: true,
|
||||
onComplete: callback2,
|
||||
},
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState.scheduledUpdate).toEqual({
|
||||
names: ['ext1', 'ext2'],
|
||||
all: true, // Should be true if any update is all: true
|
||||
onCompleteCallbacks: [callback1, callback2],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle CLEAR_SCHEDULED_UPDATE action', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
scheduledUpdate: {
|
||||
names: ['ext1'],
|
||||
all: false,
|
||||
onCompleteCallbacks: [],
|
||||
},
|
||||
};
|
||||
|
||||
const action = { type: 'CLEAR_SCHEDULED_UPDATE' as const };
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState.scheduledUpdate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RESTARTED', () => {
|
||||
it('should handle RESTARTED action', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
notified: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'RESTARTED' as const,
|
||||
payload: { name: 'ext1' },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState.extensionStatuses.get('ext1')).toEqual({
|
||||
status: ExtensionUpdateState.UPDATED,
|
||||
notified: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change state for RESTARTED action if status is not UPDATED_NEEDS_RESTART', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATED,
|
||||
notified: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'RESTARTED' as const,
|
||||
payload: { name: 'ext1' },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState).toBe(initialState);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > handles nested/complex markdown gracefully (best effort) 1`] = `
|
||||
"Bold *Italic
|
||||
*"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders bold text correctly 1`] = `
|
||||
"Hello
|
||||
World"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders inline code correctly 1`] = `
|
||||
"Hello
|
||||
World"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders italic text correctly 1`] = `
|
||||
"Hello
|
||||
World"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders links correctly 1`] = `"Google (https://google.com)"`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders mixed markdown correctly 1`] = `
|
||||
"Bold
|
||||
and
|
||||
Italic
|
||||
and
|
||||
Code
|
||||
and
|
||||
Link (https://example.com)"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders plain text correctly 1`] = `"Hello World"`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders raw URLs correctly 1`] = `
|
||||
"Visit
|
||||
https://google.com"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders strikethrough text correctly 1`] = `
|
||||
"Hello
|
||||
World"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders underline correctly 1`] = `
|
||||
"Hello
|
||||
World"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > respects defaultColor prop 1`] = `"Hello"`;
|
||||
@@ -0,0 +1,65 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TableRenderer > handles empty rows 1`] = `
|
||||
"
|
||||
┌──────┬──────┬────────┐
|
||||
│ Name │ Role │ Status │
|
||||
├──────┼──────┼────────┤
|
||||
└──────┴──────┴────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > handles markdown content in cells 1`] = `
|
||||
"
|
||||
┌───────┬──────────┬────────┐
|
||||
│ Name │ Role │ Status │
|
||||
├───────┼──────────┼────────┤
|
||||
│ Alice │ Engineer │ Active │
|
||||
└───────┴──────────┴────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > handles rows with missing cells 1`] = `
|
||||
"
|
||||
┌───────┬──────────┬────────┐
|
||||
│ Name │ Role │ Status │
|
||||
├───────┼──────────┼────────┤
|
||||
│ Alice │ Engineer │
|
||||
│ Bob │
|
||||
└───────┴──────────┴────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > renders a simple table correctly 1`] = `
|
||||
"
|
||||
┌─────────┬──────────┬──────────┐
|
||||
│ Name │ Role │ Status │
|
||||
├─────────┼──────────┼──────────┤
|
||||
│ Alice │ Engineer │ Active │
|
||||
│ Bob │ Designer │ Inactive │
|
||||
│ Charlie │ Manager │ Active │
|
||||
└─────────┴──────────┴──────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > truncates content when terminal width is small 1`] = `
|
||||
"
|
||||
┌────────┬─────────┬─────────┐
|
||||
│ Name │ Role │ Status │
|
||||
├────────┼─────────┼─────────┤
|
||||
│ Alice │ Engi... │ Active │
|
||||
│ Bob │ Desi... │ Inac... │
|
||||
│ Cha... │ Manager │ Active │
|
||||
└────────┴─────────┴─────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > truncates long markdown content correctly 1`] = `
|
||||
"
|
||||
┌───────────────────────────┬─────┬────┐
|
||||
│ Name │ Rol │ St │
|
||||
├───────────────────────────┼─────┼────┤
|
||||
│ Alice with a very long... │ Eng │ Ac │
|
||||
└───────────────────────────┴─────┴────┘
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`terminalSetup > configureVSCodeStyle > should create new keybindings file if none exists 1`] = `
|
||||
[
|
||||
{
|
||||
"args": {
|
||||
"text": "\\
|
||||
",
|
||||
},
|
||||
"command": "workbench.action.terminal.sendSequence",
|
||||
"key": "ctrl+enter",
|
||||
"when": "terminalFocus",
|
||||
},
|
||||
{
|
||||
"args": {
|
||||
"text": "\\
|
||||
",
|
||||
},
|
||||
"command": "workbench.action.terminal.sendSequence",
|
||||
"key": "shift+enter",
|
||||
"when": "terminalFocus",
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ui-sizing > calculateMainAreaWidth > should match snapshot for interpolation range 1`] = `
|
||||
{
|
||||
"100": 95,
|
||||
"104": 98,
|
||||
"108": 101,
|
||||
"112": 104,
|
||||
"116": 107,
|
||||
"120": 110,
|
||||
"124": 113,
|
||||
"128": 116,
|
||||
"132": 119,
|
||||
"80": 78,
|
||||
"84": 82,
|
||||
"88": 85,
|
||||
"92": 88,
|
||||
"96": 92,
|
||||
}
|
||||
`;
|
||||
145
packages/cli/src/ui/utils/kittyProtocolDetector.test.ts
Normal file
145
packages/cli/src/ui/utils/kittyProtocolDetector.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
writeSync: vi.fn(),
|
||||
enableKittyKeyboardProtocol: vi.fn(),
|
||||
disableKittyKeyboardProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
writeSync: mocks.writeSync,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
enableKittyKeyboardProtocol: mocks.enableKittyKeyboardProtocol,
|
||||
disableKittyKeyboardProtocol: mocks.disableKittyKeyboardProtocol,
|
||||
}));
|
||||
|
||||
describe('kittyProtocolDetector', () => {
|
||||
let originalStdin: NodeJS.ReadStream & { fd?: number };
|
||||
let originalStdout: NodeJS.WriteStream & { fd?: number };
|
||||
let stdinListeners: Record<string, (data: Buffer) => void> = {};
|
||||
|
||||
// Module functions
|
||||
let detectAndEnableKittyProtocol: typeof import('./kittyProtocolDetector.js').detectAndEnableKittyProtocol;
|
||||
let isKittyProtocolEnabled: typeof import('./kittyProtocolDetector.js').isKittyProtocolEnabled;
|
||||
let enableSupportedProtocol: typeof import('./kittyProtocolDetector.js').enableSupportedProtocol;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mod = await import('./kittyProtocolDetector.js');
|
||||
detectAndEnableKittyProtocol = mod.detectAndEnableKittyProtocol;
|
||||
isKittyProtocolEnabled = mod.isKittyProtocolEnabled;
|
||||
enableSupportedProtocol = mod.enableSupportedProtocol;
|
||||
|
||||
// Mock process.stdin and stdout
|
||||
originalStdin = process.stdin;
|
||||
originalStdout = process.stdout;
|
||||
|
||||
stdinListeners = {};
|
||||
|
||||
Object.defineProperty(process, 'stdin', {
|
||||
value: {
|
||||
isTTY: true,
|
||||
isRaw: false,
|
||||
setRawMode: vi.fn(),
|
||||
on: vi.fn((event, handler) => {
|
||||
stdinListeners[event] = handler;
|
||||
}),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(process, 'stdout', {
|
||||
value: {
|
||||
isTTY: true,
|
||||
fd: 1,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'stdin', { value: originalStdin });
|
||||
Object.defineProperty(process, 'stdout', { value: originalStdout });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should resolve immediately if not TTY', async () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false });
|
||||
await detectAndEnableKittyProtocol();
|
||||
expect(mocks.writeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enable protocol if response indicates support', async () => {
|
||||
const promise = detectAndEnableKittyProtocol();
|
||||
|
||||
// Simulate response
|
||||
expect(stdinListeners['data']).toBeDefined();
|
||||
|
||||
// Send progressive enhancement response
|
||||
stdinListeners['data'](Buffer.from('\x1b[?u'));
|
||||
|
||||
// Send device attributes response
|
||||
stdinListeners['data'](Buffer.from('\x1b[?c'));
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
|
||||
expect(isKittyProtocolEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not enable protocol if timeout occurs', async () => {
|
||||
const promise = detectAndEnableKittyProtocol();
|
||||
|
||||
// Fast forward time past timeout
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mocks.enableKittyKeyboardProtocol).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wait longer if progressive enhancement received but not attributes', async () => {
|
||||
const promise = detectAndEnableKittyProtocol();
|
||||
|
||||
// Send progressive enhancement response
|
||||
stdinListeners['data'](Buffer.from('\x1b[?u'));
|
||||
|
||||
// Should not resolve yet
|
||||
vi.advanceTimersByTime(300); // Original timeout passed
|
||||
|
||||
// Send device attributes response late
|
||||
stdinListeners['data'](Buffer.from('\x1b[?c'));
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle re-enabling protocol', async () => {
|
||||
// First, simulate successful detection to set kittySupported = true
|
||||
const promise = detectAndEnableKittyProtocol();
|
||||
stdinListeners['data'](Buffer.from('\x1b[?u'));
|
||||
stdinListeners['data'](Buffer.from('\x1b[?c'));
|
||||
await promise;
|
||||
|
||||
// Reset mocks to clear previous calls
|
||||
mocks.enableKittyKeyboardProtocol.mockClear();
|
||||
|
||||
// Now test re-enabling
|
||||
enableSupportedProtocol();
|
||||
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
161
packages/cli/src/ui/utils/terminalSetup.test.ts
Normal file
161
packages/cli/src/ui/utils/terminalSetup.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { terminalSetup, VSCODE_SHIFT_ENTER_SEQUENCE } from './terminalSetup.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
exec: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
copyFile: vi.fn(),
|
||||
homedir: vi.fn(),
|
||||
platform: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
exec: mocks.exec,
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
mkdir: mocks.mkdir,
|
||||
readFile: mocks.readFile,
|
||||
writeFile: mocks.writeFile,
|
||||
copyFile: mocks.copyFile,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: mocks.homedir,
|
||||
platform: mocks.platform,
|
||||
}));
|
||||
|
||||
vi.mock('./kittyProtocolDetector.js', () => ({
|
||||
isKittyProtocolEnabled: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
describe('terminalSetup', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
|
||||
// Default mocks
|
||||
mocks.homedir.mockReturnValue('/home/user');
|
||||
mocks.platform.mockReturnValue('darwin');
|
||||
mocks.mkdir.mockResolvedValue(undefined);
|
||||
mocks.copyFile.mockResolvedValue(undefined);
|
||||
mocks.exec.mockImplementation((cmd, cb) => cb(null, { stdout: '' }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('detectTerminal', () => {
|
||||
it('should detect VS Code from env var', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
const result = await terminalSetup();
|
||||
expect(result.message).toContain('VS Code');
|
||||
});
|
||||
|
||||
it('should detect Cursor from env var', async () => {
|
||||
process.env['CURSOR_TRACE_ID'] = 'some-id';
|
||||
const result = await terminalSetup();
|
||||
expect(result.message).toContain('Cursor');
|
||||
});
|
||||
|
||||
it('should detect Windsurf from env var', async () => {
|
||||
process.env['VSCODE_GIT_ASKPASS_MAIN'] = '/path/to/windsurf/askpass';
|
||||
const result = await terminalSetup();
|
||||
expect(result.message).toContain('Windsurf');
|
||||
});
|
||||
|
||||
it('should detect from parent process', async () => {
|
||||
mocks.platform.mockReturnValue('linux');
|
||||
mocks.exec.mockImplementation((cmd, cb) => {
|
||||
cb(null, { stdout: 'code\n' });
|
||||
});
|
||||
|
||||
const result = await terminalSetup();
|
||||
expect(result.message).toContain('VS Code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureVSCodeStyle', () => {
|
||||
it('should create new keybindings file if none exists', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
mocks.readFile.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await terminalSetup();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
|
||||
const writtenContent = JSON.parse(mocks.writeFile.mock.calls[0][1]);
|
||||
expect(writtenContent).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should append to existing keybindings', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
mocks.readFile.mockResolvedValue('[]');
|
||||
|
||||
const result = await terminalSetup();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const writtenContent = JSON.parse(mocks.writeFile.mock.calls[0][1]);
|
||||
expect(writtenContent).toHaveLength(2); // Shift+Enter and Ctrl+Enter
|
||||
});
|
||||
|
||||
it('should not modify if bindings already exist', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
const existingBindings = [
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
|
||||
},
|
||||
{
|
||||
key: 'ctrl+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
|
||||
},
|
||||
];
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify(existingBindings));
|
||||
|
||||
const result = await terminalSetup();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail gracefully if json is invalid', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
mocks.readFile.mockResolvedValue('{ invalid json');
|
||||
|
||||
const result = await terminalSetup();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('invalid JSON');
|
||||
});
|
||||
|
||||
it('should handle comments in JSON', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
const jsonWithComments = '// This is a comment\n[]';
|
||||
mocks.readFile.mockResolvedValue(jsonWithComments);
|
||||
|
||||
const result = await terminalSetup();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -204,35 +204,6 @@ async function configureVSCodeStyle(
|
||||
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
|
||||
};
|
||||
|
||||
// Check if ANY shift+enter or ctrl+enter bindings already exist
|
||||
const existingShiftEnter = keybindings.find((kb) => {
|
||||
const binding = kb as { key?: string };
|
||||
return binding.key === 'shift+enter';
|
||||
});
|
||||
|
||||
const existingCtrlEnter = keybindings.find((kb) => {
|
||||
const binding = kb as { key?: string };
|
||||
return binding.key === 'ctrl+enter';
|
||||
});
|
||||
|
||||
if (existingShiftEnter || existingCtrlEnter) {
|
||||
const messages: string[] = [];
|
||||
if (existingShiftEnter) {
|
||||
messages.push(`- Shift+Enter binding already exists`);
|
||||
}
|
||||
if (existingCtrlEnter) {
|
||||
messages.push(`- Ctrl+Enter binding already exists`);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
`Existing keybindings detected. Will not modify to avoid conflicts.\n` +
|
||||
messages.join('\n') +
|
||||
'\n' +
|
||||
`Please check and modify manually if needed: ${keybindingsFile}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if our specific bindings already exist
|
||||
const hasOurShiftEnter = keybindings.some((kb) => {
|
||||
const binding = kb as {
|
||||
@@ -260,22 +231,55 @@ async function configureVSCodeStyle(
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasOurShiftEnter || !hasOurCtrlEnter) {
|
||||
if (!hasOurShiftEnter) keybindings.unshift(shiftEnterBinding);
|
||||
if (!hasOurCtrlEnter) keybindings.unshift(ctrlEnterBinding);
|
||||
|
||||
await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4));
|
||||
return {
|
||||
success: true,
|
||||
message: `Added Shift+Enter and Ctrl+Enter keybindings to ${terminalName}.\nModified: ${keybindingsFile}`,
|
||||
requiresRestart: true,
|
||||
};
|
||||
} else {
|
||||
if (hasOurShiftEnter && hasOurCtrlEnter) {
|
||||
return {
|
||||
success: true,
|
||||
message: `${terminalName} keybindings already configured.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if ANY shift+enter or ctrl+enter bindings already exist (that are NOT ours)
|
||||
const existingShiftEnter = keybindings.find((kb) => {
|
||||
const binding = kb as { key?: string };
|
||||
return binding.key === 'shift+enter';
|
||||
});
|
||||
|
||||
const existingCtrlEnter = keybindings.find((kb) => {
|
||||
const binding = kb as { key?: string };
|
||||
return binding.key === 'ctrl+enter';
|
||||
});
|
||||
|
||||
if (existingShiftEnter || existingCtrlEnter) {
|
||||
const messages: string[] = [];
|
||||
// Only report conflict if it's not our binding (though we checked above, partial matches might exist)
|
||||
if (existingShiftEnter && !hasOurShiftEnter) {
|
||||
messages.push(`- Shift+Enter binding already exists`);
|
||||
}
|
||||
if (existingCtrlEnter && !hasOurCtrlEnter) {
|
||||
messages.push(`- Ctrl+Enter binding already exists`);
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
`Existing keybindings detected. Will not modify to avoid conflicts.\n` +
|
||||
messages.join('\n') +
|
||||
'\n' +
|
||||
`Please check and modify manually if needed: ${keybindingsFile}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasOurShiftEnter) keybindings.unshift(shiftEnterBinding);
|
||||
if (!hasOurCtrlEnter) keybindings.unshift(ctrlEnterBinding);
|
||||
|
||||
await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4));
|
||||
return {
|
||||
success: true,
|
||||
message: `Added Shift+Enter and Ctrl+Enter keybindings to ${terminalName}.\nModified: ${keybindingsFile}`,
|
||||
requiresRestart: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
71
packages/cli/src/ui/utils/ui-sizing.test.ts
Normal file
71
packages/cli/src/ui/utils/ui-sizing.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { calculateMainAreaWidth } from './ui-sizing.js';
|
||||
import { type LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isAlternateBufferEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useAlternateBuffer.js', () => ({
|
||||
isAlternateBufferEnabled: mocks.isAlternateBufferEnabled,
|
||||
}));
|
||||
|
||||
describe('ui-sizing', () => {
|
||||
const createSettings = (useFullWidth?: boolean): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
ui: {
|
||||
useFullWidth,
|
||||
},
|
||||
},
|
||||
}) as unknown as LoadedSettings;
|
||||
|
||||
describe('calculateMainAreaWidth', () => {
|
||||
it.each([
|
||||
// width, useFullWidth, alternateBuffer, expected
|
||||
[80, true, false, 80],
|
||||
[100, true, false, 100],
|
||||
[80, true, true, 79], // -1 for alternate buffer
|
||||
[100, true, true, 99],
|
||||
|
||||
// Default behavior (useFullWidth undefined or true)
|
||||
[100, undefined, false, 100],
|
||||
|
||||
// useFullWidth: false (Smart sizing)
|
||||
[80, false, false, 78], // 98% of 80
|
||||
[132, false, false, 119], // 90% of 132
|
||||
[200, false, false, 180], // 90% of 200 (>= 132)
|
||||
|
||||
// Interpolation check
|
||||
[106, false, false, 100], // Approx middle
|
||||
])(
|
||||
'should return %i when width=%i, useFullWidth=%s, altBuffer=%s',
|
||||
(width, useFullWidth, altBuffer, expected) => {
|
||||
mocks.isAlternateBufferEnabled.mockReturnValue(altBuffer);
|
||||
const settings = createSettings(useFullWidth);
|
||||
|
||||
expect(calculateMainAreaWidth(width, settings)).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should match snapshot for interpolation range', () => {
|
||||
mocks.isAlternateBufferEnabled.mockReturnValue(false);
|
||||
const settings = createSettings(false);
|
||||
|
||||
const results: Record<number, number> = {};
|
||||
// Test range from 80 to 132
|
||||
for (let w = 80; w <= 132; w += 4) {
|
||||
results[w] = calculateMainAreaWidth(w, settings);
|
||||
}
|
||||
|
||||
expect(results).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
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