mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-07-17 22:11:41 +00:00
529 lines
13 KiB
JavaScript
529 lines
13 KiB
JavaScript
![]() |
/**
|
||
|
* Tests for the remove-task MCP tool
|
||
|
*
|
||
|
* Note: This test does NOT test the actual implementation. It tests that:
|
||
|
* 1. The tool is registered correctly with the correct parameters
|
||
|
* 2. Arguments are passed correctly to removeTaskDirect
|
||
|
* 3. Error handling works as expected
|
||
|
* 4. Tag parameter is properly handled and passed through
|
||
|
*
|
||
|
* We do NOT import the real implementation - everything is mocked
|
||
|
*/
|
||
|
|
||
|
import { jest } from '@jest/globals';
|
||
|
|
||
|
// Mock EVERYTHING
|
||
|
const mockRemoveTaskDirect = jest.fn();
|
||
|
jest.mock('../../../../mcp-server/src/core/task-master-core.js', () => ({
|
||
|
removeTaskDirect: mockRemoveTaskDirect
|
||
|
}));
|
||
|
|
||
|
const mockHandleApiResult = jest.fn((result) => result);
|
||
|
const mockWithNormalizedProjectRoot = jest.fn((fn) => fn);
|
||
|
const mockCreateErrorResponse = jest.fn((msg) => ({
|
||
|
success: false,
|
||
|
error: { code: 'ERROR', message: msg }
|
||
|
}));
|
||
|
const mockFindTasksPath = jest.fn(() => '/mock/project/tasks.json');
|
||
|
|
||
|
jest.mock('../../../../mcp-server/src/tools/utils.js', () => ({
|
||
|
handleApiResult: mockHandleApiResult,
|
||
|
createErrorResponse: mockCreateErrorResponse,
|
||
|
withNormalizedProjectRoot: mockWithNormalizedProjectRoot
|
||
|
}));
|
||
|
|
||
|
jest.mock('../../../../mcp-server/src/core/utils/path-utils.js', () => ({
|
||
|
findTasksPath: mockFindTasksPath
|
||
|
}));
|
||
|
|
||
|
// Mock the z object from zod
|
||
|
const mockZod = {
|
||
|
object: jest.fn(() => mockZod),
|
||
|
string: jest.fn(() => mockZod),
|
||
|
boolean: jest.fn(() => mockZod),
|
||
|
optional: jest.fn(() => mockZod),
|
||
|
describe: jest.fn(() => mockZod),
|
||
|
_def: {
|
||
|
shape: () => ({
|
||
|
id: {},
|
||
|
file: {},
|
||
|
projectRoot: {},
|
||
|
confirm: {},
|
||
|
tag: {}
|
||
|
})
|
||
|
}
|
||
|
};
|
||
|
|
||
|
jest.mock('zod', () => ({
|
||
|
z: mockZod
|
||
|
}));
|
||
|
|
||
|
// DO NOT import the real module - create a fake implementation
|
||
|
// This is the fake implementation of registerRemoveTaskTool
|
||
|
const registerRemoveTaskTool = (server) => {
|
||
|
// Create simplified version of the tool config
|
||
|
const toolConfig = {
|
||
|
name: 'remove_task',
|
||
|
description: 'Remove a task or subtask permanently from the tasks list',
|
||
|
parameters: mockZod,
|
||
|
|
||
|
// Create a simplified mock of the execute function
|
||
|
execute: mockWithNormalizedProjectRoot(async (args, context) => {
|
||
|
const { log, session } = context;
|
||
|
|
||
|
try {
|
||
|
log.info && log.info(`Removing task(s) with ID(s): ${args.id}`);
|
||
|
|
||
|
// Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot)
|
||
|
let tasksJsonPath;
|
||
|
try {
|
||
|
tasksJsonPath = mockFindTasksPath(
|
||
|
{ projectRoot: args.projectRoot, file: args.file },
|
||
|
log
|
||
|
);
|
||
|
} catch (error) {
|
||
|
log.error && log.error(`Error finding tasks.json: ${error.message}`);
|
||
|
return mockCreateErrorResponse(
|
||
|
`Failed to find tasks.json: ${error.message}`
|
||
|
);
|
||
|
}
|
||
|
|
||
|
log.info && log.info(`Using tasks file path: ${tasksJsonPath}`);
|
||
|
|
||
|
const result = await mockRemoveTaskDirect(
|
||
|
{
|
||
|
tasksJsonPath: tasksJsonPath,
|
||
|
id: args.id,
|
||
|
projectRoot: args.projectRoot,
|
||
|
tag: args.tag
|
||
|
},
|
||
|
log,
|
||
|
{ session }
|
||
|
);
|
||
|
|
||
|
if (result.success) {
|
||
|
log.info && log.info(`Successfully removed task: ${args.id}`);
|
||
|
} else {
|
||
|
log.error &&
|
||
|
log.error(`Failed to remove task: ${result.error.message}`);
|
||
|
}
|
||
|
|
||
|
return mockHandleApiResult(
|
||
|
result,
|
||
|
log,
|
||
|
'Error removing task',
|
||
|
undefined,
|
||
|
args.projectRoot
|
||
|
);
|
||
|
} catch (error) {
|
||
|
log.error && log.error(`Error in remove-task tool: ${error.message}`);
|
||
|
return mockCreateErrorResponse(error.message);
|
||
|
}
|
||
|
})
|
||
|
};
|
||
|
|
||
|
// Register the tool with the server
|
||
|
server.addTool(toolConfig);
|
||
|
};
|
||
|
|
||
|
describe('MCP Tool: remove-task', () => {
|
||
|
// Create mock server
|
||
|
let mockServer;
|
||
|
let executeFunction;
|
||
|
|
||
|
// Create mock logger
|
||
|
const mockLogger = {
|
||
|
debug: jest.fn(),
|
||
|
info: jest.fn(),
|
||
|
warn: jest.fn(),
|
||
|
error: jest.fn()
|
||
|
};
|
||
|
|
||
|
// Test data
|
||
|
const validArgs = {
|
||
|
id: '5',
|
||
|
projectRoot: '/mock/project/root',
|
||
|
file: '/mock/project/tasks.json',
|
||
|
confirm: true,
|
||
|
tag: 'feature-branch'
|
||
|
};
|
||
|
|
||
|
const multipleTaskArgs = {
|
||
|
id: '5,6.1,7',
|
||
|
projectRoot: '/mock/project/root',
|
||
|
tag: 'master'
|
||
|
};
|
||
|
|
||
|
// Standard responses
|
||
|
const successResponse = {
|
||
|
success: true,
|
||
|
data: {
|
||
|
totalTasks: 1,
|
||
|
successful: 1,
|
||
|
failed: 0,
|
||
|
removedTasks: [
|
||
|
{
|
||
|
id: 5,
|
||
|
title: 'Removed Task',
|
||
|
status: 'pending'
|
||
|
}
|
||
|
],
|
||
|
messages: ["Successfully removed task 5 from tag 'feature-branch'"],
|
||
|
errors: [],
|
||
|
tasksPath: '/mock/project/tasks.json',
|
||
|
tag: 'feature-branch'
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const multipleTasksSuccessResponse = {
|
||
|
success: true,
|
||
|
data: {
|
||
|
totalTasks: 3,
|
||
|
successful: 3,
|
||
|
failed: 0,
|
||
|
removedTasks: [
|
||
|
{ id: 5, title: 'Task 5', status: 'pending' },
|
||
|
{ id: 1, title: 'Subtask 6.1', status: 'done', parentTaskId: 6 },
|
||
|
{ id: 7, title: 'Task 7', status: 'in-progress' }
|
||
|
],
|
||
|
messages: [
|
||
|
"Successfully removed task 5 from tag 'master'",
|
||
|
"Successfully removed subtask 6.1 from tag 'master'",
|
||
|
"Successfully removed task 7 from tag 'master'"
|
||
|
],
|
||
|
errors: [],
|
||
|
tasksPath: '/mock/project/tasks.json',
|
||
|
tag: 'master'
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const errorResponse = {
|
||
|
success: false,
|
||
|
error: {
|
||
|
code: 'INVALID_TASK_ID',
|
||
|
message: "The following tasks were not found in tag 'feature-branch': 999"
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const pathErrorResponse = {
|
||
|
success: false,
|
||
|
error: {
|
||
|
code: 'PATH_ERROR',
|
||
|
message: 'Failed to find tasks.json: No tasks.json found'
|
||
|
}
|
||
|
};
|
||
|
|
||
|
beforeEach(() => {
|
||
|
// Reset all mocks
|
||
|
jest.clearAllMocks();
|
||
|
|
||
|
// Create mock server
|
||
|
mockServer = {
|
||
|
addTool: jest.fn((config) => {
|
||
|
executeFunction = config.execute;
|
||
|
})
|
||
|
};
|
||
|
|
||
|
// Setup default successful response
|
||
|
mockRemoveTaskDirect.mockResolvedValue(successResponse);
|
||
|
mockFindTasksPath.mockReturnValue('/mock/project/tasks.json');
|
||
|
|
||
|
// Register the tool
|
||
|
registerRemoveTaskTool(mockServer);
|
||
|
});
|
||
|
|
||
|
test('should register the tool correctly', () => {
|
||
|
// Verify tool was registered
|
||
|
expect(mockServer.addTool).toHaveBeenCalledWith(
|
||
|
expect.objectContaining({
|
||
|
name: 'remove_task',
|
||
|
description: 'Remove a task or subtask permanently from the tasks list',
|
||
|
parameters: expect.any(Object),
|
||
|
execute: expect.any(Function)
|
||
|
})
|
||
|
);
|
||
|
|
||
|
// Verify the tool config was passed
|
||
|
const toolConfig = mockServer.addTool.mock.calls[0][0];
|
||
|
expect(toolConfig).toHaveProperty('parameters');
|
||
|
expect(toolConfig).toHaveProperty('execute');
|
||
|
});
|
||
|
|
||
|
test('should execute the tool with valid parameters including tag', async () => {
|
||
|
// Setup context
|
||
|
const mockContext = {
|
||
|
log: mockLogger,
|
||
|
session: { workingDirectory: '/mock/dir' }
|
||
|
};
|
||
|
|
||
|
// Execute the function
|
||
|
await executeFunction(validArgs, mockContext);
|
||
|
|
||
|
// Verify findTasksPath was called with correct arguments
|
||
|
expect(mockFindTasksPath).toHaveBeenCalledWith(
|
||
|
{
|
||
|
projectRoot: validArgs.projectRoot,
|
||
|
file: validArgs.file
|
||
|
},
|
||
|
mockLogger
|
||
|
);
|
||
|
|
||
|
// Verify removeTaskDirect was called with correct arguments including tag
|
||
|
expect(mockRemoveTaskDirect).toHaveBeenCalledWith(
|
||
|
expect.objectContaining({
|
||
|
tasksJsonPath: '/mock/project/tasks.json',
|
||
|
id: validArgs.id,
|
||
|
projectRoot: validArgs.projectRoot,
|
||
|
tag: validArgs.tag // This is the key test - tag parameter should be passed through
|
||
|
}),
|
||
|
mockLogger,
|
||
|
{
|
||
|
session: mockContext.session
|
||
|
}
|
||
|
);
|
||
|
|
||
|
// Verify handleApiResult was called
|
||
|
expect(mockHandleApiResult).toHaveBeenCalledWith(
|
||
|
successResponse,
|
||
|
mockLogger,
|
||
|
'Error removing task',
|
||
|
undefined,
|
||
|
validArgs.projectRoot
|
||
|
);
|
||
|
});
|
||
|
|
||
|
test('should handle multiple task IDs with tag context', async () => {
|
||
|
// Setup multiple tasks response
|
||
|
mockRemoveTaskDirect.mockResolvedValueOnce(multipleTasksSuccessResponse);
|
||
|
|
||
|
// Setup context
|
||
|
const mockContext = {
|
||
|
log: mockLogger,
|
||
|
session: { workingDirectory: '/mock/dir' }
|
||
|
};
|
||
|
|
||
|
// Execute the function
|
||
|
await executeFunction(multipleTaskArgs, mockContext);
|
||
|
|
||
|
// Verify removeTaskDirect was called with comma-separated IDs and tag
|
||
|
expect(mockRemoveTaskDirect).toHaveBeenCalledWith(
|
||
|
expect.objectContaining({
|
||
|
id: '5,6.1,7',
|
||
|
tag: 'master'
|
||
|
}),
|
||
|
mockLogger,
|
||
|
expect.any(Object)
|
||
|
);
|
||
|
|
||
|
// Verify successful handling of multiple tasks
|
||
|
expect(mockHandleApiResult).toHaveBeenCalledWith(
|
||
|
multipleTasksSuccessResponse,
|
||
|
mockLogger,
|
||
|
'Error removing task',
|
||
|
undefined,
|
||
|
multipleTaskArgs.projectRoot
|
||
|
);
|
||
|
});
|
||
|
|
||
|
test('should handle missing tag parameter (defaults to current tag)', async () => {
|
||
|
const argsWithoutTag = {
|
||
|
id: '5',
|
||
|
projectRoot: '/mock/project/root'
|
||
|
};
|
||
|
|
||
|
// Setup context
|
||
|
const mockContext = {
|
||
|
log: mockLogger,
|
||
|
session: { workingDirectory: '/mock/dir' }
|
||
|
};
|
||
|
|
||
|
// Execute the function
|
||
|
await executeFunction(argsWithoutTag, mockContext);
|
||
|
|
||
|
// Verify removeTaskDirect was called with undefined tag (should default to current tag)
|
||
|
expect(mockRemoveTaskDirect).toHaveBeenCalledWith(
|
||
|
expect.objectContaining({
|
||
|
id: '5',
|
||
|
projectRoot: '/mock/project/root',
|
||
|
tag: undefined // Should be undefined when not provided
|
||
|
}),
|
||
|
mockLogger,
|
||
|
expect.any(Object)
|
||
|
);
|
||
|
});
|
||
|
|
||
|
test('should handle errors from removeTaskDirect', async () => {
|
||
|
// Setup error response
|
||
|
mockRemoveTaskDirect.mockResolvedValueOnce(errorResponse);
|
||
|
|
||
|
// Setup context
|
||
|
const mockContext = {
|
||
|
log: mockLogger,
|
||
|
session: { workingDirectory: '/mock/dir' }
|
||
|
};
|
||
|
|
||
|
// Execute the function
|
||
|
await executeFunction(validArgs, mockContext);
|
||
|
|
||
|
// Verify removeTaskDirect was called
|
||
|
expect(mockRemoveTaskDirect).toHaveBeenCalled();
|
||
|
|
||
|
// Verify error logging
|
||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||
|
"Failed to remove task: The following tasks were not found in tag 'feature-branch': 999"
|
||
|
);
|
||
|
|
||
|
// Verify handleApiResult was called with error response
|
||
|
expect(mockHandleApiResult).toHaveBeenCalledWith(
|
||
|
errorResponse,
|
||
|
mockLogger,
|
||
|
'Error removing task',
|
||
|
undefined,
|
||
|
validArgs.projectRoot
|
||
|
);
|
||
|
});
|
||
|
|
||
|
test('should handle path finding errors', async () => {
|
||
|
// Setup path finding error
|
||
|
mockFindTasksPath.mockImplementationOnce(() => {
|
||
|
throw new Error('No tasks.json found');
|
||
|
});
|
||
|
|
||
|
// Setup context
|
||
|
const mockContext = {
|
||
|
log: mockLogger,
|
||
|
session: { workingDirectory: '/mock/dir' }
|
||
|
};
|
||
|
|
||
|
// Execute the function
|
||
|
const result = await executeFunction(validArgs, mockContext);
|
||
|
|
||
|
// Verify error logging
|
||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||
|
'Error finding tasks.json: No tasks.json found'
|
||
|
);
|
||
|
|
||
|
// Verify error response was returned
|
||
|
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
|
||
|
'Failed to find tasks.json: No tasks.json found'
|
||
|
);
|
||
|
|
||
|
// Verify removeTaskDirect was NOT called
|
||
|
expect(mockRemoveTaskDirect).not.toHaveBeenCalled();
|
||
|
});
|
||
|
|
||
|
test('should handle unexpected errors in execute function', async () => {
|
||
|
// Setup unexpected error
|
||
|
mockRemoveTaskDirect.mockImplementationOnce(() => {
|
||
|
throw new Error('Unexpected error');
|
||
|
});
|
||
|
|
||
|
// Setup context
|
||
|
const mockContext = {
|
||
|
log: mockLogger,
|
||
|
session: { workingDirectory: '/mock/dir' }
|
||
|
};
|
||
|
|
||
|
// Execute the function
|
||
|
await executeFunction(validArgs, mockContext);
|
||
|
|
||
|
// Verify error logging
|
||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||
|
'Error in remove-task tool: Unexpected error'
|
||
|
);
|
||
|
|
||
|
// Verify error response was returned
|
||
|
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unexpected error');
|
||
|
});
|
||
|
|
||
|
test('should properly handle withNormalizedProjectRoot wrapper', () => {
|
||
|
// Verify that withNormalizedProjectRoot was called with the execute function
|
||
|
expect(mockWithNormalizedProjectRoot).toHaveBeenCalledWith(
|
||
|
expect.any(Function)
|
||
|
);
|
||
|
});
|
||
|
|
||
|
test('should log appropriate info messages for successful operations', async () => {
|
||
|
// Setup context
|
||
|
const mockContext = {
|
||
|
log: mockLogger,
|
||
|
session: { workingDirectory: '/mock/dir' }
|
||
|
};
|
||
|
|
||
|
// Execute the function
|
||
|
await executeFunction(validArgs, mockContext);
|
||
|
|
||
|
// Verify appropriate logging
|
||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||
|
'Removing task(s) with ID(s): 5'
|
||
|
);
|
||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||
|
'Using tasks file path: /mock/project/tasks.json'
|
||
|
);
|
||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||
|
'Successfully removed task: 5'
|
||
|
);
|
||
|
});
|
||
|
|
||
|
test('should handle subtask removal with proper tag context', async () => {
|
||
|
const subtaskArgs = {
|
||
|
id: '5.2',
|
||
|
projectRoot: '/mock/project/root',
|
||
|
tag: 'feature-branch'
|
||
|
};
|
||
|
|
||
|
const subtaskSuccessResponse = {
|
||
|
success: true,
|
||
|
data: {
|
||
|
totalTasks: 1,
|
||
|
successful: 1,
|
||
|
failed: 0,
|
||
|
removedTasks: [
|
||
|
{
|
||
|
id: 2,
|
||
|
title: 'Removed Subtask',
|
||
|
status: 'pending',
|
||
|
parentTaskId: 5
|
||
|
}
|
||
|
],
|
||
|
messages: [
|
||
|
"Successfully removed subtask 5.2 from tag 'feature-branch'"
|
||
|
],
|
||
|
errors: [],
|
||
|
tasksPath: '/mock/project/tasks.json',
|
||
|
tag: 'feature-branch'
|
||
|
}
|
||
|
};
|
||
|
|
||
|
mockRemoveTaskDirect.mockResolvedValueOnce(subtaskSuccessResponse);
|
||
|
|
||
|
// Setup context
|
||
|
const mockContext = {
|
||
|
log: mockLogger,
|
||
|
session: { workingDirectory: '/mock/dir' }
|
||
|
};
|
||
|
|
||
|
// Execute the function
|
||
|
await executeFunction(subtaskArgs, mockContext);
|
||
|
|
||
|
// Verify removeTaskDirect was called with subtask ID and tag
|
||
|
expect(mockRemoveTaskDirect).toHaveBeenCalledWith(
|
||
|
expect.objectContaining({
|
||
|
id: '5.2',
|
||
|
tag: 'feature-branch'
|
||
|
}),
|
||
|
mockLogger,
|
||
|
expect.any(Object)
|
||
|
);
|
||
|
|
||
|
// Verify successful handling
|
||
|
expect(mockHandleApiResult).toHaveBeenCalledWith(
|
||
|
subtaskSuccessResponse,
|
||
|
mockLogger,
|
||
|
'Error removing task',
|
||
|
undefined,
|
||
|
subtaskArgs.projectRoot
|
||
|
);
|
||
|
});
|
||
|
});
|