Change threshold parameter in analyze_project_complexity from union type to coerce.number with min/max validation. Fix Invalid type error that occurred with certain input formats. Add test implementation to avoid real API calls and proper tests for parameter validation.
3169 lines
92 KiB
JavaScript
3169 lines
92 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 - Update this mock
|
|
jest.mock('../../scripts/modules/ai-services.js', () => ({
|
|
callClaude: mockCallClaude,
|
|
callPerplexity: mockCallPerplexity,
|
|
generateSubtasks: jest.fn(), // <<<<< Add other functions as needed
|
|
generateSubtasksWithPerplexity: jest.fn(), // <<<<< Add other functions as needed
|
|
generateComplexityAnalysisPrompt: jest.fn(), // <<<<< Add other functions as needed
|
|
getAvailableAIModel: mockGetAvailableAIModel, // <<<<< Use the new mock function
|
|
handleClaudeError: jest.fn() // <<<<< Add other functions as needed
|
|
}));
|
|
|
|
// 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 to control what gets imported
|
|
jest.mock('../../scripts/modules/task-manager.js', () => {
|
|
// Get the original module to preserve function implementations
|
|
const originalModule = jest.requireActual(
|
|
'../../scripts/modules/task-manager.js'
|
|
);
|
|
|
|
// Return a modified module with our custom implementation of generateTaskFiles
|
|
return {
|
|
...originalModule,
|
|
generateTaskFiles: mockGenerateTaskFiles,
|
|
isTaskDependentOn: mockIsTaskDependentOn
|
|
};
|
|
});
|
|
|
|
// Create a simplified version of parsePRD for testing
|
|
const testParsePRD = async (prdPath, outputPath, numTasks) => {
|
|
try {
|
|
// Check if the output file already exists
|
|
if (mockExistsSync(outputPath)) {
|
|
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');
|
|
const tasks = await mockCallClaude(prdContent, prdPath, numTasks);
|
|
const dir = mockDirname(outputPath);
|
|
|
|
if (!mockExistsSync(dir)) {
|
|
mockMkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
mockWriteJSON(outputPath, tasks);
|
|
await mockGenerateTaskFiles(outputPath, dir);
|
|
|
|
return tasks;
|
|
} 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) => {
|
|
// 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';
|
|
|
|
// 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';
|
|
|
|
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
|
|
);
|
|
});
|
|
});
|
|
|
|
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 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();
|
|
});
|
|
});
|
|
});
|
|
|
|
// 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;
|
|
};
|
|
|
|
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;
|
|
}
|
|
};
|