feat: add comma-separated status filtering to list-tasks
- supports multiple statuses like 'blocked,deferred' with comprehensive test coverage and backward compatibility - also adjusts biome.json to stop bitching about templating.
This commit is contained in:
7
.changeset/tiny-ads-decide.md
Normal file
7
.changeset/tiny-ads-decide.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"task-master-ai": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Adds support for filtering tasks by multiple statuses at once using comma-separated statuses.
|
||||||
|
|
||||||
|
Example: `cancelled,deferred`
|
||||||
@@ -28,7 +28,9 @@ export function registerListTasksTool(server) {
|
|||||||
status: z
|
status: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Filter tasks by status (e.g., 'pending', 'done')"),
|
.describe(
|
||||||
|
"Filter tasks by status (e.g., 'pending', 'done') or multiple statuses separated by commas (e.g., 'blocked,deferred')"
|
||||||
|
),
|
||||||
withSubtasks: z
|
withSubtasks: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
24073
package-lock.json
generated
24073
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ import {
|
|||||||
/**
|
/**
|
||||||
* List all tasks
|
* List all tasks
|
||||||
* @param {string} tasksPath - Path to the tasks.json file
|
* @param {string} tasksPath - Path to the tasks.json file
|
||||||
* @param {string} statusFilter - Filter by status
|
* @param {string} statusFilter - Filter by status (single status or comma-separated list, e.g., 'pending' or 'blocked,deferred')
|
||||||
* @param {string} reportPath - Path to the complexity report
|
* @param {string} reportPath - Path to the complexity report
|
||||||
* @param {boolean} withSubtasks - Whether to show subtasks
|
* @param {boolean} withSubtasks - Whether to show subtasks
|
||||||
* @param {string} outputFormat - Output format (text or json)
|
* @param {string} outputFormat - Output format (text or json)
|
||||||
@@ -48,15 +48,23 @@ function listTasks(
|
|||||||
data.tasks.forEach((task) => addComplexityToTask(task, complexityReport));
|
data.tasks.forEach((task) => addComplexityToTask(task, complexityReport));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter tasks by status if specified
|
// Filter tasks by status if specified - now supports comma-separated statuses
|
||||||
const filteredTasks =
|
let filteredTasks;
|
||||||
statusFilter && statusFilter.toLowerCase() !== 'all' // <-- Added check for 'all'
|
if (statusFilter && statusFilter.toLowerCase() !== 'all') {
|
||||||
? data.tasks.filter(
|
// Handle comma-separated statuses
|
||||||
(task) =>
|
const allowedStatuses = statusFilter
|
||||||
task.status &&
|
.split(',')
|
||||||
task.status.toLowerCase() === statusFilter.toLowerCase()
|
.map((s) => s.trim().toLowerCase())
|
||||||
)
|
.filter((s) => s.length > 0); // Remove empty strings
|
||||||
: data.tasks; // Default to all tasks if no filter or filter is 'all'
|
|
||||||
|
filteredTasks = data.tasks.filter(
|
||||||
|
(task) =>
|
||||||
|
task.status && allowedStatuses.includes(task.status.toLowerCase())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Default to all tasks if no filter or filter is 'all'
|
||||||
|
filteredTasks = data.tasks;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate completion statistics
|
// Calculate completion statistics
|
||||||
const totalTasks = data.tasks.length;
|
const totalTasks = data.tasks.length;
|
||||||
@@ -83,6 +91,9 @@ function listTasks(
|
|||||||
const cancelledCount = data.tasks.filter(
|
const cancelledCount = data.tasks.filter(
|
||||||
(task) => task.status === 'cancelled'
|
(task) => task.status === 'cancelled'
|
||||||
).length;
|
).length;
|
||||||
|
const reviewCount = data.tasks.filter(
|
||||||
|
(task) => task.status === 'review'
|
||||||
|
).length;
|
||||||
|
|
||||||
// Count subtasks and their statuses
|
// Count subtasks and their statuses
|
||||||
let totalSubtasks = 0;
|
let totalSubtasks = 0;
|
||||||
@@ -92,6 +103,7 @@ function listTasks(
|
|||||||
let blockedSubtasks = 0;
|
let blockedSubtasks = 0;
|
||||||
let deferredSubtasks = 0;
|
let deferredSubtasks = 0;
|
||||||
let cancelledSubtasks = 0;
|
let cancelledSubtasks = 0;
|
||||||
|
let reviewSubtasks = 0;
|
||||||
|
|
||||||
data.tasks.forEach((task) => {
|
data.tasks.forEach((task) => {
|
||||||
if (task.subtasks && task.subtasks.length > 0) {
|
if (task.subtasks && task.subtasks.length > 0) {
|
||||||
@@ -114,6 +126,9 @@ function listTasks(
|
|||||||
cancelledSubtasks += task.subtasks.filter(
|
cancelledSubtasks += task.subtasks.filter(
|
||||||
(st) => st.status === 'cancelled'
|
(st) => st.status === 'cancelled'
|
||||||
).length;
|
).length;
|
||||||
|
reviewSubtasks += task.subtasks.filter(
|
||||||
|
(st) => st.status === 'review'
|
||||||
|
).length;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,6 +237,7 @@ function listTasks(
|
|||||||
blocked: blockedCount,
|
blocked: blockedCount,
|
||||||
deferred: deferredCount,
|
deferred: deferredCount,
|
||||||
cancelled: cancelledCount,
|
cancelled: cancelledCount,
|
||||||
|
review: reviewCount,
|
||||||
completionPercentage,
|
completionPercentage,
|
||||||
subtasks: {
|
subtasks: {
|
||||||
total: totalSubtasks,
|
total: totalSubtasks,
|
||||||
@@ -257,6 +273,7 @@ function listTasks(
|
|||||||
blockedSubtasks,
|
blockedSubtasks,
|
||||||
deferredSubtasks,
|
deferredSubtasks,
|
||||||
cancelledSubtasks,
|
cancelledSubtasks,
|
||||||
|
reviewSubtasks,
|
||||||
tasksWithNoDeps,
|
tasksWithNoDeps,
|
||||||
tasksReadyToWork,
|
tasksReadyToWork,
|
||||||
tasksWithUnsatisfiedDeps,
|
tasksWithUnsatisfiedDeps,
|
||||||
@@ -278,7 +295,8 @@ function listTasks(
|
|||||||
pending: totalTasks > 0 ? (pendingCount / totalTasks) * 100 : 0,
|
pending: totalTasks > 0 ? (pendingCount / totalTasks) * 100 : 0,
|
||||||
blocked: totalTasks > 0 ? (blockedCount / totalTasks) * 100 : 0,
|
blocked: totalTasks > 0 ? (blockedCount / totalTasks) * 100 : 0,
|
||||||
deferred: totalTasks > 0 ? (deferredCount / totalTasks) * 100 : 0,
|
deferred: totalTasks > 0 ? (deferredCount / totalTasks) * 100 : 0,
|
||||||
cancelled: totalTasks > 0 ? (cancelledCount / totalTasks) * 100 : 0
|
cancelled: totalTasks > 0 ? (cancelledCount / totalTasks) * 100 : 0,
|
||||||
|
review: totalTasks > 0 ? (reviewCount / totalTasks) * 100 : 0
|
||||||
};
|
};
|
||||||
|
|
||||||
const subtaskStatusBreakdown = {
|
const subtaskStatusBreakdown = {
|
||||||
@@ -289,7 +307,8 @@ function listTasks(
|
|||||||
deferred:
|
deferred:
|
||||||
totalSubtasks > 0 ? (deferredSubtasks / totalSubtasks) * 100 : 0,
|
totalSubtasks > 0 ? (deferredSubtasks / totalSubtasks) * 100 : 0,
|
||||||
cancelled:
|
cancelled:
|
||||||
totalSubtasks > 0 ? (cancelledSubtasks / totalSubtasks) * 100 : 0
|
totalSubtasks > 0 ? (cancelledSubtasks / totalSubtasks) * 100 : 0,
|
||||||
|
review: totalSubtasks > 0 ? (reviewSubtasks / totalSubtasks) * 100 : 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create progress bars with status breakdowns
|
// Create progress bars with status breakdowns
|
||||||
|
|||||||
459
tests/unit/mcp/tools/get-tasks.test.js
Normal file
459
tests/unit/mcp/tools/get-tasks.test.js
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the get-tasks MCP tool
|
||||||
|
*
|
||||||
|
* This test verifies the MCP tool properly handles comma-separated status filtering
|
||||||
|
* and passes arguments correctly to the underlying direct function.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import {
|
||||||
|
sampleTasks,
|
||||||
|
emptySampleTasks
|
||||||
|
} from '../../../fixtures/sample-tasks.js';
|
||||||
|
|
||||||
|
// Mock EVERYTHING
|
||||||
|
const mockListTasksDirect = jest.fn();
|
||||||
|
jest.mock('../../../../mcp-server/src/core/task-master-core.js', () => ({
|
||||||
|
listTasksDirect: mockListTasksDirect
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockHandleApiResult = jest.fn((result) => result);
|
||||||
|
const mockWithNormalizedProjectRoot = jest.fn((executeFn) => executeFn);
|
||||||
|
const mockCreateErrorResponse = jest.fn((msg) => ({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'ERROR', message: msg }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockResolveTasksPath = jest.fn(() => '/mock/project/tasks.json');
|
||||||
|
const mockResolveComplexityReportPath = jest.fn(
|
||||||
|
() => '/mock/project/complexity-report.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('../../../../mcp-server/src/tools/utils.js', () => ({
|
||||||
|
withNormalizedProjectRoot: mockWithNormalizedProjectRoot,
|
||||||
|
handleApiResult: mockHandleApiResult,
|
||||||
|
createErrorResponse: mockCreateErrorResponse,
|
||||||
|
createContentResponse: jest.fn((content) => ({
|
||||||
|
success: true,
|
||||||
|
data: content
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../../mcp-server/src/core/utils/path-utils.js', () => ({
|
||||||
|
resolveTasksPath: mockResolveTasksPath,
|
||||||
|
resolveComplexityReportPath: mockResolveComplexityReportPath
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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: () => ({
|
||||||
|
status: {},
|
||||||
|
withSubtasks: {},
|
||||||
|
file: {},
|
||||||
|
complexityReport: {},
|
||||||
|
projectRoot: {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('zod', () => ({
|
||||||
|
z: mockZod
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DO NOT import the real module - create a fake implementation
|
||||||
|
const registerListTasksTool = (server) => {
|
||||||
|
const toolConfig = {
|
||||||
|
name: 'get_tasks',
|
||||||
|
description:
|
||||||
|
'Get all tasks from Task Master, optionally filtering by status and including subtasks.',
|
||||||
|
parameters: mockZod,
|
||||||
|
|
||||||
|
execute: (args, context) => {
|
||||||
|
const { log, session } = context;
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info &&
|
||||||
|
log.info(`Getting tasks with filters: ${JSON.stringify(args)}`);
|
||||||
|
|
||||||
|
// Resolve paths using mock functions
|
||||||
|
let tasksJsonPath;
|
||||||
|
try {
|
||||||
|
tasksJsonPath = mockResolveTasksPath(args, log);
|
||||||
|
} catch (error) {
|
||||||
|
log.error && log.error(`Error finding tasks.json: ${error.message}`);
|
||||||
|
return mockCreateErrorResponse(
|
||||||
|
`Failed to find tasks.json: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let complexityReportPath;
|
||||||
|
try {
|
||||||
|
complexityReportPath = mockResolveComplexityReportPath(args, session);
|
||||||
|
} catch (error) {
|
||||||
|
log.error &&
|
||||||
|
log.error(`Error finding complexity report: ${error.message}`);
|
||||||
|
complexityReportPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = mockListTasksDirect(
|
||||||
|
{
|
||||||
|
tasksJsonPath: tasksJsonPath,
|
||||||
|
status: args.status,
|
||||||
|
withSubtasks: args.withSubtasks,
|
||||||
|
reportPath: complexityReportPath
|
||||||
|
},
|
||||||
|
log
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info &&
|
||||||
|
log.info(
|
||||||
|
`Retrieved ${result.success ? result.data?.tasks?.length || 0 : 0} tasks`
|
||||||
|
);
|
||||||
|
return mockHandleApiResult(result, log, 'Error getting tasks');
|
||||||
|
} catch (error) {
|
||||||
|
log.error && log.error(`Error getting tasks: ${error.message}`);
|
||||||
|
return mockCreateErrorResponse(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
server.addTool(toolConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MCP Tool: get-tasks', () => {
|
||||||
|
let mockServer;
|
||||||
|
let executeFunction;
|
||||||
|
|
||||||
|
const mockLogger = {
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sample response data with different statuses for testing
|
||||||
|
const tasksResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
tasks: [
|
||||||
|
{ id: 1, title: 'Task 1', status: 'done' },
|
||||||
|
{ id: 2, title: 'Task 2', status: 'pending' },
|
||||||
|
{ id: 3, title: 'Task 3', status: 'in-progress' },
|
||||||
|
{ id: 4, title: 'Task 4', status: 'blocked' },
|
||||||
|
{ id: 5, title: 'Task 5', status: 'deferred' },
|
||||||
|
{ id: 6, title: 'Task 6', status: 'review' }
|
||||||
|
],
|
||||||
|
filter: 'all',
|
||||||
|
stats: {
|
||||||
|
total: 6,
|
||||||
|
completed: 1,
|
||||||
|
inProgress: 1,
|
||||||
|
pending: 1,
|
||||||
|
blocked: 1,
|
||||||
|
deferred: 1,
|
||||||
|
review: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
mockServer = {
|
||||||
|
addTool: jest.fn((config) => {
|
||||||
|
executeFunction = config.execute;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup default successful response
|
||||||
|
mockListTasksDirect.mockReturnValue(tasksResponse);
|
||||||
|
|
||||||
|
// Register the tool
|
||||||
|
registerListTasksTool(mockServer);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should register the tool correctly', () => {
|
||||||
|
expect(mockServer.addTool).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'get_tasks',
|
||||||
|
description: expect.stringContaining('Get all tasks from Task Master'),
|
||||||
|
parameters: expect.any(Object),
|
||||||
|
execute: expect.any(Function)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle single status filter', () => {
|
||||||
|
const mockContext = {
|
||||||
|
log: mockLogger,
|
||||||
|
session: { workingDirectory: '/mock/dir' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
status: 'pending',
|
||||||
|
withSubtasks: false,
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
};
|
||||||
|
|
||||||
|
executeFunction(args, mockContext);
|
||||||
|
|
||||||
|
expect(mockListTasksDirect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'pending'
|
||||||
|
}),
|
||||||
|
mockLogger
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle comma-separated status filter', () => {
|
||||||
|
const mockContext = {
|
||||||
|
log: mockLogger,
|
||||||
|
session: { workingDirectory: '/mock/dir' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
status: 'done,pending,in-progress',
|
||||||
|
withSubtasks: false,
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
};
|
||||||
|
|
||||||
|
executeFunction(args, mockContext);
|
||||||
|
|
||||||
|
expect(mockListTasksDirect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'done,pending,in-progress'
|
||||||
|
}),
|
||||||
|
mockLogger
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle comma-separated status with spaces', () => {
|
||||||
|
const mockContext = {
|
||||||
|
log: mockLogger,
|
||||||
|
session: { workingDirectory: '/mock/dir' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
status: 'blocked, deferred , review',
|
||||||
|
withSubtasks: true,
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
};
|
||||||
|
|
||||||
|
executeFunction(args, mockContext);
|
||||||
|
|
||||||
|
expect(mockListTasksDirect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'blocked, deferred , review',
|
||||||
|
withSubtasks: true
|
||||||
|
}),
|
||||||
|
mockLogger
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle withSubtasks parameter correctly', () => {
|
||||||
|
const mockContext = {
|
||||||
|
log: mockLogger,
|
||||||
|
session: { workingDirectory: '/mock/dir' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test with withSubtasks=true
|
||||||
|
executeFunction(
|
||||||
|
{
|
||||||
|
status: 'pending',
|
||||||
|
withSubtasks: true,
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
},
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockListTasksDirect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
withSubtasks: true
|
||||||
|
}),
|
||||||
|
mockLogger
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Test with withSubtasks=false
|
||||||
|
executeFunction(
|
||||||
|
{
|
||||||
|
status: 'pending',
|
||||||
|
withSubtasks: false,
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
},
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockListTasksDirect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
withSubtasks: false
|
||||||
|
}),
|
||||||
|
mockLogger
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle path resolution errors gracefully', () => {
|
||||||
|
mockResolveTasksPath.mockImplementationOnce(() => {
|
||||||
|
throw new Error('Tasks file not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockContext = {
|
||||||
|
log: mockLogger,
|
||||||
|
session: { workingDirectory: '/mock/dir' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
status: 'pending',
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = executeFunction(args, mockContext);
|
||||||
|
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
'Error finding tasks.json: Tasks file not found'
|
||||||
|
);
|
||||||
|
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
|
||||||
|
'Failed to find tasks.json: Tasks file not found'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle complexity report path resolution errors gracefully', () => {
|
||||||
|
mockResolveComplexityReportPath.mockImplementationOnce(() => {
|
||||||
|
throw new Error('Complexity report not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockContext = {
|
||||||
|
log: mockLogger,
|
||||||
|
session: { workingDirectory: '/mock/dir' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
status: 'pending',
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
};
|
||||||
|
|
||||||
|
executeFunction(args, mockContext);
|
||||||
|
|
||||||
|
// Should not fail the operation but set complexityReportPath to null
|
||||||
|
expect(mockListTasksDirect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
reportPath: null
|
||||||
|
}),
|
||||||
|
mockLogger
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle listTasksDirect errors', () => {
|
||||||
|
const errorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'LIST_TASKS_ERROR',
|
||||||
|
message: 'Failed to list tasks'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockListTasksDirect.mockReturnValueOnce(errorResponse);
|
||||||
|
|
||||||
|
const mockContext = {
|
||||||
|
log: mockLogger,
|
||||||
|
session: { workingDirectory: '/mock/dir' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
status: 'pending',
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
};
|
||||||
|
|
||||||
|
executeFunction(args, mockContext);
|
||||||
|
|
||||||
|
expect(mockHandleApiResult).toHaveBeenCalledWith(
|
||||||
|
errorResponse,
|
||||||
|
mockLogger,
|
||||||
|
'Error getting tasks'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle unexpected errors', () => {
|
||||||
|
const testError = new Error('Unexpected error');
|
||||||
|
mockListTasksDirect.mockImplementationOnce(() => {
|
||||||
|
throw testError;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockContext = {
|
||||||
|
log: mockLogger,
|
||||||
|
session: { workingDirectory: '/mock/dir' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
status: 'pending',
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
};
|
||||||
|
|
||||||
|
executeFunction(args, mockContext);
|
||||||
|
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
'Error getting tasks: Unexpected error'
|
||||||
|
);
|
||||||
|
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unexpected error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should pass all parameters correctly', () => {
|
||||||
|
const mockContext = {
|
||||||
|
log: mockLogger,
|
||||||
|
session: { workingDirectory: '/mock/dir' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
status: 'done,pending',
|
||||||
|
withSubtasks: true,
|
||||||
|
file: 'custom-tasks.json',
|
||||||
|
complexityReport: 'custom-report.json',
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
};
|
||||||
|
|
||||||
|
executeFunction(args, mockContext);
|
||||||
|
|
||||||
|
// Verify path resolution functions were called with correct arguments
|
||||||
|
expect(mockResolveTasksPath).toHaveBeenCalledWith(args, mockLogger);
|
||||||
|
expect(mockResolveComplexityReportPath).toHaveBeenCalledWith(
|
||||||
|
args,
|
||||||
|
mockContext.session
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify listTasksDirect was called with correct parameters
|
||||||
|
expect(mockListTasksDirect).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
tasksJsonPath: '/mock/project/tasks.json',
|
||||||
|
status: 'done,pending',
|
||||||
|
withSubtasks: true,
|
||||||
|
reportPath: '/mock/project/complexity-report.json'
|
||||||
|
},
|
||||||
|
mockLogger
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should log task count after successful retrieval', () => {
|
||||||
|
const mockContext = {
|
||||||
|
log: mockLogger,
|
||||||
|
session: { workingDirectory: '/mock/dir' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
status: 'pending',
|
||||||
|
projectRoot: '/mock/project'
|
||||||
|
};
|
||||||
|
|
||||||
|
executeFunction(args, mockContext);
|
||||||
|
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
`Retrieved ${tasksResponse.data.tasks.length} tasks`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -109,6 +109,14 @@ const sampleTasks = {
|
|||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
dependencies: [2, 3],
|
dependencies: [2, 3],
|
||||||
priority: 'low'
|
priority: 'low'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Code Review',
|
||||||
|
description: 'Review code for quality and standards',
|
||||||
|
status: 'review',
|
||||||
|
dependencies: [3],
|
||||||
|
priority: 'medium'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -154,7 +162,8 @@ describe('listTasks', () => {
|
|||||||
expect.objectContaining({ id: 1 }),
|
expect.objectContaining({ id: 1 }),
|
||||||
expect.objectContaining({ id: 2 }),
|
expect.objectContaining({ id: 2 }),
|
||||||
expect.objectContaining({ id: 3 }),
|
expect.objectContaining({ id: 3 }),
|
||||||
expect.objectContaining({ id: 4 })
|
expect.objectContaining({ id: 4 }),
|
||||||
|
expect.objectContaining({ id: 5 })
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -174,6 +183,7 @@ describe('listTasks', () => {
|
|||||||
// Verify only pending tasks are returned
|
// Verify only pending tasks are returned
|
||||||
expect(result.tasks).toHaveLength(1);
|
expect(result.tasks).toHaveLength(1);
|
||||||
expect(result.tasks[0].status).toBe('pending');
|
expect(result.tasks[0].status).toBe('pending');
|
||||||
|
expect(result.tasks[0].id).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should filter tasks by done status', async () => {
|
test('should filter tasks by done status', async () => {
|
||||||
@@ -190,6 +200,21 @@ describe('listTasks', () => {
|
|||||||
expect(result.tasks[0].status).toBe('done');
|
expect(result.tasks[0].status).toBe('done');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should filter tasks by review status', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'review';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Verify only review tasks are returned
|
||||||
|
expect(result.tasks).toHaveLength(1);
|
||||||
|
expect(result.tasks[0].status).toBe('review');
|
||||||
|
expect(result.tasks[0].id).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
test('should include subtasks when withSubtasks option is true', async () => {
|
test('should include subtasks when withSubtasks option is true', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const tasksPath = 'tasks/tasks.json';
|
const tasksPath = 'tasks/tasks.json';
|
||||||
@@ -320,13 +345,203 @@ describe('listTasks', () => {
|
|||||||
tasks: expect.any(Array),
|
tasks: expect.any(Array),
|
||||||
filter: 'all',
|
filter: 'all',
|
||||||
stats: expect.objectContaining({
|
stats: expect.objectContaining({
|
||||||
total: 4,
|
total: 5,
|
||||||
completed: expect.any(Number),
|
completed: expect.any(Number),
|
||||||
inProgress: expect.any(Number),
|
inProgress: expect.any(Number),
|
||||||
pending: expect.any(Number)
|
pending: expect.any(Number)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(result.tasks).toHaveLength(4);
|
expect(result.tasks).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tests for comma-separated status filtering
|
||||||
|
describe('Comma-separated status filtering', () => {
|
||||||
|
test('should filter tasks by multiple statuses separated by commas', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'done,pending';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(readJSON).toHaveBeenCalledWith(tasksPath);
|
||||||
|
|
||||||
|
// Should return tasks with 'done' or 'pending' status
|
||||||
|
expect(result.tasks).toHaveLength(2);
|
||||||
|
expect(result.tasks.map((task) => task.status)).toEqual(
|
||||||
|
expect.arrayContaining(['done', 'pending'])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify specific tasks
|
||||||
|
const taskIds = result.tasks.map((task) => task.id);
|
||||||
|
expect(taskIds).toContain(1); // done task
|
||||||
|
expect(taskIds).toContain(2); // pending task
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter tasks by three or more statuses', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'done,pending,in-progress';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should return tasks with 'done', 'pending', or 'in-progress' status
|
||||||
|
expect(result.tasks).toHaveLength(3);
|
||||||
|
const statusValues = result.tasks.map((task) => task.status);
|
||||||
|
expect(statusValues).toEqual(
|
||||||
|
expect.arrayContaining(['done', 'pending', 'in-progress'])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify all matching tasks are included
|
||||||
|
const taskIds = result.tasks.map((task) => task.id);
|
||||||
|
expect(taskIds).toContain(1); // done
|
||||||
|
expect(taskIds).toContain(2); // pending
|
||||||
|
expect(taskIds).toContain(3); // in-progress
|
||||||
|
expect(taskIds).not.toContain(4); // cancelled - should not be included
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle spaces around commas in status filter', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'done, pending , in-progress';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should trim spaces and work correctly
|
||||||
|
expect(result.tasks).toHaveLength(3);
|
||||||
|
const statusValues = result.tasks.map((task) => task.status);
|
||||||
|
expect(statusValues).toEqual(
|
||||||
|
expect.arrayContaining(['done', 'pending', 'in-progress'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty status values in comma-separated list', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'done,,pending,';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should ignore empty values and work with valid ones
|
||||||
|
expect(result.tasks).toHaveLength(2);
|
||||||
|
const statusValues = result.tasks.map((task) => task.status);
|
||||||
|
expect(statusValues).toEqual(expect.arrayContaining(['done', 'pending']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle case-insensitive matching for comma-separated statuses', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'DONE,Pending,IN-PROGRESS';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should match case-insensitively
|
||||||
|
expect(result.tasks).toHaveLength(3);
|
||||||
|
const statusValues = result.tasks.map((task) => task.status);
|
||||||
|
expect(statusValues).toEqual(
|
||||||
|
expect.arrayContaining(['done', 'pending', 'in-progress'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return empty array when no tasks match comma-separated statuses', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'blocked,deferred';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should return empty array as no tasks have these statuses
|
||||||
|
expect(result.tasks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should work with single status when using comma syntax', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'pending,';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should work the same as single status filter
|
||||||
|
expect(result.tasks).toHaveLength(1);
|
||||||
|
expect(result.tasks[0].status).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set correct filter value in response for comma-separated statuses', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'done,pending';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should return the original filter string
|
||||||
|
expect(result.filter).toBe('done,pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle all statuses filter with comma syntax', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'all';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should return all tasks when filter is 'all'
|
||||||
|
expect(result.tasks).toHaveLength(5);
|
||||||
|
expect(result.filter).toBe('all');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mixed existing and non-existing statuses', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'done,nonexistent,pending';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should return only tasks with existing statuses
|
||||||
|
expect(result.tasks).toHaveLength(2);
|
||||||
|
const statusValues = result.tasks.map((task) => task.status);
|
||||||
|
expect(statusValues).toEqual(expect.arrayContaining(['done', 'pending']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter by review status in comma-separated list', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tasksPath = 'tasks/tasks.json';
|
||||||
|
const statusFilter = 'review,cancelled';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should return tasks with 'review' or 'cancelled' status
|
||||||
|
expect(result.tasks).toHaveLength(2);
|
||||||
|
const statusValues = result.tasks.map((task) => task.status);
|
||||||
|
expect(statusValues).toEqual(
|
||||||
|
expect.arrayContaining(['review', 'cancelled'])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify specific tasks
|
||||||
|
const taskIds = result.tasks.map((task) => task.id);
|
||||||
|
expect(taskIds).toContain(4); // cancelled task
|
||||||
|
expect(taskIds).toContain(5); // review task
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user