import fs from 'fs'; import path from 'path'; import { jest } from '@jest/globals'; import { fileURLToPath } from 'url'; // Mock modules first before any imports jest.mock('fs', () => ({ existsSync: jest.fn((filePath) => { // Prevent Jest internal file access if ( filePath.includes('jest-message-util') || filePath.includes('node_modules') ) { return false; } return false; // Default to false for config discovery prevention }), readFileSync: jest.fn(() => '{}'), writeFileSync: jest.fn(), mkdirSync: jest.fn() })); jest.mock('path', () => ({ join: jest.fn((dir, file) => `${dir}/${file}`), dirname: jest.fn((filePath) => filePath.split('/').slice(0, -1).join('/')), resolve: jest.fn((...paths) => paths.join('/')), basename: jest.fn((filePath) => filePath.split('/').pop()) })); jest.mock('chalk', () => ({ red: jest.fn((text) => text), blue: jest.fn((text) => text), green: jest.fn((text) => text), yellow: jest.fn((text) => text), white: jest.fn((text) => ({ bold: jest.fn((text) => text) })), reset: jest.fn((text) => text), dim: jest.fn((text) => text) // Add dim function to prevent chalk errors })); // Mock console to prevent Jest internal access const mockConsole = { log: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }; global.console = mockConsole; // --- Define Mock Function Instances --- const mockFindConfigPath = jest.fn(() => null); // Default to null, can be overridden in tests // Mock path-utils to prevent config file path discovery and logging jest.mock('../../src/utils/path-utils.js', () => ({ __esModule: true, findProjectRoot: jest.fn(() => '/mock/project'), findConfigPath: mockFindConfigPath, // Use the mock function instance findTasksPath: jest.fn(() => '/mock/tasks.json'), findComplexityReportPath: jest.fn(() => null), resolveTasksOutputPath: jest.fn(() => '/mock/tasks.json'), resolveComplexityReportOutputPath: jest.fn(() => '/mock/report.json') })); // --- Read REAL supported-models.json data BEFORE mocks --- const __filename = fileURLToPath(import.meta.url); // Get current file path const __dirname = path.dirname(__filename); // Get current directory const realSupportedModelsPath = path.resolve( __dirname, '../../scripts/modules/supported-models.json' ); let REAL_SUPPORTED_MODELS_CONTENT; let REAL_SUPPORTED_MODELS_DATA; try { REAL_SUPPORTED_MODELS_CONTENT = fs.readFileSync( realSupportedModelsPath, 'utf-8' ); REAL_SUPPORTED_MODELS_DATA = JSON.parse(REAL_SUPPORTED_MODELS_CONTENT); } catch (err) { console.error( 'FATAL TEST SETUP ERROR: Could not read or parse real supported-models.json', err ); REAL_SUPPORTED_MODELS_CONTENT = '{}'; // Default to empty object on error REAL_SUPPORTED_MODELS_DATA = {}; process.exit(1); // Exit if essential test data can't be loaded } // --- Define Mock Function Instances --- const mockFindProjectRoot = jest.fn(); const mockLog = jest.fn(); // --- Mock Dependencies BEFORE importing the module under test --- // Mock the 'utils.js' module using a factory function jest.mock('../../scripts/modules/utils.js', () => ({ __esModule: true, // Indicate it's an ES module mock findProjectRoot: mockFindProjectRoot, // Use the mock function instance log: mockLog, // Use the mock function instance // Include other necessary exports from utils if config-manager uses them directly resolveEnvVariable: jest.fn() // Example if needed })); // --- Import the module under test AFTER mocks are defined --- import * as configManager from '../../scripts/modules/config-manager.js'; // Import the mocked 'fs' module to allow spying on its functions import fsMocked from 'fs'; // --- Test Data (Keep as is, ensure DEFAULT_CONFIG is accurate) --- const MOCK_PROJECT_ROOT = '/mock/project'; const MOCK_CONFIG_PATH = path.join( MOCK_PROJECT_ROOT, '.taskmaster/config.json' ); // Updated DEFAULT_CONFIG reflecting the implementation const DEFAULT_CONFIG = { models: { main: { provider: 'anthropic', modelId: 'claude-3-7-sonnet-20250219', maxTokens: 64000, temperature: 0.2 }, research: { provider: 'perplexity', modelId: 'sonar-pro', maxTokens: 8700, temperature: 0.1 }, fallback: { provider: 'anthropic', modelId: 'claude-3-5-sonnet', maxTokens: 8192, temperature: 0.2 } }, global: { logLevel: 'info', debug: false, defaultNumTasks: 10, defaultSubtasks: 5, defaultPriority: 'medium', projectName: 'Task Master', ollamaBaseURL: 'http://localhost:11434/api', bedrockBaseURL: 'https://bedrock.us-east-1.amazonaws.com', responseLanguage: 'English' }, claudeCode: {} }; // Other test data (VALID_CUSTOM_CONFIG, PARTIAL_CONFIG, INVALID_PROVIDER_CONFIG) const VALID_CUSTOM_CONFIG = { models: { main: { provider: 'openai', modelId: 'gpt-4o', maxTokens: 4096, temperature: 0.5 }, research: { provider: 'google', modelId: 'gemini-1.5-pro-latest', maxTokens: 8192, temperature: 0.3 }, fallback: { provider: 'anthropic', modelId: 'claude-3-opus-20240229', maxTokens: 100000, temperature: 0.4 } }, global: { logLevel: 'debug', defaultPriority: 'high', projectName: 'My Custom Project' } }; const PARTIAL_CONFIG = { models: { main: { provider: 'openai', modelId: 'gpt-4-turbo' } }, global: { projectName: 'Partial Project' } }; const INVALID_PROVIDER_CONFIG = { models: { main: { provider: 'invalid-provider', modelId: 'some-model' }, research: { provider: 'perplexity', modelId: 'llama-3-sonar-large-32k-online' } }, global: { logLevel: 'warn' } }; // Claude Code test data const VALID_CLAUDE_CODE_CONFIG = { maxTurns: 5, customSystemPrompt: 'You are a helpful coding assistant', appendSystemPrompt: 'Always follow best practices', permissionMode: 'acceptEdits', allowedTools: ['Read', 'LS', 'Edit'], disallowedTools: ['Write'], mcpServers: { 'test-server': { type: 'stdio', command: 'node', args: ['server.js'], env: { NODE_ENV: 'test' } } }, commandSpecific: { 'add-task': { maxTurns: 3, permissionMode: 'plan' }, research: { customSystemPrompt: 'You are a research assistant' } } }; const INVALID_CLAUDE_CODE_CONFIG = { maxTurns: 'invalid', // Should be number permissionMode: 'invalid-mode', // Invalid enum value allowedTools: 'not-an-array', // Should be array mcpServers: { 'invalid-server': { type: 'invalid-type', // Invalid enum value url: 'not-a-valid-url' // Invalid URL format } }, commandSpecific: { 'invalid-command': { // Invalid command name maxTurns: -1 // Invalid negative number } } }; const PARTIAL_CLAUDE_CODE_CONFIG = { maxTurns: 10, permissionMode: 'default', commandSpecific: { 'expand-task': { customSystemPrompt: 'Focus on task breakdown' } } }; // Define spies globally to be restored in afterAll let consoleErrorSpy; let consoleWarnSpy; let fsReadFileSyncSpy; let fsWriteFileSyncSpy; let fsExistsSyncSpy; beforeAll(() => { // Set up console spies consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); }); afterAll(() => { // Restore all spies jest.restoreAllMocks(); }); // Reset mocks before each test for isolation beforeEach(() => { // Clear all mock calls and reset implementations between tests jest.clearAllMocks(); // Reset the external mock instances for utils mockFindProjectRoot.mockReset(); mockLog.mockReset(); mockFindConfigPath.mockReset(); // --- Set up spies ON the imported 'fs' mock --- fsExistsSyncSpy = jest.spyOn(fsMocked, 'existsSync'); fsReadFileSyncSpy = jest.spyOn(fsMocked, 'readFileSync'); fsWriteFileSyncSpy = jest.spyOn(fsMocked, 'writeFileSync'); // --- Default Mock Implementations --- mockFindProjectRoot.mockReturnValue(MOCK_PROJECT_ROOT); // Default for utils.findProjectRoot mockFindConfigPath.mockReturnValue(null); // Default to no config file found fsExistsSyncSpy.mockReturnValue(true); // Assume files exist by default // Default readFileSync: Return REAL models content, mocked config, or throw error fsReadFileSyncSpy.mockImplementation((filePath) => { const baseName = path.basename(filePath); if (baseName === 'supported-models.json') { // Return the REAL file content stringified return REAL_SUPPORTED_MODELS_CONTENT; } else if (filePath === MOCK_CONFIG_PATH) { // Still mock the .taskmasterconfig reads return JSON.stringify(DEFAULT_CONFIG); // Default behavior } // For Jest internal files or other unexpected files, return empty string instead of throwing // This prevents Jest's internal file operations from breaking tests if ( filePath.includes('jest-message-util') || filePath.includes('node_modules') ) { return '{}'; // Return empty JSON for Jest internal files } // Throw for truly unexpected reads that should be caught in tests throw new Error(`Unexpected fs.readFileSync call in test: ${filePath}`); }); // Default writeFileSync: Do nothing, just allow calls fsWriteFileSyncSpy.mockImplementation(() => {}); }); // --- Validation Functions --- describe('Validation Functions', () => { // Tests for validateProvider and validateProviderModelCombination test('validateProvider should return true for valid providers', () => { expect(configManager.validateProvider('openai')).toBe(true); expect(configManager.validateProvider('anthropic')).toBe(true); expect(configManager.validateProvider('google')).toBe(true); expect(configManager.validateProvider('perplexity')).toBe(true); expect(configManager.validateProvider('ollama')).toBe(true); expect(configManager.validateProvider('openrouter')).toBe(true); expect(configManager.validateProvider('bedrock')).toBe(true); }); test('validateProvider should return false for invalid providers', () => { expect(configManager.validateProvider('invalid-provider')).toBe(false); expect(configManager.validateProvider('grok')).toBe(false); // Not in mock map expect(configManager.validateProvider('')).toBe(false); expect(configManager.validateProvider(null)).toBe(false); }); test('validateProviderModelCombination should validate known good combinations', () => { // Re-load config to ensure MODEL_MAP is populated from mock (now real data) configManager.getConfig(MOCK_PROJECT_ROOT, true); expect( configManager.validateProviderModelCombination('openai', 'gpt-4o') ).toBe(true); expect( configManager.validateProviderModelCombination( 'anthropic', 'claude-3-5-sonnet-20241022' ) ).toBe(true); }); test('validateProviderModelCombination should return false for known bad combinations', () => { // Re-load config to ensure MODEL_MAP is populated from mock (now real data) configManager.getConfig(MOCK_PROJECT_ROOT, true); expect( configManager.validateProviderModelCombination( 'openai', 'claude-3-opus-20240229' ) ).toBe(false); }); test('validateProviderModelCombination should return true for ollama/openrouter (empty lists in map)', () => { // Re-load config to ensure MODEL_MAP is populated from mock (now real data) configManager.getConfig(MOCK_PROJECT_ROOT, true); expect( configManager.validateProviderModelCombination('ollama', 'any-model') ).toBe(false); expect( configManager.validateProviderModelCombination('openrouter', 'any/model') ).toBe(false); }); test('validateProviderModelCombination should return true for providers not in map', () => { // Re-load config to ensure MODEL_MAP is populated from mock (now real data) configManager.getConfig(MOCK_PROJECT_ROOT, true); // The implementation returns true if the provider isn't in the map expect( configManager.validateProviderModelCombination( 'unknown-provider', 'some-model' ) ).toBe(true); }); }); // --- Claude Code Validation Tests --- describe('Claude Code Validation', () => { test('validateClaudeCodeSettings should return valid settings for correct input', () => { const result = configManager.validateClaudeCodeSettings( VALID_CLAUDE_CODE_CONFIG ); expect(result).toEqual(VALID_CLAUDE_CODE_CONFIG); expect(consoleWarnSpy).not.toHaveBeenCalled(); }); test('validateClaudeCodeSettings should return empty object for invalid input', () => { const result = configManager.validateClaudeCodeSettings( INVALID_CLAUDE_CODE_CONFIG ); expect(result).toEqual({}); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Warning: Invalid Claude Code settings in config') ); }); test('validateClaudeCodeSettings should handle partial valid configuration', () => { const result = configManager.validateClaudeCodeSettings( PARTIAL_CLAUDE_CODE_CONFIG ); expect(result).toEqual(PARTIAL_CLAUDE_CODE_CONFIG); expect(consoleWarnSpy).not.toHaveBeenCalled(); }); test('validateClaudeCodeSettings should return empty object for empty input', () => { const result = configManager.validateClaudeCodeSettings({}); expect(result).toEqual({}); expect(consoleWarnSpy).not.toHaveBeenCalled(); }); test('validateClaudeCodeSettings should handle null/undefined input', () => { expect(configManager.validateClaudeCodeSettings(null)).toEqual({}); expect(configManager.validateClaudeCodeSettings(undefined)).toEqual({}); expect(consoleWarnSpy).toHaveBeenCalledTimes(2); }); }); // --- Claude Code Getter Tests --- describe('Claude Code Getter Functions', () => { test('getClaudeCodeSettings should return default empty object when no config exists', () => { // No config file exists, should return empty object fsExistsSyncSpy.mockReturnValue(false); const settings = configManager.getClaudeCodeSettings(MOCK_PROJECT_ROOT); expect(settings).toEqual({}); }); test('getClaudeCodeSettings should return merged settings from config file', () => { // Config file with Claude Code settings const configWithClaudeCode = { ...VALID_CUSTOM_CONFIG, claudeCode: VALID_CLAUDE_CODE_CONFIG }; // Mock findConfigPath to return the mock config path mockFindConfigPath.mockReturnValue(MOCK_CONFIG_PATH); fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) return JSON.stringify(configWithClaudeCode); if (path.basename(filePath) === 'supported-models.json') { return JSON.stringify({ openai: [{ id: 'gpt-4o' }], google: [{ id: 'gemini-1.5-pro-latest' }], anthropic: [ { id: 'claude-3-opus-20240229' }, { id: 'claude-3-7-sonnet-20250219' }, { id: 'claude-3-5-sonnet' } ], perplexity: [{ id: 'sonar-pro' }], ollama: [], openrouter: [] }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); const settings = configManager.getClaudeCodeSettings( MOCK_PROJECT_ROOT, true ); // Force reload expect(settings).toEqual(VALID_CLAUDE_CODE_CONFIG); }); test('getClaudeCodeSettingsForCommand should return command-specific settings', () => { // Config with command-specific settings const configWithClaudeCode = { ...VALID_CUSTOM_CONFIG, claudeCode: VALID_CLAUDE_CODE_CONFIG }; // Mock findConfigPath to return the mock config path mockFindConfigPath.mockReturnValue(MOCK_CONFIG_PATH); fsReadFileSyncSpy.mockImplementation((filePath) => { if (path.basename(filePath) === 'supported-models.json') return '{}'; if (filePath === MOCK_CONFIG_PATH) return JSON.stringify(configWithClaudeCode); throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); const settings = configManager.getClaudeCodeSettingsForCommand( 'add-task', MOCK_PROJECT_ROOT, true ); // Force reload // Should merge global settings with command-specific settings const expectedSettings = { ...VALID_CLAUDE_CODE_CONFIG, ...VALID_CLAUDE_CODE_CONFIG.commandSpecific['add-task'] }; expect(settings).toEqual(expectedSettings); }); test('getClaudeCodeSettingsForCommand should return global settings for unknown command', () => { // Config with Claude Code settings const configWithClaudeCode = { ...VALID_CUSTOM_CONFIG, claudeCode: PARTIAL_CLAUDE_CODE_CONFIG }; // Mock findConfigPath to return the mock config path mockFindConfigPath.mockReturnValue(MOCK_CONFIG_PATH); fsReadFileSyncSpy.mockImplementation((filePath) => { if (path.basename(filePath) === 'supported-models.json') return '{}'; if (filePath === MOCK_CONFIG_PATH) return JSON.stringify(configWithClaudeCode); throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); const settings = configManager.getClaudeCodeSettingsForCommand( 'unknown-command', MOCK_PROJECT_ROOT, true ); // Force reload // Should return global settings only expect(settings).toEqual(PARTIAL_CLAUDE_CODE_CONFIG); }); }); // --- getConfig Tests --- describe('getConfig Tests', () => { test('should return default config if .taskmasterconfig does not exist', () => { // Arrange fsExistsSyncSpy.mockReturnValue(false); // findProjectRoot mock is set in beforeEach // Act: Call getConfig with explicit root const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); // Force reload // Assert expect(config).toEqual(DEFAULT_CONFIG); expect(mockFindProjectRoot).not.toHaveBeenCalled(); // Explicit root provided expect(fsExistsSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH); expect(fsReadFileSyncSpy).not.toHaveBeenCalled(); // No read if file doesn't exist expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('not found at provided project root') ); }); test.skip('should use findProjectRoot and return defaults if file not found', () => { // TODO: Fix mock interaction, findProjectRoot isn't being registered as called // Arrange fsExistsSyncSpy.mockReturnValue(false); // findProjectRoot mock is set in beforeEach // Act: Call getConfig without explicit root const config = configManager.getConfig(null, true); // Force reload // Assert expect(mockFindProjectRoot).toHaveBeenCalled(); // Should be called now expect(fsExistsSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH); expect(config).toEqual(DEFAULT_CONFIG); expect(fsReadFileSyncSpy).not.toHaveBeenCalled(); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('not found at derived root') ); // Adjusted expected warning }); test('should read and merge valid config file with defaults', () => { // Arrange: Override readFileSync for this test fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) return JSON.stringify(VALID_CUSTOM_CONFIG); if (path.basename(filePath) === 'supported-models.json') { // Provide necessary models for validation within getConfig return JSON.stringify({ openai: [{ id: 'gpt-4o' }], google: [{ id: 'gemini-1.5-pro-latest' }], perplexity: [{ id: 'sonar-pro' }], anthropic: [ { id: 'claude-3-opus-20240229' }, { id: 'claude-3-5-sonnet' }, { id: 'claude-3-7-sonnet-20250219' }, { id: 'claude-3-5-sonnet' } ], ollama: [], openrouter: [] }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // findProjectRoot mock set in beforeEach // Act const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); // Force reload // Assert: Construct expected merged config const expectedMergedConfig = { models: { main: { ...DEFAULT_CONFIG.models.main, ...VALID_CUSTOM_CONFIG.models.main }, research: { ...DEFAULT_CONFIG.models.research, ...VALID_CUSTOM_CONFIG.models.research }, fallback: { ...DEFAULT_CONFIG.models.fallback, ...VALID_CUSTOM_CONFIG.models.fallback } }, global: { ...DEFAULT_CONFIG.global, ...VALID_CUSTOM_CONFIG.global }, claudeCode: { ...DEFAULT_CONFIG.claudeCode, ...VALID_CUSTOM_CONFIG.claudeCode } }; expect(config).toEqual(expectedMergedConfig); expect(fsExistsSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH); expect(fsReadFileSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH, 'utf-8'); }); test('should merge defaults for partial config file', () => { // Arrange fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) return JSON.stringify(PARTIAL_CONFIG); if (path.basename(filePath) === 'supported-models.json') { return JSON.stringify({ openai: [{ id: 'gpt-4-turbo' }], perplexity: [{ id: 'sonar-pro' }], anthropic: [ { id: 'claude-3-7-sonnet-20250219' }, { id: 'claude-3-5-sonnet' } ], ollama: [], openrouter: [] }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // findProjectRoot mock set in beforeEach // Act const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); // Assert: Construct expected merged config const expectedMergedConfig = { models: { main: { ...DEFAULT_CONFIG.models.main, ...PARTIAL_CONFIG.models.main }, research: { ...DEFAULT_CONFIG.models.research }, fallback: { ...DEFAULT_CONFIG.models.fallback } }, global: { ...DEFAULT_CONFIG.global, ...PARTIAL_CONFIG.global }, claudeCode: { ...DEFAULT_CONFIG.claudeCode, ...VALID_CUSTOM_CONFIG.claudeCode } }; expect(config).toEqual(expectedMergedConfig); expect(fsReadFileSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH, 'utf-8'); }); test('should handle JSON parsing error and return defaults', () => { // Arrange fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) return 'invalid json'; // Mock models read needed for initial load before parse error if (path.basename(filePath) === 'supported-models.json') { return JSON.stringify({ anthropic: [{ id: 'claude-3-7-sonnet-20250219' }], perplexity: [{ id: 'sonar-pro' }], fallback: [{ id: 'claude-3-5-sonnet' }], ollama: [], openrouter: [] }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // findProjectRoot mock set in beforeEach // Act const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); // Assert expect(config).toEqual(DEFAULT_CONFIG); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Error reading or parsing') ); }); test('should handle file read error and return defaults', () => { // Arrange const readError = new Error('Permission denied'); fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) throw readError; // Mock models read needed for initial load before read error if (path.basename(filePath) === 'supported-models.json') { return JSON.stringify({ anthropic: [{ id: 'claude-3-7-sonnet-20250219' }], perplexity: [{ id: 'sonar-pro' }], fallback: [{ id: 'claude-3-5-sonnet' }], ollama: [], openrouter: [] }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // findProjectRoot mock set in beforeEach // Act const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); // Assert expect(config).toEqual(DEFAULT_CONFIG); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Permission denied. Using default configuration.') ); }); test('should validate provider and fallback to default if invalid', () => { // Arrange fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) return JSON.stringify(INVALID_PROVIDER_CONFIG); if (path.basename(filePath) === 'supported-models.json') { return JSON.stringify({ perplexity: [{ id: 'llama-3-sonar-large-32k-online' }], anthropic: [ { id: 'claude-3-7-sonnet-20250219' }, { id: 'claude-3-5-sonnet' } ], ollama: [], openrouter: [] }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // findProjectRoot mock set in beforeEach // Act const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); // Assert expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining( 'Warning: Invalid main provider "invalid-provider"' ) ); const expectedMergedConfig = { models: { main: { ...DEFAULT_CONFIG.models.main }, research: { ...DEFAULT_CONFIG.models.research, ...INVALID_PROVIDER_CONFIG.models.research }, fallback: { ...DEFAULT_CONFIG.models.fallback } }, global: { ...DEFAULT_CONFIG.global, ...INVALID_PROVIDER_CONFIG.global }, claudeCode: { ...DEFAULT_CONFIG.claudeCode, ...VALID_CUSTOM_CONFIG.claudeCode } }; expect(config).toEqual(expectedMergedConfig); }); }); // --- writeConfig Tests --- describe('writeConfig', () => { test('should write valid config to file', () => { // Arrange (Default mocks are sufficient) // findProjectRoot mock set in beforeEach fsWriteFileSyncSpy.mockImplementation(() => {}); // Ensure it doesn't throw // Act const success = configManager.writeConfig( VALID_CUSTOM_CONFIG, MOCK_PROJECT_ROOT ); // Assert expect(success).toBe(true); expect(fsWriteFileSyncSpy).toHaveBeenCalledWith( MOCK_CONFIG_PATH, JSON.stringify(VALID_CUSTOM_CONFIG, null, 2) // writeConfig stringifies ); expect(consoleErrorSpy).not.toHaveBeenCalled(); }); test('should return false and log error if write fails', () => { // Arrange const mockWriteError = new Error('Disk full'); fsWriteFileSyncSpy.mockImplementation(() => { throw mockWriteError; }); // findProjectRoot mock set in beforeEach // Act const success = configManager.writeConfig( VALID_CUSTOM_CONFIG, MOCK_PROJECT_ROOT ); // Assert expect(success).toBe(false); expect(fsWriteFileSyncSpy).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Disk full') ); }); test.skip('should return false if project root cannot be determined', () => { // TODO: Fix mock interaction or function logic, returns true unexpectedly in test // Arrange: Override mock for this specific test mockFindProjectRoot.mockReturnValue(null); // Act: Call without explicit root const success = configManager.writeConfig(VALID_CUSTOM_CONFIG); // Assert expect(success).toBe(false); // Function should return false if root is null expect(mockFindProjectRoot).toHaveBeenCalled(); expect(fsWriteFileSyncSpy).not.toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Could not determine project root') ); }); }); // --- Getter Functions --- describe('Getter Functions', () => { test('getMainProvider should return provider from config', () => { // Arrange: Set up readFileSync to return VALID_CUSTOM_CONFIG fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) return JSON.stringify(VALID_CUSTOM_CONFIG); if (path.basename(filePath) === 'supported-models.json') { return JSON.stringify({ openai: [{ id: 'gpt-4o' }], google: [{ id: 'gemini-1.5-pro-latest' }], anthropic: [ { id: 'claude-3-opus-20240229' }, { id: 'claude-3-7-sonnet-20250219' }, { id: 'claude-3-5-sonnet' } ], perplexity: [{ id: 'sonar-pro' }], ollama: [], openrouter: [] }); // Added perplexity } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // findProjectRoot mock set in beforeEach // Act const provider = configManager.getMainProvider(MOCK_PROJECT_ROOT); // Assert expect(provider).toBe(VALID_CUSTOM_CONFIG.models.main.provider); }); test('getLogLevel should return logLevel from config', () => { // Arrange: Set up readFileSync to return VALID_CUSTOM_CONFIG fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) return JSON.stringify(VALID_CUSTOM_CONFIG); if (path.basename(filePath) === 'supported-models.json') { // Provide enough mock model data for validation within getConfig return JSON.stringify({ openai: [{ id: 'gpt-4o' }], google: [{ id: 'gemini-1.5-pro-latest' }], anthropic: [ { id: 'claude-3-opus-20240229' }, { id: 'claude-3-7-sonnet-20250219' }, { id: 'claude-3-5-sonnet' } ], perplexity: [{ id: 'sonar-pro' }], ollama: [], openrouter: [] }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // findProjectRoot mock set in beforeEach // Act const logLevel = configManager.getLogLevel(MOCK_PROJECT_ROOT); // Assert expect(logLevel).toBe(VALID_CUSTOM_CONFIG.global.logLevel); }); test('getResponseLanguage should return responseLanguage from config', () => { // Arrange // Prepare a config object with responseLanguage property for this test const configWithLanguage = JSON.stringify({ models: { main: { provider: 'openai', modelId: 'gpt-4-turbo' } }, global: { projectName: 'Test Project', responseLanguage: '中文' } }); // Set up fs.readFileSync to return our test config fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) { return configWithLanguage; } if (path.basename(filePath) === 'supported-models.json') { return JSON.stringify({ openai: [{ id: 'gpt-4-turbo' }] }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // Ensure getConfig returns new values instead of cached ones configManager.getConfig(MOCK_PROJECT_ROOT, true); // Act const responseLanguage = configManager.getResponseLanguage(MOCK_PROJECT_ROOT); // Assert expect(responseLanguage).toBe('中文'); }); test('getResponseLanguage should return undefined when responseLanguage is not in config', () => { // Arrange const configWithoutLanguage = JSON.stringify({ models: { main: { provider: 'openai', modelId: 'gpt-4-turbo' } }, global: { projectName: 'Test Project' // No responseLanguage property } }); fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) { return configWithoutLanguage; } if (path.basename(filePath) === 'supported-models.json') { return JSON.stringify({ openai: [{ id: 'gpt-4-turbo' }] }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // Ensure getConfig returns new values instead of cached ones configManager.getConfig(MOCK_PROJECT_ROOT, true); // Act const responseLanguage = configManager.getResponseLanguage(MOCK_PROJECT_ROOT); // Assert expect(responseLanguage).toBe('English'); }); // Add more tests for other getters (getResearchProvider, getProjectName, etc.) }); // --- isConfigFilePresent Tests --- describe('isConfigFilePresent', () => { test('should return true if config file exists', () => { fsExistsSyncSpy.mockReturnValue(true); // findProjectRoot mock set in beforeEach expect(configManager.isConfigFilePresent(MOCK_PROJECT_ROOT)).toBe(true); expect(fsExistsSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH); }); test('should return false if config file does not exist', () => { fsExistsSyncSpy.mockReturnValue(false); // findProjectRoot mock set in beforeEach expect(configManager.isConfigFilePresent(MOCK_PROJECT_ROOT)).toBe(false); expect(fsExistsSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH); }); test.skip('should use findProjectRoot if explicitRoot is not provided', () => { // TODO: Fix mock interaction, findProjectRoot isn't being registered as called fsExistsSyncSpy.mockReturnValue(true); // findProjectRoot mock set in beforeEach expect(configManager.isConfigFilePresent()).toBe(true); expect(mockFindProjectRoot).toHaveBeenCalled(); // Should be called now }); }); // --- getAllProviders Tests --- describe('getAllProviders', () => { test('should return all providers from ALL_PROVIDERS constant', () => { // Arrange: Ensure config is loaded with real data configManager.getConfig(null, true); // Force load using the mock that returns real data // Act const providers = configManager.getAllProviders(); // Assert // getAllProviders() should return the same as the ALL_PROVIDERS constant expect(providers).toEqual(configManager.ALL_PROVIDERS); expect(providers.length).toBe(configManager.ALL_PROVIDERS.length); // Verify it includes both validated and custom providers expect(providers).toEqual( expect.arrayContaining(configManager.VALIDATED_PROVIDERS) ); expect(providers).toEqual( expect.arrayContaining(Object.values(configManager.CUSTOM_PROVIDERS)) ); }); }); // Add tests for getParametersForRole if needed // --- defaultNumTasks Tests --- describe('Configuration Getters', () => { test('getDefaultNumTasks should return default value when config is valid', () => { // Arrange: Mock fs.readFileSync to return valid config when called with the expected path fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) { return JSON.stringify({ global: { defaultNumTasks: 15 } }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // Force reload to clear cache configManager.getConfig(MOCK_PROJECT_ROOT, true); // Act: Call getDefaultNumTasks with explicit root const result = configManager.getDefaultNumTasks(MOCK_PROJECT_ROOT); // Assert expect(result).toBe(15); }); test('getDefaultNumTasks should return fallback when config value is invalid', () => { // Arrange: Mock fs.readFileSync to return invalid config fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) { return JSON.stringify({ global: { defaultNumTasks: 'invalid' } }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // Force reload to clear cache configManager.getConfig(MOCK_PROJECT_ROOT, true); // Act: Call getDefaultNumTasks with explicit root const result = configManager.getDefaultNumTasks(MOCK_PROJECT_ROOT); // Assert expect(result).toBe(10); // Should fallback to DEFAULTS.global.defaultNumTasks }); test('getDefaultNumTasks should return fallback when config value is missing', () => { // Arrange: Mock fs.readFileSync to return config without defaultNumTasks fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) { return JSON.stringify({ global: {} }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // Force reload to clear cache configManager.getConfig(MOCK_PROJECT_ROOT, true); // Act: Call getDefaultNumTasks with explicit root const result = configManager.getDefaultNumTasks(MOCK_PROJECT_ROOT); // Assert expect(result).toBe(10); // Should fallback to DEFAULTS.global.defaultNumTasks }); test('getDefaultNumTasks should handle non-existent config file', () => { // Arrange: Mock file not existing fsExistsSyncSpy.mockReturnValue(false); // Force reload to clear cache configManager.getConfig(MOCK_PROJECT_ROOT, true); // Act: Call getDefaultNumTasks with explicit root const result = configManager.getDefaultNumTasks(MOCK_PROJECT_ROOT); // Assert expect(result).toBe(10); // Should fallback to DEFAULTS.global.defaultNumTasks }); test('getDefaultNumTasks should accept explicit project root', () => { // Arrange: Mock fs.readFileSync to return valid config fsReadFileSyncSpy.mockImplementation((filePath) => { if (filePath === MOCK_CONFIG_PATH) { return JSON.stringify({ global: { defaultNumTasks: 20 } }); } throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); }); fsExistsSyncSpy.mockReturnValue(true); // Force reload to clear cache configManager.getConfig(MOCK_PROJECT_ROOT, true); // Act: Call getDefaultNumTasks with explicit project root const result = configManager.getDefaultNumTasks(MOCK_PROJECT_ROOT); // Assert expect(result).toBe(20); }); }); // Note: Tests for setMainModel, setResearchModel were removed as the functions were removed in the implementation. // If similar setter functions exist, add tests for them following the writeConfig pattern.