/** * Commands module tests */ import { jest } from '@jest/globals'; // Mock functions that need jest.fn methods const mockParsePRD = jest.fn().mockResolvedValue(undefined); const mockUpdateTaskById = jest.fn().mockResolvedValue({ id: 2, title: 'Updated Task', description: 'Updated description' }); const mockDisplayBanner = jest.fn(); const mockDisplayHelp = jest.fn(); const mockLog = jest.fn(); // Mock modules first jest.mock('fs', () => ({ existsSync: jest.fn(), readFileSync: jest.fn() })); jest.mock('path', () => ({ join: jest.fn((dir, file) => `${dir}/${file}`) })); 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) })); jest.mock('../../scripts/modules/ui.js', () => ({ displayBanner: mockDisplayBanner, displayHelp: mockDisplayHelp })); jest.mock('../../scripts/modules/task-manager.js', () => ({ parsePRD: mockParsePRD, updateTaskById: mockUpdateTaskById })); // Add this function before the mock of utils.js /** * Convert camelCase to kebab-case * @param {string} str - String to convert * @returns {string} kebab-case version of the input */ const toKebabCase = (str) => { return str .replace(/([a-z0-9])([A-Z])/g, '$1-$2') .toLowerCase() .replace(/^-/, ''); // Remove leading hyphen if present }; /** * Detect camelCase flags in command arguments * @param {string[]} args - Command line arguments to check * @returns {Array<{original: string, kebabCase: string}>} - List of flags that should be converted */ function detectCamelCaseFlags(args) { const camelCaseFlags = []; for (const arg of args) { if (arg.startsWith('--')) { const flagName = arg.split('=')[0].slice(2); // Remove -- and anything after = // Skip if it's a single word (no hyphens) or already in kebab-case if (!flagName.includes('-')) { // Check for camelCase pattern (lowercase followed by uppercase) if (/[a-z][A-Z]/.test(flagName)) { const kebabVersion = toKebabCase(flagName); if (kebabVersion !== flagName) { camelCaseFlags.push({ original: flagName, kebabCase: kebabVersion }); } } } } } return camelCaseFlags; } // Then update the utils.js mock to include these functions jest.mock('../../scripts/modules/utils.js', () => ({ CONFIG: { projectVersion: '1.5.0' }, log: mockLog, toKebabCase: toKebabCase, detectCamelCaseFlags: detectCamelCaseFlags })); // Import all modules after mocking import fs from 'fs'; import path from 'path'; import chalk from 'chalk'; import { setupCLI } from '../../scripts/modules/commands.js'; // We'll use a simplified, direct test approach instead of Commander mocking describe('Commands Module', () => { // Set up spies on the mocked modules const mockExistsSync = jest.spyOn(fs, 'existsSync'); const mockReadFileSync = jest.spyOn(fs, 'readFileSync'); const mockJoin = jest.spyOn(path, 'join'); const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); beforeEach(() => { jest.clearAllMocks(); mockExistsSync.mockReturnValue(true); }); afterAll(() => { jest.restoreAllMocks(); }); describe('setupCLI function', () => { test('should return Commander program instance', () => { const program = setupCLI(); expect(program).toBeDefined(); expect(program.name()).toBe('dev'); }); test('should read version from package.json when available', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue('{"version": "1.0.0"}'); mockJoin.mockReturnValue('package.json'); const program = setupCLI(); const version = program._version(); expect(mockReadFileSync).toHaveBeenCalledWith('package.json', 'utf8'); expect(version).toBe('1.0.0'); }); test('should use default version when package.json is not available', () => { mockExistsSync.mockReturnValue(false); const program = setupCLI(); const version = program._version(); expect(mockReadFileSync).not.toHaveBeenCalled(); expect(version).toBe('1.5.0'); }); test('should use default version when package.json reading throws an error', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockImplementation(() => { throw new Error('Invalid JSON'); }); const program = setupCLI(); const version = program._version(); expect(mockReadFileSync).toHaveBeenCalled(); expect(version).toBe('1.5.0'); }); }); describe('Kebab Case Validation', () => { test('should detect camelCase flags correctly', () => { const args = ['node', 'task-master', '--camelCase', '--kebab-case']; const camelCaseFlags = args.filter(arg => arg.startsWith('--') && /[A-Z]/.test(arg) && !arg.includes('-[A-Z]') ); expect(camelCaseFlags).toContain('--camelCase'); expect(camelCaseFlags).not.toContain('--kebab-case'); }); test('should accept kebab-case flags correctly', () => { const args = ['node', 'task-master', '--kebab-case']; const camelCaseFlags = args.filter(arg => arg.startsWith('--') && /[A-Z]/.test(arg) && !arg.includes('-[A-Z]') ); expect(camelCaseFlags).toHaveLength(0); }); }); describe('parse-prd command', () => { // Since mocking Commander is complex, we'll test the action handler directly // Recreate the action handler logic based on commands.js async function parsePrdAction(file, options) { // Use input option if file argument not provided const inputFile = file || options.input; const defaultPrdPath = 'scripts/prd.txt'; // If no input file specified, check for default PRD location if (!inputFile) { if (fs.existsSync(defaultPrdPath)) { console.log(chalk.blue(`Using default PRD file: ${defaultPrdPath}`)); const numTasks = parseInt(options.numTasks, 10); const outputPath = options.output; console.log(chalk.blue(`Generating ${numTasks} tasks...`)); await mockParsePRD(defaultPrdPath, outputPath, numTasks); return; } console.log(chalk.yellow('No PRD file specified and default PRD file not found at scripts/prd.txt.')); return; } const numTasks = parseInt(options.numTasks, 10); const outputPath = options.output; console.log(chalk.blue(`Parsing PRD file: ${inputFile}`)); console.log(chalk.blue(`Generating ${numTasks} tasks...`)); await mockParsePRD(inputFile, outputPath, numTasks); } beforeEach(() => { // Reset the parsePRD mock mockParsePRD.mockClear(); }); test('should use default PRD path when no arguments provided', async () => { // Arrange mockExistsSync.mockReturnValue(true); // Act - call the handler directly with the right params await parsePrdAction(undefined, { numTasks: '10', output: 'tasks/tasks.json' }); // Assert expect(mockExistsSync).toHaveBeenCalledWith('scripts/prd.txt'); expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Using default PRD file')); expect(mockParsePRD).toHaveBeenCalledWith( 'scripts/prd.txt', 'tasks/tasks.json', 10 // Default value from command definition ); }); test('should display help when no arguments and no default PRD exists', async () => { // Arrange mockExistsSync.mockReturnValue(false); // Act - call the handler directly with the right params await parsePrdAction(undefined, { numTasks: '10', output: 'tasks/tasks.json' }); // Assert expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('No PRD file specified')); expect(mockParsePRD).not.toHaveBeenCalled(); }); test('should use explicitly provided file path', async () => { // Arrange const testFile = 'test/prd.txt'; // Act - call the handler directly with the right params await parsePrdAction(testFile, { numTasks: '10', output: 'tasks/tasks.json' }); // Assert expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining(`Parsing PRD file: ${testFile}`)); expect(mockParsePRD).toHaveBeenCalledWith(testFile, 'tasks/tasks.json', 10); expect(mockExistsSync).not.toHaveBeenCalledWith('scripts/prd.txt'); }); test('should use file path from input option when provided', async () => { // Arrange const testFile = 'test/prd.txt'; // Act - call the handler directly with the right params await parsePrdAction(undefined, { input: testFile, numTasks: '10', output: 'tasks/tasks.json' }); // Assert expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining(`Parsing PRD file: ${testFile}`)); expect(mockParsePRD).toHaveBeenCalledWith(testFile, 'tasks/tasks.json', 10); expect(mockExistsSync).not.toHaveBeenCalledWith('scripts/prd.txt'); }); test('should respect numTasks and output options', async () => { // Arrange const testFile = 'test/prd.txt'; const outputFile = 'custom/output.json'; const numTasks = 15; // Act - call the handler directly with the right params await parsePrdAction(testFile, { numTasks: numTasks.toString(), output: outputFile }); // Assert expect(mockParsePRD).toHaveBeenCalledWith(testFile, outputFile, numTasks); }); }); describe('updateTask command', () => { // Since mocking Commander is complex, we'll test the action handler directly // Recreate the action handler logic based on commands.js async function updateTaskAction(options) { try { const tasksPath = options.file; // Validate required parameters if (!options.id) { console.error(chalk.red('Error: --id parameter is required')); console.log(chalk.yellow('Usage example: task-master update-task --id=23 --prompt="Update with new information"')); process.exit(1); return; // Add early return to prevent calling updateTaskById } // Parse the task ID and validate it's a number const taskId = parseInt(options.id, 10); if (isNaN(taskId) || taskId <= 0) { console.error(chalk.red(`Error: Invalid task ID: ${options.id}. Task ID must be a positive integer.`)); console.log(chalk.yellow('Usage example: task-master update-task --id=23 --prompt="Update with new information"')); process.exit(1); return; // Add early return to prevent calling updateTaskById } if (!options.prompt) { console.error(chalk.red('Error: --prompt parameter is required. Please provide information about the changes.')); console.log(chalk.yellow('Usage example: task-master update-task --id=23 --prompt="Update with new information"')); process.exit(1); return; // Add early return to prevent calling updateTaskById } const prompt = options.prompt; const useResearch = options.research || false; // Validate tasks file exists if (!fs.existsSync(tasksPath)) { console.error(chalk.red(`Error: Tasks file not found at path: ${tasksPath}`)); if (tasksPath === 'tasks/tasks.json') { console.log(chalk.yellow('Hint: Run task-master init or task-master parse-prd to create tasks.json first')); } else { console.log(chalk.yellow(`Hint: Check if the file path is correct: ${tasksPath}`)); } process.exit(1); return; // Add early return to prevent calling updateTaskById } console.log(chalk.blue(`Updating task ${taskId} with prompt: "${prompt}"`)); console.log(chalk.blue(`Tasks file: ${tasksPath}`)); if (useResearch) { // Verify Perplexity API key exists if using research if (!process.env.PERPLEXITY_API_KEY) { console.log(chalk.yellow('Warning: PERPLEXITY_API_KEY environment variable is missing. Research-backed updates will not be available.')); console.log(chalk.yellow('Falling back to Claude AI for task update.')); } else { console.log(chalk.blue('Using Perplexity AI for research-backed task update')); } } const result = await mockUpdateTaskById(tasksPath, taskId, prompt, useResearch); // If the task wasn't updated (e.g., if it was already marked as done) if (!result) { console.log(chalk.yellow('\nTask update was not completed. Review the messages above for details.')); } } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); // Provide more helpful error messages for common issues if (error.message.includes('task') && error.message.includes('not found')) { console.log(chalk.yellow('\nTo fix this issue:')); console.log(' 1. Run task-master list to see all available task IDs'); console.log(' 2. Use a valid task ID with the --id parameter'); } else if (error.message.includes('API key')) { console.log(chalk.yellow('\nThis error is related to API keys. Check your environment variables.')); } if (true) { // CONFIG.debug console.error(error); } process.exit(1); } } beforeEach(() => { // Reset all mocks jest.clearAllMocks(); // Set up spy for existsSync (already mocked in the outer scope) mockExistsSync.mockReturnValue(true); }); test('should validate required parameters - missing ID', async () => { // Set up the command options without ID const options = { file: 'test-tasks.json', prompt: 'Update the task' }; // Call the action directly await updateTaskAction(options); // Verify validation error expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('--id parameter is required')); expect(mockExit).toHaveBeenCalledWith(1); expect(mockUpdateTaskById).not.toHaveBeenCalled(); }); test('should validate required parameters - invalid ID', async () => { // Set up the command options with invalid ID const options = { file: 'test-tasks.json', id: 'not-a-number', prompt: 'Update the task' }; // Call the action directly await updateTaskAction(options); // Verify validation error expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid task ID')); expect(mockExit).toHaveBeenCalledWith(1); expect(mockUpdateTaskById).not.toHaveBeenCalled(); }); test('should validate required parameters - missing prompt', async () => { // Set up the command options without prompt const options = { file: 'test-tasks.json', id: '2' }; // Call the action directly await updateTaskAction(options); // Verify validation error expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('--prompt parameter is required')); expect(mockExit).toHaveBeenCalledWith(1); expect(mockUpdateTaskById).not.toHaveBeenCalled(); }); test('should validate tasks file exists', async () => { // Mock file not existing mockExistsSync.mockReturnValue(false); // Set up the command options const options = { file: 'missing-tasks.json', id: '2', prompt: 'Update the task' }; // Call the action directly await updateTaskAction(options); // Verify validation error expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Tasks file not found')); expect(mockExit).toHaveBeenCalledWith(1); expect(mockUpdateTaskById).not.toHaveBeenCalled(); }); test('should call updateTaskById with correct parameters', async () => { // Set up the command options const options = { file: 'test-tasks.json', id: '2', prompt: 'Update the task', research: true }; // Mock perplexity API key process.env.PERPLEXITY_API_KEY = 'dummy-key'; // Call the action directly await updateTaskAction(options); // Verify updateTaskById was called with correct parameters expect(mockUpdateTaskById).toHaveBeenCalledWith( 'test-tasks.json', 2, 'Update the task', true ); // Verify console output expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Updating task 2')); expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Using Perplexity AI')); // Clean up delete process.env.PERPLEXITY_API_KEY; }); test('should handle null result from updateTaskById', async () => { // Mock updateTaskById returning null (e.g., task already completed) mockUpdateTaskById.mockResolvedValueOnce(null); // Set up the command options const options = { file: 'test-tasks.json', id: '2', prompt: 'Update the task' }; // Call the action directly await updateTaskAction(options); // Verify updateTaskById was called expect(mockUpdateTaskById).toHaveBeenCalled(); // Verify console output for null result expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Task update was not completed')); }); test('should handle errors from updateTaskById', async () => { // Mock updateTaskById throwing an error mockUpdateTaskById.mockRejectedValueOnce(new Error('Task update failed')); // Set up the command options const options = { file: 'test-tasks.json', id: '2', prompt: 'Update the task' }; // Call the action directly await updateTaskAction(options); // Verify error handling expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Error: Task update failed')); expect(mockExit).toHaveBeenCalledWith(1); }); }); }); // Test the version comparison utility describe('Version comparison', () => { // Use a dynamic import for the commands module let compareVersions; beforeAll(async () => { // Import the function we want to test dynamically const commandsModule = await import('../../scripts/modules/commands.js'); compareVersions = commandsModule.compareVersions; }); test('compareVersions correctly compares semantic versions', () => { expect(compareVersions('1.0.0', '1.0.0')).toBe(0); expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); expect(compareVersions('1.0.1', '1.0.0')).toBe(1); expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); expect(compareVersions('1.1.0', '1.0.0')).toBe(1); expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); expect(compareVersions('2.0.0', '1.0.0')).toBe(1); expect(compareVersions('1.0', '1.0.0')).toBe(0); expect(compareVersions('1.0.0.0', '1.0.0')).toBe(0); expect(compareVersions('1.0.0', '1.0.0.1')).toBe(-1); }); }); // Test the update check functionality describe('Update check', () => { let displayUpgradeNotification; let consoleLogSpy; beforeAll(async () => { // Import the function we want to test dynamically const commandsModule = await import('../../scripts/modules/commands.js'); displayUpgradeNotification = commandsModule.displayUpgradeNotification; }); beforeEach(() => { // Spy on console.log consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { consoleLogSpy.mockRestore(); }); test('displays upgrade notification when newer version is available', () => { // Test displayUpgradeNotification function displayUpgradeNotification('1.0.0', '1.1.0'); expect(consoleLogSpy).toHaveBeenCalled(); expect(consoleLogSpy.mock.calls[0][0]).toContain('Update Available!'); expect(consoleLogSpy.mock.calls[0][0]).toContain('1.0.0'); expect(consoleLogSpy.mock.calls[0][0]).toContain('1.1.0'); }); });