Files
claude-task-master/tests/unit/commands.test.js
2025-04-09 00:30:05 +02:00

698 lines
20 KiB
JavaScript

/**
* 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');
});
});