/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Mock } from 'vitest'; import { Config, ConfigParameters, SandboxConfig } from './config.js'; import * as path from 'path'; import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js'; import { DEFAULT_TELEMETRY_TARGET, DEFAULT_OTLP_ENDPOINT, } from '../telemetry/index.js'; import { AuthType, ContentGeneratorConfig, createContentGeneratorConfig, } from '../core/contentGenerator.js'; import { GeminiClient } from '../core/client.js'; import { GitService } from '../services/gitService.js'; import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js'; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, existsSync: vi.fn().mockReturnValue(true), statSync: vi.fn().mockReturnValue({ isDirectory: vi.fn().mockReturnValue(true), }), realpathSync: vi.fn((path) => path), }; }); // Mock dependencies that might be called during Config construction or createServerConfig vi.mock('../tools/tool-registry', () => { const ToolRegistryMock = vi.fn(); ToolRegistryMock.prototype.registerTool = vi.fn(); ToolRegistryMock.prototype.discoverAllTools = vi.fn(); ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed ToolRegistryMock.prototype.getTool = vi.fn(); ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []); return { ToolRegistry: ToolRegistryMock }; }); vi.mock('../utils/memoryDiscovery.js', () => ({ loadServerHierarchicalMemory: vi.fn(), })); // Mock individual tools if their constructors are complex or have side effects vi.mock('../tools/ls'); vi.mock('../tools/read-file'); vi.mock('../tools/grep'); vi.mock('../tools/glob'); vi.mock('../tools/edit'); vi.mock('../tools/shell'); vi.mock('../tools/write-file'); vi.mock('../tools/web-fetch'); vi.mock('../tools/read-many-files'); vi.mock('../tools/memoryTool', () => ({ MemoryTool: vi.fn(), setGeminiMdFilename: vi.fn(), getCurrentGeminiMdFilename: vi.fn(() => 'GEMINI.md'), // Mock the original filename DEFAULT_CONTEXT_FILENAME: 'GEMINI.md', GEMINI_CONFIG_DIR: '.gemini', })); vi.mock('../core/contentGenerator.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, createContentGeneratorConfig: vi.fn(), }; }); vi.mock('../core/client.js', () => ({ GeminiClient: vi.fn().mockImplementation(() => ({ initialize: vi.fn().mockResolvedValue(undefined), })), })); vi.mock('../telemetry/index.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, initializeTelemetry: vi.fn(), }; }); vi.mock('../services/gitService.js', () => { const GitServiceMock = vi.fn(); GitServiceMock.prototype.initialize = vi.fn(); return { GitService: GitServiceMock }; }); describe('Server Config (config.ts)', () => { const MODEL = 'gemini-pro'; const SANDBOX: SandboxConfig = { command: 'docker', image: 'gemini-cli-sandbox', }; const TARGET_DIR = '/path/to/target'; const DEBUG_MODE = false; const QUESTION = 'test question'; const FULL_CONTEXT = false; const USER_MEMORY = 'Test User Memory'; const TELEMETRY_SETTINGS = { enabled: false }; const EMBEDDING_MODEL = 'gemini-embedding'; const SESSION_ID = 'test-session-id'; const baseParams: ConfigParameters = { cwd: '/tmp', embeddingModel: EMBEDDING_MODEL, sandbox: SANDBOX, targetDir: TARGET_DIR, debugMode: DEBUG_MODE, question: QUESTION, fullContext: FULL_CONTEXT, userMemory: USER_MEMORY, telemetry: TELEMETRY_SETTINGS, sessionId: SESSION_ID, model: MODEL, usageStatisticsEnabled: false, }; beforeEach(() => { // Reset mocks if necessary vi.clearAllMocks(); vi.spyOn( ClearcutLogger.prototype, 'logStartSessionEvent', ).mockImplementation(() => undefined); }); describe('initialize', () => { it('should throw an error if checkpointing is enabled and GitService fails', async () => { const gitError = new Error('Git is not installed'); (GitService.prototype.initialize as Mock).mockRejectedValue(gitError); const config = new Config({ ...baseParams, checkpointing: true, }); await expect(config.initialize()).rejects.toThrow(gitError); }); it('should not throw an error if checkpointing is disabled and GitService fails', async () => { const gitError = new Error('Git is not installed'); (GitService.prototype.initialize as Mock).mockRejectedValue(gitError); const config = new Config({ ...baseParams, checkpointing: false, }); await expect(config.initialize()).resolves.toBeUndefined(); }); it('should throw an error if initialized more than once', async () => { const config = new Config({ ...baseParams, checkpointing: false, }); await expect(config.initialize()).resolves.toBeUndefined(); await expect(config.initialize()).rejects.toThrow( 'Config was already initialized', ); }); }); describe('refreshAuth', () => { it('should refresh auth and update config', async () => { const config = new Config(baseParams); const authType = AuthType.USE_GEMINI; const newModel = 'gemini-flash'; const mockContentConfig = { model: newModel, apiKey: 'test-key', }; (createContentGeneratorConfig as Mock).mockReturnValue(mockContentConfig); // Set fallback mode to true to ensure it gets reset config.setFallbackMode(true); expect(config.isInFallbackMode()).toBe(true); await config.refreshAuth(authType); expect(createContentGeneratorConfig).toHaveBeenCalledWith( config, authType, ); // Verify that contentGeneratorConfig is updated with the new model expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig); expect(config.getContentGeneratorConfig().model).toBe(newModel); expect(config.getModel()).toBe(newModel); // getModel() should return the updated model expect(GeminiClient).toHaveBeenCalledWith(config); // Verify that fallback mode is reset expect(config.isInFallbackMode()).toBe(false); }); it('should preserve conversation history when refreshing auth', async () => { const config = new Config(baseParams); const authType = AuthType.USE_GEMINI; const mockContentConfig = { model: 'gemini-pro', apiKey: 'test-key', }; (createContentGeneratorConfig as Mock).mockReturnValue(mockContentConfig); // Mock the existing client with some history const mockExistingHistory = [ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [{ text: 'Hi there!' }] }, { role: 'user', parts: [{ text: 'How are you?' }] }, ]; const mockExistingClient = { isInitialized: vi.fn().mockReturnValue(true), getHistory: vi.fn().mockReturnValue(mockExistingHistory), }; const mockNewClient = { isInitialized: vi.fn().mockReturnValue(true), getHistory: vi.fn().mockReturnValue([]), setHistory: vi.fn(), initialize: vi.fn().mockResolvedValue(undefined), }; // Set the existing client ( config as unknown as { geminiClient: typeof mockExistingClient } ).geminiClient = mockExistingClient; (GeminiClient as Mock).mockImplementation(() => mockNewClient); await config.refreshAuth(authType); // Verify that existing history was retrieved expect(mockExistingClient.getHistory).toHaveBeenCalled(); // Verify that new client was created and initialized expect(GeminiClient).toHaveBeenCalledWith(config); expect(mockNewClient.initialize).toHaveBeenCalledWith(mockContentConfig); // Verify that history was restored to the new client expect(mockNewClient.setHistory).toHaveBeenCalledWith( mockExistingHistory, { stripThoughts: false }, ); }); it('should handle case when no existing client is initialized', async () => { const config = new Config(baseParams); const authType = AuthType.USE_GEMINI; const mockContentConfig = { model: 'gemini-pro', apiKey: 'test-key', }; (createContentGeneratorConfig as Mock).mockReturnValue(mockContentConfig); const mockNewClient = { isInitialized: vi.fn().mockReturnValue(true), getHistory: vi.fn().mockReturnValue([]), setHistory: vi.fn(), initialize: vi.fn().mockResolvedValue(undefined), }; // No existing client (config as unknown as { geminiClient: null }).geminiClient = null; (GeminiClient as Mock).mockImplementation(() => mockNewClient); await config.refreshAuth(authType); // Verify that new client was created and initialized expect(GeminiClient).toHaveBeenCalledWith(config); expect(mockNewClient.initialize).toHaveBeenCalledWith(mockContentConfig); // Verify that setHistory was not called since there was no existing history expect(mockNewClient.setHistory).not.toHaveBeenCalled(); }); it('should strip thoughts when switching from GenAI to Vertex', async () => { const config = new Config(baseParams); const mockContentConfig = { model: 'gemini-pro', apiKey: 'test-key', authType: AuthType.USE_GEMINI, }; ( config as unknown as { contentGeneratorConfig: ContentGeneratorConfig } ).contentGeneratorConfig = mockContentConfig; (createContentGeneratorConfig as Mock).mockReturnValue({ ...mockContentConfig, authType: AuthType.LOGIN_WITH_GOOGLE, }); const mockExistingHistory = [ { role: 'user', parts: [{ text: 'Hello' }] }, ]; const mockExistingClient = { isInitialized: vi.fn().mockReturnValue(true), getHistory: vi.fn().mockReturnValue(mockExistingHistory), }; const mockNewClient = { isInitialized: vi.fn().mockReturnValue(true), getHistory: vi.fn().mockReturnValue([]), setHistory: vi.fn(), initialize: vi.fn().mockResolvedValue(undefined), }; ( config as unknown as { geminiClient: typeof mockExistingClient } ).geminiClient = mockExistingClient; (GeminiClient as Mock).mockImplementation(() => mockNewClient); await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); expect(mockNewClient.setHistory).toHaveBeenCalledWith( mockExistingHistory, { stripThoughts: true }, ); }); it('should not strip thoughts when switching from Vertex to GenAI', async () => { const config = new Config(baseParams); const mockContentConfig = { model: 'gemini-pro', apiKey: 'test-key', authType: AuthType.LOGIN_WITH_GOOGLE, }; ( config as unknown as { contentGeneratorConfig: ContentGeneratorConfig } ).contentGeneratorConfig = mockContentConfig; (createContentGeneratorConfig as Mock).mockReturnValue({ ...mockContentConfig, authType: AuthType.USE_GEMINI, }); const mockExistingHistory = [ { role: 'user', parts: [{ text: 'Hello' }] }, ]; const mockExistingClient = { isInitialized: vi.fn().mockReturnValue(true), getHistory: vi.fn().mockReturnValue(mockExistingHistory), }; const mockNewClient = { isInitialized: vi.fn().mockReturnValue(true), getHistory: vi.fn().mockReturnValue([]), setHistory: vi.fn(), initialize: vi.fn().mockResolvedValue(undefined), }; ( config as unknown as { geminiClient: typeof mockExistingClient } ).geminiClient = mockExistingClient; (GeminiClient as Mock).mockImplementation(() => mockNewClient); await config.refreshAuth(AuthType.USE_GEMINI); expect(mockNewClient.setHistory).toHaveBeenCalledWith( mockExistingHistory, { stripThoughts: false }, ); }); }); it('Config constructor should store userMemory correctly', () => { const config = new Config(baseParams); expect(config.getUserMemory()).toBe(USER_MEMORY); // Verify other getters if needed expect(config.getTargetDir()).toBe(path.resolve(TARGET_DIR)); // Check resolved path }); it('Config constructor should default userMemory to empty string if not provided', () => { const paramsWithoutMemory: ConfigParameters = { ...baseParams }; delete paramsWithoutMemory.userMemory; const config = new Config(paramsWithoutMemory); expect(config.getUserMemory()).toBe(''); }); it('Config constructor should call setGeminiMdFilename with contextFileName if provided', () => { const contextFileName = 'CUSTOM_AGENTS.md'; const paramsWithContextFile: ConfigParameters = { ...baseParams, contextFileName, }; new Config(paramsWithContextFile); expect(mockSetGeminiMdFilename).toHaveBeenCalledWith(contextFileName); }); it('Config constructor should not call setGeminiMdFilename if contextFileName is not provided', () => { new Config(baseParams); // baseParams does not have contextFileName expect(mockSetGeminiMdFilename).not.toHaveBeenCalled(); }); it('should set default file filtering settings when not provided', () => { const config = new Config(baseParams); expect(config.getFileFilteringRespectGitIgnore()).toBe(true); }); it('should set custom file filtering settings when provided', () => { const paramsWithFileFiltering: ConfigParameters = { ...baseParams, fileFiltering: { respectGitIgnore: false, }, }; const config = new Config(paramsWithFileFiltering); expect(config.getFileFilteringRespectGitIgnore()).toBe(false); }); it('should initialize WorkspaceContext with includeDirectories', () => { const includeDirectories = ['/path/to/dir1', '/path/to/dir2']; const paramsWithIncludeDirs: ConfigParameters = { ...baseParams, includeDirectories, }; const config = new Config(paramsWithIncludeDirs); const workspaceContext = config.getWorkspaceContext(); const directories = workspaceContext.getDirectories(); // Should include the target directory plus the included directories expect(directories).toHaveLength(3); expect(directories).toContain(path.resolve(baseParams.targetDir)); expect(directories).toContain('/path/to/dir1'); expect(directories).toContain('/path/to/dir2'); }); it('Config constructor should set telemetry to true when provided as true', () => { const paramsWithTelemetry: ConfigParameters = { ...baseParams, telemetry: { enabled: true }, }; const config = new Config(paramsWithTelemetry); expect(config.getTelemetryEnabled()).toBe(true); }); it('Config constructor should set telemetry to false when provided as false', () => { const paramsWithTelemetry: ConfigParameters = { ...baseParams, telemetry: { enabled: false }, }; const config = new Config(paramsWithTelemetry); expect(config.getTelemetryEnabled()).toBe(false); }); it('Config constructor should default telemetry to default value if not provided', () => { const paramsWithoutTelemetry: ConfigParameters = { ...baseParams }; delete paramsWithoutTelemetry.telemetry; const config = new Config(paramsWithoutTelemetry); expect(config.getTelemetryEnabled()).toBe(TELEMETRY_SETTINGS.enabled); }); it('should have a getFileService method that returns FileDiscoveryService', () => { const config = new Config(baseParams); const fileService = config.getFileService(); expect(fileService).toBeDefined(); }); describe('Usage Statistics', () => { it('defaults usage statistics to enabled if not specified', () => { const config = new Config({ ...baseParams, usageStatisticsEnabled: undefined, }); expect(config.getUsageStatisticsEnabled()).toBe(true); }); it.each([{ enabled: true }, { enabled: false }])( 'sets usage statistics based on the provided value (enabled: $enabled)', ({ enabled }) => { const config = new Config({ ...baseParams, usageStatisticsEnabled: enabled, }); expect(config.getUsageStatisticsEnabled()).toBe(enabled); }, ); it('logs the session start event', () => { new Config({ ...baseParams, usageStatisticsEnabled: true, }); expect( ClearcutLogger.prototype.logStartSessionEvent, ).toHaveBeenCalledOnce(); }); }); describe('Telemetry Settings', () => { it('should return default telemetry target if not provided', () => { const params: ConfigParameters = { ...baseParams, telemetry: { enabled: true }, }; const config = new Config(params); expect(config.getTelemetryTarget()).toBe(DEFAULT_TELEMETRY_TARGET); }); it('should return provided OTLP endpoint', () => { const endpoint = 'http://custom.otel.collector:4317'; const params: ConfigParameters = { ...baseParams, telemetry: { enabled: true, otlpEndpoint: endpoint }, }; const config = new Config(params); expect(config.getTelemetryOtlpEndpoint()).toBe(endpoint); }); it('should return default OTLP endpoint if not provided', () => { const params: ConfigParameters = { ...baseParams, telemetry: { enabled: true }, }; const config = new Config(params); expect(config.getTelemetryOtlpEndpoint()).toBe(DEFAULT_OTLP_ENDPOINT); }); it('should return provided logPrompts setting', () => { const params: ConfigParameters = { ...baseParams, telemetry: { enabled: true, logPrompts: false }, }; const config = new Config(params); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); it('should return default logPrompts setting (true) if not provided', () => { const params: ConfigParameters = { ...baseParams, telemetry: { enabled: true }, }; const config = new Config(params); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); it('should return default logPrompts setting (true) if telemetry object is not provided', () => { const paramsWithoutTelemetry: ConfigParameters = { ...baseParams }; delete paramsWithoutTelemetry.telemetry; const config = new Config(paramsWithoutTelemetry); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); it('should return default telemetry target if telemetry object is not provided', () => { const paramsWithoutTelemetry: ConfigParameters = { ...baseParams }; delete paramsWithoutTelemetry.telemetry; const config = new Config(paramsWithoutTelemetry); expect(config.getTelemetryTarget()).toBe(DEFAULT_TELEMETRY_TARGET); }); it('should return default OTLP endpoint if telemetry object is not provided', () => { const paramsWithoutTelemetry: ConfigParameters = { ...baseParams }; delete paramsWithoutTelemetry.telemetry; const config = new Config(paramsWithoutTelemetry); expect(config.getTelemetryOtlpEndpoint()).toBe(DEFAULT_OTLP_ENDPOINT); }); it('should return provided OTLP protocol', () => { const params: ConfigParameters = { ...baseParams, telemetry: { enabled: true, otlpProtocol: 'http' }, }; const config = new Config(params); expect(config.getTelemetryOtlpProtocol()).toBe('http'); }); it('should return default OTLP protocol if not provided', () => { const params: ConfigParameters = { ...baseParams, telemetry: { enabled: true }, }; const config = new Config(params); expect(config.getTelemetryOtlpProtocol()).toBe('grpc'); }); it('should return default OTLP protocol if telemetry object is not provided', () => { const paramsWithoutTelemetry: ConfigParameters = { ...baseParams }; delete paramsWithoutTelemetry.telemetry; const config = new Config(paramsWithoutTelemetry); expect(config.getTelemetryOtlpProtocol()).toBe('grpc'); }); }); });