claude-task-master/tests/integration/move-task-cross-tag.integration.test.js
Parthy 8783708e5e
Improve cross-tag move UX and safety; add MCP suggestions and CLI tips (#1135)
* docs: Auto-update and format models.md

* docs(ui,cli): remove --force from cross-tag move guidance; recommend --with-dependencies/--ignore-dependencies

- scripts/modules/ui.js: drop force tip in conflict resolution
- scripts/modules/commands.js: remove force examples from move help
- docs/cross-tag-task-movement.md: purge force mentions; add explicit with/ignore examples

* test(move): update cross-tag move tests to drop --force; assert with/ignore deps behavior and current-tag fallback

- CLI integration: remove force expectations, keep with/ignore, current-tag fallback
- Integration: remove force-path test
- Unit: add scoped traversal test, adjust fixtures to avoid id collision

* fix(move): scope dependency traversal to source tag; tag-aware ignore-dependencies filtering

- resolveDependencies: traverse only sourceTag tasks to avoid cross-tag contamination
- filter dependent IDs to those present in source tag, numeric only
- ignore-dependencies: drop deps pointing to tasks from sourceTag; keep targetTag deps

* test(mcp): ensure cross-tag move passes only with/ignore options and returns conflict suggestions

- new test: tests/unit/mcp/tools/move-task-cross-tag-options.test.js

* feat(move): add advisory tips when ignoring cross-tag dependencies; add integration test case

* feat(cli/move): improve ID collision UX for cross-tag moves\n\n- Print Next Steps tips when core returns them (e.g., after ignore-dependencies)\n- Add dedicated help block when an ID already exists in target tag

* feat(move/mcp): improve ID collision UX and suggestions\n\n- Core: include suggestions on TASK_ALREADY_EXISTS errors\n- MCP: map ID collision to TASK_ALREADY_EXISTS with suggestions\n- Tests: add MCP unit test for ID collision suggestions

* test(move/cli): print tips on ignore-dependencies results; print ID collision suggestions\n\n- CLI integration test: assert Next Steps tips printed when result.tips present\n- Integration test: assert TASK_ALREADY_EXISTS error includes suggestions payload

* chore(changeset): add changeset for cross-tag move UX improvements (CLI/MCP/core/tests)

* Add cross-tag task movement help and validation improvements

- Introduced a detailed help command for cross-tag task movement, enhancing user guidance on usage and options.
- Updated validation logic in `validateCrossTagMove` to include checks for indirect dependencies, improving accuracy in conflict detection.
- Refactored tests to ensure comprehensive coverage of new validation scenarios and error handling.
- Cleaned up documentation to reflect the latest changes in task movement functionality.

* refactor(commands): remove redundant tips printing after move operation

- Eliminated duplicate printing of tips for next steps after the move operation, streamlining the output for users.
- This change enhances clarity by ensuring tips are only displayed when relevant, improving overall user experience.

* docs(move): clarify "force move" options and improve examples

- Updated documentation to replace the deprecated "force move" concept with clear alternatives: `--with-dependencies` and `--ignore-dependencies`.
- Enhanced Scenario 3 with explicit options and improved inline comments for better readability.
- Removed confusing commented code in favor of a straightforward note in the Force Move section.

* chore: run formatter

* Update .changeset/clarify-force-move-docs.md

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

* Update docs/cross-tag-task-movement.md

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

* Update tests/unit/scripts/modules/task-manager/move-task-cross-tag.test.js

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

* test(move): add test for dependency traversal scoping with --with-dependencies option

- Introduced a new test to ensure that the dependency traversal is limited to tasks from the source tag when using the --with-dependencies option, addressing potential ID collisions across tags.

* test(move): enhance tips validation in cross-tag task movement integration test

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-28 19:02:00 +02:00

794 lines
20 KiB
JavaScript

import { jest } from '@jest/globals';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Mock dependencies before importing
const mockUtils = {
readJSON: jest.fn(),
writeJSON: jest.fn(),
findProjectRoot: jest.fn(() => '/test/project/root'),
log: jest.fn(),
setTasksForTag: jest.fn(),
traverseDependencies: jest.fn((sourceTasks, allTasks, options = {}) => {
// Mock realistic dependency behavior for testing
const { direction = 'forward' } = options;
if (direction === 'forward') {
// Return dependencies that tasks have
const result = [];
sourceTasks.forEach((task) => {
if (task.dependencies && Array.isArray(task.dependencies)) {
result.push(...task.dependencies);
}
});
return result;
} else if (direction === 'reverse') {
// Return tasks that depend on the source tasks
const sourceIds = sourceTasks.map((t) => t.id);
const normalizedSourceIds = sourceIds.map((id) => String(id));
const result = [];
allTasks.forEach((task) => {
if (task.dependencies && Array.isArray(task.dependencies)) {
const hasDependency = task.dependencies.some((depId) =>
normalizedSourceIds.includes(String(depId))
);
if (hasDependency) {
result.push(task.id);
}
}
});
return result;
}
return [];
})
};
// Mock the utils module
jest.unstable_mockModule('../../scripts/modules/utils.js', () => mockUtils);
// Mock other dependencies
jest.unstable_mockModule(
'../../scripts/modules/task-manager/is-task-dependent.js',
() => ({
default: jest.fn(() => false)
})
);
jest.unstable_mockModule('../../scripts/modules/dependency-manager.js', () => ({
findCrossTagDependencies: jest.fn(() => {
// Since dependencies can only exist within the same tag,
// this function should never find any cross-tag conflicts
return [];
}),
getDependentTaskIds: jest.fn(
(sourceTasks, crossTagDependencies, allTasks) => {
// Since we now use findAllDependenciesRecursively in the actual implementation,
// this mock simulates finding all dependencies recursively within the same tag
const dependentIds = new Set();
const processedIds = new Set();
function findAllDependencies(taskId) {
if (processedIds.has(taskId)) return;
processedIds.add(taskId);
const task = allTasks.find((t) => t.id === taskId);
if (!task || !Array.isArray(task.dependencies)) return;
task.dependencies.forEach((depId) => {
const normalizedDepId =
typeof depId === 'string' ? parseInt(depId, 10) : depId;
if (!isNaN(normalizedDepId) && normalizedDepId !== taskId) {
dependentIds.add(normalizedDepId);
findAllDependencies(normalizedDepId);
}
});
}
sourceTasks.forEach((sourceTask) => {
if (sourceTask && sourceTask.id) {
findAllDependencies(sourceTask.id);
}
});
return Array.from(dependentIds);
}
),
validateSubtaskMove: jest.fn((taskId, sourceTag, targetTag) => {
// Throw error for subtask IDs
const taskIdStr = String(taskId);
if (taskIdStr.includes('.')) {
throw new Error('Cannot move subtasks directly between tags');
}
})
}));
jest.unstable_mockModule(
'../../scripts/modules/task-manager/generate-task-files.js',
() => ({
default: jest.fn().mockResolvedValue()
})
);
// Import the modules we'll be testing after mocking
const { moveTasksBetweenTags } = await import(
'../../scripts/modules/task-manager/move-task.js'
);
describe('Cross-Tag Task Movement Integration Tests', () => {
let testDataPath;
let mockTasksData;
beforeEach(() => {
// Setup test data path
testDataPath = path.join(__dirname, 'temp-test-tasks.json');
// Initialize mock data with multiple tags
mockTasksData = {
backlog: {
tasks: [
{
id: 1,
title: 'Backlog Task 1',
description: 'A task in backlog',
status: 'pending',
dependencies: [],
priority: 'medium',
tag: 'backlog'
},
{
id: 2,
title: 'Backlog Task 2',
description: 'Another task in backlog',
status: 'pending',
dependencies: [1],
priority: 'high',
tag: 'backlog'
},
{
id: 3,
title: 'Backlog Task 3',
description: 'Independent task',
status: 'pending',
dependencies: [],
priority: 'low',
tag: 'backlog'
}
]
},
'in-progress': {
tasks: [
{
id: 4,
title: 'In Progress Task 1',
description: 'A task being worked on',
status: 'in-progress',
dependencies: [],
priority: 'high',
tag: 'in-progress'
}
]
},
done: {
tasks: [
{
id: 5,
title: 'Completed Task 1',
description: 'A completed task',
status: 'done',
dependencies: [],
priority: 'medium',
tag: 'done'
}
]
}
};
// Setup mock utils
mockUtils.readJSON.mockReturnValue(mockTasksData);
mockUtils.writeJSON.mockImplementation((path, data, projectRoot, tag) => {
// Simulate writing to file
return Promise.resolve();
});
});
afterEach(() => {
jest.clearAllMocks();
// Clean up temp file if it exists
if (fs.existsSync(testDataPath)) {
fs.unlinkSync(testDataPath);
}
});
describe('Basic Cross-Tag Movement', () => {
it('should move a single task between tags successfully', async () => {
const taskIds = [1];
const sourceTag = 'backlog';
const targetTag = 'in-progress';
const result = await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
);
// Verify readJSON was called with correct parameters
expect(mockUtils.readJSON).toHaveBeenCalledWith(
testDataPath,
'/test/project',
sourceTag
);
// Verify writeJSON was called with updated data
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
testDataPath,
expect.objectContaining({
backlog: expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 2 }),
expect.objectContaining({ id: 3 })
])
}),
'in-progress': expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 4 }),
expect.objectContaining({
id: 1,
tag: 'in-progress'
})
])
})
}),
'/test/project',
null
);
// Verify result structure
expect(result).toEqual({
message: 'Successfully moved 1 tasks from "backlog" to "in-progress"',
movedTasks: [
{
id: 1,
fromTag: 'backlog',
toTag: 'in-progress'
}
]
});
});
it('should move multiple tasks between tags', async () => {
const taskIds = [1, 3];
const sourceTag = 'backlog';
const targetTag = 'done';
const result = await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
);
// Verify the moved tasks are in the target tag
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
testDataPath,
expect.objectContaining({
backlog: expect.objectContaining({
tasks: expect.arrayContaining([expect.objectContaining({ id: 2 })])
}),
done: expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 5 }),
expect.objectContaining({
id: 1,
tag: 'done'
}),
expect.objectContaining({
id: 3,
tag: 'done'
})
])
})
}),
'/test/project',
null
);
// Verify result structure
expect(result.movedTasks).toHaveLength(2);
expect(result.movedTasks).toEqual(
expect.arrayContaining([
{ id: 1, fromTag: 'backlog', toTag: 'done' },
{ id: 3, fromTag: 'backlog', toTag: 'done' }
])
);
});
it('should create target tag if it does not exist', async () => {
const taskIds = [1];
const sourceTag = 'backlog';
const targetTag = 'new-tag';
const result = await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
);
// Verify new tag was created
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
testDataPath,
expect.objectContaining({
'new-tag': expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({
id: 1,
tag: 'new-tag'
})
])
})
}),
'/test/project',
null
);
});
});
describe('Dependency Handling', () => {
it('should move task with dependencies when withDependencies is true', async () => {
const taskIds = [2]; // Task 2 depends on Task 1
const sourceTag = 'backlog';
const targetTag = 'in-progress';
const result = await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{ withDependencies: true },
{ projectRoot: '/test/project' }
);
// Verify both task 2 and its dependency (task 1) were moved
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
testDataPath,
expect.objectContaining({
backlog: expect.objectContaining({
tasks: expect.arrayContaining([expect.objectContaining({ id: 3 })])
}),
'in-progress': expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 4 }),
expect.objectContaining({
id: 1,
tag: 'in-progress'
}),
expect.objectContaining({
id: 2,
tag: 'in-progress'
})
])
})
}),
'/test/project',
null
);
});
it('should move task normally when ignoreDependencies is true (no cross-tag conflicts to ignore)', async () => {
const taskIds = [2]; // Task 2 depends on Task 1
const sourceTag = 'backlog';
const targetTag = 'in-progress';
const result = await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{ ignoreDependencies: true },
{ projectRoot: '/test/project' }
);
// Since dependencies only exist within tags, there are no cross-tag conflicts to ignore
// Task 2 moves with its dependencies intact
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
testDataPath,
expect.objectContaining({
backlog: expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 1 }),
expect.objectContaining({ id: 3 })
])
}),
'in-progress': expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 4 }),
expect.objectContaining({
id: 2,
tag: 'in-progress',
dependencies: [1] // Dependencies preserved since no cross-tag conflicts
})
])
})
}),
'/test/project',
null
);
});
it('should provide advisory tips when ignoreDependencies breaks deps', async () => {
// Move a task that has dependencies so cross-tag conflicts would be broken
const taskIds = [2]; // backlog:2 depends on 1
const sourceTag = 'backlog';
const targetTag = 'in-progress';
// Override cross-tag detection to simulate conflicts for this case
const depManager = await import(
'../../scripts/modules/dependency-manager.js'
);
depManager.findCrossTagDependencies.mockReturnValueOnce([
{ taskId: 2, dependencyId: 1, dependencyTag: sourceTag }
]);
const result = await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{ ignoreDependencies: true },
{ projectRoot: '/test/project' }
);
expect(Array.isArray(result.tips)).toBe(true);
const expectedTips = [
'Run "task-master validate-dependencies" to check for dependency issues.',
'Run "task-master fix-dependencies" to automatically repair dangling dependencies.'
];
expect(result.tips).toHaveLength(expectedTips.length);
expect(result.tips).toEqual(expect.arrayContaining(expectedTips));
});
it('should move task without cross-tag dependency conflicts (since dependencies only exist within tags)', async () => {
const taskIds = [2]; // Task 2 depends on Task 1 (both in same tag)
const sourceTag = 'backlog';
const targetTag = 'in-progress';
// Since dependencies can only exist within the same tag,
// there should be no cross-tag conflicts
const result = await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
);
// Verify task was moved successfully (without dependencies)
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
testDataPath,
expect.objectContaining({
backlog: expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 1 }), // Task 1 stays in backlog
expect.objectContaining({ id: 3 })
])
}),
'in-progress': expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 4 }),
expect.objectContaining({
id: 2,
tag: 'in-progress'
})
])
})
}),
'/test/project',
null
);
});
});
describe('Error Handling', () => {
it('should throw error for invalid source tag', async () => {
const taskIds = [1];
const sourceTag = 'nonexistent-tag';
const targetTag = 'in-progress';
// Mock readJSON to return data without the source tag
mockUtils.readJSON.mockReturnValue({
'in-progress': { tasks: [] }
});
await expect(
moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
)
).rejects.toThrow('Source tag "nonexistent-tag" not found or invalid');
});
it('should throw error for invalid task IDs', async () => {
const taskIds = [999]; // Non-existent task ID
const sourceTag = 'backlog';
const targetTag = 'in-progress';
await expect(
moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
)
).rejects.toThrow('Task 999 not found in source tag "backlog"');
});
it('should throw error for subtask movement', async () => {
const taskIds = ['1.1']; // Subtask ID
const sourceTag = 'backlog';
const targetTag = 'in-progress';
await expect(
moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
)
).rejects.toThrow('Cannot move subtasks directly between tags');
});
it('should handle ID conflicts in target tag', async () => {
// Setup data with conflicting IDs
const conflictingData = {
backlog: {
tasks: [
{
id: 1,
title: 'Backlog Task',
tag: 'backlog'
}
]
},
'in-progress': {
tasks: [
{
id: 1, // Same ID as in backlog
title: 'In Progress Task',
tag: 'in-progress'
}
]
}
};
mockUtils.readJSON.mockReturnValue(conflictingData);
const taskIds = [1];
const sourceTag = 'backlog';
const targetTag = 'in-progress';
await expect(
moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
)
).rejects.toThrow('Task 1 already exists in target tag "in-progress"');
// Validate suggestions on the error payload
try {
await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
);
} catch (err) {
expect(err.code).toBe('TASK_ALREADY_EXISTS');
expect(Array.isArray(err.data?.suggestions)).toBe(true);
const s = (err.data?.suggestions || []).join(' ');
expect(s).toContain('different target tag');
expect(s).toContain('different set of IDs');
expect(s).toContain('within-tag');
}
});
});
describe('Edge Cases', () => {
it('should handle empty task list in source tag', async () => {
const emptyData = {
backlog: { tasks: [] },
'in-progress': { tasks: [] }
};
mockUtils.readJSON.mockReturnValue(emptyData);
const taskIds = [1];
const sourceTag = 'backlog';
const targetTag = 'in-progress';
await expect(
moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
)
).rejects.toThrow('Task 1 not found in source tag "backlog"');
});
it('should preserve task metadata during move', async () => {
const taskIds = [1];
const sourceTag = 'backlog';
const targetTag = 'in-progress';
const result = await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
);
// Verify task metadata is preserved
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
testDataPath,
expect.objectContaining({
'in-progress': expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({
id: 1,
title: 'Backlog Task 1',
description: 'A task in backlog',
status: 'pending',
priority: 'medium',
tag: 'in-progress', // Tag should be updated
metadata: expect.objectContaining({
moveHistory: expect.arrayContaining([
expect.objectContaining({
fromTag: 'backlog',
toTag: 'in-progress',
timestamp: expect.any(String)
})
])
})
})
])
})
}),
'/test/project',
null
);
});
// Note: force flag deprecated for cross-tag moves; covered by with/ignore dependencies tests
});
describe('Complex Scenarios', () => {
it('should handle complex moves without cross-tag conflicts (dependencies only within tags)', async () => {
// Setup data with valid within-tag dependencies
const validData = {
backlog: {
tasks: [
{
id: 1,
title: 'Task 1',
dependencies: [], // No dependencies
tag: 'backlog'
},
{
id: 3,
title: 'Task 3',
dependencies: [1], // Depends on Task 1 (same tag)
tag: 'backlog'
}
]
},
'in-progress': {
tasks: [
{
id: 2,
title: 'Task 2',
dependencies: [], // No dependencies
tag: 'in-progress'
}
]
}
};
mockUtils.readJSON.mockReturnValue(validData);
const taskIds = [3];
const sourceTag = 'backlog';
const targetTag = 'in-progress';
// Should succeed since there are no cross-tag conflicts
const result = await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{},
{ projectRoot: '/test/project' }
);
expect(result).toEqual({
message: 'Successfully moved 1 tasks from "backlog" to "in-progress"',
movedTasks: [{ id: 3, fromTag: 'backlog', toTag: 'in-progress' }]
});
});
it('should handle bulk move with mixed dependency scenarios', async () => {
const taskIds = [1, 2, 3]; // Multiple tasks with dependencies
const sourceTag = 'backlog';
const targetTag = 'in-progress';
const result = await moveTasksBetweenTags(
testDataPath,
taskIds,
sourceTag,
targetTag,
{ withDependencies: true },
{ projectRoot: '/test/project' }
);
// Verify all tasks were moved
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
testDataPath,
expect.objectContaining({
backlog: expect.objectContaining({
tasks: [] // All tasks should be moved
}),
'in-progress': expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 4 }),
expect.objectContaining({ id: 1, tag: 'in-progress' }),
expect.objectContaining({ id: 2, tag: 'in-progress' }),
expect.objectContaining({ id: 3, tag: 'in-progress' })
])
})
}),
'/test/project',
null
);
// Verify result structure
expect(result.movedTasks).toHaveLength(3);
expect(result.movedTasks).toEqual(
expect.arrayContaining([
{ id: 1, fromTag: 'backlog', toTag: 'in-progress' },
{ id: 2, fromTag: 'backlog', toTag: 'in-progress' },
{ id: 3, fromTag: 'backlog', toTag: 'in-progress' }
])
);
});
});
});