Files
claude-task-master/tests/unit/task-manager.test.js
Ralph Khreish da317f2607 fix: error handling of task status settings (#523)
* fix: error handling of task status settings

* fix: update import path

---------

Co-authored-by: shenysun <shenysun@163.com>
2025-05-16 15:47:01 +02:00

3392 lines
99 KiB
JavaScript

/**
* Task Manager module tests
*/
import { jest } from '@jest/globals';
import fs from 'fs';
import path from 'path';
// Mock implementations
const mockReadFileSync = jest.fn();
const mockExistsSync = jest.fn();
const mockMkdirSync = jest.fn();
const mockDirname = jest.fn();
const mockCallClaude = jest.fn().mockResolvedValue({ tasks: [] }); // Default resolved value
const mockCallPerplexity = jest.fn().mockResolvedValue({ tasks: [] }); // Default resolved value
const mockWriteJSON = jest.fn();
const mockGenerateTaskFiles = jest.fn();
const mockWriteFileSync = jest.fn();
const mockFormatDependenciesWithStatus = jest.fn();
const mockDisplayTaskList = jest.fn();
const mockValidateAndFixDependencies = jest.fn();
const mockReadJSON = jest.fn();
const mockLog = jest.fn();
const mockIsTaskDependentOn = jest.fn().mockReturnValue(false);
const mockCreate = jest.fn(); // Mock for Anthropic messages.create
const mockChatCompletionsCreate = jest.fn(); // Mock for Perplexity chat.completions.create
const mockGetAvailableAIModel = jest.fn(); // <<<<< Added mock function
const mockPromptYesNo = jest.fn(); // Mock for confirmation prompt
// Mock fs module
jest.mock('fs', () => ({
readFileSync: mockReadFileSync,
existsSync: mockExistsSync,
mkdirSync: mockMkdirSync,
writeFileSync: mockWriteFileSync
}));
// Mock path module
jest.mock('path', () => ({
dirname: mockDirname,
join: jest.fn((dir, file) => `${dir}/${file}`)
}));
// Mock ui
jest.mock('../../scripts/modules/ui.js', () => ({
formatDependenciesWithStatus: mockFormatDependenciesWithStatus,
displayBanner: jest.fn(),
displayTaskList: mockDisplayTaskList,
startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), // <<<<< Added mock
stopLoadingIndicator: jest.fn(), // <<<<< Added mock
createProgressBar: jest.fn(() => ' MOCK_PROGRESS_BAR '), // <<<<< Added mock (used by listTasks)
getStatusWithColor: jest.fn((status) => status), // Basic mock for status
getComplexityWithColor: jest.fn((score) => `Score: ${score}`) // Basic mock for complexity
}));
// Mock dependency-manager
jest.mock('../../scripts/modules/dependency-manager.js', () => ({
validateAndFixDependencies: mockValidateAndFixDependencies,
validateTaskDependencies: jest.fn()
}));
// Mock utils
jest.mock('../../scripts/modules/utils.js', () => ({
writeJSON: mockWriteJSON,
readJSON: mockReadJSON,
log: mockLog,
CONFIG: {
// <<<<< Added CONFIG mock
model: 'mock-claude-model',
maxTokens: 4000,
temperature: 0.7,
debug: false,
defaultSubtasks: 3
// Add other necessary CONFIG properties if needed
},
sanitizePrompt: jest.fn((prompt) => prompt), // <<<<< Added mock
findTaskById: jest.fn((tasks, id) =>
tasks.find((t) => t.id === parseInt(id))
), // <<<<< Added mock
readComplexityReport: jest.fn(), // <<<<< Added mock
findTaskInComplexityReport: jest.fn(), // <<<<< Added mock
truncate: jest.fn((str, len) => str.slice(0, len)), // <<<<< Added mock
promptYesNo: mockPromptYesNo // Added mock for confirmation prompt
}));
// Mock AI services - Needs to be defined before importing the module that uses it
jest.mock('../../scripts/modules/ai-services-unified.js', () => ({
generateTextService: jest.fn(),
generateObjectService: jest.fn() // Ensure this mock function is created
}));
// Mock Anthropic SDK
jest.mock('@anthropic-ai/sdk', () => {
return {
Anthropic: jest.fn().mockImplementation(() => ({
messages: {
create: mockCreate
}
}))
};
});
// Mock Perplexity using OpenAI
jest.mock('openai', () => {
return {
default: jest.fn().mockImplementation(() => ({
chat: {
completions: {
create: mockChatCompletionsCreate
}
}
}))
};
});
// Mock the task-manager module itself (if needed, like for generateTaskFiles)
// jest.mock('../../scripts/modules/task-manager.js', ... )
// ---> ADD IMPORTS HERE <---
// Import the mocked service functions AFTER the mock is defined
import { generateObjectService } from '../../scripts/modules/ai-services-unified.js';
// Import the function to test AFTER mocks are defined
import { updateTasks } from '../../scripts/modules/task-manager.js';
// Create a simplified version of parsePRD for testing
const testParsePRD = async (prdPath, outputPath, numTasks, options = {}) => {
const { append = false } = options;
try {
// Handle existing tasks when append flag is true
let existingTasks = { tasks: [] };
let lastTaskId = 0;
// Check if the output file already exists
if (mockExistsSync(outputPath)) {
if (append) {
// Simulate reading existing tasks.json
existingTasks = {
tasks: [
{ id: 1, title: 'Existing Task 1', status: 'done' },
{ id: 2, title: 'Existing Task 2', status: 'pending' }
]
};
lastTaskId = 2; // Highest existing ID
} else {
const confirmOverwrite = await mockPromptYesNo(
`Warning: ${outputPath} already exists. Overwrite?`,
false
);
if (!confirmOverwrite) {
console.log(`Operation cancelled. ${outputPath} was not modified.`);
return null;
}
}
}
const prdContent = mockReadFileSync(prdPath, 'utf8');
// Modify mockCallClaude to accept lastTaskId parameter
let newTasks = await mockCallClaude(prdContent, prdPath, numTasks);
// Merge tasks if appending
const tasksData = append
? {
...existingTasks,
tasks: [...existingTasks.tasks, ...newTasks.tasks]
}
: newTasks;
const dir = mockDirname(outputPath);
if (!mockExistsSync(dir)) {
mockMkdirSync(dir, { recursive: true });
}
mockWriteJSON(outputPath, tasksData);
await mockGenerateTaskFiles(outputPath, dir);
return tasksData;
} catch (error) {
console.error(`Error parsing PRD: ${error.message}`);
process.exit(1);
}
};
// Create a simplified version of setTaskStatus for testing
const testSetTaskStatus = (tasksData, taskIdInput, newStatus) => {
// Handle multiple task IDs (comma-separated)
const taskIds = taskIdInput.split(',').map((id) => id.trim());
const updatedTasks = [];
// Update each task
for (const id of taskIds) {
testUpdateSingleTaskStatus(tasksData, id, newStatus);
updatedTasks.push(id);
}
return tasksData;
};
// Simplified version of updateSingleTaskStatus for testing
const testUpdateSingleTaskStatus = (tasksData, taskIdInput, newStatus) => {
if (!isValidTaskStatus(newStatus)) {
throw new Error(
`Error: Invalid status value: ${newStatus}. Use one of: ${TASK_STATUS_OPTIONS.join(', ')}`
);
}
// Check if it's a subtask (e.g., "1.2")
if (taskIdInput.includes('.')) {
const [parentId, subtaskId] = taskIdInput
.split('.')
.map((id) => parseInt(id, 10));
// Find the parent task
const parentTask = tasksData.tasks.find((t) => t.id === parentId);
if (!parentTask) {
throw new Error(`Parent task ${parentId} not found`);
}
// Find the subtask
if (!parentTask.subtasks) {
throw new Error(`Parent task ${parentId} has no subtasks`);
}
const subtask = parentTask.subtasks.find((st) => st.id === subtaskId);
if (!subtask) {
throw new Error(
`Subtask ${subtaskId} not found in parent task ${parentId}`
);
}
// Update the subtask status
subtask.status = newStatus;
// Check if all subtasks are done (if setting to 'done')
if (
newStatus.toLowerCase() === 'done' ||
newStatus.toLowerCase() === 'completed'
) {
const allSubtasksDone = parentTask.subtasks.every(
(st) => st.status === 'done' || st.status === 'completed'
);
// For testing, we don't need to output suggestions
}
} else {
// Handle regular task
const taskId = parseInt(taskIdInput, 10);
const task = tasksData.tasks.find((t) => t.id === taskId);
if (!task) {
throw new Error(`Task ${taskId} not found`);
}
// Update the task status
task.status = newStatus;
// If marking as done, also mark all subtasks as done
if (
(newStatus.toLowerCase() === 'done' ||
newStatus.toLowerCase() === 'completed') &&
task.subtasks &&
task.subtasks.length > 0
) {
task.subtasks.forEach((subtask) => {
subtask.status = newStatus;
});
}
}
return true;
};
// Create a simplified version of listTasks for testing
const testListTasks = (tasksData, statusFilter, withSubtasks = false) => {
// Filter tasks by status if specified
const filteredTasks = statusFilter
? tasksData.tasks.filter(
(task) =>
task.status &&
task.status.toLowerCase() === statusFilter.toLowerCase()
)
: tasksData.tasks;
// Call the displayTaskList mock for testing
mockDisplayTaskList(tasksData, statusFilter, withSubtasks);
return {
filteredTasks,
tasksData
};
};
// Create a simplified version of addTask for testing
const testAddTask = (
tasksData,
taskPrompt,
dependencies = [],
priority = 'medium'
) => {
// Create a new task with a higher ID
const highestId = Math.max(...tasksData.tasks.map((t) => t.id));
const newId = highestId + 1;
// Create mock task based on what would be generated by AI
const newTask = {
id: newId,
title: `Task from prompt: ${taskPrompt.substring(0, 20)}...`,
description: `Task generated from: ${taskPrompt}`,
status: 'pending',
dependencies: dependencies,
priority: priority,
details: `Implementation details for task generated from prompt: ${taskPrompt}`,
testStrategy: 'Write unit tests to verify functionality'
};
// Check dependencies
for (const depId of dependencies) {
const dependency = tasksData.tasks.find((t) => t.id === depId);
if (!dependency) {
throw new Error(`Dependency task ${depId} not found`);
}
}
// Add task to tasks array
tasksData.tasks.push(newTask);
return {
updatedData: tasksData,
newTask
};
};
// Import after mocks
import * as taskManager from '../../scripts/modules/task-manager.js';
import { sampleClaudeResponse } from '../fixtures/sample-claude-response.js';
import { sampleTasks, emptySampleTasks } from '../fixtures/sample-tasks.js';
import {
isValidTaskStatus,
TASK_STATUS_OPTIONS
} from '../../src/constants/task-status.js';
// Destructure the required functions for convenience
const { findNextTask, generateTaskFiles, clearSubtasks, updateTaskById } =
taskManager;
describe('Task Manager Module', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('findNextTask function', () => {
test('should return the highest priority task with all dependencies satisfied', () => {
const tasks = [
{
id: 1,
title: 'Setup Project',
status: 'done',
dependencies: [],
priority: 'high'
},
{
id: 2,
title: 'Implement Core Features',
status: 'pending',
dependencies: [1],
priority: 'high'
},
{
id: 3,
title: 'Create Documentation',
status: 'pending',
dependencies: [1],
priority: 'medium'
},
{
id: 4,
title: 'Deploy Application',
status: 'pending',
dependencies: [2, 3],
priority: 'high'
}
];
const nextTask = findNextTask(tasks);
expect(nextTask).toBeDefined();
expect(nextTask.id).toBe(2);
expect(nextTask.title).toBe('Implement Core Features');
});
test('should prioritize by priority level when dependencies are equal', () => {
const tasks = [
{
id: 1,
title: 'Setup Project',
status: 'done',
dependencies: [],
priority: 'high'
},
{
id: 2,
title: 'Low Priority Task',
status: 'pending',
dependencies: [1],
priority: 'low'
},
{
id: 3,
title: 'Medium Priority Task',
status: 'pending',
dependencies: [1],
priority: 'medium'
},
{
id: 4,
title: 'High Priority Task',
status: 'pending',
dependencies: [1],
priority: 'high'
}
];
const nextTask = findNextTask(tasks);
expect(nextTask.id).toBe(4);
expect(nextTask.priority).toBe('high');
});
test('should return null when all tasks are completed', () => {
const tasks = [
{
id: 1,
title: 'Setup Project',
status: 'done',
dependencies: [],
priority: 'high'
},
{
id: 2,
title: 'Implement Features',
status: 'done',
dependencies: [1],
priority: 'high'
}
];
const nextTask = findNextTask(tasks);
expect(nextTask).toBeNull();
});
test('should return null when all pending tasks have unsatisfied dependencies', () => {
const tasks = [
{
id: 1,
title: 'Setup Project',
status: 'pending',
dependencies: [2],
priority: 'high'
},
{
id: 2,
title: 'Implement Features',
status: 'pending',
dependencies: [1],
priority: 'high'
}
];
const nextTask = findNextTask(tasks);
expect(nextTask).toBeNull();
});
test('should handle empty tasks array', () => {
const nextTask = findNextTask([]);
expect(nextTask).toBeNull();
});
});
describe('analyzeTaskComplexity function', () => {
// Setup common test variables
const tasksPath = 'tasks/tasks.json';
const reportPath = 'scripts/task-complexity-report.json';
const thresholdScore = 5;
const baseOptions = {
file: tasksPath,
output: reportPath,
threshold: thresholdScore.toString(),
research: false // Default to false
};
// Sample response structure (simplified for these tests)
const sampleApiResponse = {
tasks: [
{ id: 1, complexity: 3, subtaskCount: 2 },
{ id: 2, complexity: 7, subtaskCount: 5 },
{ id: 3, complexity: 9, subtaskCount: 8 }
]
};
beforeEach(() => {
jest.clearAllMocks();
// Setup default mock implementations
mockReadJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks)));
mockWriteJSON.mockImplementation((path, data) => data); // Return data for chaining/assertions
// Just set the mock resolved values directly - no spies needed
mockCallClaude.mockResolvedValue(sampleApiResponse);
mockCallPerplexity.mockResolvedValue(sampleApiResponse);
// Mock console methods to prevent test output clutter
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
// Restore console methods
console.log.mockRestore();
console.error.mockRestore();
});
test('should call Claude when research flag is false', async () => {
// Arrange
const options = { ...baseOptions, research: false };
// Act
await testAnalyzeTaskComplexity(options);
// Assert
expect(mockCallClaude).toHaveBeenCalled();
expect(mockCallPerplexity).not.toHaveBeenCalled();
expect(mockWriteJSON).toHaveBeenCalledWith(
reportPath,
expect.any(Object)
);
});
test('should call Perplexity when research flag is true', async () => {
// Arrange
const options = { ...baseOptions, research: true };
// Act
await testAnalyzeTaskComplexity(options);
// Assert
expect(mockCallPerplexity).toHaveBeenCalled();
expect(mockCallClaude).not.toHaveBeenCalled();
expect(mockWriteJSON).toHaveBeenCalledWith(
reportPath,
expect.any(Object)
);
});
test('should handle valid JSON response from LLM (Claude)', async () => {
// Arrange
const options = { ...baseOptions, research: false };
// Act
await testAnalyzeTaskComplexity(options);
// Assert
expect(mockReadJSON).toHaveBeenCalledWith(tasksPath);
expect(mockCallClaude).toHaveBeenCalled();
expect(mockCallPerplexity).not.toHaveBeenCalled();
expect(mockWriteJSON).toHaveBeenCalledWith(
reportPath,
expect.objectContaining({
complexityAnalysis: expect.arrayContaining([
expect.objectContaining({ taskId: 1 })
])
})
);
expect(mockLog).toHaveBeenCalledWith(
'info',
expect.stringContaining('Successfully analyzed')
);
});
test('should handle and fix malformed JSON string response (Claude)', async () => {
// Arrange
const malformedJsonResponse = {
tasks: [{ id: 1, complexity: 3 }]
};
mockCallClaude.mockResolvedValueOnce(malformedJsonResponse);
const options = { ...baseOptions, research: false };
// Act
await testAnalyzeTaskComplexity(options);
// Assert
expect(mockCallClaude).toHaveBeenCalled();
expect(mockCallPerplexity).not.toHaveBeenCalled();
expect(mockWriteJSON).toHaveBeenCalled();
});
test('should handle missing tasks in the response (Claude)', async () => {
// Arrange
const incompleteResponse = { tasks: [sampleApiResponse.tasks[0]] };
mockCallClaude.mockResolvedValueOnce(incompleteResponse);
const options = { ...baseOptions, research: false };
// Act
await testAnalyzeTaskComplexity(options);
// Assert
expect(mockCallClaude).toHaveBeenCalled();
expect(mockCallPerplexity).not.toHaveBeenCalled();
expect(mockWriteJSON).toHaveBeenCalled();
});
// Add a new test specifically for threshold handling
test('should handle different threshold parameter types correctly', async () => {
// Test with string threshold
let options = { ...baseOptions, threshold: '7' };
const report1 = await testAnalyzeTaskComplexity(options);
expect(report1.meta.thresholdScore).toBe(7);
expect(mockCallClaude).toHaveBeenCalled();
// Reset mocks
jest.clearAllMocks();
// Test with number threshold
options = { ...baseOptions, threshold: 8 };
const report2 = await testAnalyzeTaskComplexity(options);
expect(report2.meta.thresholdScore).toBe(8);
expect(mockCallClaude).toHaveBeenCalled();
// Reset mocks
jest.clearAllMocks();
// Test with float threshold
options = { ...baseOptions, threshold: 6.5 };
const report3 = await testAnalyzeTaskComplexity(options);
expect(report3.meta.thresholdScore).toBe(6.5);
expect(mockCallClaude).toHaveBeenCalled();
// Reset mocks
jest.clearAllMocks();
// Test with undefined threshold (should use default)
const { threshold, ...optionsWithoutThreshold } = baseOptions;
const report4 = await testAnalyzeTaskComplexity(optionsWithoutThreshold);
expect(report4.meta.thresholdScore).toBe(5); // Default value from the function
expect(mockCallClaude).toHaveBeenCalled();
});
});
describe('parsePRD function', () => {
// Mock the sample PRD content
const samplePRDContent = '# Sample PRD for Testing';
// Mock existing tasks for append test
const existingTasks = {
tasks: [
{ id: 1, title: 'Existing Task 1', status: 'done' },
{ id: 2, title: 'Existing Task 2', status: 'pending' }
]
};
// Mock new tasks with continuing IDs for append test
const newTasksWithContinuedIds = {
tasks: [
{ id: 3, title: 'New Task 3' },
{ id: 4, title: 'New Task 4' }
]
};
// Mock merged tasks for append test
const mergedTasks = {
tasks: [...existingTasks.tasks, ...newTasksWithContinuedIds.tasks]
};
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Set up mocks for fs, path and other modules
mockReadFileSync.mockReturnValue(samplePRDContent);
mockExistsSync.mockReturnValue(true);
mockDirname.mockReturnValue('tasks');
mockCallClaude.mockResolvedValue(sampleClaudeResponse);
mockGenerateTaskFiles.mockResolvedValue(undefined);
mockPromptYesNo.mockResolvedValue(true); // Default to "yes" for confirmation
});
test('should parse a PRD file and generate tasks', async () => {
// Call the test version of parsePRD
await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
// Verify fs.readFileSync was called with the correct arguments
expect(mockReadFileSync).toHaveBeenCalledWith('path/to/prd.txt', 'utf8');
// Verify callClaude was called with the correct arguments
expect(mockCallClaude).toHaveBeenCalledWith(
samplePRDContent,
'path/to/prd.txt',
3
);
// Verify directory check
expect(mockExistsSync).toHaveBeenCalledWith('tasks');
// Verify writeJSON was called with the correct arguments
expect(mockWriteJSON).toHaveBeenCalledWith(
'tasks/tasks.json',
sampleClaudeResponse
);
// Verify generateTaskFiles was called
expect(mockGenerateTaskFiles).toHaveBeenCalledWith(
'tasks/tasks.json',
'tasks'
);
});
test('should create the tasks directory if it does not exist', async () => {
// Mock existsSync to return false specifically for the directory check
// but true for the output file check (so we don't trigger confirmation path)
mockExistsSync.mockImplementation((path) => {
if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
if (path === 'tasks') return false; // Directory doesn't exist
return true; // Default for other paths
});
// Call the function
await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
// Verify mkdir was called
expect(mockMkdirSync).toHaveBeenCalledWith('tasks', { recursive: true });
});
test('should handle errors in the PRD parsing process', async () => {
// Mock an error in callClaude
const testError = new Error('Test error in Claude API call');
mockCallClaude.mockRejectedValueOnce(testError);
// Mock console.error and process.exit
const mockConsoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockProcessExit = jest
.spyOn(process, 'exit')
.mockImplementation(() => {});
// Call the function
await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
// Verify error handling
expect(mockConsoleError).toHaveBeenCalled();
expect(mockProcessExit).toHaveBeenCalledWith(1);
// Restore mocks
mockConsoleError.mockRestore();
mockProcessExit.mockRestore();
});
test('should generate individual task files after creating tasks.json', async () => {
// Call the function
await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
// Verify generateTaskFiles was called
expect(mockGenerateTaskFiles).toHaveBeenCalledWith(
'tasks/tasks.json',
'tasks'
);
});
test('should prompt for confirmation when tasks.json already exists', async () => {
// Setup mocks to simulate tasks.json already exists
mockExistsSync.mockImplementation((path) => {
if (path === 'tasks/tasks.json') return true; // Output file exists
if (path === 'tasks') return true; // Directory exists
return false;
});
// Call the function
await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
// Verify prompt was called with expected message
expect(mockPromptYesNo).toHaveBeenCalledWith(
'Warning: tasks/tasks.json already exists. Overwrite?',
false
);
// Verify the file was written after confirmation
expect(mockWriteJSON).toHaveBeenCalledWith(
'tasks/tasks.json',
sampleClaudeResponse
);
});
test('should not overwrite tasks.json when user declines confirmation', async () => {
// Setup mocks to simulate tasks.json already exists
mockExistsSync.mockImplementation((path) => {
if (path === 'tasks/tasks.json') return true; // Output file exists
if (path === 'tasks') return true; // Directory exists
return false;
});
// Mock user declining the confirmation
mockPromptYesNo.mockResolvedValueOnce(false);
// Mock console.log to capture output
const mockConsoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => {});
// Call the function
const result = await testParsePRD(
'path/to/prd.txt',
'tasks/tasks.json',
3
);
// Verify prompt was called
expect(mockPromptYesNo).toHaveBeenCalledWith(
'Warning: tasks/tasks.json already exists. Overwrite?',
false
);
// Verify the file was NOT written
expect(mockWriteJSON).not.toHaveBeenCalled();
// Verify appropriate message was logged
expect(mockConsoleLog).toHaveBeenCalledWith(
'Operation cancelled. tasks/tasks.json was not modified.'
);
// Verify result is null when operation is cancelled
expect(result).toBeNull();
// Restore console.log
mockConsoleLog.mockRestore();
});
test('should not prompt for confirmation when tasks.json does not exist', async () => {
// Setup mocks to simulate tasks.json does not exist
mockExistsSync.mockImplementation((path) => {
if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
if (path === 'tasks') return true; // Directory exists
return false;
});
// Call the function
await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
// Verify prompt was NOT called
expect(mockPromptYesNo).not.toHaveBeenCalled();
// Verify the file was written without confirmation
expect(mockWriteJSON).toHaveBeenCalledWith(
'tasks/tasks.json',
sampleClaudeResponse
);
});
test('should append new tasks when append option is true', async () => {
// Setup mocks to simulate tasks.json already exists
mockExistsSync.mockImplementation((path) => {
if (path === 'tasks/tasks.json') return true; // Output file exists
if (path === 'tasks') return true; // Directory exists
return false;
});
// Mock for reading existing tasks
mockReadJSON.mockReturnValue(existingTasks);
// mockReadJSON = jest.fn().mockReturnValue(existingTasks);
// Mock callClaude to return new tasks with continuing IDs
mockCallClaude.mockResolvedValueOnce(newTasksWithContinuedIds);
// Call the function with append option
const result = await testParsePRD(
'path/to/prd.txt',
'tasks/tasks.json',
2,
{ append: true }
);
// Verify prompt was NOT called (no confirmation needed for append)
expect(mockPromptYesNo).not.toHaveBeenCalled();
// Verify the file was written with merged tasks
expect(mockWriteJSON).toHaveBeenCalledWith(
'tasks/tasks.json',
expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 1 }),
expect.objectContaining({ id: 2 }),
expect.objectContaining({ id: 3 }),
expect.objectContaining({ id: 4 })
])
})
);
// Verify the result contains merged tasks
expect(result.tasks.length).toBe(4);
});
test('should skip prompt and not overwrite when append is true', async () => {
// Setup mocks to simulate tasks.json already exists
mockExistsSync.mockImplementation((path) => {
if (path === 'tasks/tasks.json') return true; // Output file exists
if (path === 'tasks') return true; // Directory exists
return false;
});
// Call the function with append option
await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
append: true
});
// Verify prompt was NOT called with append flag
expect(mockPromptYesNo).not.toHaveBeenCalled();
});
});
describe.skip('updateTasks function', () => {
test('should update tasks based on new context', async () => {
// This test would verify that:
// 1. The function reads the tasks file correctly
// 2. It filters tasks with ID >= fromId and not 'done'
// 3. It properly calls the AI model with the correct prompt
// 4. It updates the tasks with the AI response
// 5. It writes the updated tasks back to the file
expect(true).toBe(true);
});
test('should handle streaming responses from Claude API', async () => {
// This test would verify that:
// 1. The function correctly handles streaming API calls
// 2. It processes the stream data properly
// 3. It combines the chunks into a complete response
expect(true).toBe(true);
});
test('should use Perplexity AI when research flag is set', async () => {
// This test would verify that:
// 1. The function uses Perplexity when the research flag is set
// 2. It formats the prompt correctly for Perplexity
// 3. It properly processes the Perplexity response
expect(true).toBe(true);
});
test('should handle no tasks to update', async () => {
// This test would verify that:
// 1. The function handles the case when no tasks need updating
// 2. It provides appropriate feedback to the user
expect(true).toBe(true);
});
test('should handle errors during the update process', async () => {
// This test would verify that:
// 1. The function handles errors in the AI API calls
// 2. It provides appropriate error messages
// 3. It exits gracefully
expect(true).toBe(true);
});
});
describe('generateTaskFiles function', () => {
// Sample task data for testing
const sampleTasks = {
meta: { projectName: 'Test Project' },
tasks: [
{
id: 1,
title: 'Task 1',
description: 'First task description',
status: 'pending',
dependencies: [],
priority: 'high',
details: 'Detailed information for task 1',
testStrategy: 'Test strategy for task 1'
},
{
id: 2,
title: 'Task 2',
description: 'Second task description',
status: 'pending',
dependencies: [1],
priority: 'medium',
details: 'Detailed information for task 2',
testStrategy: 'Test strategy for task 2'
},
{
id: 3,
title: 'Task with Subtasks',
description: 'Task with subtasks description',
status: 'pending',
dependencies: [1, 2],
priority: 'high',
details: 'Detailed information for task 3',
testStrategy: 'Test strategy for task 3',
subtasks: [
{
id: 1,
title: 'Subtask 1',
description: 'First subtask',
status: 'pending',
dependencies: [],
details: 'Details for subtask 1'
},
{
id: 2,
title: 'Subtask 2',
description: 'Second subtask',
status: 'pending',
dependencies: [1],
details: 'Details for subtask 2'
}
]
}
]
};
test('should generate task files from tasks.json - working test', () => {
// Set up mocks for this specific test
mockReadJSON.mockImplementationOnce(() => sampleTasks);
mockExistsSync.mockImplementationOnce(() => true);
// Implement a simplified version of generateTaskFiles
const tasksPath = 'tasks/tasks.json';
const outputDir = 'tasks';
// Manual implementation instead of calling the function
// 1. Read the data
const data = mockReadJSON(tasksPath);
expect(mockReadJSON).toHaveBeenCalledWith(tasksPath);
// 2. Validate and fix dependencies
mockValidateAndFixDependencies(data, tasksPath);
expect(mockValidateAndFixDependencies).toHaveBeenCalledWith(
data,
tasksPath
);
// 3. Generate files
data.tasks.forEach((task) => {
const taskPath = `${outputDir}/task_${task.id.toString().padStart(3, '0')}.txt`;
let content = `# Task ID: ${task.id}\n`;
content += `# Title: ${task.title}\n`;
mockWriteFileSync(taskPath, content);
});
// Verify the files were written
expect(mockWriteFileSync).toHaveBeenCalledTimes(3);
// Verify specific file paths
expect(mockWriteFileSync).toHaveBeenCalledWith(
'tasks/task_001.txt',
expect.any(String)
);
expect(mockWriteFileSync).toHaveBeenCalledWith(
'tasks/task_002.txt',
expect.any(String)
);
expect(mockWriteFileSync).toHaveBeenCalledWith(
'tasks/task_003.txt',
expect.any(String)
);
});
// Skip the remaining tests for now until we get the basic test working
test.skip('should format dependencies with status indicators', () => {
// Test implementation
});
test.skip('should handle tasks with no subtasks', () => {
// Test implementation
});
test.skip("should create the output directory if it doesn't exist", () => {
// This test skipped until we find a better way to mock the modules
// The key functionality is:
// 1. When outputDir doesn't exist (fs.existsSync returns false)
// 2. The function should call fs.mkdirSync to create it
});
test.skip('should format task files with proper sections', () => {
// Test implementation
});
test.skip('should include subtasks in task files when present', () => {
// Test implementation
});
test.skip('should handle errors during file generation', () => {
// Test implementation
});
test.skip('should validate dependencies before generating files', () => {
// Test implementation
});
});
describe('setTaskStatus function', () => {
test('should update task status in tasks.json', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Act
const updatedData = testSetTaskStatus(testTasksData, '2', 'done');
// Assert
expect(updatedData.tasks[1].id).toBe(2);
expect(updatedData.tasks[1].status).toBe('done');
});
test('should update subtask status when using dot notation', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Act
const updatedData = testSetTaskStatus(testTasksData, '3.1', 'done');
// Assert
const subtaskParent = updatedData.tasks.find((t) => t.id === 3);
expect(subtaskParent).toBeDefined();
expect(subtaskParent.subtasks[0].status).toBe('done');
});
test('should update multiple tasks when given comma-separated IDs', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Act
const updatedData = testSetTaskStatus(testTasksData, '1,2', 'pending');
// Assert
expect(updatedData.tasks[0].status).toBe('pending');
expect(updatedData.tasks[1].status).toBe('pending');
});
test('should automatically mark subtasks as done when parent is marked done', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Act
const updatedData = testSetTaskStatus(testTasksData, '3', 'done');
// Assert
const parentTask = updatedData.tasks.find((t) => t.id === 3);
expect(parentTask.status).toBe('done');
expect(parentTask.subtasks[0].status).toBe('done');
expect(parentTask.subtasks[1].status).toBe('done');
});
test('should throw error for non-existent task ID', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Assert
expect(() => testSetTaskStatus(testTasksData, '99', 'done')).toThrow(
'Task 99 not found'
);
});
});
describe('updateSingleTaskStatus function', () => {
test('should update regular task status', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Act
const result = testUpdateSingleTaskStatus(testTasksData, '2', 'done');
// Assert
expect(result).toBe(true);
expect(testTasksData.tasks[1].status).toBe('done');
});
test('should throw error for invalid status', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Assert
expect(() =>
testUpdateSingleTaskStatus(testTasksData, '2', 'Done')
).toThrow(/Error: Invalid status value: Done./);
});
test('should update subtask status', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Act
const result = testUpdateSingleTaskStatus(testTasksData, '3.1', 'done');
// Assert
expect(result).toBe(true);
expect(testTasksData.tasks[2].subtasks[0].status).toBe('done');
});
test('should handle parent tasks without subtasks', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Remove subtasks from task 3
const taskWithoutSubtasks = { ...testTasksData.tasks[2] };
delete taskWithoutSubtasks.subtasks;
testTasksData.tasks[2] = taskWithoutSubtasks;
// Assert
expect(() =>
testUpdateSingleTaskStatus(testTasksData, '3.1', 'done')
).toThrow('has no subtasks');
});
test('should handle non-existent subtask ID', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Assert
expect(() =>
testUpdateSingleTaskStatus(testTasksData, '3.99', 'done')
).toThrow('Subtask 99 not found');
});
});
describe('listTasks function', () => {
test('should display all tasks when no filter is provided', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Act
const result = testListTasks(testTasksData);
// Assert
expect(result.filteredTasks.length).toBe(testTasksData.tasks.length);
expect(mockDisplayTaskList).toHaveBeenCalledWith(
testTasksData,
undefined,
false
);
});
test('should filter tasks by status when filter is provided', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
const statusFilter = 'done';
// Act
const result = testListTasks(testTasksData, statusFilter);
// Assert
expect(result.filteredTasks.length).toBe(
testTasksData.tasks.filter((t) => t.status === statusFilter).length
);
expect(mockDisplayTaskList).toHaveBeenCalledWith(
testTasksData,
statusFilter,
false
);
});
test('should display subtasks when withSubtasks flag is true', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
// Act
testListTasks(testTasksData, undefined, true);
// Assert
expect(mockDisplayTaskList).toHaveBeenCalledWith(
testTasksData,
undefined,
true
);
});
test('should handle empty tasks array', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(emptySampleTasks));
// Act
const result = testListTasks(testTasksData);
// Assert
expect(result.filteredTasks.length).toBe(0);
expect(mockDisplayTaskList).toHaveBeenCalledWith(
testTasksData,
undefined,
false
);
});
});
describe.skip('expandTask function', () => {
test('should generate subtasks for a task', async () => {
// This test would verify that:
// 1. The function reads the tasks file correctly
// 2. It finds the target task by ID
// 3. It generates subtasks with unique IDs
// 4. It adds the subtasks to the task
// 5. It writes the updated tasks back to the file
expect(true).toBe(true);
});
test('should use complexity report for subtask count', async () => {
// This test would verify that:
// 1. The function checks for a complexity report
// 2. It uses the recommended subtask count from the report
// 3. It uses the expansion prompt from the report
expect(true).toBe(true);
});
test('should use Perplexity AI when research flag is set', async () => {
// This test would verify that:
// 1. The function uses Perplexity for research-backed generation
// 2. It handles the Perplexity response correctly
expect(true).toBe(true);
});
test('should append subtasks to existing ones', async () => {
// This test would verify that:
// 1. The function appends new subtasks to existing ones
// 2. It generates unique subtask IDs
expect(true).toBe(true);
});
test('should skip completed tasks', async () => {
// This test would verify that:
// 1. The function skips tasks marked as done or completed
// 2. It provides appropriate feedback
expect(true).toBe(true);
});
test('should handle errors during subtask generation', async () => {
// This test would verify that:
// 1. The function handles errors in the AI API calls
// 2. It provides appropriate error messages
// 3. It exits gracefully
expect(true).toBe(true);
});
});
describe.skip('expandAllTasks function', () => {
test('should expand all pending tasks', async () => {
// This test would verify that:
// 1. The function identifies all pending tasks
// 2. It expands each task with appropriate subtasks
// 3. It writes the updated tasks back to the file
expect(true).toBe(true);
});
test('should sort tasks by complexity when report is available', async () => {
// This test would verify that:
// 1. The function reads the complexity report
// 2. It sorts tasks by complexity score
// 3. It prioritizes high-complexity tasks
expect(true).toBe(true);
});
test('should skip tasks with existing subtasks unless force flag is set', async () => {
// This test would verify that:
// 1. The function skips tasks with existing subtasks
// 2. It processes them when force flag is set
expect(true).toBe(true);
});
test('should use task-specific parameters from complexity report', async () => {
// This test would verify that:
// 1. The function uses task-specific subtask counts
// 2. It uses task-specific expansion prompts
expect(true).toBe(true);
});
test('should handle empty tasks array', async () => {
// This test would verify that:
// 1. The function handles an empty tasks array gracefully
// 2. It displays an appropriate message
expect(true).toBe(true);
});
test('should handle errors for individual tasks without failing the entire operation', async () => {
// This test would verify that:
// 1. The function continues processing tasks even if some fail
// 2. It reports errors for individual tasks
// 3. It completes the operation for successful tasks
expect(true).toBe(true);
});
});
describe('clearSubtasks function', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// Test implementation of clearSubtasks that just returns the updated data
const testClearSubtasks = (tasksData, taskIds) => {
// Create a deep copy of the data to avoid modifying the original
const data = JSON.parse(JSON.stringify(tasksData));
let clearedCount = 0;
// Handle multiple task IDs (comma-separated)
const taskIdArray = taskIds.split(',').map((id) => id.trim());
taskIdArray.forEach((taskId) => {
const id = parseInt(taskId, 10);
if (isNaN(id)) {
return;
}
const task = data.tasks.find((t) => t.id === id);
if (!task) {
// Log error for non-existent task
mockLog('error', `Task ${id} not found`);
return;
}
if (!task.subtasks || task.subtasks.length === 0) {
// No subtasks to clear
return;
}
const subtaskCount = task.subtasks.length;
delete task.subtasks;
clearedCount++;
});
return { data, clearedCount };
};
test('should clear subtasks from a specific task', () => {
// Create a deep copy of the sample data
const testData = JSON.parse(JSON.stringify(sampleTasks));
// Execute the test function
const { data, clearedCount } = testClearSubtasks(testData, '3');
// Verify results
expect(clearedCount).toBe(1);
// Verify the task's subtasks were removed
const task = data.tasks.find((t) => t.id === 3);
expect(task).toBeDefined();
expect(task.subtasks).toBeUndefined();
});
test('should clear subtasks from multiple tasks when given comma-separated IDs', () => {
// Setup data with subtasks on multiple tasks
const testData = JSON.parse(JSON.stringify(sampleTasks));
// Add subtasks to task 2
testData.tasks[1].subtasks = [
{
id: 1,
title: 'Test Subtask',
description: 'A test subtask',
status: 'pending',
dependencies: []
}
];
// Execute the test function
const { data, clearedCount } = testClearSubtasks(testData, '2,3');
// Verify results
expect(clearedCount).toBe(2);
// Verify both tasks had their subtasks cleared
const task2 = data.tasks.find((t) => t.id === 2);
const task3 = data.tasks.find((t) => t.id === 3);
expect(task2.subtasks).toBeUndefined();
expect(task3.subtasks).toBeUndefined();
});
test('should handle tasks with no subtasks', () => {
// Task 1 has no subtasks in the sample data
const testData = JSON.parse(JSON.stringify(sampleTasks));
// Execute the test function
const { clearedCount } = testClearSubtasks(testData, '1');
// Verify no tasks were cleared
expect(clearedCount).toBe(0);
});
test('should handle non-existent task IDs', () => {
const testData = JSON.parse(JSON.stringify(sampleTasks));
// Execute the test function
testClearSubtasks(testData, '99');
// Verify an error was logged
expect(mockLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('Task 99 not found')
);
});
test('should handle multiple task IDs including both valid and non-existent IDs', () => {
const testData = JSON.parse(JSON.stringify(sampleTasks));
// Execute the test function
const { data, clearedCount } = testClearSubtasks(testData, '3,99');
// Verify results
expect(clearedCount).toBe(1);
expect(mockLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('Task 99 not found')
);
// Verify the valid task's subtasks were removed
const task3 = data.tasks.find((t) => t.id === 3);
expect(task3.subtasks).toBeUndefined();
});
});
describe('addTask function', () => {
test('should add a new task using AI', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
const prompt = 'Create a new authentication system';
// Act
const result = testAddTask(testTasksData, prompt);
// Assert
expect(result.newTask.id).toBe(
Math.max(...sampleTasks.tasks.map((t) => t.id)) + 1
);
expect(result.newTask.status).toBe('pending');
expect(result.newTask.title).toContain(prompt.substring(0, 20));
expect(testTasksData.tasks.length).toBe(sampleTasks.tasks.length + 1);
});
test('should validate dependencies when adding a task', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
const prompt = 'Create a new authentication system';
const validDependencies = [1, 2]; // These exist in sampleTasks
// Act
const result = testAddTask(testTasksData, prompt, validDependencies);
// Assert
expect(result.newTask.dependencies).toEqual(validDependencies);
// Test invalid dependency
expect(() => {
testAddTask(testTasksData, prompt, [999]); // Non-existent task ID
}).toThrow('Dependency task 999 not found');
});
test('should use specified priority', async () => {
// Arrange
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
const prompt = 'Create a new authentication system';
const priority = 'high';
// Act
const result = testAddTask(testTasksData, prompt, [], priority);
// Assert
expect(result.newTask.priority).toBe(priority);
});
});
// Add test suite for addSubtask function
describe('addSubtask function', () => {
// Reset mocks before each test
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementations
mockReadJSON.mockImplementation(() => ({
tasks: [
{
id: 1,
title: 'Parent Task',
description: 'This is a parent task',
status: 'pending',
dependencies: []
},
{
id: 2,
title: 'Existing Task',
description: 'This is an existing task',
status: 'pending',
dependencies: []
},
{
id: 3,
title: 'Another Task',
description: 'This is another task',
status: 'pending',
dependencies: [1]
}
]
}));
// Setup success write response
mockWriteJSON.mockImplementation((path, data) => {
return data;
});
// Set up default behavior for dependency check
mockIsTaskDependentOn.mockReturnValue(false);
});
test('should add a new subtask to a parent task', async () => {
// Create new subtask data
const newSubtaskData = {
title: 'New Subtask',
description: 'This is a new subtask',
details: 'Implementation details for the subtask',
status: 'pending',
dependencies: []
};
// Execute the test version of addSubtask
const newSubtask = testAddSubtask(
'tasks/tasks.json',
1,
null,
newSubtaskData,
true
);
// Verify readJSON was called with the correct path
expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json');
// Verify writeJSON was called with the correct path
expect(mockWriteJSON).toHaveBeenCalledWith(
'tasks/tasks.json',
expect.any(Object)
);
// Verify the subtask was created with correct data
expect(newSubtask).toBeDefined();
expect(newSubtask.id).toBe(1);
expect(newSubtask.title).toBe('New Subtask');
expect(newSubtask.parentTaskId).toBe(1);
// Verify generateTaskFiles was called
expect(mockGenerateTaskFiles).toHaveBeenCalled();
});
test('should convert an existing task to a subtask', async () => {
// Execute the test version of addSubtask to convert task 2 to a subtask of task 1
const convertedSubtask = testAddSubtask(
'tasks/tasks.json',
1,
2,
null,
true
);
// Verify readJSON was called with the correct path
expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json');
// Verify writeJSON was called
expect(mockWriteJSON).toHaveBeenCalled();
// Verify the subtask was created with correct data
expect(convertedSubtask).toBeDefined();
expect(convertedSubtask.id).toBe(1);
expect(convertedSubtask.title).toBe('Existing Task');
expect(convertedSubtask.parentTaskId).toBe(1);
// Verify generateTaskFiles was called
expect(mockGenerateTaskFiles).toHaveBeenCalled();
});
test('should throw an error if parent task does not exist', async () => {
// Create new subtask data
const newSubtaskData = {
title: 'New Subtask',
description: 'This is a new subtask'
};
// Override mockReadJSON for this specific test case
mockReadJSON.mockImplementationOnce(() => ({
tasks: [
{
id: 1,
title: 'Task 1',
status: 'pending'
}
]
}));
// Expect an error when trying to add a subtask to a non-existent parent
expect(() =>
testAddSubtask('tasks/tasks.json', 999, null, newSubtaskData)
).toThrow(/Parent task with ID 999 not found/);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
});
test('should throw an error if existing task does not exist', async () => {
// Expect an error when trying to convert a non-existent task
expect(() => testAddSubtask('tasks/tasks.json', 1, 999, null)).toThrow(
/Task with ID 999 not found/
);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
});
test('should throw an error if trying to create a circular dependency', async () => {
// Force the isTaskDependentOn mock to return true for this test only
mockIsTaskDependentOn.mockReturnValueOnce(true);
// Expect an error when trying to create a circular dependency
expect(() => testAddSubtask('tasks/tasks.json', 3, 1, null)).toThrow(
/circular dependency/
);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
});
test('should not regenerate task files if generateFiles is false', async () => {
// Create new subtask data
const newSubtaskData = {
title: 'New Subtask',
description: 'This is a new subtask'
};
// Execute the test version of addSubtask with generateFiles = false
testAddSubtask('tasks/tasks.json', 1, null, newSubtaskData, false);
// Verify writeJSON was called
expect(mockWriteJSON).toHaveBeenCalled();
// Verify task files were not regenerated
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
});
// Test suite for removeSubtask function
describe('removeSubtask function', () => {
// Reset mocks before each test
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementations
mockReadJSON.mockImplementation(() => ({
tasks: [
{
id: 1,
title: 'Parent Task',
description: 'This is a parent task',
status: 'pending',
dependencies: [],
subtasks: [
{
id: 1,
title: 'Subtask 1',
description: 'This is subtask 1',
status: 'pending',
dependencies: [],
parentTaskId: 1
},
{
id: 2,
title: 'Subtask 2',
description: 'This is subtask 2',
status: 'in-progress',
dependencies: [1], // Depends on subtask 1
parentTaskId: 1
}
]
},
{
id: 2,
title: 'Another Task',
description: 'This is another task',
status: 'pending',
dependencies: [1]
}
]
}));
// Setup success write response
mockWriteJSON.mockImplementation((path, data) => {
return data;
});
});
test('should remove a subtask from its parent task', async () => {
// Execute the test version of removeSubtask to remove subtask 1.1
testRemoveSubtask('tasks/tasks.json', '1.1', false, true);
// Verify readJSON was called with the correct path
expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json');
// Verify writeJSON was called with updated data
expect(mockWriteJSON).toHaveBeenCalled();
// Verify generateTaskFiles was called
expect(mockGenerateTaskFiles).toHaveBeenCalled();
});
test('should convert a subtask to a standalone task', async () => {
// Execute the test version of removeSubtask to convert subtask 1.1 to a standalone task
const result = testRemoveSubtask('tasks/tasks.json', '1.1', true, true);
// Verify the result is the new task
expect(result).toBeDefined();
expect(result.id).toBe(3);
expect(result.title).toBe('Subtask 1');
expect(result.dependencies).toContain(1);
// Verify writeJSON was called
expect(mockWriteJSON).toHaveBeenCalled();
// Verify generateTaskFiles was called
expect(mockGenerateTaskFiles).toHaveBeenCalled();
});
test('should throw an error if subtask ID format is invalid', async () => {
// Expect an error for invalid subtask ID format
expect(() => testRemoveSubtask('tasks/tasks.json', '1', false)).toThrow(
/Invalid subtask ID format/
);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
});
test('should throw an error if parent task does not exist', async () => {
// Expect an error for non-existent parent task
expect(() =>
testRemoveSubtask('tasks/tasks.json', '999.1', false)
).toThrow(/Parent task with ID 999 not found/);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
});
test('should throw an error if subtask does not exist', async () => {
// Expect an error for non-existent subtask
expect(() =>
testRemoveSubtask('tasks/tasks.json', '1.999', false)
).toThrow(/Subtask 1.999 not found/);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
});
test('should remove subtasks array if last subtask is removed', async () => {
// Create a data object with just one subtask
mockReadJSON.mockImplementationOnce(() => ({
tasks: [
{
id: 1,
title: 'Parent Task',
description: 'This is a parent task',
status: 'pending',
dependencies: [],
subtasks: [
{
id: 1,
title: 'Last Subtask',
description: 'This is the last subtask',
status: 'pending',
dependencies: [],
parentTaskId: 1
}
]
},
{
id: 2,
title: 'Another Task',
description: 'This is another task',
status: 'pending',
dependencies: [1]
}
]
}));
// Mock the behavior of writeJSON to capture the updated tasks data
const updatedTasksData = { tasks: [] };
mockWriteJSON.mockImplementation((path, data) => {
// Store the data for assertions
updatedTasksData.tasks = [...data.tasks];
return data;
});
// Remove the last subtask
testRemoveSubtask('tasks/tasks.json', '1.1', false, true);
// Verify writeJSON was called
expect(mockWriteJSON).toHaveBeenCalled();
// Verify the subtasks array was removed completely
const parentTask = updatedTasksData.tasks.find((t) => t.id === 1);
expect(parentTask).toBeDefined();
expect(parentTask.subtasks).toBeUndefined();
// Verify generateTaskFiles was called
expect(mockGenerateTaskFiles).toHaveBeenCalled();
});
test('should not regenerate task files if generateFiles is false', async () => {
// Execute the test version of removeSubtask with generateFiles = false
testRemoveSubtask('tasks/tasks.json', '1.1', false, false);
// Verify writeJSON was called
expect(mockWriteJSON).toHaveBeenCalled();
// Verify task files were not regenerated
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
});
describe.skip('updateTaskById function', () => {
let mockConsoleLog;
let mockConsoleError;
let mockProcess;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Set up default mock values
mockExistsSync.mockReturnValue(true);
mockWriteJSON.mockImplementation(() => {});
mockGenerateTaskFiles.mockResolvedValue(undefined);
// Create a deep copy of sample tasks for tests - use imported ES module instead of require
const sampleTasksDeepCopy = JSON.parse(JSON.stringify(sampleTasks));
mockReadJSON.mockReturnValue(sampleTasksDeepCopy);
// Mock console and process.exit
mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
mockConsoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
mockProcess = jest.spyOn(process, 'exit').mockImplementation(() => {});
});
afterEach(() => {
// Restore console and process.exit
mockConsoleLog.mockRestore();
mockConsoleError.mockRestore();
mockProcess.mockRestore();
});
test('should update a task successfully', async () => {
// Mock the return value of messages.create and Anthropic
const mockTask = {
id: 2,
title: 'Updated Core Functionality',
description: 'Updated description',
status: 'in-progress',
dependencies: [1],
priority: 'high',
details: 'Updated details',
testStrategy: 'Updated test strategy'
};
// Mock streaming for successful response
const mockStream = {
[Symbol.asyncIterator]: jest.fn().mockImplementation(() => {
return {
next: jest
.fn()
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: {
text: '{"id": 2, "title": "Updated Core Functionality",'
}
}
})
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: {
text: '"description": "Updated description", "status": "in-progress",'
}
}
})
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: {
text: '"dependencies": [1], "priority": "high", "details": "Updated details",'
}
}
})
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: { text: '"testStrategy": "Updated test strategy"}' }
}
})
.mockResolvedValueOnce({ done: true })
};
})
};
mockCreate.mockResolvedValue(mockStream);
// Call the function
const result = await updateTaskById(
'test-tasks.json',
2,
'Update task 2 with new information'
);
// Verify the task was updated
expect(result).toBeDefined();
expect(result.title).toBe('Updated Core Functionality');
expect(result.description).toBe('Updated description');
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockCreate).toHaveBeenCalled();
expect(mockWriteJSON).toHaveBeenCalled();
expect(mockGenerateTaskFiles).toHaveBeenCalled();
// Verify the task was updated in the tasks data
const tasksData = mockWriteJSON.mock.calls[0][1];
const updatedTask = tasksData.tasks.find((task) => task.id === 2);
expect(updatedTask).toEqual(mockTask);
});
test('should return null when task is already completed', async () => {
// Call the function with a completed task
const result = await updateTaskById(
'test-tasks.json',
1,
'Update task 1 with new information'
);
// Verify the result is null
expect(result).toBeNull();
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockCreate).not.toHaveBeenCalled();
expect(mockWriteJSON).not.toHaveBeenCalled();
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
test('should handle task not found error', async () => {
// Call the function with a non-existent task
const result = await updateTaskById(
'test-tasks.json',
999,
'Update non-existent task'
);
// Verify the result is null
expect(result).toBeNull();
// Verify the error was logged
expect(mockLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('Task with ID 999 not found')
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining('Task with ID 999 not found')
);
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockCreate).not.toHaveBeenCalled();
expect(mockWriteJSON).not.toHaveBeenCalled();
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
test('should preserve completed subtasks', async () => {
// Modify the sample data to have a task with completed subtasks
const tasksData = mockReadJSON();
const task = tasksData.tasks.find((t) => t.id === 3);
if (task && task.subtasks && task.subtasks.length > 0) {
// Mark the first subtask as completed
task.subtasks[0].status = 'done';
task.subtasks[0].title = 'Completed Header Component';
mockReadJSON.mockReturnValue(tasksData);
}
// Mock a response that tries to modify the completed subtask
const mockStream = {
[Symbol.asyncIterator]: jest.fn().mockImplementation(() => {
return {
next: jest
.fn()
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: { text: '{"id": 3, "title": "Updated UI Components",' }
}
})
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: {
text: '"description": "Updated description", "status": "pending",'
}
}
})
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: {
text: '"dependencies": [2], "priority": "medium", "subtasks": ['
}
}
})
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: {
text: '{"id": 1, "title": "Modified Header Component", "status": "pending"},'
}
}
})
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: {
text: '{"id": 2, "title": "Create Footer Component", "status": "pending"}]}'
}
}
})
.mockResolvedValueOnce({ done: true })
};
})
};
mockCreate.mockResolvedValue(mockStream);
// Call the function
const result = await updateTaskById(
'test-tasks.json',
3,
'Update UI components task'
);
// Verify the subtasks were preserved
expect(result).toBeDefined();
expect(result.subtasks[0].title).toBe('Completed Header Component');
expect(result.subtasks[0].status).toBe('done');
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockCreate).toHaveBeenCalled();
expect(mockWriteJSON).toHaveBeenCalled();
expect(mockGenerateTaskFiles).toHaveBeenCalled();
});
test('should handle missing tasks file', async () => {
// Mock file not existing
mockExistsSync.mockReturnValue(false);
// Call the function
const result = await updateTaskById(
'missing-tasks.json',
2,
'Update task'
);
// Verify the result is null
expect(result).toBeNull();
// Verify the error was logged
expect(mockLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('Tasks file not found')
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining('Tasks file not found')
);
// Verify the correct functions were called
expect(mockReadJSON).not.toHaveBeenCalled();
expect(mockCreate).not.toHaveBeenCalled();
expect(mockWriteJSON).not.toHaveBeenCalled();
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
test('should handle API errors', async () => {
// Mock API error
mockCreate.mockRejectedValue(new Error('API error'));
// Call the function
const result = await updateTaskById('test-tasks.json', 2, 'Update task');
// Verify the result is null
expect(result).toBeNull();
// Verify the error was logged
expect(mockLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('API error')
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining('API error')
);
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockCreate).toHaveBeenCalled();
expect(mockWriteJSON).not.toHaveBeenCalled(); // Should not write on error
expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); // Should not generate on error
});
test('should use Perplexity AI when research flag is true', async () => {
// Mock Perplexity API response
const mockPerplexityResponse = {
choices: [
{
message: {
content:
'{"id": 2, "title": "Researched Core Functionality", "description": "Research-backed description", "status": "in-progress", "dependencies": [1], "priority": "high", "details": "Research-backed details", "testStrategy": "Research-backed test strategy"}'
}
}
]
};
mockChatCompletionsCreate.mockResolvedValue(mockPerplexityResponse);
// Set the Perplexity API key in environment
process.env.PERPLEXITY_API_KEY = 'dummy-key';
// Call the function with research flag
const result = await updateTaskById(
'test-tasks.json',
2,
'Update task with research',
true
);
// Verify the task was updated with research-backed information
expect(result).toBeDefined();
expect(result.title).toBe('Researched Core Functionality');
expect(result.description).toBe('Research-backed description');
// Verify the Perplexity API was called
expect(mockChatCompletionsCreate).toHaveBeenCalled();
expect(mockCreate).not.toHaveBeenCalled(); // Claude should not be called
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockWriteJSON).toHaveBeenCalled();
expect(mockGenerateTaskFiles).toHaveBeenCalled();
// Clean up
delete process.env.PERPLEXITY_API_KEY;
});
});
// Mock implementation of updateSubtaskById for testing
const testUpdateSubtaskById = async (
tasksPath,
subtaskId,
prompt,
useResearch = false
) => {
try {
// Parse parent and subtask IDs
if (
!subtaskId ||
typeof subtaskId !== 'string' ||
!subtaskId.includes('.')
) {
throw new Error(`Invalid subtask ID format: ${subtaskId}`);
}
const [parentIdStr, subtaskIdStr] = subtaskId.split('.');
const parentId = parseInt(parentIdStr, 10);
const subtaskIdNum = parseInt(subtaskIdStr, 10);
if (
isNaN(parentId) ||
parentId <= 0 ||
isNaN(subtaskIdNum) ||
subtaskIdNum <= 0
) {
throw new Error(`Invalid subtask ID format: ${subtaskId}`);
}
// Validate prompt
if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') {
throw new Error('Prompt cannot be empty');
}
// Check if tasks file exists
if (!mockExistsSync(tasksPath)) {
throw new Error(`Tasks file not found at path: ${tasksPath}`);
}
// Read the tasks file
const data = mockReadJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
}
// Find the parent task
const parentTask = data.tasks.find((t) => t.id === parentId);
if (!parentTask) {
throw new Error(`Parent task with ID ${parentId} not found`);
}
// Find the subtask
if (!parentTask.subtasks || !Array.isArray(parentTask.subtasks)) {
throw new Error(`Parent task ${parentId} has no subtasks`);
}
const subtask = parentTask.subtasks.find((st) => st.id === subtaskIdNum);
if (!subtask) {
throw new Error(`Subtask with ID ${subtaskId} not found`);
}
// Check if subtask is already completed
if (subtask.status === 'done' || subtask.status === 'completed') {
return null;
}
// Generate additional information
let additionalInformation;
if (useResearch) {
const result = await mockChatCompletionsCreate();
additionalInformation = result.choices[0].message.content;
} else {
const mockStream = {
[Symbol.asyncIterator]: jest.fn().mockImplementation(() => {
return {
next: jest
.fn()
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: { text: 'Additional information about' }
}
})
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: { text: ' the subtask implementation.' }
}
})
.mockResolvedValueOnce({ done: true })
};
})
};
const stream = await mockCreate();
additionalInformation =
'Additional information about the subtask implementation.';
}
// Create timestamp
const timestamp = new Date().toISOString();
// Format the additional information with timestamp
const formattedInformation = `\n\n<info added on ${timestamp}>\n${additionalInformation}\n</info added on ${timestamp}>`;
// Append to subtask details
if (subtask.details) {
subtask.details += formattedInformation;
} else {
subtask.details = formattedInformation;
}
// Update description with update marker for shorter updates
if (subtask.description && additionalInformation.length < 200) {
subtask.description += ` [Updated: ${new Date().toLocaleDateString()}]`;
}
// Write the updated tasks to the file
mockWriteJSON(tasksPath, data);
// Generate individual task files
await mockGenerateTaskFiles(tasksPath, path.dirname(tasksPath));
return subtask;
} catch (error) {
mockLog('error', `Error updating subtask: ${error.message}`);
return null;
}
};
describe.skip('updateSubtaskById function', () => {
let mockConsoleLog;
let mockConsoleError;
let mockProcess;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Set up default mock values
mockExistsSync.mockReturnValue(true);
mockWriteJSON.mockImplementation(() => {});
mockGenerateTaskFiles.mockResolvedValue(undefined);
// Create a deep copy of sample tasks for tests - use imported ES module instead of require
const sampleTasksDeepCopy = JSON.parse(JSON.stringify(sampleTasks));
// Ensure the sample tasks has a task with subtasks for testing
// Task 3 should have subtasks
if (sampleTasksDeepCopy.tasks && sampleTasksDeepCopy.tasks.length > 2) {
const task3 = sampleTasksDeepCopy.tasks.find((t) => t.id === 3);
if (task3 && (!task3.subtasks || task3.subtasks.length === 0)) {
task3.subtasks = [
{
id: 1,
title: 'Create Header Component',
description: 'Create a reusable header component',
status: 'pending'
},
{
id: 2,
title: 'Create Footer Component',
description: 'Create a reusable footer component',
status: 'pending'
}
];
}
}
mockReadJSON.mockReturnValue(sampleTasksDeepCopy);
// Mock console and process.exit
mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
mockConsoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
mockProcess = jest.spyOn(process, 'exit').mockImplementation(() => {});
});
afterEach(() => {
// Restore console and process.exit
mockConsoleLog.mockRestore();
mockConsoleError.mockRestore();
mockProcess.mockRestore();
});
test('should update a subtask successfully', async () => {
// Mock streaming for successful response
const mockStream = {
[Symbol.asyncIterator]: jest.fn().mockImplementation(() => {
return {
next: jest
.fn()
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: {
text: 'Additional information about the subtask implementation.'
}
}
})
.mockResolvedValueOnce({ done: true })
};
})
};
mockCreate.mockResolvedValue(mockStream);
// Call the function
const result = await testUpdateSubtaskById(
'test-tasks.json',
'3.1',
'Add details about API endpoints'
);
// Verify the subtask was updated
expect(result).toBeDefined();
expect(result.details).toContain('<info added on');
expect(result.details).toContain(
'Additional information about the subtask implementation'
);
expect(result.details).toContain('</info added on');
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockCreate).toHaveBeenCalled();
expect(mockWriteJSON).toHaveBeenCalled();
expect(mockGenerateTaskFiles).toHaveBeenCalled();
// Verify the subtask was updated in the tasks data
const tasksData = mockWriteJSON.mock.calls[0][1];
const parentTask = tasksData.tasks.find((task) => task.id === 3);
const updatedSubtask = parentTask.subtasks.find((st) => st.id === 1);
expect(updatedSubtask.details).toContain(
'Additional information about the subtask implementation'
);
});
test('should return null when subtask is already completed', async () => {
// Modify the sample data to have a completed subtask
const tasksData = mockReadJSON();
const task = tasksData.tasks.find((t) => t.id === 3);
if (task && task.subtasks && task.subtasks.length > 0) {
// Mark the first subtask as completed
task.subtasks[0].status = 'done';
mockReadJSON.mockReturnValue(tasksData);
}
// Call the function with a completed subtask
const result = await testUpdateSubtaskById(
'test-tasks.json',
'3.1',
'Update completed subtask'
);
// Verify the result is null
expect(result).toBeNull();
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockCreate).not.toHaveBeenCalled();
expect(mockWriteJSON).not.toHaveBeenCalled();
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
test('should handle subtask not found error', async () => {
// Call the function with a non-existent subtask
const result = await testUpdateSubtaskById(
'test-tasks.json',
'3.999',
'Update non-existent subtask'
);
// Verify the result is null
expect(result).toBeNull();
// Verify the error was logged
expect(mockLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('Subtask with ID 3.999 not found')
);
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockCreate).not.toHaveBeenCalled();
expect(mockWriteJSON).not.toHaveBeenCalled();
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
test('should handle invalid subtask ID format', async () => {
// Call the function with an invalid subtask ID
const result = await testUpdateSubtaskById(
'test-tasks.json',
'invalid-id',
'Update subtask with invalid ID'
);
// Verify the result is null
expect(result).toBeNull();
// Verify the error was logged
expect(mockLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('Invalid subtask ID format')
);
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockCreate).not.toHaveBeenCalled();
expect(mockWriteJSON).not.toHaveBeenCalled();
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
test('should handle missing tasks file', async () => {
// Mock file not existing
mockExistsSync.mockReturnValue(false);
// Call the function
const result = await testUpdateSubtaskById(
'missing-tasks.json',
'3.1',
'Update subtask'
);
// Verify the result is null
expect(result).toBeNull();
// Verify the error was logged
expect(mockLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('Tasks file not found')
);
// Verify the correct functions were called
expect(mockReadJSON).not.toHaveBeenCalled();
expect(mockCreate).not.toHaveBeenCalled();
expect(mockWriteJSON).not.toHaveBeenCalled();
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
test('should handle empty prompt', async () => {
// Call the function with an empty prompt
const result = await testUpdateSubtaskById('test-tasks.json', '3.1', '');
// Verify the result is null
expect(result).toBeNull();
// Verify the error was logged
expect(mockLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('Prompt cannot be empty')
);
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockCreate).not.toHaveBeenCalled();
expect(mockWriteJSON).not.toHaveBeenCalled();
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
test('should use Perplexity AI when research flag is true', async () => {
// Mock Perplexity API response
const mockPerplexityResponse = {
choices: [
{
message: {
content:
'Research-backed information about the subtask implementation.'
}
}
]
};
mockChatCompletionsCreate.mockResolvedValue(mockPerplexityResponse);
// Set the Perplexity API key in environment
process.env.PERPLEXITY_API_KEY = 'dummy-key';
// Call the function with research flag
const result = await testUpdateSubtaskById(
'test-tasks.json',
'3.1',
'Add research-backed details',
true
);
// Verify the subtask was updated with research-backed information
expect(result).toBeDefined();
expect(result.details).toContain('<info added on');
expect(result.details).toContain(
'Research-backed information about the subtask implementation'
);
expect(result.details).toContain('</info added on');
// Verify the Perplexity API was called
expect(mockChatCompletionsCreate).toHaveBeenCalled();
expect(mockCreate).not.toHaveBeenCalled(); // Claude should not be called
// Verify the correct functions were called
expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json');
expect(mockWriteJSON).toHaveBeenCalled();
expect(mockGenerateTaskFiles).toHaveBeenCalled();
// Clean up
delete process.env.PERPLEXITY_API_KEY;
});
test('should append timestamp correctly in XML-like format', async () => {
// Mock streaming for successful response
const mockStream = {
[Symbol.asyncIterator]: jest.fn().mockImplementation(() => {
return {
next: jest
.fn()
.mockResolvedValueOnce({
done: false,
value: {
type: 'content_block_delta',
delta: {
text: 'Additional information about the subtask implementation.'
}
}
})
.mockResolvedValueOnce({ done: true })
};
})
};
mockCreate.mockResolvedValue(mockStream);
// Call the function
const result = await testUpdateSubtaskById(
'test-tasks.json',
'3.1',
'Add details about API endpoints'
);
// Verify the XML-like format with timestamp
expect(result).toBeDefined();
expect(result.details).toMatch(
/<info added on [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z>/
);
expect(result.details).toMatch(
/<\/info added on [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z>/
);
// Verify the same timestamp is used in both opening and closing tags
const openingMatch = result.details.match(
/<info added on ([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z)>/
);
const closingMatch = result.details.match(
/<\/info added on ([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z)>/
);
expect(openingMatch).toBeTruthy();
expect(closingMatch).toBeTruthy();
expect(openingMatch[1]).toBe(closingMatch[1]);
});
let mockTasksData;
const tasksPath = 'test-tasks.json';
const outputDir = 'test-tasks-output'; // Assuming generateTaskFiles needs this
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
// Reset mock data (deep copy to avoid test interference)
mockTasksData = JSON.parse(
JSON.stringify({
tasks: [
{
id: 1,
title: 'Parent Task 1',
status: 'pending',
dependencies: [],
priority: 'medium',
description: 'Parent description',
details: 'Parent details',
testStrategy: 'Parent tests',
subtasks: [
{
id: 1,
title: 'Subtask 1.1',
description: 'Subtask 1.1 description',
details: 'Initial subtask details.',
status: 'pending',
dependencies: []
},
{
id: 2,
title: 'Subtask 1.2',
description: 'Subtask 1.2 description',
details: 'Initial subtask details for 1.2.',
status: 'done', // Completed subtask
dependencies: []
}
]
}
]
})
);
// Default mock behaviors
mockReadJSON.mockReturnValue(mockTasksData);
mockDirname.mockReturnValue(outputDir); // Mock path.dirname needed by generateTaskFiles
mockGenerateTaskFiles.mockResolvedValue(); // Assume generateTaskFiles succeeds
});
test('should successfully update subtask using Claude (non-research)', async () => {
const subtaskIdToUpdate = '1.1'; // Valid format
const updatePrompt = 'Add more technical details about API integration.'; // Non-empty prompt
const expectedClaudeResponse =
'Here are the API integration details you requested.';
// --- Arrange ---
// **Explicitly reset and configure mocks for this test**
jest.clearAllMocks(); // Ensure clean state
// Configure mocks used *before* readJSON
mockExistsSync.mockReturnValue(true); // Ensure file is found
mockGetAvailableAIModel.mockReturnValue({
// Ensure this returns the correct structure
type: 'claude',
client: { messages: { create: mockCreate } }
});
// Configure mocks used *after* readJSON (as before)
mockReadJSON.mockReturnValue(mockTasksData); // Ensure readJSON returns valid data
async function* createMockStream() {
yield {
type: 'content_block_delta',
delta: { text: expectedClaudeResponse.substring(0, 10) }
};
yield {
type: 'content_block_delta',
delta: { text: expectedClaudeResponse.substring(10) }
};
yield { type: 'message_stop' };
}
mockCreate.mockResolvedValue(createMockStream());
mockDirname.mockReturnValue(outputDir);
mockGenerateTaskFiles.mockResolvedValue();
// --- Act ---
const updatedSubtask = await taskManager.updateSubtaskById(
tasksPath,
subtaskIdToUpdate,
updatePrompt,
false
);
// --- Assert ---
// **Add an assertion right at the start to check if readJSON was called**
expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); // <<< Let's see if this passes now
// ... (rest of the assertions as before) ...
expect(mockGetAvailableAIModel).toHaveBeenCalledWith({
claudeOverloaded: false,
requiresResearch: false
});
expect(mockCreate).toHaveBeenCalledTimes(1);
// ... etc ...
});
test('should successfully update subtask using Perplexity (research)', async () => {
const subtaskIdToUpdate = '1.1';
const updatePrompt = 'Research best practices for this subtask.';
const expectedPerplexityResponse =
'Based on research, here are the best practices...';
const perplexityModelName = 'mock-perplexity-model'; // Define a mock model name
// --- Arrange ---
// Mock environment variable for Perplexity model if needed by CONFIG/logic
process.env.PERPLEXITY_MODEL = perplexityModelName;
// Mock getAvailableAIModel to return Perplexity client when research is required
mockGetAvailableAIModel.mockReturnValue({
type: 'perplexity',
client: { chat: { completions: { create: mockChatCompletionsCreate } } } // Match the mocked structure
});
// Mock Perplexity's response
mockChatCompletionsCreate.mockResolvedValue({
choices: [{ message: { content: expectedPerplexityResponse } }]
});
// --- Act ---
const updatedSubtask = await taskManager.updateSubtaskById(
tasksPath,
subtaskIdToUpdate,
updatePrompt,
true
); // useResearch = true
// --- Assert ---
expect(mockReadJSON).toHaveBeenCalledWith(tasksPath);
// Verify getAvailableAIModel was called correctly for research
expect(mockGetAvailableAIModel).toHaveBeenCalledWith({
claudeOverloaded: false,
requiresResearch: true
});
expect(mockChatCompletionsCreate).toHaveBeenCalledTimes(1);
// Verify Perplexity API call parameters
expect(mockChatCompletionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: perplexityModelName, // Check the correct model is used
temperature: 0.7, // From CONFIG mock
max_tokens: 4000, // From CONFIG mock
messages: expect.arrayContaining([
expect.objectContaining({
role: 'system',
content: expect.any(String)
}),
expect.objectContaining({
role: 'user',
content: expect.stringContaining(updatePrompt) // Check prompt is included
})
])
})
);
// Verify subtask data was updated
const writtenData = mockWriteJSON.mock.calls[0][1]; // Get data passed to writeJSON
const parentTask = writtenData.tasks.find((t) => t.id === 1);
const targetSubtask = parentTask.subtasks.find((st) => st.id === 1);
expect(targetSubtask.details).toContain(expectedPerplexityResponse);
expect(targetSubtask.details).toMatch(/<info added on .*>/); // Check for timestamp tag
expect(targetSubtask.description).toMatch(/\[Updated: .*]/); // Check description update
// Verify writeJSON and generateTaskFiles were called
expect(mockWriteJSON).toHaveBeenCalledWith(tasksPath, writtenData);
expect(mockGenerateTaskFiles).toHaveBeenCalledWith(tasksPath, outputDir);
// Verify the function returned the updated subtask
expect(updatedSubtask).toBeDefined();
expect(updatedSubtask.id).toBe(1);
expect(updatedSubtask.parentTaskId).toBe(1);
expect(updatedSubtask.details).toContain(expectedPerplexityResponse);
// Clean up env var if set
delete process.env.PERPLEXITY_MODEL;
});
test('should fall back to Perplexity if Claude is overloaded', async () => {
const subtaskIdToUpdate = '1.1';
const updatePrompt = 'Add details, trying Claude first.';
const expectedPerplexityResponse =
'Perplexity provided these details as fallback.';
const perplexityModelName = 'mock-perplexity-model-fallback';
// --- Arrange ---
// Mock environment variable for Perplexity model
process.env.PERPLEXITY_MODEL = perplexityModelName;
// Mock getAvailableAIModel: Return Claude first, then Perplexity
mockGetAvailableAIModel
.mockReturnValueOnce({
// First call: Return Claude
type: 'claude',
client: { messages: { create: mockCreate } }
})
.mockReturnValueOnce({
// Second call: Return Perplexity (after overload)
type: 'perplexity',
client: {
chat: { completions: { create: mockChatCompletionsCreate } }
}
});
// Mock Claude to throw an overload error
const overloadError = new Error('Claude API is overloaded.');
overloadError.type = 'overloaded_error'; // Match one of the specific checks
mockCreate.mockRejectedValue(overloadError); // Simulate Claude failing
// Mock Perplexity's successful response
mockChatCompletionsCreate.mockResolvedValue({
choices: [{ message: { content: expectedPerplexityResponse } }]
});
// --- Act ---
const updatedSubtask = await taskManager.updateSubtaskById(
tasksPath,
subtaskIdToUpdate,
updatePrompt,
false
); // Start with useResearch = false
// --- Assert ---
expect(mockReadJSON).toHaveBeenCalledWith(tasksPath);
// Verify getAvailableAIModel calls
expect(mockGetAvailableAIModel).toHaveBeenCalledTimes(2);
expect(mockGetAvailableAIModel).toHaveBeenNthCalledWith(1, {
claudeOverloaded: false,
requiresResearch: false
});
expect(mockGetAvailableAIModel).toHaveBeenNthCalledWith(2, {
claudeOverloaded: true,
requiresResearch: false
}); // claudeOverloaded should now be true
// Verify Claude was attempted and failed
expect(mockCreate).toHaveBeenCalledTimes(1);
// Verify Perplexity was called as fallback
expect(mockChatCompletionsCreate).toHaveBeenCalledTimes(1);
// Verify Perplexity API call parameters
expect(mockChatCompletionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: perplexityModelName,
messages: expect.arrayContaining([
expect.objectContaining({
role: 'user',
content: expect.stringContaining(updatePrompt)
})
])
})
);
// Verify subtask data was updated with Perplexity's response
const writtenData = mockWriteJSON.mock.calls[0][1];
const parentTask = writtenData.tasks.find((t) => t.id === 1);
const targetSubtask = parentTask.subtasks.find((st) => st.id === 1);
expect(targetSubtask.details).toContain(expectedPerplexityResponse); // Should contain fallback response
expect(targetSubtask.details).toMatch(/<info added on .*>/);
expect(targetSubtask.description).toMatch(/\[Updated: .*]/);
// Verify writeJSON and generateTaskFiles were called
expect(mockWriteJSON).toHaveBeenCalledWith(tasksPath, writtenData);
expect(mockGenerateTaskFiles).toHaveBeenCalledWith(tasksPath, outputDir);
// Verify the function returned the updated subtask
expect(updatedSubtask).toBeDefined();
expect(updatedSubtask.details).toContain(expectedPerplexityResponse);
// Clean up env var if set
delete process.env.PERPLEXITY_MODEL;
});
// More tests will go here...
});
// Add this test-specific implementation after the other test functions like testParsePRD
const testAnalyzeTaskComplexity = async (options) => {
try {
// Get base options or use defaults
const thresholdScore = parseFloat(options.threshold || '5');
const useResearch = options.research === true;
const tasksPath = options.file || 'tasks/tasks.json';
const reportPath =
options.output || 'scripts/task-complexity-report.json';
const modelName = options.model || 'mock-claude-model';
// Read tasks file
const tasksData = mockReadJSON(tasksPath);
if (!tasksData || !Array.isArray(tasksData.tasks)) {
throw new Error(`No valid tasks found in ${tasksPath}`);
}
// Filter tasks for analysis (non-completed)
const activeTasks = tasksData.tasks.filter(
(task) => task.status !== 'done' && task.status !== 'completed'
);
// Call the appropriate mock API based on research flag
let apiResponse;
if (useResearch) {
apiResponse = await mockCallPerplexity();
} else {
apiResponse = await mockCallClaude();
}
// Format report with threshold check
const report = {
meta: {
generatedAt: new Date().toISOString(),
tasksAnalyzed: activeTasks.length,
thresholdScore: thresholdScore,
projectName: tasksData.meta?.projectName || 'Test Project',
usedResearch: useResearch,
model: modelName
},
complexityAnalysis:
apiResponse.tasks?.map((task) => ({
taskId: task.id,
complexityScore: task.complexity || 5,
recommendedSubtasks: task.subtaskCount || 3,
expansionPrompt: `Generate ${task.subtaskCount || 3} subtasks`,
reasoning: 'Mock reasoning for testing'
})) || []
};
// Write the report
mockWriteJSON(reportPath, report);
// Log success
mockLog(
'info',
`Successfully analyzed ${activeTasks.length} tasks with threshold ${thresholdScore}`
);
return report;
} catch (error) {
mockLog('error', `Error during complexity analysis: ${error.message}`);
throw error;
}
};
describe.skip('updateTasks function', () => {
// ---> CHANGE test.skip to test and REMOVE dynamic imports <---
test('should update tasks based on new context', async () => {
// Arrange
const mockTasksPath = '/mock/path/tasks.json';
const mockFromId = 2;
const mockPrompt = 'New project direction';
const mockInitialTasks = {
tasks: [
{
id: 1,
title: 'Old Task 1',
status: 'done',
details: 'Done details'
},
{
id: 2,
title: 'Old Task 2',
status: 'pending',
details: 'Old details 2'
},
{
id: 3,
title: 'Old Task 3',
status: 'in-progress',
details: 'Old details 3'
}
]
};
const mockApiResponse = {
// Structure matching expected output from generateObjectService
tasks: [
{
id: 2,
title: 'Updated Task 2',
status: 'pending',
details: 'New details 2 based on direction'
},
{
id: 3,
title: 'Updated Task 3',
status: 'pending',
details: 'New details 3 based on direction'
}
]
};
// Configure mocks for THIS test
mockReadJSON.mockReturnValue(mockInitialTasks);
// ---> Use the top-level imported mock variable <---
generateObjectService.mockResolvedValue(mockApiResponse);
// Act - Use the top-level imported function under test
await updateTasks(mockTasksPath, mockFromId, mockPrompt, false); // research=false
// Assert
// 1. Read JSON called
expect(mockReadJSON).toHaveBeenCalledWith(mockTasksPath);
// 2. AI Service called with correct args
expect(generateObjectService).toHaveBeenCalledWith(
'main', // role
null, // session
expect.stringContaining('You are an expert project manager'), // system prompt check
expect.objectContaining({
// prompt object check
context: mockPrompt,
currentTasks: expect.arrayContaining([
expect.objectContaining({ id: 2 }),
expect.objectContaining({ id: 3 })
]),
tasksToUpdateFromId: mockFromId
}),
expect.any(Object), // Zod schema
expect.any(Boolean) // retry flag
);
// 3. Write JSON called with correctly merged tasks
const expectedFinalTasks = {
tasks: [
mockInitialTasks.tasks[0], // Task 1 untouched
mockApiResponse.tasks[0], // Task 2 updated
mockApiResponse.tasks[1] // Task 3 updated
]
};
expect(mockWriteJSON).toHaveBeenCalledWith(
mockTasksPath,
expectedFinalTasks
);
});
// ... (Keep other tests in this block as test.skip for now) ...
test.skip('should handle streaming responses from Claude API', async () => {
// ...
});
// ... etc ...
});
// ... (Rest of the file) ...
});
// Define test versions of the addSubtask and removeSubtask functions
const testAddSubtask = (
tasksPath,
parentId,
existingTaskId,
newSubtaskData,
generateFiles = true
) => {
// Read the existing tasks
const data = mockReadJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`Invalid or missing tasks file at ${tasksPath}`);
}
// Convert parent ID to number
const parentIdNum = parseInt(parentId, 10);
// Find the parent task
const parentTask = data.tasks.find((t) => t.id === parentIdNum);
if (!parentTask) {
throw new Error(`Parent task with ID ${parentIdNum} not found`);
}
// Initialize subtasks array if it doesn't exist
if (!parentTask.subtasks) {
parentTask.subtasks = [];
}
let newSubtask;
// Case 1: Convert an existing task to a subtask
if (existingTaskId !== null) {
const existingTaskIdNum = parseInt(existingTaskId, 10);
// Find the existing task
const existingTaskIndex = data.tasks.findIndex(
(t) => t.id === existingTaskIdNum
);
if (existingTaskIndex === -1) {
throw new Error(`Task with ID ${existingTaskIdNum} not found`);
}
const existingTask = data.tasks[existingTaskIndex];
// Check if task is already a subtask
if (existingTask.parentTaskId) {
throw new Error(
`Task ${existingTaskIdNum} is already a subtask of task ${existingTask.parentTaskId}`
);
}
// Check for circular dependency
if (existingTaskIdNum === parentIdNum) {
throw new Error(`Cannot make a task a subtask of itself`);
}
// Check for circular dependency using mockIsTaskDependentOn
if (mockIsTaskDependentOn()) {
throw new Error(
`Cannot create circular dependency: task ${parentIdNum} is already a subtask or dependent of task ${existingTaskIdNum}`
);
}
// Find the highest subtask ID to determine the next ID
const highestSubtaskId =
parentTask.subtasks.length > 0
? Math.max(...parentTask.subtasks.map((st) => st.id))
: 0;
const newSubtaskId = highestSubtaskId + 1;
// Clone the existing task to be converted to a subtask
newSubtask = {
...existingTask,
id: newSubtaskId,
parentTaskId: parentIdNum
};
// Add to parent's subtasks
parentTask.subtasks.push(newSubtask);
// Remove the task from the main tasks array
data.tasks.splice(existingTaskIndex, 1);
}
// Case 2: Create a new subtask
else if (newSubtaskData) {
// Find the highest subtask ID to determine the next ID
const highestSubtaskId =
parentTask.subtasks.length > 0
? Math.max(...parentTask.subtasks.map((st) => st.id))
: 0;
const newSubtaskId = highestSubtaskId + 1;
// Create the new subtask object
newSubtask = {
id: newSubtaskId,
title: newSubtaskData.title,
description: newSubtaskData.description || '',
details: newSubtaskData.details || '',
status: newSubtaskData.status || 'pending',
dependencies: newSubtaskData.dependencies || [],
parentTaskId: parentIdNum
};
// Add to parent's subtasks
parentTask.subtasks.push(newSubtask);
} else {
throw new Error('Either existingTaskId or newSubtaskData must be provided');
}
// Write the updated tasks back to the file
mockWriteJSON(tasksPath, data);
// Generate task files if requested
if (generateFiles) {
mockGenerateTaskFiles(tasksPath, path.dirname(tasksPath));
}
return newSubtask;
};
const testRemoveSubtask = (
tasksPath,
subtaskId,
convertToTask = false,
generateFiles = true
) => {
// Read the existing tasks
const data = mockReadJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`Invalid or missing tasks file at ${tasksPath}`);
}
// Parse the subtask ID (format: "parentId.subtaskId")
if (!subtaskId.includes('.')) {
throw new Error(`Invalid subtask ID format: ${subtaskId}`);
}
const [parentIdStr, subtaskIdStr] = subtaskId.split('.');
const parentId = parseInt(parentIdStr, 10);
const subtaskIdNum = parseInt(subtaskIdStr, 10);
// Find the parent task
const parentTask = data.tasks.find((t) => t.id === parentId);
if (!parentTask) {
throw new Error(`Parent task with ID ${parentId} not found`);
}
// Check if parent has subtasks
if (!parentTask.subtasks || parentTask.subtasks.length === 0) {
throw new Error(`Parent task ${parentId} has no subtasks`);
}
// Find the subtask to remove
const subtaskIndex = parentTask.subtasks.findIndex(
(st) => st.id === subtaskIdNum
);
if (subtaskIndex === -1) {
throw new Error(`Subtask ${subtaskId} not found`);
}
// Get a copy of the subtask before removing it
const removedSubtask = { ...parentTask.subtasks[subtaskIndex] };
// Remove the subtask from the parent
parentTask.subtasks.splice(subtaskIndex, 1);
// If parent has no more subtasks, remove the subtasks array
if (parentTask.subtasks.length === 0) {
delete parentTask.subtasks;
}
let convertedTask = null;
// Convert the subtask to a standalone task if requested
if (convertToTask) {
// Find the highest task ID to determine the next ID
const highestId = Math.max(...data.tasks.map((t) => t.id));
const newTaskId = highestId + 1;
// Create the new task from the subtask
convertedTask = {
id: newTaskId,
title: removedSubtask.title,
description: removedSubtask.description || '',
details: removedSubtask.details || '',
status: removedSubtask.status || 'pending',
dependencies: removedSubtask.dependencies || [],
priority: parentTask.priority || 'medium' // Inherit priority from parent
};
// Add the parent task as a dependency if not already present
if (!convertedTask.dependencies.includes(parentId)) {
convertedTask.dependencies.push(parentId);
}
// Add the converted task to the tasks array
data.tasks.push(convertedTask);
}
// Write the updated tasks back to the file
mockWriteJSON(tasksPath, data);
// Generate task files if requested
if (generateFiles) {
mockGenerateTaskFiles(tasksPath, path.dirname(tasksPath));
}
return convertedTask;
};