Feat/add.azure.and.other.providers (#607)
* fix: claude-4 not having the right max_tokens * feat: add bedrock support * chore: fix package-lock.json * fix: rename baseUrl to baseURL * feat: add azure support * fix: final touches of azure integration * feat: add google vertex provider * chore: fix tests and refactor task-manager.test.js * chore: move task 92 to 94
This commit is contained in:
309
tests/unit/scripts/modules/task-manager/add-subtask.test.js
Normal file
309
tests/unit/scripts/modules/task-manager/add-subtask.test.js
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Tests for the addSubtask function
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
import path from 'path';
|
||||
|
||||
// Mock dependencies
|
||||
const mockReadJSON = jest.fn();
|
||||
const mockWriteJSON = jest.fn();
|
||||
const mockGenerateTaskFiles = jest.fn();
|
||||
const mockIsTaskDependentOn = jest.fn().mockReturnValue(false);
|
||||
|
||||
// Mock path module
|
||||
jest.mock('path', () => ({
|
||||
dirname: jest.fn()
|
||||
}));
|
||||
|
||||
// Define test version of the addSubtask function
|
||||
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;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
400
tests/unit/scripts/modules/task-manager/add-task.test.js
Normal file
400
tests/unit/scripts/modules/task-manager/add-task.test.js
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Tests for the add-task.js module
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock the dependencies before importing the module under test
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
readJSON: jest.fn(),
|
||||
writeJSON: jest.fn(),
|
||||
log: jest.fn(),
|
||||
CONFIG: {
|
||||
model: 'mock-claude-model',
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7,
|
||||
debug: false
|
||||
},
|
||||
truncate: jest.fn((text) => text)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
displayBanner: jest.fn(),
|
||||
getStatusWithColor: jest.fn((status) => status),
|
||||
startLoadingIndicator: jest.fn(),
|
||||
stopLoadingIndicator: jest.fn(),
|
||||
displayAiUsageSummary: jest.fn()
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/ai-services-unified.js',
|
||||
() => ({
|
||||
generateObjectService: jest.fn().mockResolvedValue({
|
||||
mainResult: {
|
||||
object: {
|
||||
title: 'Task from prompt: Create a new authentication system',
|
||||
description:
|
||||
'Task generated from: Create a new authentication system',
|
||||
details:
|
||||
'Implementation details for task generated from prompt: Create a new authentication system',
|
||||
testStrategy: 'Write unit tests to verify functionality',
|
||||
dependencies: []
|
||||
}
|
||||
},
|
||||
telemetryData: {
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: '1234567890',
|
||||
commandName: 'add-task',
|
||||
modelUsed: 'claude-3-5-sonnet',
|
||||
providerName: 'anthropic',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
totalCost: 0.012414,
|
||||
currency: 'USD'
|
||||
}
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/config-manager.js',
|
||||
() => ({
|
||||
getDefaultPriority: jest.fn(() => 'medium')
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/task-manager/generate-task-files.js',
|
||||
() => ({
|
||||
default: jest.fn().mockResolvedValue()
|
||||
})
|
||||
);
|
||||
|
||||
// Mock external UI libraries
|
||||
jest.unstable_mockModule('chalk', () => ({
|
||||
default: {
|
||||
white: { bold: jest.fn((text) => text) },
|
||||
cyan: Object.assign(
|
||||
jest.fn((text) => text),
|
||||
{
|
||||
bold: jest.fn((text) => text)
|
||||
}
|
||||
),
|
||||
green: jest.fn((text) => text),
|
||||
yellow: jest.fn((text) => text),
|
||||
bold: jest.fn((text) => text)
|
||||
}
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('boxen', () => ({
|
||||
default: jest.fn((text) => text)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('cli-table3', () => ({
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
push: jest.fn(),
|
||||
toString: jest.fn(() => 'mocked table')
|
||||
}))
|
||||
}));
|
||||
|
||||
// Import the mocked modules
|
||||
const { readJSON, writeJSON, log } = await import(
|
||||
'../../../../../scripts/modules/utils.js'
|
||||
);
|
||||
|
||||
const { generateObjectService } = await import(
|
||||
'../../../../../scripts/modules/ai-services-unified.js'
|
||||
);
|
||||
|
||||
const generateTaskFiles = await import(
|
||||
'../../../../../scripts/modules/task-manager/generate-task-files.js'
|
||||
);
|
||||
|
||||
// Import the module under test
|
||||
const { default: addTask } = await import(
|
||||
'../../../../../scripts/modules/task-manager/add-task.js'
|
||||
);
|
||||
|
||||
describe('addTask', () => {
|
||||
const sampleTasks = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task 1',
|
||||
description: 'First task',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Task 2',
|
||||
description: 'Second task',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Task 3',
|
||||
description: 'Third task',
|
||||
status: 'pending',
|
||||
dependencies: [1]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Create a helper function for consistent mcpLog mock
|
||||
const createMcpLogMock = () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
readJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks)));
|
||||
|
||||
// Mock console.log to avoid output during tests
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log.mockRestore();
|
||||
});
|
||||
|
||||
test('should add a new task using AI', async () => {
|
||||
// Arrange
|
||||
const prompt = 'Create a new authentication system';
|
||||
const context = {
|
||||
mcpLog: createMcpLogMock()
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await addTask(
|
||||
'tasks/tasks.json',
|
||||
prompt,
|
||||
[],
|
||||
'medium',
|
||||
context,
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith('tasks/tasks.json');
|
||||
expect(generateObjectService).toHaveBeenCalledWith(expect.any(Object));
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 4, // Next ID after existing tasks
|
||||
title: expect.stringContaining(
|
||||
'Create a new authentication system'
|
||||
),
|
||||
status: 'pending'
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(generateTaskFiles.default).toHaveBeenCalled();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
newTaskId: 4,
|
||||
telemetryData: expect.any(Object)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should validate dependencies when adding a task', async () => {
|
||||
// Arrange
|
||||
const prompt = 'Create a new authentication system';
|
||||
const validDependencies = [1, 2]; // These exist in sampleTasks
|
||||
const context = {
|
||||
mcpLog: createMcpLogMock()
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await addTask(
|
||||
'tasks/tasks.json',
|
||||
prompt,
|
||||
validDependencies,
|
||||
'medium',
|
||||
context,
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 4,
|
||||
dependencies: validDependencies
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should filter out invalid dependencies', async () => {
|
||||
// Arrange
|
||||
const prompt = 'Create a new authentication system';
|
||||
const invalidDependencies = [999]; // Non-existent task ID
|
||||
const context = { mcpLog: createMcpLogMock() };
|
||||
|
||||
// Act
|
||||
const result = await addTask(
|
||||
'tasks/tasks.json',
|
||||
prompt,
|
||||
invalidDependencies,
|
||||
'medium',
|
||||
context,
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 4,
|
||||
dependencies: [] // Invalid dependencies should be filtered out
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(context.mcpLog.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'The following dependencies do not exist or are invalid: 999'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('should use specified priority', async () => {
|
||||
// Arrange
|
||||
const prompt = 'Create a new authentication system';
|
||||
const priority = 'high';
|
||||
const context = {
|
||||
mcpLog: createMcpLogMock()
|
||||
};
|
||||
|
||||
// Act
|
||||
await addTask('tasks/tasks.json', prompt, [], priority, context, 'json');
|
||||
|
||||
// Assert
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
priority: priority
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle empty tasks file', async () => {
|
||||
// Arrange
|
||||
readJSON.mockReturnValue({ tasks: [] });
|
||||
const prompt = 'Create a new authentication system';
|
||||
const context = {
|
||||
mcpLog: createMcpLogMock()
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await addTask(
|
||||
'tasks/tasks.json',
|
||||
prompt,
|
||||
[],
|
||||
'medium',
|
||||
context,
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.newTaskId).toBe(1); // First task should have ID 1
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 1
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle missing tasks file', async () => {
|
||||
// Arrange
|
||||
readJSON.mockReturnValue(null);
|
||||
const prompt = 'Create a new authentication system';
|
||||
const context = {
|
||||
mcpLog: createMcpLogMock()
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await addTask(
|
||||
'tasks/tasks.json',
|
||||
prompt,
|
||||
[],
|
||||
'medium',
|
||||
context,
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.newTaskId).toBe(1); // First task should have ID 1
|
||||
expect(writeJSON).toHaveBeenCalledTimes(2); // Once to create file, once to add task
|
||||
});
|
||||
|
||||
test('should handle AI service errors', async () => {
|
||||
// Arrange
|
||||
generateObjectService.mockRejectedValueOnce(new Error('AI service failed'));
|
||||
const prompt = 'Create a new authentication system';
|
||||
const context = {
|
||||
mcpLog: createMcpLogMock()
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
addTask('tasks/tasks.json', prompt, [], 'medium', context, 'json')
|
||||
).rejects.toThrow('AI service failed');
|
||||
});
|
||||
|
||||
test('should handle file read errors', async () => {
|
||||
// Arrange
|
||||
readJSON.mockImplementation(() => {
|
||||
throw new Error('File read failed');
|
||||
});
|
||||
const prompt = 'Create a new authentication system';
|
||||
const context = {
|
||||
mcpLog: createMcpLogMock()
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
addTask('tasks/tasks.json', prompt, [], 'medium', context, 'json')
|
||||
).rejects.toThrow('File read failed');
|
||||
});
|
||||
|
||||
test('should handle file write errors', async () => {
|
||||
// Arrange
|
||||
writeJSON.mockImplementation(() => {
|
||||
throw new Error('File write failed');
|
||||
});
|
||||
const prompt = 'Create a new authentication system';
|
||||
const context = {
|
||||
mcpLog: createMcpLogMock()
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
addTask('tasks/tasks.json', prompt, [], 'medium', context, 'json')
|
||||
).rejects.toThrow('File write failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Tests for the analyze-task-complexity.js module
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock the dependencies before importing the module under test
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
readJSON: jest.fn(),
|
||||
writeJSON: jest.fn(),
|
||||
log: jest.fn(),
|
||||
CONFIG: {
|
||||
model: 'mock-claude-model',
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7,
|
||||
debug: false,
|
||||
defaultSubtasks: 3
|
||||
},
|
||||
findTaskById: jest.fn(),
|
||||
readComplexityReport: jest.fn(),
|
||||
findTaskInComplexityReport: jest.fn(),
|
||||
findProjectRoot: jest.fn(() => '/mock/project/root'),
|
||||
resolveEnvVariable: jest.fn((varName) => `mock_${varName}`),
|
||||
isSilentMode: jest.fn(() => false),
|
||||
findCycles: jest.fn(() => []),
|
||||
formatTaskId: jest.fn((id) => `Task ${id}`),
|
||||
taskExists: jest.fn((tasks, id) => tasks.some((t) => t.id === id)),
|
||||
enableSilentMode: jest.fn(),
|
||||
disableSilentMode: jest.fn(),
|
||||
truncate: jest.fn((text) => text),
|
||||
addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })),
|
||||
aggregateTelemetry: jest.fn((telemetryArray) => telemetryArray[0] || {})
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/ai-services-unified.js',
|
||||
() => ({
|
||||
generateObjectService: jest.fn().mockResolvedValue({
|
||||
mainResult: {
|
||||
tasks: []
|
||||
},
|
||||
telemetryData: {
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: '1234567890',
|
||||
commandName: 'analyze-complexity',
|
||||
modelUsed: 'claude-3-5-sonnet',
|
||||
providerName: 'anthropic',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
totalCost: 0.012414,
|
||||
currency: 'USD'
|
||||
}
|
||||
}),
|
||||
generateTextService: jest.fn().mockResolvedValue({
|
||||
mainResult: '[]',
|
||||
telemetryData: {
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: '1234567890',
|
||||
commandName: 'analyze-complexity',
|
||||
modelUsed: 'claude-3-5-sonnet',
|
||||
providerName: 'anthropic',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
totalCost: 0.012414,
|
||||
currency: 'USD'
|
||||
}
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/config-manager.js',
|
||||
() => ({
|
||||
// Core config access
|
||||
getConfig: jest.fn(() => ({
|
||||
models: { main: { provider: 'anthropic', modelId: 'claude-3-5-sonnet' } },
|
||||
global: { projectName: 'Test Project' }
|
||||
})),
|
||||
writeConfig: jest.fn(() => true),
|
||||
ConfigurationError: class extends Error {},
|
||||
isConfigFilePresent: jest.fn(() => true),
|
||||
|
||||
// Validation
|
||||
validateProvider: jest.fn(() => true),
|
||||
validateProviderModelCombination: jest.fn(() => true),
|
||||
VALID_PROVIDERS: ['anthropic', 'openai', 'perplexity'],
|
||||
MODEL_MAP: {
|
||||
anthropic: [
|
||||
{
|
||||
id: 'claude-3-5-sonnet',
|
||||
cost_per_1m_tokens: { input: 3, output: 15 }
|
||||
}
|
||||
],
|
||||
openai: [{ id: 'gpt-4', cost_per_1m_tokens: { input: 30, output: 60 } }]
|
||||
},
|
||||
getAvailableModels: jest.fn(() => [
|
||||
{
|
||||
id: 'claude-3-5-sonnet',
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
provider: 'anthropic'
|
||||
},
|
||||
{ id: 'gpt-4', name: 'GPT-4', provider: 'openai' }
|
||||
]),
|
||||
|
||||
// Role-specific getters
|
||||
getMainProvider: jest.fn(() => 'anthropic'),
|
||||
getMainModelId: jest.fn(() => 'claude-3-5-sonnet'),
|
||||
getMainMaxTokens: jest.fn(() => 4000),
|
||||
getMainTemperature: jest.fn(() => 0.7),
|
||||
getResearchProvider: jest.fn(() => 'perplexity'),
|
||||
getResearchModelId: jest.fn(() => 'sonar-pro'),
|
||||
getResearchMaxTokens: jest.fn(() => 8700),
|
||||
getResearchTemperature: jest.fn(() => 0.1),
|
||||
getFallbackProvider: jest.fn(() => 'anthropic'),
|
||||
getFallbackModelId: jest.fn(() => 'claude-3-5-sonnet'),
|
||||
getFallbackMaxTokens: jest.fn(() => 4000),
|
||||
getFallbackTemperature: jest.fn(() => 0.7),
|
||||
getBaseUrlForRole: jest.fn(() => undefined),
|
||||
|
||||
// Global setting getters
|
||||
getLogLevel: jest.fn(() => 'info'),
|
||||
getDebugFlag: jest.fn(() => false),
|
||||
getDefaultNumTasks: jest.fn(() => 10),
|
||||
getDefaultSubtasks: jest.fn(() => 5),
|
||||
getDefaultPriority: jest.fn(() => 'medium'),
|
||||
getProjectName: jest.fn(() => 'Test Project'),
|
||||
getOllamaBaseURL: jest.fn(() => 'http://localhost:11434/api'),
|
||||
getAzureBaseURL: jest.fn(() => undefined),
|
||||
getParametersForRole: jest.fn(() => ({
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7
|
||||
})),
|
||||
getUserId: jest.fn(() => '1234567890'),
|
||||
|
||||
// API Key Checkers
|
||||
isApiKeySet: jest.fn(() => true),
|
||||
getMcpApiKeyStatus: jest.fn(() => true),
|
||||
|
||||
// Additional functions
|
||||
getAllProviders: jest.fn(() => ['anthropic', 'openai', 'perplexity']),
|
||||
getVertexProjectId: jest.fn(() => undefined),
|
||||
getVertexLocation: jest.fn(() => undefined)
|
||||
})
|
||||
);
|
||||
|
||||
// Import the mocked modules
|
||||
const { readJSON, writeJSON, log, CONFIG } = await import(
|
||||
'../../../../../scripts/modules/utils.js'
|
||||
);
|
||||
|
||||
const { generateObjectService, generateTextService } = await import(
|
||||
'../../../../../scripts/modules/ai-services-unified.js'
|
||||
);
|
||||
|
||||
// Import the module under test
|
||||
const { default: analyzeTaskComplexity } = await import(
|
||||
'../../../../../scripts/modules/task-manager/analyze-task-complexity.js'
|
||||
);
|
||||
|
||||
describe('analyzeTaskComplexity', () => {
|
||||
// Sample response structure (simplified for these tests)
|
||||
const sampleApiResponse = {
|
||||
mainResult: JSON.stringify({
|
||||
tasks: [
|
||||
{ id: 1, complexity: 3, subtaskCount: 2 },
|
||||
{ id: 2, complexity: 7, subtaskCount: 5 },
|
||||
{ id: 3, complexity: 9, subtaskCount: 8 }
|
||||
]
|
||||
}),
|
||||
telemetryData: {
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: '1234567890',
|
||||
commandName: 'analyze-complexity',
|
||||
modelUsed: 'claude-3-5-sonnet',
|
||||
providerName: 'anthropic',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
totalCost: 0.012414,
|
||||
currency: 'USD'
|
||||
}
|
||||
};
|
||||
|
||||
const sampleTasks = {
|
||||
meta: { projectName: 'Test Project' },
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task 1',
|
||||
description: 'First task description',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Task 2',
|
||||
description: 'Second task description',
|
||||
status: 'pending',
|
||||
dependencies: [1],
|
||||
priority: 'medium'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Task 3',
|
||||
description: 'Third task description',
|
||||
status: 'done',
|
||||
dependencies: [1, 2],
|
||||
priority: 'high'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock implementations
|
||||
readJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks)));
|
||||
generateTextService.mockResolvedValue(sampleApiResponse);
|
||||
});
|
||||
|
||||
test('should call generateTextService with the correct parameters', async () => {
|
||||
// Arrange
|
||||
const options = {
|
||||
file: 'tasks/tasks.json',
|
||||
output: 'scripts/task-complexity-report.json',
|
||||
threshold: '5',
|
||||
research: false
|
||||
};
|
||||
|
||||
// Act
|
||||
await analyzeTaskComplexity(options, {
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith('tasks/tasks.json');
|
||||
expect(generateTextService).toHaveBeenCalledWith(expect.any(Object));
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'scripts/task-complexity-report.json',
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({
|
||||
thresholdScore: 5,
|
||||
projectName: 'Test Project'
|
||||
}),
|
||||
complexityAnalysis: expect.any(Array)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should use research flag to determine which AI service to use', async () => {
|
||||
// Arrange
|
||||
const researchOptions = {
|
||||
file: 'tasks/tasks.json',
|
||||
output: 'scripts/task-complexity-report.json',
|
||||
threshold: '5',
|
||||
research: true
|
||||
};
|
||||
|
||||
// Act
|
||||
await analyzeTaskComplexity(researchOptions, {
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(generateTextService).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: 'research' // This should be present when research is true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle different threshold parameter types correctly', async () => {
|
||||
// Test with string threshold
|
||||
let options = {
|
||||
file: 'tasks/tasks.json',
|
||||
output: 'scripts/task-complexity-report.json',
|
||||
threshold: '7'
|
||||
};
|
||||
|
||||
await analyzeTaskComplexity(options, {
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
}
|
||||
});
|
||||
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'scripts/task-complexity-report.json',
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({
|
||||
thresholdScore: 7
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Test with number threshold
|
||||
options = {
|
||||
file: 'tasks/tasks.json',
|
||||
output: 'scripts/task-complexity-report.json',
|
||||
threshold: 8
|
||||
};
|
||||
|
||||
await analyzeTaskComplexity(options, {
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
}
|
||||
});
|
||||
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'scripts/task-complexity-report.json',
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({
|
||||
thresholdScore: 8
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should filter out completed tasks from analysis', async () => {
|
||||
// Arrange
|
||||
const options = {
|
||||
file: 'tasks/tasks.json',
|
||||
output: 'scripts/task-complexity-report.json',
|
||||
threshold: '5'
|
||||
};
|
||||
|
||||
// Act
|
||||
await analyzeTaskComplexity(options, {
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
// Check if the prompt sent to AI doesn't include the completed task (id: 3)
|
||||
expect(generateTextService).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.not.stringContaining('"id": 3')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle API errors gracefully', async () => {
|
||||
// Arrange
|
||||
const options = {
|
||||
file: 'tasks/tasks.json',
|
||||
output: 'scripts/task-complexity-report.json',
|
||||
threshold: '5'
|
||||
};
|
||||
|
||||
// Force API error
|
||||
generateTextService.mockRejectedValueOnce(new Error('API Error'));
|
||||
|
||||
const mockMcpLog = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
analyzeTaskComplexity(options, {
|
||||
mcpLog: mockMcpLog
|
||||
})
|
||||
).rejects.toThrow('API Error');
|
||||
|
||||
// Check that the error was logged via mcpLog
|
||||
expect(mockMcpLog.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('API Error')
|
||||
);
|
||||
});
|
||||
});
|
||||
269
tests/unit/scripts/modules/task-manager/clear-subtasks.test.js
Normal file
269
tests/unit/scripts/modules/task-manager/clear-subtasks.test.js
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Tests for the clear-subtasks.js module
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock the dependencies before importing the module under test
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
readJSON: jest.fn(),
|
||||
writeJSON: jest.fn(),
|
||||
log: jest.fn(),
|
||||
CONFIG: {
|
||||
model: 'mock-claude-model',
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7,
|
||||
debug: false
|
||||
},
|
||||
findTaskById: jest.fn(),
|
||||
isSilentMode: jest.fn(() => false),
|
||||
truncate: jest.fn((text) => text)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
displayBanner: jest.fn()
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/task-manager/generate-task-files.js',
|
||||
() => ({
|
||||
default: jest.fn().mockResolvedValue()
|
||||
})
|
||||
);
|
||||
|
||||
// Mock external UI libraries
|
||||
jest.unstable_mockModule('chalk', () => ({
|
||||
default: {
|
||||
white: {
|
||||
bold: jest.fn((text) => text)
|
||||
},
|
||||
cyan: Object.assign(
|
||||
jest.fn((text) => text),
|
||||
{
|
||||
bold: jest.fn((text) => text)
|
||||
}
|
||||
),
|
||||
green: jest.fn((text) => text),
|
||||
yellow: jest.fn((text) => text),
|
||||
bold: jest.fn((text) => text)
|
||||
}
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('boxen', () => ({
|
||||
default: jest.fn((text) => text)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('cli-table3', () => ({
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
push: jest.fn(),
|
||||
toString: jest.fn(() => 'mocked table')
|
||||
}))
|
||||
}));
|
||||
|
||||
// Import the mocked modules
|
||||
const { readJSON, writeJSON, log } = await import(
|
||||
'../../../../../scripts/modules/utils.js'
|
||||
);
|
||||
|
||||
const generateTaskFiles = await import(
|
||||
'../../../../../scripts/modules/task-manager/generate-task-files.js'
|
||||
);
|
||||
|
||||
// Import the module under test
|
||||
const { default: clearSubtasks } = await import(
|
||||
'../../../../../scripts/modules/task-manager/clear-subtasks.js'
|
||||
);
|
||||
|
||||
describe('clearSubtasks', () => {
|
||||
const sampleTasks = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task 1',
|
||||
description: 'First task',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Task 2',
|
||||
description: 'Second task',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Subtask 2.1',
|
||||
description: 'First subtask of task 2',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Task 3',
|
||||
description: 'Third task',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Subtask 3.1',
|
||||
description: 'First subtask of task 3',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Subtask 3.2',
|
||||
description: 'Second subtask of task 3',
|
||||
status: 'done',
|
||||
dependencies: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
readJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks)));
|
||||
|
||||
// Mock process.exit since this function doesn't have MCP mode support
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
// Mock console.log to avoid output during tests
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore process.exit
|
||||
process.exit.mockRestore();
|
||||
console.log.mockRestore();
|
||||
});
|
||||
|
||||
test('should clear subtasks from a specific task', () => {
|
||||
// Act
|
||||
clearSubtasks('tasks/tasks.json', '3');
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith('tasks/tasks.json');
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 3,
|
||||
subtasks: []
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(generateTaskFiles.default).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should clear subtasks from multiple tasks when given comma-separated IDs', () => {
|
||||
// Act
|
||||
clearSubtasks('tasks/tasks.json', '2,3');
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith('tasks/tasks.json');
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 2,
|
||||
subtasks: []
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 3,
|
||||
subtasks: []
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(generateTaskFiles.default).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle tasks with no subtasks', () => {
|
||||
// Act
|
||||
clearSubtasks('tasks/tasks.json', '1');
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith('tasks/tasks.json');
|
||||
// Should not write the file if no changes were made
|
||||
expect(writeJSON).not.toHaveBeenCalled();
|
||||
expect(generateTaskFiles.default).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle non-existent task IDs gracefully', () => {
|
||||
// Act
|
||||
clearSubtasks('tasks/tasks.json', '99');
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith('tasks/tasks.json');
|
||||
expect(log).toHaveBeenCalledWith('error', 'Task 99 not found');
|
||||
// Should not write the file if no changes were made
|
||||
expect(writeJSON).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle multiple task IDs including both valid and non-existent IDs', () => {
|
||||
// Act
|
||||
clearSubtasks('tasks/tasks.json', '3,99');
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith('tasks/tasks.json');
|
||||
expect(log).toHaveBeenCalledWith('error', 'Task 99 not found');
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 3,
|
||||
subtasks: []
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(generateTaskFiles.default).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle file read errors', () => {
|
||||
// Arrange
|
||||
readJSON.mockImplementation(() => {
|
||||
throw new Error('File read failed');
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
expect(() => {
|
||||
clearSubtasks('tasks/tasks.json', '3');
|
||||
}).toThrow('File read failed');
|
||||
});
|
||||
|
||||
test('should handle invalid tasks data', () => {
|
||||
// Arrange
|
||||
readJSON.mockReturnValue(null);
|
||||
|
||||
// Act & Assert
|
||||
expect(() => {
|
||||
clearSubtasks('tasks/tasks.json', '3');
|
||||
}).toThrow('process.exit called');
|
||||
|
||||
expect(log).toHaveBeenCalledWith('error', 'No valid tasks found.');
|
||||
});
|
||||
|
||||
test('should handle file write errors', () => {
|
||||
// Arrange
|
||||
writeJSON.mockImplementation(() => {
|
||||
throw new Error('File write failed');
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
expect(() => {
|
||||
clearSubtasks('tasks/tasks.json', '3');
|
||||
}).toThrow('File write failed');
|
||||
});
|
||||
});
|
||||
175
tests/unit/scripts/modules/task-manager/find-next-task.test.js
Normal file
175
tests/unit/scripts/modules/task-manager/find-next-task.test.js
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Tests for the find-next-task.js module
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
import findNextTask from '../../../../../scripts/modules/task-manager/find-next-task.js';
|
||||
|
||||
describe('findNextTask', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
test('should consider subtask dependencies when finding next task', () => {
|
||||
const tasks = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Parent Task',
|
||||
status: 'in-progress',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Subtask 1',
|
||||
status: 'done',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Subtask 2',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Dependent Task',
|
||||
status: 'pending',
|
||||
dependencies: [1],
|
||||
priority: 'high'
|
||||
}
|
||||
];
|
||||
|
||||
const nextTask = findNextTask(tasks);
|
||||
|
||||
// Task 2 should not be returned because Task 1 is not completely done
|
||||
// (it has a pending subtask)
|
||||
expect(nextTask).not.toEqual(expect.objectContaining({ id: 2 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Tests for the generate-task-files.js module
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock the dependencies before importing the module under test
|
||||
jest.unstable_mockModule('fs', () => ({
|
||||
default: {
|
||||
existsSync: jest.fn(),
|
||||
mkdirSync: jest.fn(),
|
||||
readdirSync: jest.fn(),
|
||||
unlinkSync: jest.fn(),
|
||||
writeFileSync: jest.fn()
|
||||
},
|
||||
existsSync: jest.fn(),
|
||||
mkdirSync: jest.fn(),
|
||||
readdirSync: jest.fn(),
|
||||
unlinkSync: jest.fn(),
|
||||
writeFileSync: jest.fn()
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('path', () => ({
|
||||
default: {
|
||||
join: jest.fn((...args) => args.join('/')),
|
||||
dirname: jest.fn((p) => p.split('/').slice(0, -1).join('/'))
|
||||
},
|
||||
join: jest.fn((...args) => args.join('/')),
|
||||
dirname: jest.fn((p) => p.split('/').slice(0, -1).join('/'))
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
readJSON: jest.fn(),
|
||||
writeJSON: jest.fn(),
|
||||
log: jest.fn(),
|
||||
CONFIG: {
|
||||
model: 'mock-claude-model',
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7,
|
||||
debug: false
|
||||
},
|
||||
sanitizePrompt: jest.fn((prompt) => prompt),
|
||||
truncate: jest.fn((text) => text),
|
||||
isSilentMode: jest.fn(() => false),
|
||||
findTaskById: jest.fn((tasks, id) =>
|
||||
tasks.find((t) => t.id === parseInt(id))
|
||||
),
|
||||
findProjectRoot: jest.fn(() => '/mock/project/root'),
|
||||
resolveEnvVariable: jest.fn((varName) => `mock_${varName}`)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
formatDependenciesWithStatus: jest.fn(),
|
||||
displayBanner: jest.fn(),
|
||||
displayTaskList: jest.fn(),
|
||||
startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
|
||||
stopLoadingIndicator: jest.fn(),
|
||||
createProgressBar: jest.fn(() => ' MOCK_PROGRESS_BAR '),
|
||||
getStatusWithColor: jest.fn((status) => status),
|
||||
getComplexityWithColor: jest.fn((score) => `Score: ${score}`)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/dependency-manager.js',
|
||||
() => ({
|
||||
validateAndFixDependencies: jest.fn(),
|
||||
validateTaskDependencies: jest.fn()
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/config-manager.js',
|
||||
() => ({
|
||||
getDebugFlag: jest.fn(() => false),
|
||||
getProjectName: jest.fn(() => 'Test Project')
|
||||
})
|
||||
);
|
||||
|
||||
// Import the mocked modules
|
||||
const { readJSON, writeJSON, log, findProjectRoot } = await import(
|
||||
'../../../../../scripts/modules/utils.js'
|
||||
);
|
||||
const { formatDependenciesWithStatus } = await import(
|
||||
'../../../../../scripts/modules/ui.js'
|
||||
);
|
||||
const { validateAndFixDependencies } = await import(
|
||||
'../../../../../scripts/modules/dependency-manager.js'
|
||||
);
|
||||
|
||||
const fs = (await import('fs')).default;
|
||||
const path = (await import('path')).default;
|
||||
|
||||
// Import the module under test
|
||||
const { default: generateTaskFiles } = await import(
|
||||
'../../../../../scripts/modules/task-manager/generate-task-files.js'
|
||||
);
|
||||
|
||||
describe('generateTaskFiles', () => {
|
||||
// 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should generate task files from tasks.json - working test', async () => {
|
||||
// Set up mocks for this specific test
|
||||
readJSON.mockImplementationOnce(() => sampleTasks);
|
||||
fs.existsSync.mockImplementationOnce(() => true);
|
||||
|
||||
// Call the function
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
const outputDir = 'tasks';
|
||||
|
||||
await generateTaskFiles(tasksPath, outputDir, {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Verify the data was read
|
||||
expect(readJSON).toHaveBeenCalledWith(tasksPath);
|
||||
|
||||
// Verify dependencies were validated
|
||||
expect(validateAndFixDependencies).toHaveBeenCalledWith(
|
||||
sampleTasks,
|
||||
tasksPath
|
||||
);
|
||||
|
||||
// Verify files were written for each task
|
||||
expect(fs.writeFileSync).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Verify specific file paths
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
'tasks/task_001.txt',
|
||||
expect.any(String)
|
||||
);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
'tasks/task_002.txt',
|
||||
expect.any(String)
|
||||
);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
'tasks/task_003.txt',
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
test('should format dependencies with status indicators', async () => {
|
||||
// Set up mocks
|
||||
readJSON.mockImplementationOnce(() => sampleTasks);
|
||||
fs.existsSync.mockImplementationOnce(() => true);
|
||||
formatDependenciesWithStatus.mockReturnValue(
|
||||
'✅ Task 1 (done), ⏱️ Task 2 (pending)'
|
||||
);
|
||||
|
||||
// Call the function
|
||||
await generateTaskFiles('tasks/tasks.json', 'tasks', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Verify formatDependenciesWithStatus was called for tasks with dependencies
|
||||
expect(formatDependenciesWithStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle tasks with no subtasks', async () => {
|
||||
// Create data with tasks that have no subtasks
|
||||
const tasksWithoutSubtasks = {
|
||||
meta: { projectName: 'Test Project' },
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Simple Task',
|
||||
description: 'A simple task without subtasks',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'medium',
|
||||
details: 'Simple task details',
|
||||
testStrategy: 'Simple test strategy'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
readJSON.mockImplementationOnce(() => tasksWithoutSubtasks);
|
||||
fs.existsSync.mockImplementationOnce(() => true);
|
||||
|
||||
// Call the function
|
||||
await generateTaskFiles('tasks/tasks.json', 'tasks', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Verify the file was written
|
||||
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
'tasks/task_001.txt',
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
test("should create the output directory if it doesn't exist", async () => {
|
||||
// Set up mocks
|
||||
readJSON.mockImplementationOnce(() => sampleTasks);
|
||||
fs.existsSync.mockImplementation((path) => {
|
||||
if (path === 'tasks') return false; // Directory doesn't exist
|
||||
return true; // Other paths exist
|
||||
});
|
||||
|
||||
// Call the function
|
||||
await generateTaskFiles('tasks/tasks.json', 'tasks', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Verify mkdir was called
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('tasks', { recursive: true });
|
||||
});
|
||||
|
||||
test('should format task files with proper sections', async () => {
|
||||
// Set up mocks
|
||||
readJSON.mockImplementationOnce(() => sampleTasks);
|
||||
fs.existsSync.mockImplementationOnce(() => true);
|
||||
|
||||
// Call the function
|
||||
await generateTaskFiles('tasks/tasks.json', 'tasks', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Get the content written to the first task file
|
||||
const firstTaskContent = fs.writeFileSync.mock.calls[0][1];
|
||||
|
||||
// Verify the content includes expected sections
|
||||
expect(firstTaskContent).toContain('# Task ID: 1');
|
||||
expect(firstTaskContent).toContain('# Title: Task 1');
|
||||
expect(firstTaskContent).toContain('# Description');
|
||||
expect(firstTaskContent).toContain('# Status');
|
||||
expect(firstTaskContent).toContain('# Priority');
|
||||
expect(firstTaskContent).toContain('# Dependencies');
|
||||
expect(firstTaskContent).toContain('# Details:');
|
||||
expect(firstTaskContent).toContain('# Test Strategy:');
|
||||
});
|
||||
|
||||
test('should include subtasks in task files when present', async () => {
|
||||
// Set up mocks
|
||||
readJSON.mockImplementationOnce(() => sampleTasks);
|
||||
fs.existsSync.mockImplementationOnce(() => true);
|
||||
|
||||
// Call the function
|
||||
await generateTaskFiles('tasks/tasks.json', 'tasks', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Get the content written to the task file with subtasks (task 3)
|
||||
const taskWithSubtasksContent = fs.writeFileSync.mock.calls[2][1];
|
||||
|
||||
// Verify the content includes subtasks section
|
||||
expect(taskWithSubtasksContent).toContain('# Subtasks:');
|
||||
expect(taskWithSubtasksContent).toContain('## 1. Subtask 1');
|
||||
expect(taskWithSubtasksContent).toContain('## 2. Subtask 2');
|
||||
});
|
||||
|
||||
test('should handle errors during file generation', () => {
|
||||
// Mock an error in readJSON
|
||||
readJSON.mockImplementationOnce(() => {
|
||||
throw new Error('File read failed');
|
||||
});
|
||||
|
||||
// Call the function and expect it to handle the error
|
||||
expect(() => {
|
||||
generateTaskFiles('tasks/tasks.json', 'tasks', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
}).toThrow('File read failed');
|
||||
});
|
||||
|
||||
test('should validate dependencies before generating files', async () => {
|
||||
// Set up mocks
|
||||
readJSON.mockImplementationOnce(() => sampleTasks);
|
||||
fs.existsSync.mockImplementationOnce(() => true);
|
||||
|
||||
// Call the function
|
||||
await generateTaskFiles('tasks/tasks.json', 'tasks', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Verify validateAndFixDependencies was called
|
||||
expect(validateAndFixDependencies).toHaveBeenCalledWith(
|
||||
sampleTasks,
|
||||
'tasks/tasks.json'
|
||||
);
|
||||
});
|
||||
});
|
||||
332
tests/unit/scripts/modules/task-manager/list-tasks.test.js
Normal file
332
tests/unit/scripts/modules/task-manager/list-tasks.test.js
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Tests for the list-tasks.js module
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock the dependencies before importing the module under test
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
readJSON: jest.fn(),
|
||||
writeJSON: jest.fn(),
|
||||
log: jest.fn(),
|
||||
CONFIG: {
|
||||
model: 'mock-claude-model',
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7,
|
||||
debug: false
|
||||
},
|
||||
sanitizePrompt: jest.fn((prompt) => prompt),
|
||||
truncate: jest.fn((text) => text),
|
||||
isSilentMode: jest.fn(() => false),
|
||||
findTaskById: jest.fn((tasks, id) =>
|
||||
tasks.find((t) => t.id === parseInt(id))
|
||||
),
|
||||
addComplexityToTask: jest.fn(),
|
||||
readComplexityReport: jest.fn(() => null)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
formatDependenciesWithStatus: jest.fn(),
|
||||
displayBanner: jest.fn(),
|
||||
displayTaskList: jest.fn(),
|
||||
startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
|
||||
stopLoadingIndicator: jest.fn(),
|
||||
createProgressBar: jest.fn(() => ' MOCK_PROGRESS_BAR '),
|
||||
getStatusWithColor: jest.fn((status) => status),
|
||||
getComplexityWithColor: jest.fn((score) => `Score: ${score}`)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/dependency-manager.js',
|
||||
() => ({
|
||||
validateAndFixDependencies: jest.fn(),
|
||||
validateTaskDependencies: jest.fn()
|
||||
})
|
||||
);
|
||||
|
||||
// Import the mocked modules
|
||||
const { readJSON, log, readComplexityReport, addComplexityToTask } =
|
||||
await import('../../../../../scripts/modules/utils.js');
|
||||
const { displayTaskList } = await import(
|
||||
'../../../../../scripts/modules/ui.js'
|
||||
);
|
||||
const { validateAndFixDependencies } = await import(
|
||||
'../../../../../scripts/modules/dependency-manager.js'
|
||||
);
|
||||
|
||||
// Import the module under test
|
||||
const { default: listTasks } = await import(
|
||||
'../../../../../scripts/modules/task-manager/list-tasks.js'
|
||||
);
|
||||
|
||||
// Sample data for tests
|
||||
const sampleTasks = {
|
||||
meta: { projectName: 'Test Project' },
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Setup Project',
|
||||
description: 'Initialize project structure',
|
||||
status: 'done',
|
||||
dependencies: [],
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Implement Core Features',
|
||||
description: 'Build main functionality',
|
||||
status: 'pending',
|
||||
dependencies: [1],
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Create UI Components',
|
||||
description: 'Build user interface',
|
||||
status: 'in-progress',
|
||||
dependencies: [1, 2],
|
||||
priority: 'medium',
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Create Header Component',
|
||||
description: 'Build header component',
|
||||
status: 'done',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Create Footer Component',
|
||||
description: 'Build footer component',
|
||||
status: 'pending',
|
||||
dependencies: [1]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Testing',
|
||||
description: 'Write and run tests',
|
||||
status: 'cancelled',
|
||||
dependencies: [2, 3],
|
||||
priority: 'low'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
describe('listTasks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock console methods to suppress output
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock process.exit to prevent actual exit
|
||||
jest.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit: ${code}`);
|
||||
});
|
||||
|
||||
// Set up default mock return values
|
||||
readJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks)));
|
||||
readComplexityReport.mockReturnValue(null);
|
||||
validateAndFixDependencies.mockImplementation(() => {});
|
||||
displayTaskList.mockImplementation(() => {});
|
||||
addComplexityToTask.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore console methods
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('should list all tasks when no status filter is provided', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
// Act
|
||||
const result = listTasks(tasksPath, null, null, false, 'json');
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith(tasksPath);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 1 }),
|
||||
expect.objectContaining({ id: 2 }),
|
||||
expect.objectContaining({ id: 3 }),
|
||||
expect.objectContaining({ id: 4 })
|
||||
])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should filter tasks by status when status filter is provided', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
const statusFilter = 'pending';
|
||||
|
||||
// Act
|
||||
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith(tasksPath);
|
||||
|
||||
// Verify only pending tasks are returned
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
expect(result.tasks[0].status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should filter tasks by done status', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
const statusFilter = 'done';
|
||||
|
||||
// Act
|
||||
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||
|
||||
// Assert
|
||||
// Verify only done tasks are returned
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
expect(result.tasks[0].status).toBe('done');
|
||||
});
|
||||
|
||||
test('should include subtasks when withSubtasks option is true', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
// Act
|
||||
const result = listTasks(tasksPath, null, null, true, 'json');
|
||||
|
||||
// Assert
|
||||
// Verify that the task with subtasks is included
|
||||
const taskWithSubtasks = result.tasks.find((task) => task.id === 3);
|
||||
expect(taskWithSubtasks).toBeDefined();
|
||||
expect(taskWithSubtasks.subtasks).toBeDefined();
|
||||
expect(taskWithSubtasks.subtasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should not include subtasks when withSubtasks option is false', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
// Act
|
||||
const result = listTasks(tasksPath, null, null, false, 'json');
|
||||
|
||||
// Assert
|
||||
// For JSON output, subtasks should still be included in the data structure
|
||||
// The withSubtasks flag affects display, not the data structure
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
tasks: expect.any(Array)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should return empty array when no tasks match the status filter', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
const statusFilter = 'blocked'; // Status that doesn't exist in sample data
|
||||
|
||||
// Act
|
||||
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||
|
||||
// Assert
|
||||
// Verify empty array is returned
|
||||
expect(result.tasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should handle file read errors', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
readJSON.mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
expect(() => {
|
||||
listTasks(tasksPath, null, null, false, 'json');
|
||||
}).toThrow('File not found');
|
||||
});
|
||||
|
||||
test('should validate and fix dependencies before listing', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
// Act
|
||||
listTasks(tasksPath, null, null, false, 'json');
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith(tasksPath);
|
||||
// Note: validateAndFixDependencies is not called by listTasks function
|
||||
// This test just verifies the function runs without error
|
||||
});
|
||||
|
||||
test('should pass correct options to displayTaskList', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
// Act
|
||||
const result = listTasks(tasksPath, 'pending', null, true, 'json');
|
||||
|
||||
// Assert
|
||||
// For JSON output, we don't call displayTaskList, so just verify the result structure
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
tasks: expect.any(Array),
|
||||
filter: 'pending',
|
||||
stats: expect.any(Object)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should filter tasks by in-progress status', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
const statusFilter = 'in-progress';
|
||||
|
||||
// Act
|
||||
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||
|
||||
// Assert
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
expect(result.tasks[0].status).toBe('in-progress');
|
||||
expect(result.tasks[0].id).toBe(3);
|
||||
});
|
||||
|
||||
test('should filter tasks by cancelled status', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
const statusFilter = 'cancelled';
|
||||
|
||||
// Act
|
||||
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||
|
||||
// Assert
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
expect(result.tasks[0].status).toBe('cancelled');
|
||||
expect(result.tasks[0].id).toBe(4);
|
||||
});
|
||||
|
||||
test('should return the original tasks data structure', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
// Act
|
||||
const result = listTasks(tasksPath, null, null, false, 'json');
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
tasks: expect.any(Array),
|
||||
filter: 'all',
|
||||
stats: expect.objectContaining({
|
||||
total: 4,
|
||||
completed: expect.any(Number),
|
||||
inProgress: expect.any(Number),
|
||||
pending: expect.any(Number)
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(result.tasks).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
462
tests/unit/scripts/modules/task-manager/parse-prd.test.js
Normal file
462
tests/unit/scripts/modules/task-manager/parse-prd.test.js
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Tests for the parse-prd.js module
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock the dependencies before importing the module under test
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
readJSON: jest.fn(),
|
||||
writeJSON: jest.fn(),
|
||||
log: jest.fn(),
|
||||
CONFIG: {
|
||||
model: 'mock-claude-model',
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7,
|
||||
debug: false
|
||||
},
|
||||
sanitizePrompt: jest.fn((prompt) => prompt),
|
||||
truncate: jest.fn((text) => text),
|
||||
isSilentMode: jest.fn(() => false),
|
||||
enableSilentMode: jest.fn(),
|
||||
disableSilentMode: jest.fn(),
|
||||
findTaskById: jest.fn(),
|
||||
promptYesNo: jest.fn()
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/ai-services-unified.js',
|
||||
() => ({
|
||||
generateObjectService: jest.fn().mockResolvedValue({
|
||||
mainResult: {
|
||||
tasks: []
|
||||
},
|
||||
telemetryData: {}
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
getStatusWithColor: jest.fn((status) => status),
|
||||
startLoadingIndicator: jest.fn(),
|
||||
stopLoadingIndicator: jest.fn(),
|
||||
displayAiUsageSummary: jest.fn()
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/config-manager.js',
|
||||
() => ({
|
||||
getDebugFlag: jest.fn(() => false)
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/task-manager/generate-task-files.js',
|
||||
() => ({
|
||||
default: jest.fn().mockResolvedValue()
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/task-manager/models.js',
|
||||
() => ({
|
||||
getModelConfiguration: jest.fn(() => ({
|
||||
model: 'mock-model',
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7
|
||||
}))
|
||||
})
|
||||
);
|
||||
|
||||
// Mock fs module
|
||||
jest.unstable_mockModule('fs', () => ({
|
||||
default: {
|
||||
readFileSync: jest.fn(),
|
||||
existsSync: jest.fn(),
|
||||
mkdirSync: jest.fn(),
|
||||
writeFileSync: jest.fn()
|
||||
},
|
||||
readFileSync: jest.fn(),
|
||||
existsSync: jest.fn(),
|
||||
mkdirSync: jest.fn(),
|
||||
writeFileSync: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock path module
|
||||
jest.unstable_mockModule('path', () => ({
|
||||
default: {
|
||||
dirname: jest.fn(),
|
||||
join: jest.fn((dir, file) => `${dir}/${file}`)
|
||||
},
|
||||
dirname: jest.fn(),
|
||||
join: jest.fn((dir, file) => `${dir}/${file}`)
|
||||
}));
|
||||
|
||||
// Import the mocked modules
|
||||
const { readJSON, writeJSON, log, promptYesNo } = await import(
|
||||
'../../../../../scripts/modules/utils.js'
|
||||
);
|
||||
|
||||
const { generateObjectService } = await import(
|
||||
'../../../../../scripts/modules/ai-services-unified.js'
|
||||
);
|
||||
const generateTaskFiles = (
|
||||
await import(
|
||||
'../../../../../scripts/modules/task-manager/generate-task-files.js'
|
||||
)
|
||||
).default;
|
||||
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
|
||||
// Import the module under test
|
||||
const { default: parsePRD } = await import(
|
||||
'../../../../../scripts/modules/task-manager/parse-prd.js'
|
||||
);
|
||||
|
||||
// Sample data for tests (from main test file)
|
||||
const sampleClaudeResponse = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Setup Project Structure',
|
||||
description: 'Initialize the project with necessary files and folders',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
subtasks: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Implement Core Features',
|
||||
description: 'Build the main functionality',
|
||||
status: 'pending',
|
||||
dependencies: [1],
|
||||
priority: 'high',
|
||||
subtasks: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
describe('parsePRD', () => {
|
||||
// Mock the sample PRD content
|
||||
const samplePRDContent = '# Sample PRD for Testing';
|
||||
|
||||
// Mock existing tasks for append test
|
||||
const existingTasks = {
|
||||
tasks: [
|
||||
{ id: 1, title: 'Existing Task 1', status: 'done' },
|
||||
{ id: 2, title: 'Existing Task 2', status: 'pending' }
|
||||
]
|
||||
};
|
||||
|
||||
// Mock new tasks with continuing IDs for append test
|
||||
const newTasksWithContinuedIds = {
|
||||
tasks: [
|
||||
{ id: 3, title: 'New Task 3' },
|
||||
{ id: 4, title: 'New Task 4' }
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up mocks for fs, path and other modules
|
||||
fs.default.readFileSync.mockReturnValue(samplePRDContent);
|
||||
fs.default.existsSync.mockReturnValue(true);
|
||||
path.default.dirname.mockReturnValue('tasks');
|
||||
generateObjectService.mockResolvedValue({
|
||||
mainResult: sampleClaudeResponse,
|
||||
telemetryData: {}
|
||||
});
|
||||
generateTaskFiles.mockResolvedValue(undefined);
|
||||
promptYesNo.mockResolvedValue(true); // Default to "yes" for confirmation
|
||||
|
||||
// Mock console.error to prevent output
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore all mocks after each test
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('should parse a PRD file and generate tasks', async () => {
|
||||
// Setup mocks to simulate normal conditions (no existing output file)
|
||||
fs.default.existsSync.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
|
||||
const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
|
||||
|
||||
// Verify fs.readFileSync was called with the correct arguments
|
||||
expect(fs.default.readFileSync).toHaveBeenCalledWith(
|
||||
'path/to/prd.txt',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Verify generateObjectService was called
|
||||
expect(generateObjectService).toHaveBeenCalled();
|
||||
|
||||
// Verify directory check
|
||||
expect(fs.default.existsSync).toHaveBeenCalledWith('tasks');
|
||||
|
||||
// Verify writeJSON was called with the correct arguments
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
sampleClaudeResponse
|
||||
);
|
||||
|
||||
// Verify generateTaskFiles was called
|
||||
expect(generateTaskFiles).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
'tasks',
|
||||
{ mcpLog: undefined }
|
||||
);
|
||||
|
||||
// Verify result
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
tasksPath: 'tasks/tasks.json',
|
||||
telemetryData: {}
|
||||
});
|
||||
|
||||
// Verify that the written data contains 2 tasks from sampleClaudeResponse
|
||||
const writtenData = writeJSON.mock.calls[0][1];
|
||||
expect(writtenData.tasks.length).toBe(2);
|
||||
});
|
||||
|
||||
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)
|
||||
fs.default.existsSync.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 parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
|
||||
|
||||
// Verify mkdir was called
|
||||
expect(fs.default.mkdirSync).toHaveBeenCalledWith('tasks', {
|
||||
recursive: true
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle errors in the PRD parsing process', async () => {
|
||||
// Mock an error in generateObjectService
|
||||
const testError = new Error('Test error in AI API call');
|
||||
generateObjectService.mockRejectedValueOnce(testError);
|
||||
|
||||
// Setup mocks to simulate normal file conditions (no existing file)
|
||||
fs.default.existsSync.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 with mcpLog to make it think it's in MCP mode (which throws instead of process.exit)
|
||||
await expect(
|
||||
parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('Test error in AI API call');
|
||||
});
|
||||
|
||||
test('should generate individual task files after creating tasks.json', async () => {
|
||||
// Setup mocks to simulate normal conditions (no existing output file)
|
||||
fs.default.existsSync.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 parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
|
||||
|
||||
// Verify generateTaskFiles was called
|
||||
expect(generateTaskFiles).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
'tasks',
|
||||
{ mcpLog: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
test('should overwrite tasks.json when force flag is true', async () => {
|
||||
// Setup mocks to simulate tasks.json already exists
|
||||
fs.default.existsSync.mockImplementation((path) => {
|
||||
if (path === 'tasks/tasks.json') return true; // Output file exists
|
||||
if (path === 'tasks') return true; // Directory exists
|
||||
return false;
|
||||
});
|
||||
|
||||
// Call the function with force=true to allow overwrite
|
||||
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { force: true });
|
||||
|
||||
// Verify prompt was NOT called (confirmation happens at CLI level, not in core function)
|
||||
expect(promptYesNo).not.toHaveBeenCalled();
|
||||
|
||||
// Verify the file was written after force overwrite
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
sampleClaudeResponse
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error when tasks.json exists without force flag in MCP mode', async () => {
|
||||
// Setup mocks to simulate tasks.json already exists
|
||||
fs.default.existsSync.mockImplementation((path) => {
|
||||
if (path === 'tasks/tasks.json') return true; // Output file exists
|
||||
if (path === 'tasks') return true; // Directory exists
|
||||
return false;
|
||||
});
|
||||
|
||||
// Call the function with mcpLog to make it think it's in MCP mode (which throws instead of process.exit)
|
||||
await expect(
|
||||
parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('Output file tasks/tasks.json already exists');
|
||||
|
||||
// Verify prompt was NOT called (confirmation happens at CLI level, not in core function)
|
||||
expect(promptYesNo).not.toHaveBeenCalled();
|
||||
|
||||
// Verify the file was NOT written
|
||||
expect(writeJSON).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should call process.exit when tasks.json exists without force flag in CLI mode', async () => {
|
||||
// Setup mocks to simulate tasks.json already exists
|
||||
fs.default.existsSync.mockImplementation((path) => {
|
||||
if (path === 'tasks/tasks.json') return true; // Output file exists
|
||||
if (path === 'tasks') return true; // Directory exists
|
||||
return false;
|
||||
});
|
||||
|
||||
// Mock process.exit for this specific test
|
||||
const mockProcessExit = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new Error(`process.exit: ${code}`);
|
||||
});
|
||||
|
||||
// Call the function without mcpLog (CLI mode) and expect it to throw due to mocked process.exit
|
||||
await expect(
|
||||
parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3)
|
||||
).rejects.toThrow('process.exit: 1');
|
||||
|
||||
// Verify process.exit was called with code 1
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
|
||||
// Verify the file was NOT written
|
||||
expect(writeJSON).not.toHaveBeenCalled();
|
||||
|
||||
// Restore the mock
|
||||
mockProcessExit.mockRestore();
|
||||
});
|
||||
|
||||
test('should not prompt for confirmation when tasks.json does not exist', async () => {
|
||||
// Setup mocks to simulate tasks.json does not exist
|
||||
fs.default.existsSync.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 parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
|
||||
|
||||
// Verify prompt was NOT called
|
||||
expect(promptYesNo).not.toHaveBeenCalled();
|
||||
|
||||
// Verify the file was written without confirmation
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
sampleClaudeResponse
|
||||
);
|
||||
});
|
||||
|
||||
test('should append new tasks when append option is true', async () => {
|
||||
// Setup mocks to simulate tasks.json already exists
|
||||
fs.default.existsSync.mockImplementation((path) => {
|
||||
if (path === 'tasks/tasks.json') return true; // Output file exists
|
||||
if (path === 'tasks') return true; // Directory exists
|
||||
return false;
|
||||
});
|
||||
|
||||
// Mock for reading existing tasks
|
||||
readJSON.mockReturnValue(existingTasks);
|
||||
|
||||
// Mock generateObjectService to return new tasks with continuing IDs
|
||||
generateObjectService.mockResolvedValueOnce({
|
||||
mainResult: newTasksWithContinuedIds,
|
||||
telemetryData: {}
|
||||
});
|
||||
|
||||
// Call the function with append option
|
||||
const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 2, {
|
||||
append: true
|
||||
});
|
||||
|
||||
// Verify prompt was NOT called (no confirmation needed for append)
|
||||
expect(promptYesNo).not.toHaveBeenCalled();
|
||||
|
||||
// Verify the file was written with merged tasks
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
'tasks/tasks.json',
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 1 }),
|
||||
expect.objectContaining({ id: 2 }),
|
||||
expect.objectContaining({ id: 3 }),
|
||||
expect.objectContaining({ id: 4 })
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
// Verify the result contains merged tasks
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
tasksPath: 'tasks/tasks.json',
|
||||
telemetryData: {}
|
||||
});
|
||||
|
||||
// Verify that the written data contains 4 tasks (2 existing + 2 new)
|
||||
const writtenData = writeJSON.mock.calls[0][1];
|
||||
expect(writtenData.tasks.length).toBe(4);
|
||||
});
|
||||
|
||||
test('should skip prompt and not overwrite when append is true', async () => {
|
||||
// Setup mocks to simulate tasks.json already exists
|
||||
fs.default.existsSync.mockImplementation((path) => {
|
||||
if (path === 'tasks/tasks.json') return true; // Output file exists
|
||||
if (path === 'tasks') return true; // Directory exists
|
||||
return false;
|
||||
});
|
||||
|
||||
// Call the function with append option
|
||||
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
|
||||
append: true
|
||||
});
|
||||
|
||||
// Verify prompt was NOT called with append flag
|
||||
expect(promptYesNo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
282
tests/unit/scripts/modules/task-manager/remove-subtask.test.js
Normal file
282
tests/unit/scripts/modules/task-manager/remove-subtask.test.js
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Tests for the removeSubtask function
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
import path from 'path';
|
||||
|
||||
// Mock dependencies
|
||||
const mockReadJSON = jest.fn();
|
||||
const mockWriteJSON = jest.fn();
|
||||
const mockGenerateTaskFiles = jest.fn();
|
||||
|
||||
// Mock path module
|
||||
jest.mock('path', () => ({
|
||||
dirname: jest.fn()
|
||||
}));
|
||||
|
||||
// Define test version of the removeSubtask function
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
464
tests/unit/scripts/modules/task-manager/set-task-status.test.js
Normal file
464
tests/unit/scripts/modules/task-manager/set-task-status.test.js
Normal file
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* Tests for the set-task-status.js module
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock the dependencies before importing the module under test
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
readJSON: jest.fn(),
|
||||
writeJSON: jest.fn(),
|
||||
log: jest.fn(),
|
||||
CONFIG: {
|
||||
model: 'mock-claude-model',
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7,
|
||||
debug: false
|
||||
},
|
||||
sanitizePrompt: jest.fn((prompt) => prompt),
|
||||
truncate: jest.fn((text) => text),
|
||||
isSilentMode: jest.fn(() => false),
|
||||
findTaskById: jest.fn((tasks, id) => tasks.find((t) => t.id === parseInt(id)))
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/task-manager/generate-task-files.js',
|
||||
() => ({
|
||||
default: jest.fn().mockResolvedValue()
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
formatDependenciesWithStatus: jest.fn(),
|
||||
displayBanner: jest.fn(),
|
||||
displayTaskList: jest.fn(),
|
||||
startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
|
||||
stopLoadingIndicator: jest.fn(),
|
||||
getStatusWithColor: jest.fn((status) => status)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../src/constants/task-status.js', () => ({
|
||||
isValidTaskStatus: jest.fn((status) =>
|
||||
[
|
||||
'pending',
|
||||
'done',
|
||||
'in-progress',
|
||||
'review',
|
||||
'deferred',
|
||||
'cancelled'
|
||||
].includes(status)
|
||||
),
|
||||
TASK_STATUS_OPTIONS: [
|
||||
'pending',
|
||||
'done',
|
||||
'in-progress',
|
||||
'review',
|
||||
'deferred',
|
||||
'cancelled'
|
||||
]
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/task-manager/update-single-task-status.js',
|
||||
() => ({
|
||||
default: jest.fn()
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/dependency-manager.js',
|
||||
() => ({
|
||||
validateTaskDependencies: jest.fn()
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/config-manager.js',
|
||||
() => ({
|
||||
getDebugFlag: jest.fn(() => false)
|
||||
})
|
||||
);
|
||||
|
||||
// Import the mocked modules
|
||||
const { readJSON, writeJSON, log, findTaskById } = await import(
|
||||
'../../../../../scripts/modules/utils.js'
|
||||
);
|
||||
|
||||
const generateTaskFiles = (
|
||||
await import(
|
||||
'../../../../../scripts/modules/task-manager/generate-task-files.js'
|
||||
)
|
||||
).default;
|
||||
|
||||
const updateSingleTaskStatus = (
|
||||
await import(
|
||||
'../../../../../scripts/modules/task-manager/update-single-task-status.js'
|
||||
)
|
||||
).default;
|
||||
|
||||
// Import the module under test
|
||||
const { default: setTaskStatus } = await import(
|
||||
'../../../../../scripts/modules/task-manager/set-task-status.js'
|
||||
);
|
||||
|
||||
// Sample data for tests (from main test file)
|
||||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
describe('setTaskStatus', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock console methods to suppress output
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock process.exit to prevent actual exit
|
||||
jest.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit: ${code}`);
|
||||
});
|
||||
|
||||
// Set up updateSingleTaskStatus mock to actually update the data
|
||||
updateSingleTaskStatus.mockImplementation(
|
||||
async (tasksPath, taskId, newStatus, data) => {
|
||||
// Handle subtask notation (e.g., "3.1")
|
||||
if (taskId.includes('.')) {
|
||||
const [parentId, subtaskId] = taskId
|
||||
.split('.')
|
||||
.map((id) => parseInt(id, 10));
|
||||
const parentTask = data.tasks.find((t) => t.id === parentId);
|
||||
if (!parentTask) {
|
||||
throw new Error(`Parent task ${parentId} not found`);
|
||||
}
|
||||
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}`
|
||||
);
|
||||
}
|
||||
subtask.status = newStatus;
|
||||
} else {
|
||||
// Handle regular task
|
||||
const task = data.tasks.find((t) => t.id === parseInt(taskId, 10));
|
||||
if (!task) {
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
task.status = newStatus;
|
||||
|
||||
// If marking parent as done, mark all subtasks as done too
|
||||
if (newStatus === 'done' && task.subtasks) {
|
||||
task.subtasks.forEach((subtask) => {
|
||||
subtask.status = 'done';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore console methods
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('should update task status in tasks.json', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
const tasksPath = '/mock/path/tasks.json';
|
||||
|
||||
readJSON.mockReturnValue(testTasksData);
|
||||
|
||||
// Act
|
||||
await setTaskStatus(tasksPath, '2', 'done', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith(tasksPath);
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
tasksPath,
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 2, status: 'done' })
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(generateTaskFiles).toHaveBeenCalledWith(
|
||||
tasksPath,
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test('should update subtask status when using dot notation', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
const tasksPath = '/mock/path/tasks.json';
|
||||
|
||||
readJSON.mockReturnValue(testTasksData);
|
||||
|
||||
// Act
|
||||
await setTaskStatus(tasksPath, '3.1', 'done', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith(tasksPath);
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
tasksPath,
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 3,
|
||||
subtasks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 1, status: 'done' })
|
||||
])
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should update multiple tasks when given comma-separated IDs', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
const tasksPath = '/mock/path/tasks.json';
|
||||
|
||||
readJSON.mockReturnValue(testTasksData);
|
||||
|
||||
// Act
|
||||
await setTaskStatus(tasksPath, '1,2', 'done', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith(tasksPath);
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
tasksPath,
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 1, status: 'done' }),
|
||||
expect.objectContaining({ id: 2, status: 'done' })
|
||||
])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should automatically mark subtasks as done when parent is marked done', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
const tasksPath = '/mock/path/tasks.json';
|
||||
|
||||
readJSON.mockReturnValue(testTasksData);
|
||||
|
||||
// Act
|
||||
await setTaskStatus(tasksPath, '3', 'done', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
tasksPath,
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 3,
|
||||
status: 'done',
|
||||
subtasks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 1, status: 'done' }),
|
||||
expect.objectContaining({ id: 2, status: 'done' })
|
||||
])
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error for non-existent task ID', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
const tasksPath = '/mock/path/tasks.json';
|
||||
|
||||
readJSON.mockReturnValue(testTasksData);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
setTaskStatus(tasksPath, '99', 'done', { mcpLog: { info: jest.fn() } })
|
||||
).rejects.toThrow('Task 99 not found');
|
||||
});
|
||||
|
||||
test('should throw error for invalid status', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
const tasksPath = '/mock/path/tasks.json';
|
||||
|
||||
readJSON.mockReturnValue(testTasksData);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
setTaskStatus(tasksPath, '2', 'InvalidStatus', {
|
||||
mcpLog: { info: jest.fn() }
|
||||
})
|
||||
).rejects.toThrow(/Invalid status value: InvalidStatus/);
|
||||
});
|
||||
|
||||
test('should handle parent tasks without subtasks when updating subtask', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
// Remove subtasks from task 3
|
||||
testTasksData.tasks[2] = { ...testTasksData.tasks[2] };
|
||||
delete testTasksData.tasks[2].subtasks;
|
||||
|
||||
const tasksPath = '/mock/path/tasks.json';
|
||||
readJSON.mockReturnValue(testTasksData);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
setTaskStatus(tasksPath, '3.1', 'done', { mcpLog: { info: jest.fn() } })
|
||||
).rejects.toThrow('has no subtasks');
|
||||
});
|
||||
|
||||
test('should handle non-existent subtask ID', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
const tasksPath = '/mock/path/tasks.json';
|
||||
|
||||
readJSON.mockReturnValue(testTasksData);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
setTaskStatus(tasksPath, '3.99', 'done', { mcpLog: { info: jest.fn() } })
|
||||
).rejects.toThrow('Subtask 99 not found');
|
||||
});
|
||||
|
||||
test('should handle file read errors', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
const taskId = '2';
|
||||
const newStatus = 'done';
|
||||
|
||||
readJSON.mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
setTaskStatus(tasksPath, taskId, newStatus, {
|
||||
mcpLog: { info: jest.fn() }
|
||||
})
|
||||
).rejects.toThrow('File not found');
|
||||
|
||||
// Verify that writeJSON was not called due to read error
|
||||
expect(writeJSON).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle empty task ID input', async () => {
|
||||
// Arrange
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
const emptyTaskId = '';
|
||||
const newStatus = 'done';
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
setTaskStatus(tasksPath, emptyTaskId, newStatus, {
|
||||
mcpLog: { info: jest.fn() }
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
// Verify that updateSingleTaskStatus was not called
|
||||
expect(updateSingleTaskStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle whitespace in comma-separated IDs', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
const taskIds = ' 1 , 2 , 3 '; // IDs with whitespace
|
||||
const newStatus = 'in-progress';
|
||||
|
||||
readJSON.mockReturnValue(testTasksData);
|
||||
|
||||
// Act
|
||||
const result = await setTaskStatus(tasksPath, taskIds, newStatus, {
|
||||
mcpLog: { info: jest.fn() }
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(updateSingleTaskStatus).toHaveBeenCalledTimes(3);
|
||||
expect(updateSingleTaskStatus).toHaveBeenCalledWith(
|
||||
tasksPath,
|
||||
'1',
|
||||
newStatus,
|
||||
testTasksData,
|
||||
false
|
||||
);
|
||||
expect(updateSingleTaskStatus).toHaveBeenCalledWith(
|
||||
tasksPath,
|
||||
'2',
|
||||
newStatus,
|
||||
testTasksData,
|
||||
false
|
||||
);
|
||||
expect(updateSingleTaskStatus).toHaveBeenCalledWith(
|
||||
tasksPath,
|
||||
'3',
|
||||
newStatus,
|
||||
testTasksData,
|
||||
false
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
121
tests/unit/scripts/modules/task-manager/setup.js
Normal file
121
tests/unit/scripts/modules/task-manager/setup.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Common setup for task-manager module tests
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Sample test data
|
||||
export 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const emptySampleTasks = {
|
||||
meta: { projectName: 'Empty Project' },
|
||||
tasks: []
|
||||
};
|
||||
|
||||
export const sampleClaudeResponse = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Setup Project',
|
||||
description: 'Initialize the project structure',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
details:
|
||||
'Create repository, configure build system, and setup dev environment',
|
||||
testStrategy: 'Verify project builds and tests run'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Implement Core Feature',
|
||||
description: 'Create the main functionality',
|
||||
status: 'pending',
|
||||
dependencies: [1],
|
||||
priority: 'high',
|
||||
details: 'Implement the core business logic for the application',
|
||||
testStrategy:
|
||||
'Unit tests for core functions, integration tests for workflows'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Common mock setup function
|
||||
export const setupCommonMocks = () => {
|
||||
// Clear mocks before setup
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock implementations
|
||||
const mocks = {
|
||||
readFileSync: jest.fn(),
|
||||
existsSync: jest.fn(),
|
||||
mkdirSync: jest.fn(),
|
||||
writeFileSync: jest.fn(),
|
||||
readJSON: jest.fn(),
|
||||
writeJSON: jest.fn(),
|
||||
log: jest.fn(),
|
||||
isTaskDependentOn: jest.fn().mockReturnValue(false),
|
||||
formatDependenciesWithStatus: jest.fn(),
|
||||
displayTaskList: jest.fn(),
|
||||
validateAndFixDependencies: jest.fn(),
|
||||
generateObjectService: jest.fn().mockResolvedValue({
|
||||
mainResult: { tasks: [] },
|
||||
telemetryData: {}
|
||||
})
|
||||
};
|
||||
|
||||
return mocks;
|
||||
};
|
||||
|
||||
// Helper to create a deep copy of objects to avoid test pollution
|
||||
export const cloneData = (data) => JSON.parse(JSON.stringify(data));
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Tests for the updateSingleTaskStatus function
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Import test fixtures
|
||||
import {
|
||||
isValidTaskStatus,
|
||||
TASK_STATUS_OPTIONS
|
||||
} from '../../../../../src/constants/task-status.js';
|
||||
|
||||
// Sample tasks data for testing
|
||||
const sampleTasks = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task 1',
|
||||
description: 'First task',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Task 2',
|
||||
description: 'Second task',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Task 3',
|
||||
description: 'Third task with subtasks',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Subtask 3.1',
|
||||
description: 'First subtask',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Subtask 3.2',
|
||||
description: 'Second subtask',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Simplified version of updateSingleTaskStatus for testing
|
||||
const testUpdateSingleTaskStatus = (tasksData, taskIdInput, newStatus) => {
|
||||
if (!isValidTaskStatus(newStatus)) {
|
||||
throw new Error(
|
||||
`Error: Invalid status value: ${newStatus}. Use one of: ${TASK_STATUS_OPTIONS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a subtask (e.g., "1.2")
|
||||
if (taskIdInput.includes('.')) {
|
||||
const [parentId, subtaskId] = taskIdInput
|
||||
.split('.')
|
||||
.map((id) => parseInt(id, 10));
|
||||
|
||||
// Find the parent task
|
||||
const parentTask = tasksData.tasks.find((t) => t.id === parentId);
|
||||
if (!parentTask) {
|
||||
throw new Error(`Parent task ${parentId} not found`);
|
||||
}
|
||||
|
||||
// Find the subtask
|
||||
if (!parentTask.subtasks) {
|
||||
throw new Error(`Parent task ${parentId} has no subtasks`);
|
||||
}
|
||||
|
||||
const subtask = parentTask.subtasks.find((st) => st.id === subtaskId);
|
||||
if (!subtask) {
|
||||
throw new Error(
|
||||
`Subtask ${subtaskId} not found in parent task ${parentId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Update the subtask status
|
||||
subtask.status = newStatus;
|
||||
|
||||
// Check if all subtasks are done (if setting to 'done')
|
||||
if (
|
||||
newStatus.toLowerCase() === 'done' ||
|
||||
newStatus.toLowerCase() === 'completed'
|
||||
) {
|
||||
const allSubtasksDone = parentTask.subtasks.every(
|
||||
(st) => st.status === 'done' || st.status === 'completed'
|
||||
);
|
||||
|
||||
// For testing, we don't need to output suggestions
|
||||
}
|
||||
} else {
|
||||
// Handle regular task
|
||||
const taskId = parseInt(taskIdInput, 10);
|
||||
const task = tasksData.tasks.find((t) => t.id === taskId);
|
||||
|
||||
if (!task) {
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
|
||||
// Update the task status
|
||||
task.status = newStatus;
|
||||
|
||||
// If marking as done, also mark all subtasks as done
|
||||
if (
|
||||
(newStatus.toLowerCase() === 'done' ||
|
||||
newStatus.toLowerCase() === 'completed') &&
|
||||
task.subtasks &&
|
||||
task.subtasks.length > 0
|
||||
) {
|
||||
task.subtasks.forEach((subtask) => {
|
||||
subtask.status = newStatus;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
describe('updateSingleTaskStatus function', () => {
|
||||
test('should update regular task status', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
|
||||
// Act
|
||||
const result = testUpdateSingleTaskStatus(testTasksData, '2', 'done');
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
expect(testTasksData.tasks[1].status).toBe('done');
|
||||
});
|
||||
|
||||
test('should throw error for invalid status', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
|
||||
// Assert
|
||||
expect(() =>
|
||||
testUpdateSingleTaskStatus(testTasksData, '2', 'Done')
|
||||
).toThrow(/Error: Invalid status value: Done./);
|
||||
});
|
||||
|
||||
test('should update subtask status', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
|
||||
// Act
|
||||
const result = testUpdateSingleTaskStatus(testTasksData, '3.1', 'done');
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
expect(testTasksData.tasks[2].subtasks[0].status).toBe('done');
|
||||
});
|
||||
|
||||
test('should handle parent tasks without subtasks', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
|
||||
// Remove subtasks from task 3
|
||||
const taskWithoutSubtasks = { ...testTasksData.tasks[2] };
|
||||
delete taskWithoutSubtasks.subtasks;
|
||||
testTasksData.tasks[2] = taskWithoutSubtasks;
|
||||
|
||||
// Assert
|
||||
expect(() =>
|
||||
testUpdateSingleTaskStatus(testTasksData, '3.1', 'done')
|
||||
).toThrow('has no subtasks');
|
||||
});
|
||||
|
||||
test('should handle non-existent subtask ID', async () => {
|
||||
// Arrange
|
||||
const testTasksData = JSON.parse(JSON.stringify(sampleTasks));
|
||||
|
||||
// Assert
|
||||
expect(() =>
|
||||
testUpdateSingleTaskStatus(testTasksData, '3.99', 'done')
|
||||
).toThrow('Subtask 99 not found');
|
||||
});
|
||||
});
|
||||
217
tests/unit/scripts/modules/task-manager/update-tasks.test.js
Normal file
217
tests/unit/scripts/modules/task-manager/update-tasks.test.js
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Tests for the update-tasks.js module
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock the dependencies before importing the module under test
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
readJSON: jest.fn(),
|
||||
writeJSON: jest.fn(),
|
||||
log: jest.fn(),
|
||||
CONFIG: {
|
||||
model: 'mock-claude-model',
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7,
|
||||
debug: false
|
||||
},
|
||||
sanitizePrompt: jest.fn((prompt) => prompt),
|
||||
truncate: jest.fn((text) => text),
|
||||
isSilentMode: jest.fn(() => false)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/ai-services-unified.js',
|
||||
() => ({
|
||||
generateTextService: jest.fn().mockResolvedValue({
|
||||
mainResult: '[]', // mainResult is the text string directly
|
||||
telemetryData: {}
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
getStatusWithColor: jest.fn((status) => status),
|
||||
startLoadingIndicator: jest.fn(),
|
||||
stopLoadingIndicator: jest.fn(),
|
||||
displayAiUsageSummary: jest.fn()
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/config-manager.js',
|
||||
() => ({
|
||||
getDebugFlag: jest.fn(() => false)
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/task-manager/generate-task-files.js',
|
||||
() => ({
|
||||
default: jest.fn().mockResolvedValue()
|
||||
})
|
||||
);
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/task-manager/models.js',
|
||||
() => ({
|
||||
getModelConfiguration: jest.fn(() => ({
|
||||
model: 'mock-model',
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7
|
||||
}))
|
||||
})
|
||||
);
|
||||
|
||||
// Import the mocked modules
|
||||
const { readJSON, writeJSON, log, CONFIG } = await import(
|
||||
'../../../../../scripts/modules/utils.js'
|
||||
);
|
||||
|
||||
const { generateTextService } = await import(
|
||||
'../../../../../scripts/modules/ai-services-unified.js'
|
||||
);
|
||||
|
||||
// Import the module under test
|
||||
const { default: updateTasks } = await import(
|
||||
'../../../../../scripts/modules/task-manager/update-tasks.js'
|
||||
);
|
||||
|
||||
describe('updateTasks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should update tasks based on new context', async () => {
|
||||
// Arrange
|
||||
const mockTasksPath = '/mock/path/tasks.json';
|
||||
const mockFromId = 2;
|
||||
const mockPrompt = 'New project direction';
|
||||
const mockInitialTasks = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Old Task 1',
|
||||
status: 'done',
|
||||
details: 'Done details'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Old Task 2',
|
||||
status: 'pending',
|
||||
details: 'Old details 2'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Old Task 3',
|
||||
status: 'in-progress',
|
||||
details: 'Old details 3'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const mockUpdatedTasks = [
|
||||
{
|
||||
id: 2,
|
||||
title: 'Updated Task 2',
|
||||
status: 'pending',
|
||||
details: 'New details 2 based on direction',
|
||||
description: 'Updated description',
|
||||
dependencies: [],
|
||||
priority: 'medium'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Updated Task 3',
|
||||
status: 'pending',
|
||||
details: 'New details 3 based on direction',
|
||||
description: 'Updated description',
|
||||
dependencies: [],
|
||||
priority: 'medium'
|
||||
}
|
||||
];
|
||||
|
||||
const mockApiResponse = {
|
||||
mainResult: JSON.stringify(mockUpdatedTasks), // mainResult is the JSON string directly
|
||||
telemetryData: {}
|
||||
};
|
||||
|
||||
// Configure mocks
|
||||
readJSON.mockReturnValue(mockInitialTasks);
|
||||
generateTextService.mockResolvedValue(mockApiResponse);
|
||||
|
||||
// Act
|
||||
const result = await updateTasks(
|
||||
mockTasksPath,
|
||||
mockFromId,
|
||||
mockPrompt,
|
||||
false,
|
||||
{},
|
||||
'json'
|
||||
); // Use json format to avoid console output and process.exit
|
||||
|
||||
// Assert
|
||||
// 1. Read JSON called
|
||||
expect(readJSON).toHaveBeenCalledWith(mockTasksPath);
|
||||
|
||||
// 2. AI Service called with correct args
|
||||
expect(generateTextService).toHaveBeenCalledWith(expect.any(Object));
|
||||
|
||||
// 3. Write JSON called with correctly merged tasks
|
||||
expect(writeJSON).toHaveBeenCalledWith(
|
||||
mockTasksPath,
|
||||
expect.objectContaining({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 1 }),
|
||||
expect.objectContaining({ id: 2, title: 'Updated Task 2' }),
|
||||
expect.objectContaining({ id: 3, title: 'Updated Task 3' })
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
// 4. Check return value
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
updatedTasks: mockUpdatedTasks,
|
||||
telemetryData: {}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle no tasks to update', async () => {
|
||||
// Arrange
|
||||
const mockTasksPath = '/mock/path/tasks.json';
|
||||
const mockFromId = 99; // Non-existent ID
|
||||
const mockPrompt = 'Update non-existent tasks';
|
||||
const mockInitialTasks = {
|
||||
tasks: [
|
||||
{ id: 1, status: 'done' },
|
||||
{ id: 2, status: 'done' }
|
||||
]
|
||||
};
|
||||
|
||||
// Configure mocks
|
||||
readJSON.mockReturnValue(mockInitialTasks);
|
||||
|
||||
// Act
|
||||
const result = await updateTasks(
|
||||
mockTasksPath,
|
||||
mockFromId,
|
||||
mockPrompt,
|
||||
false,
|
||||
{},
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(readJSON).toHaveBeenCalledWith(mockTasksPath);
|
||||
expect(generateTextService).not.toHaveBeenCalled();
|
||||
expect(writeJSON).not.toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
'info',
|
||||
expect.stringContaining('No tasks to update')
|
||||
);
|
||||
|
||||
// Should return early with no updates
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user