@alvinveroy/codecompass
Version:
AI-powered MCP server for codebase navigation and LLM prompt optimization
432 lines (373 loc) • 23.7 kB
text/typescript
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import fs from 'fs'; // Use actual fs for mocking its methods
import path from 'path';
// Import specific parts of winston that the test needs to interact with directly
import { transports as winstonTransports, createLogger as _winstonCreateLogger } from 'winston'; // Import named exports
import type { Format, TransformableInfo } from 'logform'; // Import types from logform
// Import the class directly for testing.
import { ConfigService as _ConfigService } from '../../lib/config-service'; // Import ConfigService class itself
// import fsActual from 'fs'; // Not strictly needed if we define the mock structure directly
// Mock the entire fs module
vi.mock('fs', () => {
const mockFs = {
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
// Add other fs functions if ConfigService uses them directly
};
return {
...mockFs, // Spread to allow named imports like `import { existsSync } from 'fs'`
default: mockFs, // Provide a default export for `import fs from 'fs'`
};
});
// Mock winston logger creation and transports
vi.mock('winston', () => {
// Define the logger instance that createLogger will return INSIDE the factory
const MOCK_LOGGER_INSTANCE = { // Changed name to avoid confusion
info: vi.fn((_message?: unknown, ..._meta: unknown[]) => undefined),
warn: vi.fn((_message?: unknown, ..._meta: unknown[]) => undefined),
error: vi.fn((_message?: unknown, ..._meta: unknown[]) => undefined),
debug: vi.fn((_message?: unknown, ..._meta: unknown[]) => undefined),
add: vi.fn((_transport: unknown) => MOCK_LOGGER_INSTANCE),
remove: vi.fn((_transport: unknown) => MOCK_LOGGER_INSTANCE),
};
// Define mocks for winston.format properties that ConfigService uses
// ConfigService uses: combine, timestamp, printf, colorize, splat, simple, json
const mockFormat = {
combine: vi.fn((..._args: Format[]): Format => ({ // combine should return a format object
// Simulate a basic format object structure.
_isFormat: true, // Custom property to identify it as a mock format object
transform: vi.fn((info: TransformableInfo) => info) as unknown as Format['transform'],
} as Format)),
timestamp: vi.fn((): Format => ({ _isFormat: true, transform: vi.fn((info: TransformableInfo) => ({...info, timestamp: new Date().toISOString()})) as unknown as Format['transform'] } as Format)),
printf: vi.fn((template: (info: TransformableInfo) => string): Format => ({ _isFormat: true, transform: vi.fn((info: TransformableInfo) => template(info)) as unknown as Format['transform'] } as Format)),
colorize: vi.fn((): Format => ({ _isFormat: true, transform: vi.fn((info: TransformableInfo) => info) as unknown as Format['transform'] } as Format)),
splat: vi.fn((): Format => ({ _isFormat: true, transform: vi.fn((info: TransformableInfo) => info) as unknown as Format['transform'] } as Format)),
simple: vi.fn((): Format => ({ _isFormat: true, transform: vi.fn((info: TransformableInfo) => info) as unknown as Format['transform'] } as Format)),
json: vi.fn((): Format => ({ _isFormat: true, transform: vi.fn((info: TransformableInfo) => info) as unknown as Format['transform'] } as Format)),
// Add any other winston.format properties if ConfigService starts using them
};
const mockedWinstonParts = {
createLogger: vi.fn().mockReturnValue(MOCK_LOGGER_INSTANCE),
transports: {
File: vi.fn().mockImplementation(() => ({ on: vi.fn(), log: vi.fn() })),
Stream: vi.fn().mockImplementation(() => ({ on: vi.fn(), log: vi.fn() })),
},
format: mockFormat,
};
return {
...mockedWinstonParts, // For named imports like `import { transports } from 'winston'`
default: mockedWinstonParts, // For `import winston from 'winston'` in ConfigService
};
});
describe('ConfigService', () => {
let originalEnv: NodeJS.ProcessEnv;
const MOCK_HOME_DIR = '/mock/home/user';
const MOCK_CONFIG_DIR = path.join(MOCK_HOME_DIR, '.codecompass');
const MOCK_MODEL_CONFIG_FILE = path.join(MOCK_CONFIG_DIR, 'model-config.json');
const MOCK_DEEPSEEK_CONFIG_FILE = path.join(MOCK_CONFIG_DIR, 'deepseek-config.json');
const MOCK_LOG_DIR = path.join(MOCK_CONFIG_DIR, 'logs');
// Helper to reset and instantiate ConfigService
const createServiceInstance = async () => {
vi.resetModules(); // This is key
// fs mocks are set per test *before* this is called.
// Import the ConfigService class directly for instance manipulation
const { ConfigService: ImportedConfigServiceClass } = await import('../../lib/config-service.js');
// Reset the private static instance variable
((ImportedConfigServiceClass as any) as { instance?: any }).instance = undefined; // eslint-disable-line @typescript-eslint/no-explicit-any
// Call the public static getter to create/get the new instance
return ImportedConfigServiceClass.getInstance();
};
beforeEach(async () => {
originalEnv = { ...process.env };
// Clear ALL relevant process.env variables
const keysToClear: string[] = [
'HOME', 'OLLAMA_HOST', 'QDRANT_HOST', 'COLLECTION_NAME',
'LLM_PROVIDER', 'SUGGESTION_MODEL', 'SUGGESTION_PROVIDER',
'EMBEDDING_PROVIDER', 'SUMMARIZATION_MODEL', 'REFINEMENT_MODEL',
'DEEPSEEK_API_KEY', 'DEEPSEEK_API_URL', 'OPENAI_API_KEY',
'GEMINI_API_KEY', 'CLAUDE_API_KEY', 'AGENT_DEFAULT_MAX_STEPS',
// Add any other env vars ConfigService might read
];
for (const key of keysToClear) {
delete process.env[key];
}
process.env.HOME = MOCK_HOME_DIR;
// Clear global state potentially set by ConfigService
// Assign undefined instead of using delete
const g = globalThis as NodeJS.Global & typeof globalThis & { [key: string]: unknown };
g.CURRENT_LLM_PROVIDER = undefined;
g.CURRENT_SUGGESTION_PROVIDER = undefined;
g.CURRENT_EMBEDDING_PROVIDER = undefined;
g.CURRENT_SUGGESTION_MODEL = undefined;
const fsMock = fs;
// Default: only .codecompass dir exists, config files don't unless specified by a test
vi.mocked(fsMock.existsSync).mockReset().mockImplementation((p) => p === MOCK_CONFIG_DIR);
// Default: config files are empty JSON unless specified by a test
vi.mocked(fsMock.readFileSync).mockReset().mockReturnValue('{}');
vi.mocked(fsMock.writeFileSync).mockReset();
vi.mocked(fsMock.mkdirSync).mockReset().mockImplementation(() => undefined);
// Reset winston mocks more thoroughly
const _winstonMockedModule = await import('winston');
const createLoggerMock = vi.mocked(_winstonMockedModule.createLogger);
// Get the MOCK_LOGGER_INSTANCE that the factory for winston mock returns
// This relies on the factory structure: vi.mock('winston', () => { const MOCK_LOGGER_INSTANCE = {...}; return { createLogger: vi.fn().mockReturnValue(MOCK_LOGGER_INSTANCE), ... } })
// Access the mock logger instance directly from the mock setup
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
const loggerInstanceFromMockFactory = (_winstonMockedModule as any).default.createLogger() as import('winston').Logger;
if (loggerInstanceFromMockFactory) {
Object.values(loggerInstanceFromMockFactory).forEach(fn => {
if (typeof fn === 'function' && 'mockClear' in fn) (fn as Mock).mockClear();
});
}
createLoggerMock.mockClear(); // Clear calls to createLogger itself
if (loggerInstanceFromMockFactory) { // If we got an instance, ensure createLogger continues to return it
createLoggerMock.mockReturnValue(loggerInstanceFromMockFactory);
}
// Ensure transports are also cleared
vi.mocked(winstonTransports.File).mockClear();
vi.mocked(winstonTransports.Stream).mockClear();
});
afterEach(() => {
process.env = originalEnv; // Restore original process.env
vi.unstubAllEnvs(); // Vitest specific: clear env stubs
});
it('should initialize with default values when no env vars or config files', async () => {
const service = await createServiceInstance();
expect(service.OLLAMA_HOST).toBe('http://127.0.0.1:11434');
expect(service.SUGGESTION_MODEL).toBe('llama3.1:8b'); // Default for Ollama
expect(service.LLM_PROVIDER).toBe('ollama');
expect(service.AGENT_DEFAULT_MAX_STEPS).toBe(service.DEFAULT_AGENT_DEFAULT_MAX_STEPS);
// ... test other important defaults
});
it('should load OLLAMA_HOST from environment variable if valid', async () => {
vi.stubEnv('OLLAMA_HOST', 'http://customhost:1234');
const service = await createServiceInstance();
expect(service.OLLAMA_HOST).toBe('http://customhost:1234');
});
it('should fallback to default OLLAMA_HOST if env var is invalid URL', async () => {
vi.stubEnv('OLLAMA_HOST', 'invalid-url-format');
const service = await createServiceInstance();
expect(service.OLLAMA_HOST).toBe('http://127.0.0.1:11434');
expect(service.logger.warn).toHaveBeenCalledWith(expect.stringContaining('OLLAMA_HOST environment variable "invalid-url-format" is not a valid URL'));
});
it('should load SUGGESTION_MODEL from model-config.json if present', async () => {
// Setup fs mocks specifically for THIS test's scenario
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p) === MOCK_CONFIG_DIR || String(p) === MOCK_MODEL_CONFIG_FILE
);
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (String(p) === MOCK_MODEL_CONFIG_FILE) return JSON.stringify({ SUGGESTION_MODEL: 'file_model_from_json' });
if (String(p) === MOCK_DEEPSEEK_CONFIG_FILE) return JSON.stringify({}); // Handle other expected reads
return '{}';
});
const service = await createServiceInstance();
expect(service.SUGGESTION_MODEL).toBe('file_model_from_json');
});
it('should prioritize model-config.json over environment variables for SUGGESTION_MODEL', async () => {
vi.stubEnv('SUGGESTION_MODEL', 'env_model_should_be_ignored');
// Setup fs mocks specifically for THIS test's scenario
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p) === MOCK_CONFIG_DIR || String(p) === MOCK_MODEL_CONFIG_FILE
);
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (String(p) === MOCK_MODEL_CONFIG_FILE) return JSON.stringify({ SUGGESTION_MODEL: 'file_model_override' });
if (String(p) === MOCK_DEEPSEEK_CONFIG_FILE) return JSON.stringify({});
return '{}';
});
const service = await createServiceInstance();
expect(service.SUGGESTION_MODEL).toBe('file_model_override');
});
it('should load DEEPSEEK_API_KEY from deepseek-config.json', async () => {
// Setup fs mocks specifically for THIS test's scenario
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p) === MOCK_CONFIG_DIR || String(p) === MOCK_DEEPSEEK_CONFIG_FILE
);
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (String(p) === MOCK_DEEPSEEK_CONFIG_FILE) return JSON.stringify({ DEEPSEEK_API_KEY: 'deepseek_key_from_file' });
if (String(p) === MOCK_MODEL_CONFIG_FILE) return JSON.stringify({});
return '{}';
});
const service = await createServiceInstance();
expect(service.DEEPSEEK_API_KEY).toBe('deepseek_key_from_file');
});
it('should derive SUMMARIZATION_MODEL from SUGGESTION_MODEL if not set', async () => {
// Ensure SUMMARIZATION_MODEL is not in env or file for this specific test
vi.stubEnv('SUMMARIZATION_MODEL', undefined as string | undefined);
vi.stubEnv('SUGGESTION_MODEL', 'test_suggestion_model');
// fs.existsSync will default to only MOCK_CONFIG_DIR existing from beforeEach, so no model-config.json
// fs.readFileSync will default to '{}'
const service = await createServiceInstance();
expect(service.SUMMARIZATION_MODEL).toBe('test_suggestion_model');
});
it('should load SUMMARIZATION_MODEL from environment if set', async () => {
vi.stubEnv('SUGGESTION_MODEL', 'default_suggestion');
vi.stubEnv('SUMMARIZATION_MODEL', 'env_summary_model');
// fs.existsSync will default to only MOCK_CONFIG_DIR existing from beforeEach
const service = await createServiceInstance();
expect(service.SUMMARIZATION_MODEL).toBe('env_summary_model');
});
it('should persist model configuration when setSuggestionModel is called', async () => {
// Ensure env vars for derived models are clear for this specific test
vi.stubEnv('SUMMARIZATION_MODEL', undefined as string | undefined);
vi.stubEnv('REFINEMENT_MODEL', undefined as string | undefined);
// Also clear any potential top-level model env vars that might interfere with defaults
vi.stubEnv('SUGGESTION_MODEL', undefined as string | undefined);
vi.stubEnv('SUGGESTION_PROVIDER', undefined as string | undefined);
vi.stubEnv('EMBEDDING_PROVIDER', undefined as string | undefined);
vi.stubEnv('OPENAI_API_KEY', undefined as string | undefined);
vi.stubEnv('GEMINI_API_KEY', undefined as string | undefined);
vi.stubEnv('CLAUDE_API_KEY', undefined as string | undefined);
// Setup fs mocks: .codecompass dir exists, but model-config.json does not yet.
vi.mocked(fs.existsSync).mockImplementation((p) => p === MOCK_CONFIG_DIR); // Only .codecompass dir exists
vi.mocked(fs.readFileSync).mockImplementation(() => '{}'); // model-config.json would be empty if read
const service = await createServiceInstance(); // Initializes with defaults
// Capture default/initial state that will be part of the persisted object
// These are the values that ConfigService._persistModelConfiguration will read from the service instance
const initialSuggestionProvider = service.SUGGESTION_PROVIDER;
const initialEmbeddingProvider = service.EMBEDDING_PROVIDER;
const initialOpenAiApiKey = service.OPENAI_API_KEY;
const initialGeminiApiKey = service.GEMINI_API_KEY;
const initialClaudeApiKey = service.CLAUDE_API_KEY;
const initialHttpPort = service.HTTP_PORT; // Capture the HTTP_PORT from the service instance
// When setSuggestionModel is called, _suggestionModel is updated.
// _summarizationModel and _refinementModel are getters that derive from _suggestionModel
// if their specific env vars are not set.
service.setSuggestionModel('new_persisted_model');
// After setSuggestionModel, the getters for SUMMARIZATION_MODEL and REFINEMENT_MODEL
// should reflect 'new_persisted_model' IF no specific env vars for them are set.
// The _persistModelConfiguration method reads these current getter values.
const expectedJsonContent = {
SUGGESTION_MODEL: 'new_persisted_model', // Directly set
SUGGESTION_PROVIDER: initialSuggestionProvider, // Persisted from initial state
EMBEDDING_PROVIDER: initialEmbeddingProvider, // Persisted from initial state
OPENAI_API_KEY: initialOpenAiApiKey, // Persisted from initial state
GEMINI_API_KEY: initialGeminiApiKey, // Persisted from initial state
CLAUDE_API_KEY: initialClaudeApiKey, // Persisted from initial state
HTTP_PORT: initialHttpPort, // Persisted from service state
SUMMARIZATION_MODEL: 'new_persisted_model', // Derived from the new suggestion model
REFINEMENT_MODEL: 'new_persisted_model' // Derived from the new suggestion model
};
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
MOCK_MODEL_CONFIG_FILE,
JSON.stringify(expectedJsonContent, null, 2)
);
});
it('should persist DeepSeek API key when setDeepSeekApiKey is called', async () => {
// Specific fs setup for this test
vi.mocked(fs.existsSync).mockImplementation((p) => p === MOCK_CONFIG_DIR); // Only .codecompass dir exists
vi.mocked(fs.readFileSync).mockImplementation(() => '{}'); // No pre-existing deepseek config
const service = await createServiceInstance();
const newApiKey = 'new_deepseek_key';
// Capture the DEEPSEEK_API_URL that the service instance has *before* calling setDeepSeekApiKey,
// as this is what _persistDeepSeekConfiguration will use.
const _expectedApiUrl = service.DEEPSEEK_API_URL;
service.setDeepSeekApiKey(newApiKey);
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(1);
const writtenArgs = vi.mocked(fs.writeFileSync).mock.calls[0];
expect(writtenArgs[0]).toBe(MOCK_DEEPSEEK_CONFIG_FILE);
const writtenData = JSON.parse(writtenArgs[1] as string) as Record<string, unknown>;
expect(writtenData).toHaveProperty('DEEPSEEK_API_KEY', newApiKey);
expect(writtenData).toHaveProperty('DEEPSEEK_API_URL', service.DEEPSEEK_API_URL); // Use the getter
expect(writtenData).toHaveProperty('timestamp');
// Ensure timestamp is a recent ISO string (optional, but good check)
// This check can be flaky due to timing, consider removing or making tolerance larger if it causes issues
// expect(new Date().getTime() - new Date(writtenData.timestamp).getTime()).toBeLessThan(5000);
});
it('should handle malformed model-config.json gracefully', async () => {
// Ensure SUGGESTION_MODEL is not set in env for this specific test
vi.stubEnv('SUGGESTION_MODEL', undefined as string | undefined);
// Specific fs setup for this test
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p) === MOCK_CONFIG_DIR || String(p) === MOCK_MODEL_CONFIG_FILE // model-config.json "exists"
);
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (String(p) === MOCK_MODEL_CONFIG_FILE) return '{"SUGGESTION_MODEL": MALFORMED'; // Malformed JSON
if (String(p) === MOCK_DEEPSEEK_CONFIG_FILE) return JSON.stringify({});
return '{}';
});
const _service = await createServiceInstance(); // Creates a new instance
const _winstonMockedModule = await import('winston');
// Ensure we get the logger instance associated with *this* service instance
// Retrieve the logger instance associated with *this* service instance
// This should be the instance returned by the mocked createLogger
const loggerInstance = _service.logger; // Access the logger from the service instance itself
expect(loggerInstance.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to load model config'));
expect(_service.SUGGESTION_MODEL).toBe('llama3.1:8b'); // Should fall back to default
});
it('should correctly set global state variables via initializeGlobalState', async () => {
// Set env vars specifically for this test BEFORE instance creation
vi.stubEnv('SUGGESTION_PROVIDER', 'test_provider_global');
vi.stubEnv('SUGGESTION_MODEL', 'test_model_global');
// Ensure other potentially interfering env vars are clear if necessary
vi.stubEnv('OLLAMA_HOST', undefined as string | undefined); // Example, if it affects global state indirectly
const _service_global_state = await createServiceInstance();
// initializeGlobalState is called in constructor
expect(global.CURRENT_SUGGESTION_PROVIDER).toBe('test_provider_global');
expect(global.CURRENT_SUGGESTION_MODEL).toBe('test_model_global');
});
it('reloadConfigsFromFile should re-read environment and file configs', async () => {
// Initial setup: no env var, no file for SUGGESTION_MODEL
vi.stubEnv('SUGGESTION_MODEL', undefined as string | undefined);
vi.mocked(fs.existsSync).mockImplementation((p) => String(p) === MOCK_CONFIG_DIR); // Only .codecompass dir
vi.mocked(fs.readFileSync).mockReturnValue('{}'); // No config files initially
const service_reload = await createServiceInstance();
expect(service_reload.SUGGESTION_MODEL).toBe('llama3.1:8b'); // Initial default
// NOW, change env and file mocks for the reload
vi.stubEnv('SUGGESTION_MODEL', 'env_reloaded_model_should_be_overridden_by_file');
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p) === MOCK_CONFIG_DIR || String(p) === MOCK_MODEL_CONFIG_FILE // model-config.json now "exists"
);
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (String(p) === MOCK_MODEL_CONFIG_FILE) return JSON.stringify({ SUGGESTION_MODEL: 'file_reloaded_model' });
if (String(p) === MOCK_DEEPSEEK_CONFIG_FILE) return JSON.stringify({});
return '{}';
});
service_reload.reloadConfigsFromFile();
expect(service_reload.SUGGESTION_MODEL).toBe('file_reloaded_model'); // File should take precedence
expect(global.CURRENT_SUGGESTION_MODEL).toBe('file_reloaded_model'); // Global state should also update
});
// Test getters for all new config values (AGENT_DEFAULT_MAX_STEPS, etc.)
// Example for AGENT_DEFAULT_MAX_STEPS
it('AGENT_DEFAULT_MAX_STEPS getter should return correct value from env or default', async () => {
const serviceDefault = await createServiceInstance();
expect(serviceDefault.AGENT_DEFAULT_MAX_STEPS).toBe(serviceDefault.DEFAULT_AGENT_DEFAULT_MAX_STEPS);
vi.stubEnv('AGENT_DEFAULT_MAX_STEPS', '5');
const serviceEnv = await createServiceInstance();
expect(serviceEnv.AGENT_DEFAULT_MAX_STEPS).toBe(5);
});
// Test log directory creation fallback
it('should fallback to local logs directory if user-specific one fails', async () => {
const _consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
// Specific fs setup for this test
vi.mocked(fs.existsSync).mockReset().mockImplementation((p) => {
const pathStr = String(p);
if (pathStr === MOCK_CONFIG_DIR) return true; // .codecompass exists
// MOCK_LOG_DIR and fallback log dir do not exist initially for this test's purpose
if (pathStr === MOCK_LOG_DIR) return false;
if (pathStr === path.join(process.cwd(), 'logs')) return false;
return false;
});
// Specific mock for mkdirSync for this test
vi.mocked(fs.mkdirSync).mockReset().mockImplementation((pathToMkdir) => {
if (String(pathToMkdir) === MOCK_LOG_DIR) {
throw new Error('Permission denied for user log dir');
}
return undefined;
});
const service = await createServiceInstance(); // This will trigger logger setup and dir creation attempts
expect(service.LOG_DIR).toBe(path.join(process.cwd(), 'logs')); // Check it fell back
// The SUT calls service.logger.error, not console.error directly in this path.
// The _consoleErrorSpy was for a different potential logging path, or can be removed if not needed.
// _consoleErrorSpy.mockRestore(); // Restore if it was spied on for other reasons in this test.
// Check that the service's logger was called with the expected error message.
// The logger instance on `service` is the MOCK_LOGGER_INSTANCE.
expect(service.logger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to create user-specific log directory: Permission denied for user log dir. Falling back to local logs dir.')
);
// Check that mkdirSync was called for the fallback directory
expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledWith(path.join(process.cwd(), 'logs'), { recursive: true });
// consoleErrorSpy.mockRestore(); // This line was commented out, ensure it's removed or handled.
});
});