claude-task-master/tests/unit/config-manager.test.js

435 lines
14 KiB
JavaScript
Raw Normal View History

import fs from 'fs';
import path from 'path';
import { jest } from '@jest/globals';
// --- Capture Mock Instances ---
const mockExistsSync = jest.fn();
const mockReadFileSync = jest.fn();
const mockWriteFileSync = jest.fn();
const mockMkdirSync = jest.fn();
// --- Mock Setup using unstable_mockModule ---
// Mock 'fs' *before* importing the module that uses it
jest.unstable_mockModule('fs', () => ({
__esModule: true, // Indicate it's an ES module mock
default: {
// Mock the default export if needed (less common for fs)
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync,
mkdirSync: mockMkdirSync
},
// Mock named exports directly
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync,
mkdirSync: mockMkdirSync
}));
// Mock path (optional, only if specific path logic needs testing)
// jest.unstable_mockModule('path');
// Mock chalk to prevent console formatting issues in tests
jest.unstable_mockModule('chalk', () => ({
__esModule: true,
default: {
yellow: jest.fn((text) => text),
red: jest.fn((text) => text),
green: jest.fn((text) => text)
},
yellow: jest.fn((text) => text),
red: jest.fn((text) => text),
green: jest.fn((text) => text)
}));
// Test Data
const MOCK_PROJECT_ROOT = '/mock/project';
const MOCK_CONFIG_PATH = path.join(MOCK_PROJECT_ROOT, '.taskmasterconfig');
const DEFAULT_CONFIG = {
models: {
main: { provider: 'anthropic', modelId: 'claude-3.7-sonnet-20250219' },
research: {
provider: 'perplexity',
modelId: 'sonar-pro'
}
}
};
const VALID_CUSTOM_CONFIG = {
models: {
main: { provider: 'openai', modelId: 'gpt-4o' },
research: { provider: 'google', modelId: 'gemini-1.5-pro-latest' }
}
};
const PARTIAL_CONFIG = {
models: {
main: { provider: 'openai', modelId: 'gpt-4-turbo' }
// research missing
}
};
const INVALID_PROVIDER_CONFIG = {
models: {
main: { provider: 'invalid-provider', modelId: 'some-model' },
research: {
provider: 'perplexity',
modelId: 'llama-3-sonar-large-32k-online'
}
}
};
// Dynamically import the module *after* setting up mocks
let configManager;
// Helper function to reset mocks
const resetMocks = () => {
mockExistsSync.mockReset();
mockReadFileSync.mockReset();
mockWriteFileSync.mockReset();
mockMkdirSync.mockReset();
// Default behaviors
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(DEFAULT_CONFIG));
};
// Set up module before tests
beforeAll(async () => {
resetMocks();
// Import after mocks are set up
configManager = await import('../../scripts/modules/config-manager.js');
// Use spyOn instead of trying to mock the module directly
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterAll(() => {
console.error.mockRestore();
console.warn.mockRestore();
});
// Reset mocks before each test
beforeEach(() => {
resetMocks();
});
// --- Validation Functions ---
describe('Validation Functions', () => {
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('grok')).toBe(true);
});
test('validateProvider should return false for invalid providers', () => {
expect(configManager.validateProvider('invalid-provider')).toBe(false);
expect(configManager.validateProvider('')).toBe(false);
expect(configManager.validateProvider(null)).toBe(false);
});
test('validateProviderModelCombination should validate known good combinations', () => {
expect(
configManager.validateProviderModelCombination('openai', 'gpt-4o')
).toBe(true);
expect(
configManager.validateProviderModelCombination(
'anthropic',
'claude-3.5-sonnet-20240620'
)
).toBe(true);
});
test('validateProviderModelCombination should return false for known bad combinations', () => {
expect(
configManager.validateProviderModelCombination(
'openai',
'claude-3-opus-20240229'
)
).toBe(false);
});
test('validateProviderModelCombination should return true for providers with empty model lists (ollama, openrouter)', () => {
expect(
configManager.validateProviderModelCombination(
'ollama',
'any-ollama-model'
)
).toBe(true);
expect(
configManager.validateProviderModelCombination(
'openrouter',
'some/model/name'
)
).toBe(true);
});
test('validateProviderModelCombination should return true for providers not in MODEL_MAP', () => {
// Assuming 'grok' is valid but not in MODEL_MAP for this test
expect(
configManager.validateProviderModelCombination('grok', 'grok-model-x')
).toBe(true);
});
});
// --- readConfig Tests ---
describe('readConfig', () => {
test('should return default config if .taskmasterconfig does not exist', () => {
// Mock that the config file doesn't exist
mockExistsSync.mockImplementation((path) => {
return path !== MOCK_CONFIG_PATH;
});
const config = configManager.readConfig(MOCK_PROJECT_ROOT);
expect(config).toEqual(DEFAULT_CONFIG);
expect(mockExistsSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH);
expect(mockReadFileSync).not.toHaveBeenCalled();
});
test('should read and parse valid config file', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(VALID_CUSTOM_CONFIG));
const config = configManager.readConfig(MOCK_PROJECT_ROOT);
expect(config).toEqual(VALID_CUSTOM_CONFIG);
expect(mockExistsSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH);
expect(mockReadFileSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH, 'utf-8');
});
test('should merge defaults for partial config file', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(PARTIAL_CONFIG));
const config = configManager.readConfig(MOCK_PROJECT_ROOT);
expect(config.models.main).toEqual(PARTIAL_CONFIG.models.main);
expect(config.models.research).toEqual(DEFAULT_CONFIG.models.research);
expect(mockReadFileSync).toHaveBeenCalled();
});
test('should handle JSON parsing error and return defaults', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('invalid json');
const config = configManager.readConfig(MOCK_PROJECT_ROOT);
expect(config).toEqual(DEFAULT_CONFIG);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('Error reading or parsing')
);
});
test('should handle file read error and return defaults', () => {
mockExistsSync.mockReturnValue(true);
const readError = new Error('Permission denied');
mockReadFileSync.mockImplementation(() => {
throw readError;
});
const config = configManager.readConfig(MOCK_PROJECT_ROOT);
expect(config).toEqual(DEFAULT_CONFIG);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining(
'Error reading or parsing /mock/project/.taskmasterconfig: Permission denied. Using default configuration.'
)
);
});
test('should validate provider and fallback to default if invalid', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(INVALID_PROVIDER_CONFIG));
const config = configManager.readConfig(MOCK_PROJECT_ROOT);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid main provider "invalid-provider"')
);
expect(config.models.main).toEqual(DEFAULT_CONFIG.models.main);
expect(config.models.research).toEqual(
INVALID_PROVIDER_CONFIG.models.research
);
});
});
// --- writeConfig Tests ---
describe('writeConfig', () => {
test('should write valid config to file', () => {
mockExistsSync.mockReturnValue(true);
const success = configManager.writeConfig(
VALID_CUSTOM_CONFIG,
MOCK_PROJECT_ROOT
);
expect(success).toBe(true);
expect(mockExistsSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH);
expect(mockWriteFileSync).toHaveBeenCalledWith(
MOCK_CONFIG_PATH,
JSON.stringify(VALID_CUSTOM_CONFIG, null, 2),
'utf-8'
);
});
test('should return false and log error if write fails', () => {
mockExistsSync.mockReturnValue(true);
const writeError = new Error('Disk full');
mockWriteFileSync.mockImplementation(() => {
throw writeError;
});
const success = configManager.writeConfig(
VALID_CUSTOM_CONFIG,
MOCK_PROJECT_ROOT
);
expect(success).toBe(false);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining(
'Error writing to /mock/project/.taskmasterconfig: Disk full.'
)
);
});
test('should return false if config file does not exist', () => {
mockExistsSync.mockReturnValue(false);
const success = configManager.writeConfig(
VALID_CUSTOM_CONFIG,
MOCK_PROJECT_ROOT
);
expect(success).toBe(false);
expect(mockWriteFileSync).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining(`.taskmasterconfig does not exist`)
);
});
});
// --- Getter/Setter Tests ---
describe('Getter and Setter Functions', () => {
test('getMainProvider should return provider from mocked config', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(VALID_CUSTOM_CONFIG));
const provider = configManager.getMainProvider(MOCK_PROJECT_ROOT);
expect(provider).toBe('openai');
expect(mockReadFileSync).toHaveBeenCalled();
});
test('getMainModelId should return modelId from mocked config', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(VALID_CUSTOM_CONFIG));
const modelId = configManager.getMainModelId(MOCK_PROJECT_ROOT);
expect(modelId).toBe('gpt-4o');
expect(mockReadFileSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH, 'utf-8');
});
test('getResearchProvider should return provider from mocked config', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(VALID_CUSTOM_CONFIG));
const provider = configManager.getResearchProvider(MOCK_PROJECT_ROOT);
expect(provider).toBe('google');
expect(mockReadFileSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH, 'utf-8');
});
test('getResearchModelId should return modelId from mocked config', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(VALID_CUSTOM_CONFIG));
const modelId = configManager.getResearchModelId(MOCK_PROJECT_ROOT);
expect(modelId).toBe('gemini-1.5-pro-latest');
expect(mockReadFileSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH, 'utf-8');
});
});
describe('setMainModel', () => {
beforeEach(() => {
resetMocks();
mockExistsSync.mockImplementation((path) => {
console.log(`>>> mockExistsSync called with: ${path}`);
return path.endsWith('.taskmasterconfig');
});
mockReadFileSync.mockImplementation((path, encoding) => {
console.log(`>>> mockReadFileSync called with: ${path}, ${encoding}`);
return JSON.stringify(DEFAULT_CONFIG);
});
});
test('should return false for invalid provider', () => {
console.log('>>> Test: Invalid provider');
const result = configManager.setMainModel('invalid-provider', 'some-model');
console.log('>>> After setMainModel(invalid-provider, some-model)');
console.log('>>> mockExistsSync calls:', mockExistsSync.mock.calls);
console.log('>>> mockReadFileSync calls:', mockReadFileSync.mock.calls);
expect(result).toBe(false);
expect(mockReadFileSync).not.toHaveBeenCalled();
expect(mockWriteFileSync).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
'Error: "invalid-provider" is not a valid provider.'
);
});
test('should update config for valid provider', () => {
console.log('>>> Test: Valid provider');
const result = configManager.setMainModel(
'openai',
'gpt-4',
MOCK_PROJECT_ROOT
);
console.log('>>> After setMainModel(openai, gpt-4, /mock/project)');
console.log('>>> mockExistsSync calls:', mockExistsSync.mock.calls);
console.log('>>> mockReadFileSync calls:', mockReadFileSync.mock.calls);
console.log('>>> mockWriteFileSync calls:', mockWriteFileSync.mock.calls);
expect(result).toBe(true);
expect(mockExistsSync).toHaveBeenCalled();
expect(mockReadFileSync).toHaveBeenCalled();
expect(mockWriteFileSync).toHaveBeenCalled();
// Check that the written config has the expected changes
const writtenConfig = JSON.parse(mockWriteFileSync.mock.calls[0][1]);
expect(writtenConfig.models.main.provider).toBe('openai');
expect(writtenConfig.models.main.modelId).toBe('gpt-4');
});
});
describe('setResearchModel', () => {
beforeEach(() => {
resetMocks();
});
test('should return false for invalid provider', () => {
const result = configManager.setResearchModel(
'invalid-provider',
'some-model'
);
expect(result).toBe(false);
expect(mockReadFileSync).not.toHaveBeenCalled();
expect(mockWriteFileSync).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
'Error: "invalid-provider" is not a valid provider.'
);
});
test('should update config for valid provider', () => {
const result = configManager.setResearchModel(
'google',
'gemini-1.5-pro-latest',
MOCK_PROJECT_ROOT
);
expect(result).toBe(true);
expect(mockExistsSync).toHaveBeenCalled();
expect(mockReadFileSync).toHaveBeenCalled();
expect(mockWriteFileSync).toHaveBeenCalled();
// Check that the written config has the expected changes
const writtenConfig = JSON.parse(mockWriteFileSync.mock.calls[0][1]);
expect(writtenConfig.models.research.provider).toBe('google');
expect(writtenConfig.models.research.modelId).toBe('gemini-1.5-pro-latest');
});
});