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

* fix(task-manager): prevent tag corruption in bulk updates and add tag preservation test - Fix writeJSON call in scripts/modules/task-manager/update-tasks.js (line 469) to include projectRoot and tag parameters. - Ensure tagged task lists maintain data integrity during bulk updates, preventing task disappearance in tagged contexts. - Update MCP tools to properly pass tag context through the call chain. - Introduce a comprehensive test case to verify that all tags are preserved when updating tasks, covering both master and feature-branch scenarios. Addresses an issue where bulk updates could corrupt tasks.json in tagged task list structures, reinforcing task management robustness. * style(tests): format task data in update-tasks test
362 lines
8.6 KiB
JavaScript
362 lines
8.6 KiB
JavaScript
/**
|
|
* 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),
|
|
findTaskById: jest.fn(),
|
|
getCurrentTag: jest.fn(() => 'master'),
|
|
ensureTagMetadata: jest.fn((tagObj) => tagObj),
|
|
flattenTasksWithSubtasks: jest.fn((tasks) => tasks),
|
|
findProjectRoot: jest.fn(() => '/mock/project/root')
|
|
}));
|
|
|
|
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 } = 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 = {
|
|
master: {
|
|
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',
|
|
testStrategy: 'Unit test the updated functionality',
|
|
subtasks: []
|
|
},
|
|
{
|
|
id: 3,
|
|
title: 'Updated Task 3',
|
|
status: 'pending',
|
|
details: 'New details 3 based on direction',
|
|
description: 'Updated description',
|
|
dependencies: [],
|
|
priority: 'medium',
|
|
testStrategy: 'Integration test the updated features',
|
|
subtasks: []
|
|
}
|
|
];
|
|
|
|
const mockApiResponse = {
|
|
mainResult: JSON.stringify(mockUpdatedTasks), // mainResult is the JSON string directly
|
|
telemetryData: {}
|
|
};
|
|
|
|
// Configure mocks - readJSON should return the resolved view with tasks at top level
|
|
readJSON.mockReturnValue({
|
|
...mockInitialTasks.master,
|
|
tag: 'master',
|
|
_rawTaggedData: mockInitialTasks
|
|
});
|
|
generateTextService.mockResolvedValue(mockApiResponse);
|
|
|
|
// Act
|
|
const result = await updateTasks(
|
|
mockTasksPath,
|
|
mockFromId,
|
|
mockPrompt,
|
|
false, // research
|
|
{ projectRoot: '/mock/path' }, // context
|
|
'json' // output format
|
|
);
|
|
|
|
// Assert
|
|
// 1. Read JSON called
|
|
expect(readJSON).toHaveBeenCalledWith(
|
|
mockTasksPath,
|
|
'/mock/path',
|
|
'master'
|
|
);
|
|
|
|
// 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({
|
|
_rawTaggedData: expect.objectContaining({
|
|
master: 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' })
|
|
])
|
|
})
|
|
})
|
|
}),
|
|
'/mock/path',
|
|
'master'
|
|
);
|
|
|
|
// 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 = {
|
|
master: {
|
|
tasks: [
|
|
{ id: 1, status: 'done' },
|
|
{ id: 2, status: 'done' }
|
|
]
|
|
}
|
|
};
|
|
|
|
// Configure mocks - readJSON should return the resolved view with tasks at top level
|
|
readJSON.mockReturnValue({
|
|
...mockInitialTasks.master,
|
|
tag: 'master',
|
|
_rawTaggedData: mockInitialTasks
|
|
});
|
|
|
|
// Act
|
|
const result = await updateTasks(
|
|
mockTasksPath,
|
|
mockFromId,
|
|
mockPrompt,
|
|
false,
|
|
{ projectRoot: '/mock/path' },
|
|
'json'
|
|
);
|
|
|
|
// Assert
|
|
expect(readJSON).toHaveBeenCalledWith(
|
|
mockTasksPath,
|
|
'/mock/path',
|
|
'master'
|
|
);
|
|
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();
|
|
});
|
|
|
|
test('should preserve all tags when updating tasks in tagged context', async () => {
|
|
// Arrange - Simple 2-tag structure to test tag corruption fix
|
|
const mockTasksPath = '/mock/path/tasks.json';
|
|
const mockFromId = 1;
|
|
const mockPrompt = 'Update master tag tasks';
|
|
|
|
const mockTaggedData = {
|
|
master: {
|
|
tasks: [
|
|
{
|
|
id: 1,
|
|
title: 'Master Task',
|
|
status: 'pending',
|
|
details: 'Old details'
|
|
},
|
|
{
|
|
id: 2,
|
|
title: 'Master Task 2',
|
|
status: 'done',
|
|
details: 'Done task'
|
|
}
|
|
],
|
|
metadata: {
|
|
created: '2024-01-01T00:00:00.000Z',
|
|
description: 'Master tag tasks'
|
|
}
|
|
},
|
|
'feature-branch': {
|
|
tasks: [
|
|
{
|
|
id: 1,
|
|
title: 'Feature Task',
|
|
status: 'pending',
|
|
details: 'Feature work'
|
|
}
|
|
],
|
|
metadata: {
|
|
created: '2024-01-02T00:00:00.000Z',
|
|
description: 'Feature branch tasks'
|
|
}
|
|
}
|
|
};
|
|
|
|
const mockUpdatedTasks = [
|
|
{
|
|
id: 1,
|
|
title: 'Updated Master Task',
|
|
status: 'pending',
|
|
details: 'Updated details',
|
|
description: 'Updated description',
|
|
dependencies: [],
|
|
priority: 'medium',
|
|
testStrategy: 'Test the updated functionality',
|
|
subtasks: []
|
|
}
|
|
];
|
|
|
|
// Configure mocks - readJSON returns resolved view for master tag
|
|
readJSON.mockReturnValue({
|
|
...mockTaggedData.master,
|
|
tag: 'master',
|
|
_rawTaggedData: mockTaggedData
|
|
});
|
|
|
|
generateTextService.mockResolvedValue({
|
|
mainResult: JSON.stringify(mockUpdatedTasks),
|
|
telemetryData: { commandName: 'update-tasks', totalCost: 0.05 }
|
|
});
|
|
|
|
// Act
|
|
const result = await updateTasks(
|
|
mockTasksPath,
|
|
mockFromId,
|
|
mockPrompt,
|
|
false, // research
|
|
{ projectRoot: '/mock/project/root', tag: 'master' },
|
|
'json'
|
|
);
|
|
|
|
// Assert - CRITICAL: Both tags must be preserved (this would fail before the fix)
|
|
expect(writeJSON).toHaveBeenCalledWith(
|
|
mockTasksPath,
|
|
expect.objectContaining({
|
|
_rawTaggedData: expect.objectContaining({
|
|
master: expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 1, title: 'Updated Master Task' }),
|
|
expect.objectContaining({ id: 2, title: 'Master Task 2' }) // Unchanged done task
|
|
])
|
|
}),
|
|
// CRITICAL: This tag would be missing/corrupted if the bug existed
|
|
'feature-branch': expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 1, title: 'Feature Task' })
|
|
]),
|
|
metadata: expect.objectContaining({
|
|
description: 'Feature branch tasks'
|
|
})
|
|
})
|
|
})
|
|
}),
|
|
'/mock/project/root',
|
|
'master'
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.updatedTasks).toEqual(mockUpdatedTasks);
|
|
});
|
|
});
|