fix(core): Implement Boundary-First Tag Resolution (#943)

* refactor(context): Standardize tag and projectRoot handling across all task tools

This commit unifies context management by adopting a boundary-first resolution strategy. All task-scoped tools now resolve `tag` and `projectRoot` at their entry point and forward these values to the underlying direct functions.

This approach centralizes context logic, ensuring consistent behavior and enhanced flexibility in multi-tag environments.

* fix(tag): Clean up tag handling in task functions and sync process

This commit refines the handling of the `tag` parameter across multiple functions, ensuring consistent context management. The `tag` is now passed more efficiently in `listTasksDirect`, `setTaskStatusDirect`, and `syncTasksToReadme`, improving clarity and reducing redundancy. Additionally, a TODO comment has been added in `sync-readme.js` to address future tag support enhancements.

* feat(tag): Implement Boundary-First Tag Resolution for consistent tag handling

This commit introduces Boundary-First Tag Resolution in the task manager, ensuring consistent and deterministic tag handling across CLI and MCP. This change resolves potential race conditions and improves the reliability of tag-specific operations.

Additionally, the `expandTask` function has been updated to use the resolved tag when writing JSON, enhancing data integrity during task updates.

* chore(biome): formatting

* fix(expand-task): Update writeJSON call to use tag instead of resolvedTag

* fix(commands): Enhance complexity report path resolution and task initialization
`resolveComplexityReportPath` function to streamline output path generation based on tag context and user-defined output.
- Improved clarity and maintainability of command handling by centralizing path resolution logic.

* Fix: unknown currentTag

* fix(task-manager): Update generateTaskFiles calls to include tag and projectRoot parameters

This commit modifies the `moveTask` and `updateSubtaskById` functions to pass the `tag` and `projectRoot` parameters to the `generateTaskFiles` function. This ensures that task files are generated with the correct context when requested, enhancing consistency in task management operations.

* fix(commands): Refactor tag handling and complexity report path resolution
This commit updates the `registerCommands` function to utilize `taskMaster.getCurrentTag()` for consistent tag retrieval across command actions. It also enhances the initialization of `TaskMaster` by passing the tag directly, improving clarity and maintainability. The complexity report path resolution is streamlined to ensure correct file naming based on the current tag context.

* fix(task-master): Update complexity report path expectations in tests
This commit modifies the `initTaskMaster` test to expect a valid string for the complexity report path, ensuring it matches the expected file naming convention. This change enhances test reliability by verifying the correct output format when the path is generated.

* fix(set-task-status): Enhance logging and tag resolution in task status updates
This commit improves the logging output in the `registerSetTaskStatusTool` function to include the tag context when setting task statuses. It also updates the tag handling by resolving the tag using the `resolveTag` utility, ensuring that the correct tag is used when updating task statuses. Additionally, the `setTaskStatus` function is modified to remove the tag parameter from the `readJSON` and `writeJSON` calls, streamlining the data handling process.

* fix(commands, expand-task, task-manager): Add complexity report option and enhance path handling
This commit introduces a new `--complexity-report` option in the `registerCommands` function, allowing users to specify a custom path for the complexity report. The `expandTask` function is updated to accept the `complexityReportPath` from the context, ensuring it is utilized correctly during task expansion. Additionally, the `setTaskStatus` function now includes the `tag` parameter in the `readJSON` and `writeJSON` calls, improving task status updates with proper context. The `initTaskMaster` function is also modified to create parent directories for output paths, enhancing file handling robustness.

* fix(expand-task): Add complexityReportPath to context for task expansion tests

This commit updates the test for the `expandTask` function by adding the `complexityReportPath` to the context object. This change ensures that the complexity report path is correctly utilized in the test, aligning with recent enhancements to complexity report handling in the task manager.

* chore: implement suggested changes

* fix(parse-prd): Clarify tag parameter description for task organization
Updated the documentation for the `tag` parameter in the `parse-prd.js` file to provide a clearer context on its purpose for organizing tasks into separate task lists.

* Fix Inconsistent tag resolution pattern.

* fix: Enhance complexity report path handling with tag support

This commit updates various functions to incorporate the `tag` parameter when resolving complexity report paths. The `expandTaskDirect`, `resolveComplexityReportPath`, and related tools now utilize the current tag context, improving consistency in task management. Additionally, the complexity report path is now correctly passed through the context in the `expand-task` and `set-task-status` tools, ensuring accurate report retrieval based on the active tag.

* Updated the JSDoc for the `tag` parameter in the `show-task.js` file.

* Remove redundant comment on tag parameter in readJSON call

* Remove unused import for getTagAwareFilePath

* Add missed complexityReportPath to args for task expansion

* fix(tests): Enhance research tests with tag-aware functionality

This commit updates the `research.test.js` file to improve the testing of the `performResearch` function by incorporating tag-aware functionality. Key changes include mocking the `findProjectRoot` to return a valid path, enhancing the `ContextGatherer` and `FuzzyTaskSearch` mocks, and adding comprehensive tests for tag parameter handling in various scenarios. The tests now cover passing different tag values, ensuring correct behavior when tags are provided, undefined, or null, and validating the integration of tags in task discovery and context gathering processes.

* Remove unused import for

* fix: Refactor complexity report path handling and improve argument destructuring

This commit enhances the `expandTaskDirect` function by improving the destructuring of arguments for better readability. It also updates the `analyze.js` and `analyze-task-complexity.js` files to utilize the new `resolveComplexityReportOutputPath` function, ensuring tag-aware resolution of output paths. Additionally, logging has been added to provide clarity on the report path being used.

* test: Add complexity report tag isolation tests and improve path handling

This commit introduces a new test file for complexity report tag isolation, ensuring that different tags maintain separate complexity reports. It enhances the existing tests in `analyze-task-complexity.test.js` by updating expectations to use `expect.stringContaining` for file paths, improving robustness against path changes. The new tests cover various scenarios, including path resolution and report generation for both master and feature tags, ensuring no cross-tag contamination occurs.

* Update scripts/modules/task-manager/list-tasks.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update scripts/modules/task-manager/list-tasks.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* test(complexity-report): Fix tag slugification in filename expectations

- Update mocks to use slugifyTagForFilePath for cross-platform compatibility
- Replace raw tag values with slugified versions in expected filenames
- Fix test expecting 'feature/user-auth-v2' to expect 'feature-user-auth-v2'
- Align test with actual filename generation logic that sanitizes special chars

---------

Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Parthy
2025-07-18 19:05:04 +02:00
committed by Ralph Khreish
parent 0451ebcc32
commit fd005c4c54
95 changed files with 3899 additions and 590 deletions

View File

@@ -237,7 +237,8 @@ describe('addTask', () => {
const prompt = 'Create a new authentication system';
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
projectRoot: '/mock/project/root',
tag: 'master'
};
// Act
@@ -253,7 +254,8 @@ describe('addTask', () => {
// Assert
expect(readJSON).toHaveBeenCalledWith(
'tasks/tasks.json',
'/mock/project/root'
'/mock/project/root',
'master'
);
expect(generateObjectService).toHaveBeenCalledWith(expect.any(Object));
expect(writeJSON).toHaveBeenCalledWith(
@@ -288,7 +290,8 @@ describe('addTask', () => {
const validDependencies = [1, 2]; // These exist in sampleTasks
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
projectRoot: '/mock/project/root',
tag: 'master'
};
// Act
@@ -325,7 +328,8 @@ describe('addTask', () => {
const invalidDependencies = [999]; // Non-existent task ID
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
projectRoot: '/mock/project/root',
tag: 'master'
};
// Act
@@ -367,7 +371,8 @@ describe('addTask', () => {
const priority = 'high';
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
projectRoot: '/mock/project/root',
tag: 'master'
};
// Act
@@ -396,7 +401,8 @@ describe('addTask', () => {
const prompt = 'Create a new authentication system';
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
projectRoot: '/mock/project/root',
tag: 'master'
};
// Act
@@ -433,7 +439,8 @@ describe('addTask', () => {
const prompt = 'Create a new authentication system';
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
projectRoot: '/mock/project/root',
tag: 'master'
};
// Act
@@ -457,7 +464,8 @@ describe('addTask', () => {
const prompt = 'Create a new authentication system';
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
projectRoot: '/mock/project/root',
tag: 'master'
};
// Act & Assert
@@ -474,7 +482,8 @@ describe('addTask', () => {
const prompt = 'Create a new authentication system';
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
projectRoot: '/mock/project/root',
tag: 'master'
};
// Act & Assert
@@ -491,7 +500,8 @@ describe('addTask', () => {
const prompt = 'Create a new authentication system';
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
projectRoot: '/mock/project/root',
tag: 'master'
};
// Act & Assert

View File

@@ -305,7 +305,7 @@ describe('analyzeTaskComplexity', () => {
);
expect(generateTextService).toHaveBeenCalledWith(expect.any(Object));
expect(mockWriteFileSync).toHaveBeenCalledWith(
'scripts/task-complexity-report.json',
expect.stringContaining('task-complexity-report.json'),
expect.stringContaining('"thresholdScore": 5'),
'utf8'
);
@@ -362,7 +362,7 @@ describe('analyzeTaskComplexity', () => {
});
expect(mockWriteFileSync).toHaveBeenCalledWith(
'scripts/task-complexity-report.json',
expect.stringContaining('task-complexity-report.json'),
expect.stringContaining('"thresholdScore": 7'),
'utf8'
);
@@ -390,7 +390,7 @@ describe('analyzeTaskComplexity', () => {
});
expect(mockWriteFileSync).toHaveBeenCalledWith(
'scripts/task-complexity-report.json',
expect.stringContaining('task-complexity-report.json'),
expect.stringContaining('"thresholdScore": 8'),
'utf8'
);

View File

@@ -103,6 +103,9 @@ describe('clearSubtasks', () => {
jest.clearAllMocks();
mockExit.mockClear();
readJSON.mockImplementation((tasksPath, projectRoot, tag) => {
// Ensure tag contract is honoured
expect(tag).toBeDefined();
expect(tag).toBe('master');
// Create a deep copy to avoid mutation issues between tests
const sampleTasksCopy = JSON.parse(JSON.stringify(sampleTasks));
// Return the data for the 'master' tag, which is what the tests use
@@ -121,12 +124,13 @@ describe('clearSubtasks', () => {
// Arrange
const taskId = '3';
const tasksPath = 'tasks/tasks.json';
const context = { tag: 'master' };
// Act
clearSubtasks(tasksPath, taskId);
clearSubtasks(tasksPath, taskId, context);
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
expect(writeJSON).toHaveBeenCalledWith(
tasksPath,
expect.objectContaining({
@@ -142,7 +146,7 @@ describe('clearSubtasks', () => {
})
}),
undefined,
undefined
'master'
);
});
@@ -150,12 +154,13 @@ describe('clearSubtasks', () => {
// Arrange
const taskIds = '3,4';
const tasksPath = 'tasks/tasks.json';
const context = { tag: 'master' };
// Act
clearSubtasks(tasksPath, taskIds);
clearSubtasks(tasksPath, taskIds, context);
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
expect(writeJSON).toHaveBeenCalledWith(
tasksPath,
expect.objectContaining({
@@ -169,7 +174,7 @@ describe('clearSubtasks', () => {
})
}),
undefined,
undefined
'master'
);
});
@@ -177,12 +182,13 @@ describe('clearSubtasks', () => {
// Arrange
const taskId = '1'; // Task 1 already has no subtasks
const tasksPath = 'tasks/tasks.json';
const context = { tag: 'master' };
// Act
clearSubtasks(tasksPath, taskId);
clearSubtasks(tasksPath, taskId, context);
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Should not write the file if no changes were made
expect(writeJSON).not.toHaveBeenCalled();
expect(generateTaskFiles).not.toHaveBeenCalled();
@@ -192,12 +198,13 @@ describe('clearSubtasks', () => {
// Arrange
const taskId = '99'; // Non-existent task
const tasksPath = 'tasks/tasks.json';
const context = { tag: 'master' };
// Act
clearSubtasks(tasksPath, taskId);
clearSubtasks(tasksPath, taskId, context);
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
expect(log).toHaveBeenCalledWith('error', 'Task 99 not found');
// Should not write the file if no changes were made
expect(writeJSON).not.toHaveBeenCalled();
@@ -208,12 +215,13 @@ describe('clearSubtasks', () => {
// Arrange
const taskIds = '3,99'; // Mix of valid and invalid IDs
const tasksPath = 'tasks/tasks.json';
const context = { tag: 'master' };
// Act
clearSubtasks(tasksPath, taskIds);
clearSubtasks(tasksPath, taskIds, context);
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
expect(log).toHaveBeenCalledWith('error', 'Task 99 not found');
// Since task 3 has subtasks that should be cleared, writeJSON should be called
expect(writeJSON).toHaveBeenCalledWith(
@@ -232,7 +240,7 @@ describe('clearSubtasks', () => {
})
}),
undefined,
undefined
'master'
);
});
@@ -244,7 +252,7 @@ describe('clearSubtasks', () => {
// Act & Assert
expect(() => {
clearSubtasks('tasks/tasks.json', '3');
clearSubtasks('tasks/tasks.json', '3', { tag: 'master' });
}).toThrow('File read failed');
});
@@ -254,7 +262,7 @@ describe('clearSubtasks', () => {
// Act & Assert
expect(() => {
clearSubtasks('tasks/tasks.json', '3');
clearSubtasks('tasks/tasks.json', '3', { tag: 'master' });
}).toThrow('process.exit called');
expect(log).toHaveBeenCalledWith('error', 'No valid tasks found.');
@@ -283,7 +291,7 @@ describe('clearSubtasks', () => {
// Act & Assert
expect(() => {
clearSubtasks('tasks/tasks.json', '3');
clearSubtasks('tasks/tasks.json', '3', { tag: 'master' });
}).toThrow('File write failed');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -198,7 +198,8 @@ describe('expandAllTasks', () => {
{
session: mockSession,
mcpLog: mockMcpLog,
projectRoot: mockProjectRoot
projectRoot: mockProjectRoot,
tag: 'master'
},
'json'
);
@@ -224,7 +225,8 @@ describe('expandAllTasks', () => {
{
session: mockSession,
mcpLog: mockMcpLog,
projectRoot: mockProjectRoot
projectRoot: mockProjectRoot,
tag: 'master'
},
'json'
);
@@ -267,7 +269,8 @@ describe('expandAllTasks', () => {
{
session: mockSession,
mcpLog: mockMcpLog,
projectRoot: mockProjectRoot
projectRoot: mockProjectRoot,
tag: 'master'
},
'json'
);
@@ -300,7 +303,8 @@ describe('expandAllTasks', () => {
{
session: mockSession,
mcpLog: mockMcpLog,
projectRoot: mockProjectRoot
projectRoot: mockProjectRoot,
tag: 'master'
},
'json'
);
@@ -326,7 +330,8 @@ describe('expandAllTasks', () => {
{
session: mockSession,
mcpLog: mockMcpLog,
projectRoot: mockProjectRoot
projectRoot: mockProjectRoot,
tag: 'master'
},
'json'
)
@@ -347,7 +352,8 @@ describe('expandAllTasks', () => {
false,
{
session: mockSession,
mcpLog: mockMcpLog
mcpLog: mockMcpLog,
tag: 'master'
// No projectRoot provided, and findProjectRoot will return null
},
'json'
@@ -384,7 +390,8 @@ describe('expandAllTasks', () => {
{
session: mockSession,
mcpLog: mockMcpLog,
projectRoot: mockProjectRoot
projectRoot: mockProjectRoot,
tag: 'master'
},
'json'
);
@@ -412,7 +419,8 @@ describe('expandAllTasks', () => {
{
session: mockSession,
mcpLog: mockMcpLog,
projectRoot: mockProjectRoot
projectRoot: mockProjectRoot,
tag: 'master'
},
'json'
);
@@ -441,7 +449,8 @@ describe('expandAllTasks', () => {
'',
false,
{
projectRoot: mockProjectRoot
projectRoot: mockProjectRoot,
tag: 'master'
// No mcpLog provided, should use CLI logger
},
'text' // CLI output format

View File

@@ -700,7 +700,9 @@ describe('expandTask', () => {
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root',
tag: 'feature-branch'
tag: 'feature-branch',
complexityReportPath:
'/mock/project/root/task-complexity-report_feature-branch.json'
};
// Stub fs.existsSync to simulate complexity report exists for this tag

View File

@@ -185,11 +185,12 @@ describe('generateTaskFiles', () => {
const outputDir = 'tasks';
await generateTaskFiles(tasksPath, outputDir, {
tag: 'master',
mcpLog: { info: jest.fn() }
});
// Verify the data was read with new signature, defaulting to master
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Verify dependencies were validated with the raw tagged data
expect(validateAndFixDependencies).toHaveBeenCalledWith(
@@ -226,6 +227,7 @@ describe('generateTaskFiles', () => {
// Call the function
await generateTaskFiles('tasks/tasks.json', 'tasks', {
tag: 'master',
mcpLog: { info: jest.fn() }
});
@@ -271,6 +273,7 @@ describe('generateTaskFiles', () => {
// Call the function
await generateTaskFiles('tasks/tasks.json', 'tasks', {
tag: 'master',
mcpLog: { info: jest.fn() }
});
@@ -288,6 +291,7 @@ describe('generateTaskFiles', () => {
// Call the function
await generateTaskFiles('tasks/tasks.json', 'tasks', {
tag: 'master',
mcpLog: { info: jest.fn() }
});

View File

@@ -21,7 +21,8 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
tasks.find((t) => t.id === parseInt(id))
),
addComplexityToTask: jest.fn(),
readComplexityReport: jest.fn(() => null)
readComplexityReport: jest.fn(() => null),
getTagAwareFilePath: jest.fn((tag, path) => '/mock/tagged/report.json')
}));
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
@@ -152,10 +153,12 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json';
// Act
const result = listTasks(tasksPath, null, null, false, 'json');
const result = listTasks(tasksPath, null, null, false, 'json', {
tag: 'master'
});
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
expect(result).toEqual(
expect.objectContaining({
tasks: expect.arrayContaining([
@@ -175,10 +178,12 @@ describe('listTasks', () => {
const statusFilter = 'pending';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Verify only pending tasks are returned
expect(result.tasks).toHaveLength(1);
@@ -192,7 +197,9 @@ describe('listTasks', () => {
const statusFilter = 'done';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Verify only done tasks are returned
@@ -206,7 +213,9 @@ describe('listTasks', () => {
const statusFilter = 'review';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Verify only review tasks are returned
@@ -220,7 +229,9 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json';
// Act
const result = listTasks(tasksPath, null, null, true, 'json');
const result = listTasks(tasksPath, null, null, true, 'json', {
tag: 'master'
});
// Assert
// Verify that the task with subtasks is included
@@ -235,7 +246,9 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json';
// Act
const result = listTasks(tasksPath, null, null, false, 'json');
const result = listTasks(tasksPath, null, null, false, 'json', {
tag: 'master'
});
// Assert
// For JSON output, subtasks should still be included in the data structure
@@ -253,7 +266,9 @@ describe('listTasks', () => {
const statusFilter = 'blocked'; // Status that doesn't exist in sample data
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Verify empty array is returned
@@ -269,7 +284,7 @@ describe('listTasks', () => {
// Act & Assert
expect(() => {
listTasks(tasksPath, null, null, false, 'json');
listTasks(tasksPath, null, null, false, 'json', { tag: 'master' });
}).toThrow('File not found');
});
@@ -278,10 +293,10 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json';
// Act
listTasks(tasksPath, null, null, false, 'json');
listTasks(tasksPath, null, null, false, 'json', { tag: 'master' });
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Note: validateAndFixDependencies is not called by listTasks function
// This test just verifies the function runs without error
});
@@ -291,7 +306,9 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json';
// Act
const result = listTasks(tasksPath, 'pending', null, true, 'json');
const result = listTasks(tasksPath, 'pending', null, true, 'json', {
tag: 'master'
});
// Assert
// For JSON output, we don't call displayTaskList, so just verify the result structure
@@ -310,7 +327,9 @@ describe('listTasks', () => {
const statusFilter = 'in-progress';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
expect(result.tasks).toHaveLength(1);
@@ -324,7 +343,9 @@ describe('listTasks', () => {
const statusFilter = 'cancelled';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
expect(result.tasks).toHaveLength(1);
@@ -337,7 +358,9 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json';
// Act
const result = listTasks(tasksPath, null, null, false, 'json');
const result = listTasks(tasksPath, null, null, false, 'json', {
tag: 'master'
});
// Assert
expect(result).toEqual(
@@ -363,10 +386,12 @@ describe('listTasks', () => {
const statusFilter = 'done,pending';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Should return tasks with 'done' or 'pending' status
expect(result.tasks).toHaveLength(2);
@@ -381,7 +406,9 @@ describe('listTasks', () => {
const statusFilter = 'done,pending,in-progress';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return tasks with 'done', 'pending', or 'in-progress' status
@@ -405,7 +432,9 @@ describe('listTasks', () => {
const statusFilter = 'done, pending , in-progress';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should trim spaces and work correctly
@@ -422,7 +451,9 @@ describe('listTasks', () => {
const statusFilter = 'done,,pending,';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should ignore empty values and work with valid ones
@@ -437,7 +468,9 @@ describe('listTasks', () => {
const statusFilter = 'DONE,Pending,IN-PROGRESS';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should match case-insensitively
@@ -454,7 +487,9 @@ describe('listTasks', () => {
const statusFilter = 'blocked,deferred';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return empty array as no tasks have these statuses
@@ -467,7 +502,9 @@ describe('listTasks', () => {
const statusFilter = 'pending,';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should work the same as single status filter
@@ -481,7 +518,9 @@ describe('listTasks', () => {
const statusFilter = 'done,pending';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return the original filter string
@@ -494,7 +533,9 @@ describe('listTasks', () => {
const statusFilter = 'all';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return all tasks when filter is 'all'
@@ -508,7 +549,9 @@ describe('listTasks', () => {
const statusFilter = 'done,nonexistent,pending';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return only tasks with existing statuses
@@ -523,7 +566,9 @@ describe('listTasks', () => {
const statusFilter = 'review,cancelled';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json');
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return tasks with 'review' or 'cancelled' status

View File

@@ -0,0 +1,94 @@
import { jest } from '@jest/globals';
// --- Mocks ---
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
readJSON: jest.fn(),
writeJSON: jest.fn(),
log: jest.fn(),
setTasksForTag: jest.fn(),
truncate: jest.fn((t) => t),
isSilentMode: 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.js',
() => ({
isTaskDependentOn: jest.fn(() => false)
})
);
// fs not needed since move-task uses writeJSON
const { readJSON, writeJSON, log } = await import(
'../../../../../scripts/modules/utils.js'
);
const generateTaskFiles = (
await import(
'../../../../../scripts/modules/task-manager/generate-task-files.js'
)
).default;
const { default: moveTask } = await import(
'../../../../../scripts/modules/task-manager/move-task.js'
);
const sampleTagged = () => ({
master: {
tasks: [
{ id: 1, title: 'A' },
{ id: 2, title: 'B', subtasks: [{ id: 1, title: 'B.1' }] }
],
metadata: {}
},
feature: {
tasks: [{ id: 10, title: 'X' }],
metadata: {}
}
});
const clone = () => JSON.parse(JSON.stringify(sampleTagged()));
describe('moveTask (unit)', () => {
beforeEach(() => {
jest.clearAllMocks();
readJSON.mockImplementation((path, projectRoot, tag) => {
const data = clone();
return { ...data[tag], tag, _rawTaggedData: data };
});
writeJSON.mockResolvedValue();
log.mockImplementation(() => {});
});
test('moves task to new ID in same tag', async () => {
await moveTask('tasks.json', '1', '3', false, { tag: 'master' });
expect(writeJSON).toHaveBeenCalled();
const written = writeJSON.mock.calls[0][1];
const ids = written.master.tasks.map((t) => t.id);
expect(ids).toEqual(expect.arrayContaining([2, 3]));
expect(ids).not.toContain(1);
});
test('throws when counts of source and dest mismatch', async () => {
await expect(
moveTask('tasks.json', '1,2', '3', {}, { tag: 'master' })
).rejects.toThrow(/Number of source IDs/);
});
test('batch move calls generateTaskFiles once when flag true', async () => {
await moveTask('tasks.json', '1,2', '3,4', true, { tag: 'master' });
expect(generateTaskFiles).toHaveBeenCalledTimes(1);
});
test('error when tag invalid', async () => {
await expect(
moveTask('tasks.json', '1', '2', false, { tag: 'ghost' })
).rejects.toThrow(/tag "ghost" not found/);
});
});

View File

@@ -233,7 +233,9 @@ describe('parsePRD', () => {
});
// Call the function
const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
tag: 'master'
});
// Verify fs.readFileSync was called with the correct arguments
expect(fs.default.readFileSync).toHaveBeenCalledWith(
@@ -276,7 +278,7 @@ describe('parsePRD', () => {
});
// Call the function
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' });
// Verify mkdir was called
expect(fs.default.mkdirSync).toHaveBeenCalledWith('tasks', {
@@ -299,6 +301,7 @@ describe('parsePRD', () => {
// Call the function with mcpLog to make it think it's in MCP mode (which throws instead of process.exit)
await expect(
parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
tag: 'master',
mcpLog: {
info: jest.fn(),
warn: jest.fn(),
@@ -319,7 +322,7 @@ describe('parsePRD', () => {
});
// Call the function
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' });
});
test('should overwrite tasks.json when force flag is true', async () => {
@@ -331,7 +334,10 @@ describe('parsePRD', () => {
});
// Call the function with force=true to allow overwrite
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { force: true });
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
force: true,
tag: 'master'
});
// Verify prompt was NOT called (confirmation happens at CLI level, not in core function)
expect(promptYesNo).not.toHaveBeenCalled();
@@ -354,6 +360,7 @@ describe('parsePRD', () => {
// Call the function with mcpLog to make it think it's in MCP mode (which throws instead of process.exit)
await expect(
parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
tag: 'master',
mcpLog: {
info: jest.fn(),
warn: jest.fn(),
@@ -383,7 +390,7 @@ describe('parsePRD', () => {
// Call the function without mcpLog (CLI mode) and expect it to throw an error
// In test environment, process.exit is prevented and error is thrown instead
await expect(
parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3)
parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' })
).rejects.toThrow(
"Tag 'master' already contains 2 tasks. Use --force to overwrite or --append to add to existing tasks."
);
@@ -411,6 +418,7 @@ describe('parsePRD', () => {
// Call the function with append option
const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 2, {
tag: 'master',
append: true
});
@@ -445,6 +453,7 @@ describe('parsePRD', () => {
// Call the function with append option
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
tag: 'master',
append: true
});
@@ -462,7 +471,9 @@ describe('parsePRD', () => {
});
// Call the function with numTasks=0 for dynamic generation
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0);
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0, {
tag: 'master'
});
// Verify generateObjectService was called
expect(generateObjectService).toHaveBeenCalled();
@@ -482,7 +493,9 @@ describe('parsePRD', () => {
});
// Call the function with specific numTasks
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 5);
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 5, {
tag: 'master'
});
// Verify generateObjectService was called
expect(generateObjectService).toHaveBeenCalled();
@@ -502,7 +515,9 @@ describe('parsePRD', () => {
});
// Call the function with numTasks=0 - should not throw error
const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0);
const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0, {
tag: 'master'
});
// Verify it completed successfully
expect(result).toEqual({
@@ -522,7 +537,9 @@ describe('parsePRD', () => {
// Call the function with negative numTasks
// Note: The main parse-prd.js module doesn't validate numTasks - validation happens at CLI/MCP level
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', -5);
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', -5, {
tag: 'master'
});
// Verify generateObjectService was called
expect(generateObjectService).toHaveBeenCalled();
@@ -543,7 +560,9 @@ describe('parsePRD', () => {
});
// Call the function with null numTasks
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', null);
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', null, {
tag: 'master'
});
// Verify generateObjectService was called with dynamic prompting
expect(generateObjectService).toHaveBeenCalled();
@@ -560,7 +579,9 @@ describe('parsePRD', () => {
});
// Call the function with invalid numTasks (string that's not a number)
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 'invalid');
await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 'invalid', {
tag: 'master'
});
// Verify generateObjectService was called with dynamic prompting
// Note: The main module doesn't validate - it just uses the value as-is

View File

@@ -19,10 +19,12 @@ const testRemoveSubtask = (
tasksPath,
subtaskId,
convertToTask = false,
generateFiles = true
generateFiles = true,
context = { tag: 'master' }
) => {
const { projectRoot = undefined, tag = 'master' } = context;
// Read the existing tasks
const data = mockReadJSON(tasksPath);
const data = mockReadJSON(tasksPath, projectRoot, tag);
if (!data || !data.tasks) {
throw new Error(`Invalid or missing tasks file at ${tasksPath}`);
}
@@ -95,7 +97,7 @@ const testRemoveSubtask = (
}
// Write the updated tasks back to the file
mockWriteJSON(tasksPath, data);
mockWriteJSON(tasksPath, data, projectRoot, tag);
// Generate task files if requested
if (generateFiles) {
@@ -111,55 +113,66 @@ describe('removeSubtask function', () => {
jest.clearAllMocks();
// Default mock implementations
mockReadJSON.mockImplementation(() => ({
tasks: [
{
id: 1,
title: 'Parent Task',
description: 'This is a parent task',
status: 'pending',
dependencies: [],
subtasks: [
{
id: 1,
title: 'Subtask 1',
description: 'This is subtask 1',
status: 'pending',
dependencies: [],
parentTaskId: 1
},
{
id: 2,
title: 'Subtask 2',
description: 'This is subtask 2',
status: 'in-progress',
dependencies: [1], // Depends on subtask 1
parentTaskId: 1
}
]
},
{
id: 2,
title: 'Another Task',
description: 'This is another task',
status: 'pending',
dependencies: [1]
}
]
}));
mockReadJSON.mockImplementation((p, root, tag) => {
expect(tag).toBeDefined();
expect(tag).toBe('master');
return {
tasks: [
{
id: 1,
title: 'Parent Task',
description: 'This is a parent task',
status: 'pending',
dependencies: [],
subtasks: [
{
id: 1,
title: 'Subtask 1',
description: 'This is subtask 1',
status: 'pending',
dependencies: [],
parentTaskId: 1
},
{
id: 2,
title: 'Subtask 2',
description: 'This is subtask 2',
status: 'in-progress',
dependencies: [1], // Depends on subtask 1
parentTaskId: 1
}
]
},
{
id: 2,
title: 'Another Task',
description: 'This is another task',
status: 'pending',
dependencies: [1]
}
]
};
});
// Setup success write response
mockWriteJSON.mockImplementation((path, data) => {
mockWriteJSON.mockImplementation((path, data, root, tag) => {
expect(tag).toBe('master');
return data;
});
});
test('should remove a subtask from its parent task', async () => {
// Execute the test version of removeSubtask to remove subtask 1.1
testRemoveSubtask('tasks/tasks.json', '1.1', false, true);
testRemoveSubtask('tasks/tasks.json', '1.1', false, true, {
tag: 'master'
});
// Verify readJSON was called with the correct path
expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json');
expect(mockReadJSON).toHaveBeenCalledWith(
'tasks/tasks.json',
undefined,
'master'
);
// Verify writeJSON was called with updated data
expect(mockWriteJSON).toHaveBeenCalled();
@@ -170,7 +183,9 @@ describe('removeSubtask function', () => {
test('should convert a subtask to a standalone task', async () => {
// Execute the test version of removeSubtask to convert subtask 1.1 to a standalone task
const result = testRemoveSubtask('tasks/tasks.json', '1.1', true, true);
const result = testRemoveSubtask('tasks/tasks.json', '1.1', true, true, {
tag: 'master'
});
// Verify the result is the new task
expect(result).toBeDefined();
@@ -187,9 +202,9 @@ describe('removeSubtask function', () => {
test('should throw an error if subtask ID format is invalid', async () => {
// Expect an error for invalid subtask ID format
expect(() => testRemoveSubtask('tasks/tasks.json', '1', false)).toThrow(
/Invalid subtask ID format/
);
expect(() =>
testRemoveSubtask('tasks/tasks.json', '1', false, true, { tag: 'master' })
).toThrow(/Invalid subtask ID format/);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
@@ -197,9 +212,11 @@ describe('removeSubtask function', () => {
test('should throw an error if parent task does not exist', async () => {
// Expect an error for non-existent parent task
expect(() => testRemoveSubtask('tasks/tasks.json', '999.1', false)).toThrow(
/Parent task with ID 999 not found/
);
expect(() =>
testRemoveSubtask('tasks/tasks.json', '999.1', false, true, {
tag: 'master'
})
).toThrow(/Parent task with ID 999 not found/);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
@@ -207,9 +224,11 @@ describe('removeSubtask function', () => {
test('should throw an error if subtask does not exist', async () => {
// Expect an error for non-existent subtask
expect(() => testRemoveSubtask('tasks/tasks.json', '1.999', false)).toThrow(
/Subtask 1.999 not found/
);
expect(() =>
testRemoveSubtask('tasks/tasks.json', '1.999', false, true, {
tag: 'master'
})
).toThrow(/Subtask 1.999 not found/);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
@@ -217,45 +236,51 @@ describe('removeSubtask function', () => {
test('should remove subtasks array if last subtask is removed', async () => {
// Create a data object with just one subtask
mockReadJSON.mockImplementationOnce(() => ({
tasks: [
{
id: 1,
title: 'Parent Task',
description: 'This is a parent task',
status: 'pending',
dependencies: [],
subtasks: [
{
id: 1,
title: 'Last Subtask',
description: 'This is the last subtask',
status: 'pending',
dependencies: [],
parentTaskId: 1
}
]
},
{
id: 2,
title: 'Another Task',
description: 'This is another task',
status: 'pending',
dependencies: [1]
}
]
}));
mockReadJSON.mockImplementationOnce((p, root, tag) => {
expect(tag).toBe('master');
return {
tasks: [
{
id: 1,
title: 'Parent Task',
description: 'This is a parent task',
status: 'pending',
dependencies: [],
subtasks: [
{
id: 1,
title: 'Last Subtask',
description: 'This is the last subtask',
status: 'pending',
dependencies: [],
parentTaskId: 1
}
]
},
{
id: 2,
title: 'Another Task',
description: 'This is another task',
status: 'pending',
dependencies: [1]
}
]
};
});
// Mock the behavior of writeJSON to capture the updated tasks data
const updatedTasksData = { tasks: [] };
mockWriteJSON.mockImplementation((path, data) => {
mockWriteJSON.mockImplementation((path, data, root, tag) => {
expect(tag).toBe('master');
// Store the data for assertions
updatedTasksData.tasks = [...data.tasks];
return data;
});
// Remove the last subtask
testRemoveSubtask('tasks/tasks.json', '1.1', false, true);
testRemoveSubtask('tasks/tasks.json', '1.1', false, true, {
tag: 'master'
});
// Verify writeJSON was called
expect(mockWriteJSON).toHaveBeenCalled();
@@ -271,7 +296,9 @@ describe('removeSubtask function', () => {
test('should not regenerate task files if generateFiles is false', async () => {
// Execute the test version of removeSubtask with generateFiles = false
testRemoveSubtask('tasks/tasks.json', '1.1', false, false);
testRemoveSubtask('tasks/tasks.json', '1.1', false, false, {
tag: 'master'
});
// Verify writeJSON was called
expect(mockWriteJSON).toHaveBeenCalled();

View File

@@ -0,0 +1,134 @@
import { jest } from '@jest/globals';
// --- Mock dependencies BEFORE module import ---
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
readJSON: jest.fn(),
writeJSON: jest.fn(),
log: jest.fn(),
CONFIG: {
model: 'mock-model',
maxTokens: 4000,
temperature: 0.7,
debug: false
},
findTaskById: jest.fn(),
truncate: jest.fn((t) => t),
isSilentMode: jest.fn(() => false)
}));
jest.unstable_mockModule(
'../../../../../scripts/modules/task-manager/generate-task-files.js',
() => ({
default: jest.fn().mockResolvedValue()
})
);
// fs is used for file deletion side-effects stub the methods we touch
jest.unstable_mockModule('fs', () => ({
existsSync: jest.fn(() => true),
unlinkSync: jest.fn()
}));
// path is fine to keep as real since only join/dirname used no side effects
// Import mocked modules
const { readJSON, writeJSON, log } = await import(
'../../../../../scripts/modules/utils.js'
);
const generateTaskFiles = (
await import(
'../../../../../scripts/modules/task-manager/generate-task-files.js'
)
).default;
const fs = await import('fs');
// Import module under test (AFTER mocks in place)
const { default: removeTask } = await import(
'../../../../../scripts/modules/task-manager/remove-task.js'
);
// ---- Test data helpers ----
const buildSampleTaggedTasks = () => ({
master: {
tasks: [
{ id: 1, title: 'Task 1', status: 'pending', dependencies: [] },
{ id: 2, title: 'Task 2', status: 'pending', dependencies: [1] },
{
id: 3,
title: 'Parent',
status: 'pending',
dependencies: [],
subtasks: [
{ id: 1, title: 'Sub 3.1', status: 'pending', dependencies: [] }
]
}
]
},
other: {
tasks: [{ id: 99, title: 'Shadow', status: 'pending', dependencies: [1] }]
}
});
// Utility to deep clone sample each test
const getFreshData = () => JSON.parse(JSON.stringify(buildSampleTaggedTasks()));
// ----- Tests -----
describe('removeTask', () => {
beforeEach(() => {
jest.clearAllMocks();
// readJSON returns deep copy so each test isolated
readJSON.mockImplementation(() => {
return {
...getFreshData().master,
tag: 'master',
_rawTaggedData: getFreshData()
};
});
writeJSON.mockResolvedValue();
log.mockImplementation(() => {});
fs.unlinkSync.mockImplementation(() => {});
});
test('removes a main task and cleans dependencies across tags', async () => {
const result = await removeTask('tasks/tasks.json', '1', { tag: 'master' });
// Expect success true
expect(result.success).toBe(true);
// writeJSON called with data where task 1 is gone in master & dependencies removed in other tags
const written = writeJSON.mock.calls[0][1];
expect(written.master.tasks.find((t) => t.id === 1)).toBeUndefined();
// deps removed from child tasks
const task2 = written.master.tasks.find((t) => t.id === 2);
expect(task2.dependencies).not.toContain(1);
const shadow = written.other.tasks.find((t) => t.id === 99);
expect(shadow.dependencies).not.toContain(1);
// Task file deletion attempted
expect(fs.unlinkSync).toHaveBeenCalled();
});
test('removes a subtask only and leaves parent intact', async () => {
const result = await removeTask('tasks/tasks.json', '3.1', {
tag: 'master'
});
expect(result.success).toBe(true);
const written = writeJSON.mock.calls[0][1];
const parent = written.master.tasks.find((t) => t.id === 3);
expect(parent.subtasks || []).toHaveLength(0);
// Ensure parent still exists
expect(parent).toBeDefined();
// No task files should be deleted for subtasks
expect(fs.unlinkSync).not.toHaveBeenCalled();
});
test('handles non-existent task gracefully', async () => {
const result = await removeTask('tasks/tasks.json', '42', {
tag: 'master'
});
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
// writeJSON not called because nothing changed
expect(writeJSON).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,663 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
findProjectRoot: jest.fn(() => '/test/project/root'),
log: jest.fn(),
readJSON: jest.fn(),
flattenTasksWithSubtasks: jest.fn(() => []),
isEmpty: jest.fn(() => false)
}));
// Mock UI-affecting external libs to minimal no-op implementations
jest.unstable_mockModule('chalk', () => ({
default: {
white: Object.assign(
jest.fn((text) => text),
{
bold: jest.fn((text) => text)
}
),
cyan: Object.assign(
jest.fn((text) => text),
{
bold: jest.fn((text) => text)
}
),
green: Object.assign(
jest.fn((text) => text),
{
bold: jest.fn((text) => text)
}
),
yellow: jest.fn((text) => text),
red: jest.fn((text) => text),
gray: jest.fn((text) => text),
blue: Object.assign(
jest.fn((text) => text),
{
bold: jest.fn((text) => text)
}
),
bold: jest.fn((text) => text)
}
}));
jest.unstable_mockModule('boxen', () => ({ default: (text) => text }));
jest.unstable_mockModule('inquirer', () => ({
default: { prompt: jest.fn() }
}));
jest.unstable_mockModule('cli-highlight', () => ({
highlight: (code) => code
}));
jest.unstable_mockModule('cli-table3', () => ({
default: jest.fn().mockImplementation(() => ({
push: jest.fn(),
toString: jest.fn(() => '')
}))
}));
jest.unstable_mockModule(
'../../../../../scripts/modules/utils/contextGatherer.js',
() => ({
ContextGatherer: jest.fn().mockImplementation(() => ({
gather: jest.fn().mockResolvedValue({
context: 'Gathered context',
tokenBreakdown: { total: 500 }
}),
countTokens: jest.fn(() => 100)
}))
})
);
jest.unstable_mockModule(
'../../../../../scripts/modules/utils/fuzzyTaskSearch.js',
() => ({
FuzzyTaskSearch: jest.fn().mockImplementation(() => ({
findRelevantTasks: jest.fn(() => []),
getTaskIds: jest.fn(() => [])
}))
})
);
jest.unstable_mockModule(
'../../../../../scripts/modules/ai-services-unified.js',
() => ({
generateTextService: jest.fn().mockResolvedValue({
mainResult:
'Test research result with ```javascript\nconsole.log("test");\n```',
telemetryData: {}
})
})
);
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
displayAiUsageSummary: jest.fn(),
startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
stopLoadingIndicator: jest.fn()
}));
jest.unstable_mockModule(
'../../../../../scripts/modules/prompt-manager.js',
() => ({
getPromptManager: jest.fn().mockReturnValue({
loadPrompt: jest.fn().mockResolvedValue({
systemPrompt: 'System prompt',
userPrompt: 'User prompt'
})
})
})
);
const { performResearch } = await import(
'../../../../../scripts/modules/task-manager/research.js'
);
// Import mocked modules for testing
const utils = await import('../../../../../scripts/modules/utils.js');
const { ContextGatherer } = await import(
'../../../../../scripts/modules/utils/contextGatherer.js'
);
const { FuzzyTaskSearch } = await import(
'../../../../../scripts/modules/utils/fuzzyTaskSearch.js'
);
const { generateTextService } = await import(
'../../../../../scripts/modules/ai-services-unified.js'
);
describe('performResearch project root validation', () => {
it('throws error when project root cannot be determined', async () => {
// Mock findProjectRoot to return null
utils.findProjectRoot.mockReturnValueOnce(null);
await expect(
performResearch('Test query', {}, {}, 'json', false)
).rejects.toThrow('Could not determine project root directory');
});
});
describe('performResearch tag-aware functionality', () => {
let mockContextGatherer;
let mockFuzzySearch;
let mockReadJSON;
let mockFlattenTasks;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Set up default mocks
utils.findProjectRoot.mockReturnValue('/test/project/root');
utils.readJSON.mockResolvedValue({
tasks: [
{ id: 1, title: 'Task 1', description: 'Description 1' },
{ id: 2, title: 'Task 2', description: 'Description 2' }
]
});
utils.flattenTasksWithSubtasks.mockReturnValue([
{ id: 1, title: 'Task 1', description: 'Description 1' },
{ id: 2, title: 'Task 2', description: 'Description 2' }
]);
// Set up ContextGatherer mock
mockContextGatherer = {
gather: jest.fn().mockResolvedValue({
context: 'Gathered context',
tokenBreakdown: { total: 500 }
}),
countTokens: jest.fn(() => 100)
};
ContextGatherer.mockImplementation(() => mockContextGatherer);
// Set up FuzzyTaskSearch mock
mockFuzzySearch = {
findRelevantTasks: jest.fn(() => [
{ id: 1, title: 'Task 1', description: 'Description 1' }
]),
getTaskIds: jest.fn(() => ['1'])
};
FuzzyTaskSearch.mockImplementation(() => mockFuzzySearch);
// Store references for easier access
mockReadJSON = utils.readJSON;
mockFlattenTasks = utils.flattenTasksWithSubtasks;
});
describe('tag parameter passing to ContextGatherer', () => {
it('passes tag parameter to ContextGatherer constructor', async () => {
const testTag = 'feature-branch';
await performResearch('Test query', { tag: testTag }, {}, 'json', false);
expect(ContextGatherer).toHaveBeenCalledWith(
'/test/project/root',
testTag
);
});
it('passes undefined tag when no tag is provided', async () => {
await performResearch('Test query', {}, {}, 'json', false);
expect(ContextGatherer).toHaveBeenCalledWith(
'/test/project/root',
undefined
);
});
it('passes empty string tag when empty string is provided', async () => {
await performResearch('Test query', { tag: '' }, {}, 'json', false);
expect(ContextGatherer).toHaveBeenCalledWith('/test/project/root', '');
});
it('passes null tag when null is provided', async () => {
await performResearch('Test query', { tag: null }, {}, 'json', false);
expect(ContextGatherer).toHaveBeenCalledWith('/test/project/root', null);
});
});
describe('tag-aware readJSON calls', () => {
it('calls readJSON with correct tag parameter for task discovery', async () => {
const testTag = 'development';
await performResearch('Test query', { tag: testTag }, {}, 'json', false);
expect(mockReadJSON).toHaveBeenCalledWith(
expect.stringContaining('tasks.json'),
'/test/project/root',
testTag
);
});
it('calls readJSON with undefined tag when no tag provided', async () => {
await performResearch('Test query', {}, {}, 'json', false);
expect(mockReadJSON).toHaveBeenCalledWith(
expect.stringContaining('tasks.json'),
'/test/project/root',
undefined
);
});
it('calls readJSON with provided projectRoot and tag', async () => {
const customProjectRoot = '/custom/project/root';
const testTag = 'production';
await performResearch(
'Test query',
{
projectRoot: customProjectRoot,
tag: testTag
},
{},
'json',
false
);
expect(mockReadJSON).toHaveBeenCalledWith(
expect.stringContaining('tasks.json'),
customProjectRoot,
testTag
);
});
});
describe('context gathering behavior for different tags', () => {
it('calls contextGatherer.gather with correct parameters', async () => {
const options = {
taskIds: ['1', '2'],
filePaths: ['src/file.js'],
customContext: 'Custom context',
includeProjectTree: true,
tag: 'feature-branch'
};
await performResearch('Test query', options, {}, 'json', false);
expect(mockContextGatherer.gather).toHaveBeenCalledWith({
tasks: expect.arrayContaining(['1', '2']),
files: ['src/file.js'],
customContext: 'Custom context',
includeProjectTree: true,
format: 'research',
includeTokenCounts: true
});
});
it('handles empty task discovery gracefully when readJSON fails', async () => {
mockReadJSON.mockRejectedValueOnce(new Error('File not found'));
const result = await performResearch(
'Test query',
{ tag: 'test-tag' },
{},
'json',
false
);
// Should still succeed even if task discovery fails
expect(result).toBeDefined();
expect(mockContextGatherer.gather).toHaveBeenCalledWith({
tasks: [],
files: [],
customContext: '',
includeProjectTree: false,
format: 'research',
includeTokenCounts: true
});
});
it('combines provided taskIds with auto-discovered tasks', async () => {
const providedTaskIds = ['3', '4'];
const autoDiscoveredIds = ['1', '2'];
mockFuzzySearch.getTaskIds.mockReturnValue(autoDiscoveredIds);
await performResearch(
'Test query',
{
taskIds: providedTaskIds,
tag: 'feature-branch'
},
{},
'json',
false
);
expect(mockContextGatherer.gather).toHaveBeenCalledWith({
tasks: expect.arrayContaining([
...providedTaskIds,
...autoDiscoveredIds
]),
files: [],
customContext: '',
includeProjectTree: false,
format: 'research',
includeTokenCounts: true
});
});
it('removes duplicate tasks when auto-discovered tasks overlap with provided tasks', async () => {
const providedTaskIds = ['1', '2'];
const autoDiscoveredIds = ['2', '3']; // '2' is duplicate
mockFuzzySearch.getTaskIds.mockReturnValue(autoDiscoveredIds);
await performResearch(
'Test query',
{
taskIds: providedTaskIds,
tag: 'feature-branch'
},
{},
'json',
false
);
expect(mockContextGatherer.gather).toHaveBeenCalledWith({
tasks: ['1', '2', '3'], // Should include '3' but not duplicate '2'
files: [],
customContext: '',
includeProjectTree: false,
format: 'research',
includeTokenCounts: true
});
});
});
describe('tag-aware fuzzy search', () => {
it('initializes FuzzyTaskSearch with flattened tasks from correct tag', async () => {
const testTag = 'development';
const mockFlattenedTasks = [
{ id: 1, title: 'Dev Task 1' },
{ id: 2, title: 'Dev Task 2' }
];
mockFlattenTasks.mockReturnValue(mockFlattenedTasks);
await performResearch('Test query', { tag: testTag }, {}, 'json', false);
expect(mockFlattenTasks).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ id: 1 }),
expect.objectContaining({ id: 2 })
])
);
expect(FuzzyTaskSearch).toHaveBeenCalledWith(
mockFlattenedTasks,
'research'
);
});
it('calls fuzzy search with correct parameters', async () => {
const testQuery = 'authentication implementation';
await performResearch(
testQuery,
{ tag: 'feature-branch' },
{},
'json',
false
);
expect(mockFuzzySearch.findRelevantTasks).toHaveBeenCalledWith(
testQuery,
{
maxResults: 8,
includeRecent: true,
includeCategoryMatches: true
}
);
});
it('handles empty tasks data gracefully', async () => {
mockReadJSON.mockResolvedValueOnce({ tasks: [] });
await performResearch(
'Test query',
{ tag: 'empty-tag' },
{},
'json',
false
);
// Should not call FuzzyTaskSearch when no tasks exist
expect(FuzzyTaskSearch).not.toHaveBeenCalled();
expect(mockContextGatherer.gather).toHaveBeenCalledWith({
tasks: [],
files: [],
customContext: '',
includeProjectTree: false,
format: 'research',
includeTokenCounts: true
});
});
it('handles null tasks data gracefully', async () => {
mockReadJSON.mockResolvedValueOnce(null);
await performResearch(
'Test query',
{ tag: 'null-tag' },
{},
'json',
false
);
// Should not call FuzzyTaskSearch when data is null
expect(FuzzyTaskSearch).not.toHaveBeenCalled();
});
});
describe('error handling for invalid tags', () => {
it('continues execution when readJSON throws error for invalid tag', async () => {
mockReadJSON.mockRejectedValueOnce(new Error('Tag not found'));
const result = await performResearch(
'Test query',
{ tag: 'invalid-tag' },
{},
'json',
false
);
// Should still succeed and return a result
expect(result).toBeDefined();
expect(mockContextGatherer.gather).toHaveBeenCalled();
});
it('logs debug message when task discovery fails', async () => {
const mockLog = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
success: jest.fn()
};
mockReadJSON.mockRejectedValueOnce(new Error('File not found'));
await performResearch(
'Test query',
{ tag: 'error-tag' },
{ mcpLog: mockLog },
'json',
false
);
expect(mockLog.debug).toHaveBeenCalledWith(
expect.stringContaining('Could not auto-discover tasks')
);
});
it('handles ContextGatherer constructor errors gracefully', async () => {
ContextGatherer.mockImplementationOnce(() => {
throw new Error('Invalid tag provided');
});
await expect(
performResearch('Test query', { tag: 'invalid-tag' }, {}, 'json', false)
).rejects.toThrow('Invalid tag provided');
});
it('handles ContextGatherer.gather errors gracefully', async () => {
mockContextGatherer.gather.mockRejectedValueOnce(
new Error('Gather failed')
);
await expect(
performResearch(
'Test query',
{ tag: 'gather-error-tag' },
{},
'json',
false
)
).rejects.toThrow('Gather failed');
});
});
describe('MCP integration with tags', () => {
it('uses MCP logger when mcpLog is provided in context', async () => {
const mockMCPLog = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
success: jest.fn()
};
mockReadJSON.mockRejectedValueOnce(new Error('Test error'));
await performResearch(
'Test query',
{ tag: 'mcp-tag' },
{ mcpLog: mockMCPLog },
'json',
false
);
expect(mockMCPLog.debug).toHaveBeenCalledWith(
expect.stringContaining('Could not auto-discover tasks')
);
});
it('passes session to generateTextService when provided', async () => {
const mockSession = { userId: 'test-user', env: {} };
await performResearch(
'Test query',
{ tag: 'session-tag' },
{ session: mockSession },
'json',
false
);
expect(generateTextService).toHaveBeenCalledWith(
expect.objectContaining({
session: mockSession
})
);
});
});
describe('output format handling with tags', () => {
it('displays UI banner only in text format', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
await performResearch('Test query', { tag: 'ui-tag' }, {}, 'text', false);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('🔍 AI Research Query')
);
consoleSpy.mockRestore();
});
it('does not display UI banner in json format', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
await performResearch('Test query', { tag: 'ui-tag' }, {}, 'json', false);
expect(consoleSpy).not.toHaveBeenCalledWith(
expect.stringContaining('🔍 AI Research Query')
);
consoleSpy.mockRestore();
});
});
describe('comprehensive tag integration test', () => {
it('performs complete research flow with tag-aware functionality', async () => {
const testOptions = {
taskIds: ['1', '2'],
filePaths: ['src/main.js'],
customContext: 'Testing tag integration',
includeProjectTree: true,
detailLevel: 'high',
tag: 'integration-test',
projectRoot: '/custom/root'
};
const testContext = {
session: { userId: 'test-user' },
mcpLog: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
success: jest.fn()
},
commandName: 'test-research',
outputType: 'mcp'
};
// Mock successful task discovery
mockFuzzySearch.getTaskIds.mockReturnValue(['3', '4']);
const result = await performResearch(
'Integration test query',
testOptions,
testContext,
'json',
false
);
// Verify ContextGatherer was initialized with correct tag
expect(ContextGatherer).toHaveBeenCalledWith(
'/custom/root',
'integration-test'
);
// Verify readJSON was called with correct parameters
expect(mockReadJSON).toHaveBeenCalledWith(
expect.stringContaining('tasks.json'),
'/custom/root',
'integration-test'
);
// Verify context gathering was called with combined tasks
expect(mockContextGatherer.gather).toHaveBeenCalledWith({
tasks: ['1', '2', '3', '4'],
files: ['src/main.js'],
customContext: 'Testing tag integration',
includeProjectTree: true,
format: 'research',
includeTokenCounts: true
});
// Verify AI service was called with session
expect(generateTextService).toHaveBeenCalledWith(
expect.objectContaining({
session: testContext.session,
role: 'research'
})
);
expect(result).toBeDefined();
});
});
});

View File

@@ -234,11 +234,12 @@ describe('setTaskStatus', () => {
// Act
await setTaskStatus(tasksPath, '2', 'done', {
tag: 'master',
mcpLog: { info: jest.fn() }
});
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
expect(writeJSON).toHaveBeenCalledWith(
tasksPath,
expect.objectContaining({
@@ -271,11 +272,12 @@ describe('setTaskStatus', () => {
// Act
await setTaskStatus(tasksPath, '3.1', 'done', {
tag: 'master',
mcpLog: { info: jest.fn() }
});
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
expect(writeJSON).toHaveBeenCalledWith(
tasksPath,
expect.objectContaining({
@@ -308,11 +310,12 @@ describe('setTaskStatus', () => {
// Act
await setTaskStatus(tasksPath, '1,2', 'done', {
tag: 'master',
mcpLog: { info: jest.fn() }
});
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined);
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
expect(writeJSON).toHaveBeenCalledWith(
tasksPath,
expect.objectContaining({
@@ -341,6 +344,7 @@ describe('setTaskStatus', () => {
// Act
await setTaskStatus(tasksPath, '3', 'done', {
tag: 'master',
mcpLog: { info: jest.fn() }
});
@@ -379,7 +383,10 @@ describe('setTaskStatus', () => {
// Act & Assert
await expect(
setTaskStatus(tasksPath, '99', 'done', { mcpLog: { info: jest.fn() } })
setTaskStatus(tasksPath, '99', 'done', {
tag: 'master',
mcpLog: { info: jest.fn() }
})
).rejects.toThrow('Task 99 not found');
});
@@ -418,7 +425,10 @@ describe('setTaskStatus', () => {
// Act & Assert
await expect(
setTaskStatus(tasksPath, '3.1', 'done', { mcpLog: { info: jest.fn() } })
setTaskStatus(tasksPath, '3.1', 'done', {
tag: 'master',
mcpLog: { info: jest.fn() }
})
).rejects.toThrow('has no subtasks');
});
@@ -435,7 +445,10 @@ describe('setTaskStatus', () => {
// Act & Assert
await expect(
setTaskStatus(tasksPath, '3.99', 'done', { mcpLog: { info: jest.fn() } })
setTaskStatus(tasksPath, '3.99', 'done', {
tag: 'master',
mcpLog: { info: jest.fn() }
})
).rejects.toThrow('Subtask 99 not found');
});
@@ -492,6 +505,7 @@ describe('setTaskStatus', () => {
// Act
const result = await setTaskStatus(tasksPath, taskIds, newStatus, {
tag: 'master',
mcpLog: { info: jest.fn() }
});
@@ -555,6 +569,7 @@ describe('setTaskStatus', () => {
// Act
await setTaskStatus(tasksPath, '1', 'done', {
tag: 'master',
mcpLog: { info: jest.fn() }
});

View File

@@ -0,0 +1,201 @@
import { jest } from '@jest/globals';
// Provide fs mock early so existsSync can be stubbed
jest.unstable_mockModule('fs', () => {
const mockFs = {
existsSync: jest.fn(() => true),
writeFileSync: jest.fn(),
readFileSync: jest.fn(),
unlinkSync: jest.fn()
};
return { default: mockFs, ...mockFs };
});
// --- Mock dependencies ---
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
readJSON: jest.fn(),
writeJSON: jest.fn(),
log: jest.fn(),
isSilentMode: jest.fn(() => false),
findProjectRoot: jest.fn(() => '/project'),
flattenTasksWithSubtasks: jest.fn(() => []),
truncate: jest.fn((t) => t),
isEmpty: jest.fn(() => false),
resolveEnvVariable: jest.fn(),
findTaskById: jest.fn(),
getCurrentTag: jest.fn(() => 'master')
}));
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
getStatusWithColor: jest.fn((s) => s),
startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
stopLoadingIndicator: jest.fn(),
displayAiUsageSummary: jest.fn()
}));
jest.unstable_mockModule(
'../../../../../scripts/modules/task-manager/generate-task-files.js',
() => ({
default: jest.fn().mockResolvedValue()
})
);
jest.unstable_mockModule(
'../../../../../scripts/modules/ai-services-unified.js',
() => ({
generateTextService: jest
.fn()
.mockResolvedValue({ mainResult: { content: '' }, telemetryData: {} })
})
);
jest.unstable_mockModule(
'../../../../../scripts/modules/config-manager.js',
() => ({
getDebugFlag: jest.fn(() => false)
})
);
jest.unstable_mockModule(
'../../../../../scripts/modules/prompt-manager.js',
() => ({
default: jest.fn().mockReturnValue({
loadPrompt: jest.fn().mockReturnValue('Update the subtask')
}),
getPromptManager: jest.fn().mockReturnValue({
loadPrompt: jest.fn().mockReturnValue('Update the subtask')
})
})
);
jest.unstable_mockModule(
'../../../../../scripts/modules/utils/contextGatherer.js',
() => ({
ContextGatherer: jest.fn().mockImplementation(() => ({
gather: jest.fn().mockReturnValue({
fullContext: '',
summary: ''
})
}))
})
);
// Import mocked utils to leverage mocks later
const { readJSON, log } = await import(
'../../../../../scripts/modules/utils.js'
);
// Import function under test
const { default: updateSubtaskById } = await import(
'../../../../../scripts/modules/task-manager/update-subtask-by-id.js'
);
describe('updateSubtaskById validation', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
});
test('throws error on invalid subtask id format', async () => {
await expect(
updateSubtaskById(
'tasks/tasks.json',
'invalid',
'my prompt',
false,
{
tag: 'master'
},
'json'
)
).rejects.toThrow('Invalid subtask ID format');
});
test('throws error when prompt is empty', async () => {
await expect(
updateSubtaskById(
'tasks/tasks.json',
'1.1',
'',
false,
{ tag: 'master' },
'json'
)
).rejects.toThrow('Prompt cannot be empty');
});
test('throws error if tasks file does not exist', async () => {
// Mock fs.existsSync to return false via jest.spyOn (dynamic import of fs)
const fs = await import('fs');
fs.existsSync.mockReturnValue(false);
await expect(
updateSubtaskById(
'tasks/tasks.json',
'1.1',
'prompt',
false,
{
tag: 'master'
},
'json'
)
).rejects.toThrow('Tasks file not found');
});
test('throws error if parent task missing', async () => {
// Mock existsSync true
const fs = await import('fs');
fs.existsSync.mockReturnValue(true);
// readJSON returns tasks without parent id 1
readJSON.mockReturnValue({ tag: 'master', tasks: [] });
await expect(
updateSubtaskById(
'tasks/tasks.json',
'1.1',
'prompt',
false,
{
tag: 'master'
},
'json'
)
).rejects.toThrow('Parent task with ID 1 not found');
// log called with error level
expect(log).toHaveBeenCalled();
});
test('successfully updates subtask with valid inputs', async () => {
const fs = await import('fs');
const { writeJSON } = await import(
'../../../../../scripts/modules/utils.js'
);
fs.existsSync.mockReturnValue(true);
readJSON.mockReturnValue({
tag: 'master',
tasks: [
{
id: 1,
title: 'Parent Task',
subtasks: [{ id: 1, title: 'Original subtask', status: 'pending' }]
}
]
});
// updateSubtaskById doesn't return a value on success, it just executes
await expect(
updateSubtaskById(
'tasks/tasks.json',
'1.1',
'Update this subtask',
false,
{ tag: 'master' },
'json'
)
).resolves.not.toThrow();
expect(writeJSON).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,121 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('fs', () => {
const mockFs = {
existsSync: jest.fn(() => true),
writeFileSync: jest.fn(),
readFileSync: jest.fn(),
unlinkSync: jest.fn()
};
return { default: mockFs, ...mockFs };
});
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
readJSON: jest.fn(),
writeJSON: jest.fn(),
log: jest.fn(),
isSilentMode: jest.fn(() => false),
findProjectRoot: jest.fn(() => '/project'),
flattenTasksWithSubtasks: jest.fn(() => []),
truncate: jest.fn((t) => t),
isEmpty: jest.fn(() => false),
resolveEnvVariable: jest.fn(),
findTaskById: jest.fn(),
getCurrentTag: jest.fn(() => 'master')
}));
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
getStatusWithColor: jest.fn((s) => s),
startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
stopLoadingIndicator: jest.fn(),
displayAiUsageSummary: jest.fn()
}));
jest.unstable_mockModule(
'../../../../../scripts/modules/task-manager/generate-task-files.js',
() => ({
default: jest.fn().mockResolvedValue()
})
);
jest.unstable_mockModule(
'../../../../../scripts/modules/ai-services-unified.js',
() => ({
generateTextService: jest
.fn()
.mockResolvedValue({ mainResult: { content: '{}' }, telemetryData: {} })
})
);
jest.unstable_mockModule(
'../../../../../scripts/modules/config-manager.js',
() => ({
getDebugFlag: jest.fn(() => false),
isApiKeySet: jest.fn(() => true)
})
);
const { readJSON, log } = await import(
'../../../../../scripts/modules/utils.js'
);
const { default: updateTaskById } = await import(
'../../../../../scripts/modules/task-manager/update-task-by-id.js'
);
describe('updateTaskById validation', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
});
test('throws error if prompt is empty', async () => {
await expect(
updateTaskById(
'tasks/tasks.json',
1,
'',
false,
{ tag: 'master' },
'json'
)
).rejects.toThrow('Prompt cannot be empty');
});
test('throws error if task file missing', async () => {
const fs = await import('fs');
fs.existsSync.mockReturnValue(false);
await expect(
updateTaskById(
'tasks/tasks.json',
1,
'prompt',
false,
{
tag: 'master'
},
'json'
)
).rejects.toThrow('Tasks file not found');
});
test('throws error when task ID not found', async () => {
const fs = await import('fs');
fs.existsSync.mockReturnValue(true);
readJSON.mockReturnValue({ tag: 'master', tasks: [] });
await expect(
updateTaskById(
'tasks/tasks.json',
42,
'prompt',
false,
{
tag: 'master'
},
'json'
)
).rejects.toThrow('Task with ID 42 not found');
expect(log).toHaveBeenCalled();
});
});

View File

@@ -171,7 +171,7 @@ describe('updateTasks', () => {
mockFromId,
mockPrompt,
false, // research
{ projectRoot: '/mock/path' }, // context
{ projectRoot: '/mock/path', tag: 'master' }, // context
'json' // output format
);
@@ -241,7 +241,7 @@ describe('updateTasks', () => {
mockFromId,
mockPrompt,
false,
{ projectRoot: '/mock/path' },
{ projectRoot: '/mock/path', tag: 'master' },
'json'
);