mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-07-05 08:01:34 +00:00

* feat(tasks): Fix critical tag corruption bug in task management - Fixed missing context parameters in writeJSON calls across add-task, remove-task, and add-subtask functions - Added projectRoot and tag parameters to prevent data corruption in multi-tag environments - Re-enabled generateTaskFiles calls to ensure markdown files are updated after operations - Enhanced add_subtask MCP tool with tag parameter support - Refactored addSubtaskDirect function to properly pass context to core logic - Streamlined codebase by removing deprecated functionality This resolves the critical bug where task operations in one tag context would corrupt or delete tasks from other tags in tasks.json. * feat(task-manager): Enhance addSubtask with current tag support - Added `getCurrentTag` utility to retrieve the current tag context for task operations. - Updated `addSubtask` to use the current tag when reading and writing tasks, ensuring proper context handling. - Refactored tests to accommodate changes in the `addSubtask` function, ensuring accurate mock implementations and expectations. - Cleaned up test cases for better readability and maintainability. This improves task management by preventing tag-related data corruption and enhances the overall functionality of the task manager. * feat(remove-task): Add tag support for task removal and enhance error handling - Introduced `tag` parameter in `removeTaskDirect` to specify context for task operations, improving multi-tag support. - Updated logging to include tag context in messages for better traceability. - Refactored task removal logic to streamline the process and improve error reporting. - Added comprehensive unit tests to validate tag handling and ensure robust error management. This enhancement prevents task data corruption across different tags and improves the overall reliability of the task management system. * feat(add-task): Add projectRoot and tag parameters to addTask tests - Updated `addTask` unit tests to include `projectRoot` and `tag` parameters for better context handling. - Enhanced test cases to ensure accurate expectations and improve overall test coverage. This change aligns with recent enhancements in task management, ensuring consistency across task operations. * feat(set-task-status): Add tag parameter support and enhance task status handling - Introduced `tag` parameter in `setTaskStatusDirect` and related functions to improve context management in multi-tag environments. - Updated `writeJSON` calls to ensure task data integrity across different tags. - Enhanced unit tests to validate tag preservation during task status updates, ensuring robust functionality. This change aligns with recent improvements in task management, preventing data corruption and enhancing overall reliability. * feat(tag-management): Enhance writeJSON calls to preserve tag context - Updated `writeJSON` calls in `createTag`, `deleteTag`, `renameTag`, `copyTag`, and `enhanceTagsWithMetadata` to include `projectRoot` for better context management and to prevent tag corruption. - Added comprehensive unit tests for tag management functions to ensure data integrity and proper tag handling during operations. This change improves the reliability of tag management by ensuring that operations do not corrupt existing tags and maintains the overall structure of the task data. * feat(expand-task): Update writeJSON to include projectRoot and tag context - Modified `writeJSON` call in `expandTaskDirect` to pass `projectRoot` and `tag` parameters, ensuring proper context management when saving tasks.json. - This change aligns with recent enhancements in task management, preventing potential data corruption and improving overall reliability. * feat(fix-dependencies): Add projectRoot and tag parameters for enhanced context management - Updated `fixDependenciesDirect` and `registerFixDependenciesTool` to include `projectRoot` and `tag` parameters, improving context handling during dependency fixes. - Introduced a new unit test for `fixDependenciesCommand` to ensure proper preservation of projectRoot and tag data in JSON outputs. This change enhances the reliability of dependency management by ensuring that context is maintained across operations, preventing potential data issues. * fix(context): propagate projectRoot and tag through dependency, expansion, status-update and tag-management commands to prevent cross-tag data corruption * test(fix-dependencies): Enhance unit tests for fixDependenciesCommand - Refactored tests to use unstable mocks for utils, ui, and task-manager modules, improving isolation and reliability. - Added checks for process.exit to ensure proper handling of invalid data scenarios. - Updated test cases to verify writeJSON calls with projectRoot and tag parameters, ensuring accurate context preservation during dependency fixes. This change strengthens the test suite for dependency management, ensuring robust functionality and preventing potential data issues. * chore(plan): remove outdated fix plan for `writeJSON` context parameters
577 lines
14 KiB
JavaScript
577 lines
14 KiB
JavaScript
/**
|
|
* 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))
|
|
),
|
|
ensureTagMetadata: jest.fn((tagObj) => tagObj),
|
|
getCurrentTag: jest.fn(() => 'master')
|
|
}));
|
|
|
|
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) - TAGGED FORMAT
|
|
const sampleTasks = {
|
|
master: {
|
|
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) => {
|
|
// This mock now operates on the tasks array passed in the `data` object
|
|
const { tasks } = data;
|
|
// Handle subtask notation (e.g., "3.1")
|
|
if (taskId.includes('.')) {
|
|
const [parentId, subtaskId] = taskId
|
|
.split('.')
|
|
.map((id) => parseInt(id, 10));
|
|
const parentTask = 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 = 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.master,
|
|
tag: 'master',
|
|
_rawTaggedData: testTasksData
|
|
});
|
|
|
|
// Act
|
|
await setTaskStatus(tasksPath, '2', 'done', {
|
|
mcpLog: { info: jest.fn() }
|
|
});
|
|
|
|
// Assert
|
|
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined);
|
|
expect(writeJSON).toHaveBeenCalledWith(
|
|
tasksPath,
|
|
expect.objectContaining({
|
|
master: expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 2, status: 'done' })
|
|
])
|
|
})
|
|
}),
|
|
undefined,
|
|
'master'
|
|
);
|
|
// 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.master,
|
|
tag: 'master',
|
|
_rawTaggedData: testTasksData
|
|
});
|
|
|
|
// Act
|
|
await setTaskStatus(tasksPath, '3.1', 'done', {
|
|
mcpLog: { info: jest.fn() }
|
|
});
|
|
|
|
// Assert
|
|
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined);
|
|
expect(writeJSON).toHaveBeenCalledWith(
|
|
tasksPath,
|
|
expect.objectContaining({
|
|
master: expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 3,
|
|
subtasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 1, status: 'done' })
|
|
])
|
|
})
|
|
])
|
|
})
|
|
}),
|
|
undefined,
|
|
'master'
|
|
);
|
|
});
|
|
|
|
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.master,
|
|
tag: 'master',
|
|
_rawTaggedData: testTasksData
|
|
});
|
|
|
|
// Act
|
|
await setTaskStatus(tasksPath, '1,2', 'done', {
|
|
mcpLog: { info: jest.fn() }
|
|
});
|
|
|
|
// Assert
|
|
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined);
|
|
expect(writeJSON).toHaveBeenCalledWith(
|
|
tasksPath,
|
|
expect.objectContaining({
|
|
master: expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 1, status: 'done' }),
|
|
expect.objectContaining({ id: 2, status: 'done' })
|
|
])
|
|
})
|
|
}),
|
|
undefined,
|
|
'master'
|
|
);
|
|
});
|
|
|
|
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.master,
|
|
tag: 'master',
|
|
_rawTaggedData: testTasksData
|
|
});
|
|
|
|
// Act
|
|
await setTaskStatus(tasksPath, '3', 'done', {
|
|
mcpLog: { info: jest.fn() }
|
|
});
|
|
|
|
// Assert
|
|
expect(writeJSON).toHaveBeenCalledWith(
|
|
tasksPath,
|
|
expect.objectContaining({
|
|
master: 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' })
|
|
])
|
|
})
|
|
])
|
|
})
|
|
}),
|
|
undefined,
|
|
'master'
|
|
);
|
|
});
|
|
|
|
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.master,
|
|
tag: 'master',
|
|
_rawTaggedData: 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.master,
|
|
tag: 'master',
|
|
_rawTaggedData: 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
|
|
const { subtasks, ...taskWithoutSubtasks } = testTasksData.master.tasks[2];
|
|
testTasksData.master.tasks[2] = taskWithoutSubtasks;
|
|
|
|
const tasksPath = '/mock/path/tasks.json';
|
|
readJSON.mockReturnValue({
|
|
...testTasksData.master,
|
|
tag: 'master',
|
|
_rawTaggedData: 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.master,
|
|
tag: 'master',
|
|
_rawTaggedData: 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.master,
|
|
tag: 'master',
|
|
_rawTaggedData: testTasksData
|
|
});
|
|
|
|
// Act
|
|
const result = await setTaskStatus(tasksPath, taskIds, newStatus, {
|
|
mcpLog: { info: jest.fn() }
|
|
});
|
|
|
|
// Assert
|
|
expect(updateSingleTaskStatus).toHaveBeenCalledTimes(3);
|
|
expect(updateSingleTaskStatus).toHaveBeenCalledWith(
|
|
tasksPath,
|
|
'1',
|
|
newStatus,
|
|
expect.objectContaining({
|
|
tasks: expect.any(Array),
|
|
tag: 'master',
|
|
_rawTaggedData: expect.any(Object)
|
|
}),
|
|
false
|
|
);
|
|
expect(updateSingleTaskStatus).toHaveBeenCalledWith(
|
|
tasksPath,
|
|
'2',
|
|
newStatus,
|
|
expect.objectContaining({
|
|
tasks: expect.any(Array),
|
|
tag: 'master',
|
|
_rawTaggedData: expect.any(Object)
|
|
}),
|
|
false
|
|
);
|
|
expect(updateSingleTaskStatus).toHaveBeenCalledWith(
|
|
tasksPath,
|
|
'3',
|
|
newStatus,
|
|
expect.objectContaining({
|
|
tasks: expect.any(Array),
|
|
tag: 'master',
|
|
_rawTaggedData: expect.any(Object)
|
|
}),
|
|
false
|
|
);
|
|
expect(result).toBeDefined();
|
|
});
|
|
|
|
// Regression test to ensure tag preservation when updating in multi-tag environment
|
|
test('should preserve other tags when updating task status', async () => {
|
|
// Arrange
|
|
const multiTagData = {
|
|
master: JSON.parse(JSON.stringify(sampleTasks.master)),
|
|
'feature-branch': {
|
|
tasks: [
|
|
{ id: 10, title: 'FB Task', status: 'pending', dependencies: [] }
|
|
],
|
|
metadata: { description: 'Feature branch tasks' }
|
|
}
|
|
};
|
|
const tasksPath = '/mock/path/tasks.json';
|
|
|
|
readJSON.mockReturnValue({
|
|
...multiTagData.master, // resolved view not used
|
|
tag: 'master',
|
|
_rawTaggedData: multiTagData
|
|
});
|
|
|
|
// Act
|
|
await setTaskStatus(tasksPath, '1', 'done', {
|
|
mcpLog: { info: jest.fn() }
|
|
});
|
|
|
|
// Assert: writeJSON should be called with data containing both tags intact
|
|
const writeArgs = writeJSON.mock.calls[0];
|
|
expect(writeArgs[0]).toBe(tasksPath);
|
|
const writtenData = writeArgs[1];
|
|
expect(writtenData).toHaveProperty('master');
|
|
expect(writtenData).toHaveProperty('feature-branch');
|
|
// master task updated
|
|
const updatedTask = writtenData.master.tasks.find((t) => t.id === 1);
|
|
expect(updatedTask.status).toBe('done');
|
|
// feature-branch untouched
|
|
expect(writtenData['feature-branch'].tasks[0].status).toBe('pending');
|
|
// ensure additional args (projectRoot undefined, tag 'master') present
|
|
expect(writeArgs[2]).toBeUndefined();
|
|
expect(writeArgs[3]).toBe('master');
|
|
});
|
|
});
|