mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-07-05 08:01:34 +00:00
503 lines
11 KiB
JavaScript
503 lines
11 KiB
JavaScript
![]() |
/**
|
||
|
* Tests for the expand-all-tasks.js module
|
||
|
*/
|
||
|
import { jest } from '@jest/globals';
|
||
|
|
||
|
// Mock the dependencies before importing the module under test
|
||
|
jest.unstable_mockModule(
|
||
|
'../../../../../scripts/modules/task-manager/expand-task.js',
|
||
|
() => ({
|
||
|
default: jest.fn()
|
||
|
})
|
||
|
);
|
||
|
|
||
|
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||
|
readJSON: jest.fn(),
|
||
|
log: jest.fn(),
|
||
|
isSilentMode: jest.fn(() => false),
|
||
|
findProjectRoot: jest.fn(() => '/test/project'),
|
||
|
aggregateTelemetry: jest.fn()
|
||
|
}));
|
||
|
|
||
|
jest.unstable_mockModule(
|
||
|
'../../../../../scripts/modules/config-manager.js',
|
||
|
() => ({
|
||
|
getDebugFlag: jest.fn(() => false)
|
||
|
})
|
||
|
);
|
||
|
|
||
|
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||
|
startLoadingIndicator: jest.fn(),
|
||
|
stopLoadingIndicator: jest.fn(),
|
||
|
displayAiUsageSummary: jest.fn()
|
||
|
}));
|
||
|
|
||
|
jest.unstable_mockModule('chalk', () => ({
|
||
|
default: {
|
||
|
white: { bold: jest.fn((text) => text) },
|
||
|
cyan: jest.fn((text) => text),
|
||
|
green: jest.fn((text) => text),
|
||
|
gray: jest.fn((text) => text),
|
||
|
red: jest.fn((text) => text),
|
||
|
bold: jest.fn((text) => text)
|
||
|
}
|
||
|
}));
|
||
|
|
||
|
jest.unstable_mockModule('boxen', () => ({
|
||
|
default: jest.fn((text) => text)
|
||
|
}));
|
||
|
|
||
|
// Import the mocked modules
|
||
|
const { default: expandTask } = await import(
|
||
|
'../../../../../scripts/modules/task-manager/expand-task.js'
|
||
|
);
|
||
|
const { readJSON, aggregateTelemetry, findProjectRoot } = await import(
|
||
|
'../../../../../scripts/modules/utils.js'
|
||
|
);
|
||
|
|
||
|
// Import the module under test
|
||
|
const { default: expandAllTasks } = await import(
|
||
|
'../../../../../scripts/modules/task-manager/expand-all-tasks.js'
|
||
|
);
|
||
|
|
||
|
const mockExpandTask = expandTask;
|
||
|
const mockReadJSON = readJSON;
|
||
|
const mockAggregateTelemetry = aggregateTelemetry;
|
||
|
const mockFindProjectRoot = findProjectRoot;
|
||
|
|
||
|
describe('expandAllTasks', () => {
|
||
|
const mockTasksPath = '/test/tasks.json';
|
||
|
const mockProjectRoot = '/test/project';
|
||
|
const mockSession = { userId: 'test-user' };
|
||
|
const mockMcpLog = {
|
||
|
info: jest.fn(),
|
||
|
warn: jest.fn(),
|
||
|
error: jest.fn(),
|
||
|
debug: jest.fn()
|
||
|
};
|
||
|
|
||
|
const sampleTasksData = {
|
||
|
tag: 'master',
|
||
|
tasks: [
|
||
|
{
|
||
|
id: 1,
|
||
|
title: 'Pending Task 1',
|
||
|
status: 'pending',
|
||
|
subtasks: []
|
||
|
},
|
||
|
{
|
||
|
id: 2,
|
||
|
title: 'In Progress Task',
|
||
|
status: 'in-progress',
|
||
|
subtasks: []
|
||
|
},
|
||
|
{
|
||
|
id: 3,
|
||
|
title: 'Done Task',
|
||
|
status: 'done',
|
||
|
subtasks: []
|
||
|
},
|
||
|
{
|
||
|
id: 4,
|
||
|
title: 'Task with Subtasks',
|
||
|
status: 'pending',
|
||
|
subtasks: [{ id: '4.1', title: 'Existing subtask' }]
|
||
|
}
|
||
|
]
|
||
|
};
|
||
|
|
||
|
beforeEach(() => {
|
||
|
jest.clearAllMocks();
|
||
|
mockReadJSON.mockReturnValue(sampleTasksData);
|
||
|
mockAggregateTelemetry.mockReturnValue({
|
||
|
timestamp: '2024-01-01T00:00:00.000Z',
|
||
|
commandName: 'expand-all-tasks',
|
||
|
totalCost: 0.1,
|
||
|
totalTokens: 2000,
|
||
|
inputTokens: 1200,
|
||
|
outputTokens: 800
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('successful expansion', () => {
|
||
|
test('should expand all eligible pending tasks', async () => {
|
||
|
// Arrange
|
||
|
const mockTelemetryData = {
|
||
|
timestamp: '2024-01-01T00:00:00.000Z',
|
||
|
commandName: 'expand-task',
|
||
|
totalCost: 0.05,
|
||
|
totalTokens: 1000
|
||
|
};
|
||
|
|
||
|
mockExpandTask.mockResolvedValue({
|
||
|
telemetryData: mockTelemetryData
|
||
|
});
|
||
|
|
||
|
// Act
|
||
|
const result = await expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
3, // numSubtasks
|
||
|
false, // useResearch
|
||
|
'test context', // additionalContext
|
||
|
false, // force
|
||
|
{
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog,
|
||
|
projectRoot: mockProjectRoot,
|
||
|
tag: 'master'
|
||
|
},
|
||
|
'json' // outputFormat
|
||
|
);
|
||
|
|
||
|
// Assert
|
||
|
expect(result.success).toBe(true);
|
||
|
expect(result.expandedCount).toBe(2); // Tasks 1 and 2 (pending and in-progress)
|
||
|
expect(result.failedCount).toBe(0);
|
||
|
expect(result.skippedCount).toBe(0);
|
||
|
expect(result.tasksToExpand).toBe(2);
|
||
|
expect(result.telemetryData).toBeDefined();
|
||
|
|
||
|
// Verify readJSON was called correctly
|
||
|
expect(mockReadJSON).toHaveBeenCalledWith(
|
||
|
mockTasksPath,
|
||
|
mockProjectRoot,
|
||
|
'master'
|
||
|
);
|
||
|
|
||
|
// Verify expandTask was called for eligible tasks
|
||
|
expect(mockExpandTask).toHaveBeenCalledTimes(2);
|
||
|
expect(mockExpandTask).toHaveBeenCalledWith(
|
||
|
mockTasksPath,
|
||
|
1,
|
||
|
3,
|
||
|
false,
|
||
|
'test context',
|
||
|
expect.objectContaining({
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog,
|
||
|
projectRoot: mockProjectRoot,
|
||
|
tag: 'master'
|
||
|
}),
|
||
|
false
|
||
|
);
|
||
|
});
|
||
|
|
||
|
test('should handle force flag to expand tasks with existing subtasks', async () => {
|
||
|
// Arrange
|
||
|
mockExpandTask.mockResolvedValue({
|
||
|
telemetryData: { commandName: 'expand-task', totalCost: 0.05 }
|
||
|
});
|
||
|
|
||
|
// Act
|
||
|
const result = await expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
2,
|
||
|
false,
|
||
|
'',
|
||
|
true, // force = true
|
||
|
{
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog,
|
||
|
projectRoot: mockProjectRoot
|
||
|
},
|
||
|
'json'
|
||
|
);
|
||
|
|
||
|
// Assert
|
||
|
expect(result.expandedCount).toBe(3); // Tasks 1, 2, and 4 (including task with existing subtasks)
|
||
|
expect(mockExpandTask).toHaveBeenCalledTimes(3);
|
||
|
});
|
||
|
|
||
|
test('should handle research flag', async () => {
|
||
|
// Arrange
|
||
|
mockExpandTask.mockResolvedValue({
|
||
|
telemetryData: { commandName: 'expand-task', totalCost: 0.08 }
|
||
|
});
|
||
|
|
||
|
// Act
|
||
|
const result = await expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
undefined, // numSubtasks not specified
|
||
|
true, // useResearch = true
|
||
|
'research context',
|
||
|
false,
|
||
|
{
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog,
|
||
|
projectRoot: mockProjectRoot
|
||
|
},
|
||
|
'json'
|
||
|
);
|
||
|
|
||
|
// Assert
|
||
|
expect(result.success).toBe(true);
|
||
|
expect(mockExpandTask).toHaveBeenCalledWith(
|
||
|
mockTasksPath,
|
||
|
expect.any(Number),
|
||
|
undefined,
|
||
|
true, // research flag passed correctly
|
||
|
'research context',
|
||
|
expect.any(Object),
|
||
|
false
|
||
|
);
|
||
|
});
|
||
|
|
||
|
test('should return success with message when no tasks are eligible', async () => {
|
||
|
// Arrange - Mock tasks data with no eligible tasks
|
||
|
const noEligibleTasksData = {
|
||
|
tag: 'master',
|
||
|
tasks: [
|
||
|
{ id: 1, status: 'done', subtasks: [] },
|
||
|
{
|
||
|
id: 2,
|
||
|
status: 'pending',
|
||
|
subtasks: [{ id: '2.1', title: 'existing' }]
|
||
|
}
|
||
|
]
|
||
|
};
|
||
|
mockReadJSON.mockReturnValue(noEligibleTasksData);
|
||
|
|
||
|
// Act
|
||
|
const result = await expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
3,
|
||
|
false,
|
||
|
'',
|
||
|
false, // force = false, so task with subtasks won't be expanded
|
||
|
{
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog,
|
||
|
projectRoot: mockProjectRoot
|
||
|
},
|
||
|
'json'
|
||
|
);
|
||
|
|
||
|
// Assert
|
||
|
expect(result.success).toBe(true);
|
||
|
expect(result.expandedCount).toBe(0);
|
||
|
expect(result.failedCount).toBe(0);
|
||
|
expect(result.skippedCount).toBe(0);
|
||
|
expect(result.tasksToExpand).toBe(0);
|
||
|
expect(result.message).toBe('No tasks eligible for expansion.');
|
||
|
expect(mockExpandTask).not.toHaveBeenCalled();
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('error handling', () => {
|
||
|
test('should handle expandTask failures gracefully', async () => {
|
||
|
// Arrange
|
||
|
mockExpandTask
|
||
|
.mockResolvedValueOnce({ telemetryData: { totalCost: 0.05 } }) // First task succeeds
|
||
|
.mockRejectedValueOnce(new Error('AI service error')); // Second task fails
|
||
|
|
||
|
// Act
|
||
|
const result = await expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
3,
|
||
|
false,
|
||
|
'',
|
||
|
false,
|
||
|
{
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog,
|
||
|
projectRoot: mockProjectRoot
|
||
|
},
|
||
|
'json'
|
||
|
);
|
||
|
|
||
|
// Assert
|
||
|
expect(result.success).toBe(true);
|
||
|
expect(result.expandedCount).toBe(1);
|
||
|
expect(result.failedCount).toBe(1);
|
||
|
});
|
||
|
|
||
|
test('should throw error when tasks.json is invalid', async () => {
|
||
|
// Arrange
|
||
|
mockReadJSON.mockReturnValue(null);
|
||
|
|
||
|
// Act & Assert
|
||
|
await expect(
|
||
|
expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
3,
|
||
|
false,
|
||
|
'',
|
||
|
false,
|
||
|
{
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog,
|
||
|
projectRoot: mockProjectRoot
|
||
|
},
|
||
|
'json'
|
||
|
)
|
||
|
).rejects.toThrow('Invalid tasks data');
|
||
|
});
|
||
|
|
||
|
test('should throw error when project root cannot be determined', async () => {
|
||
|
// Arrange - Mock findProjectRoot to return null for this test
|
||
|
mockFindProjectRoot.mockReturnValueOnce(null);
|
||
|
|
||
|
// Act & Assert
|
||
|
await expect(
|
||
|
expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
3,
|
||
|
false,
|
||
|
'',
|
||
|
false,
|
||
|
{
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog
|
||
|
// No projectRoot provided, and findProjectRoot will return null
|
||
|
},
|
||
|
'json'
|
||
|
)
|
||
|
).rejects.toThrow('Could not determine project root directory');
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('telemetry aggregation', () => {
|
||
|
test('should aggregate telemetry data from multiple expand operations', async () => {
|
||
|
// Arrange
|
||
|
const telemetryData1 = {
|
||
|
commandName: 'expand-task',
|
||
|
totalCost: 0.03,
|
||
|
totalTokens: 600
|
||
|
};
|
||
|
const telemetryData2 = {
|
||
|
commandName: 'expand-task',
|
||
|
totalCost: 0.04,
|
||
|
totalTokens: 800
|
||
|
};
|
||
|
|
||
|
mockExpandTask
|
||
|
.mockResolvedValueOnce({ telemetryData: telemetryData1 })
|
||
|
.mockResolvedValueOnce({ telemetryData: telemetryData2 });
|
||
|
|
||
|
// Act
|
||
|
const result = await expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
3,
|
||
|
false,
|
||
|
'',
|
||
|
false,
|
||
|
{
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog,
|
||
|
projectRoot: mockProjectRoot
|
||
|
},
|
||
|
'json'
|
||
|
);
|
||
|
|
||
|
// Assert
|
||
|
expect(mockAggregateTelemetry).toHaveBeenCalledWith(
|
||
|
[telemetryData1, telemetryData2],
|
||
|
'expand-all-tasks'
|
||
|
);
|
||
|
expect(result.telemetryData).toBeDefined();
|
||
|
expect(result.telemetryData.commandName).toBe('expand-all-tasks');
|
||
|
});
|
||
|
|
||
|
test('should handle missing telemetry data gracefully', async () => {
|
||
|
// Arrange
|
||
|
mockExpandTask.mockResolvedValue({}); // No telemetryData
|
||
|
|
||
|
// Act
|
||
|
const result = await expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
3,
|
||
|
false,
|
||
|
'',
|
||
|
false,
|
||
|
{
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog,
|
||
|
projectRoot: mockProjectRoot
|
||
|
},
|
||
|
'json'
|
||
|
);
|
||
|
|
||
|
// Assert
|
||
|
expect(result.success).toBe(true);
|
||
|
expect(mockAggregateTelemetry).toHaveBeenCalledWith(
|
||
|
[],
|
||
|
'expand-all-tasks'
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('output format handling', () => {
|
||
|
test('should use text output format for CLI calls', async () => {
|
||
|
// Arrange
|
||
|
mockExpandTask.mockResolvedValue({
|
||
|
telemetryData: { commandName: 'expand-task', totalCost: 0.05 }
|
||
|
});
|
||
|
|
||
|
// Act
|
||
|
const result = await expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
3,
|
||
|
false,
|
||
|
'',
|
||
|
false,
|
||
|
{
|
||
|
projectRoot: mockProjectRoot
|
||
|
// No mcpLog provided, should use CLI logger
|
||
|
},
|
||
|
'text' // CLI output format
|
||
|
);
|
||
|
|
||
|
// Assert
|
||
|
expect(result.success).toBe(true);
|
||
|
// In text mode, loading indicators and console output would be used
|
||
|
// This is harder to test directly but we can verify the result structure
|
||
|
});
|
||
|
|
||
|
test('should handle context tag properly', async () => {
|
||
|
// Arrange
|
||
|
const taggedTasksData = {
|
||
|
...sampleTasksData,
|
||
|
tag: 'feature-branch'
|
||
|
};
|
||
|
mockReadJSON.mockReturnValue(taggedTasksData);
|
||
|
mockExpandTask.mockResolvedValue({
|
||
|
telemetryData: { commandName: 'expand-task', totalCost: 0.05 }
|
||
|
});
|
||
|
|
||
|
// Act
|
||
|
const result = await expandAllTasks(
|
||
|
mockTasksPath,
|
||
|
3,
|
||
|
false,
|
||
|
'',
|
||
|
false,
|
||
|
{
|
||
|
session: mockSession,
|
||
|
mcpLog: mockMcpLog,
|
||
|
projectRoot: mockProjectRoot,
|
||
|
tag: 'feature-branch'
|
||
|
},
|
||
|
'json'
|
||
|
);
|
||
|
|
||
|
// Assert
|
||
|
expect(mockReadJSON).toHaveBeenCalledWith(
|
||
|
mockTasksPath,
|
||
|
mockProjectRoot,
|
||
|
'feature-branch'
|
||
|
);
|
||
|
expect(mockExpandTask).toHaveBeenCalledWith(
|
||
|
mockTasksPath,
|
||
|
expect.any(Number),
|
||
|
3,
|
||
|
false,
|
||
|
'',
|
||
|
expect.objectContaining({
|
||
|
tag: 'feature-branch'
|
||
|
}),
|
||
|
false
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
});
|