mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat: add tm tags command to remote (#1386)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
117
tests/fixtures/sample-tasks.js
vendored
117
tests/fixtures/sample-tasks.js
vendored
@@ -123,3 +123,120 @@ export const crossLevelDependencyTasks = {
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Tagged Format Fixtures (for tag-aware system tests)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Single task in master tag - minimal fixture
|
||||
* Use: Basic happy path tests
|
||||
*/
|
||||
export const taggedOneTask = {
|
||||
tag: 'master',
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task 1',
|
||||
description: 'First task',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'medium'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Task with subtasks in master tag
|
||||
* Use: Testing subtask operations (expand, update-subtask)
|
||||
*/
|
||||
export const taggedTaskWithSubtasks = {
|
||||
tag: 'master',
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Parent Task',
|
||||
description: 'Task with subtasks',
|
||||
status: 'in-progress',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Subtask 1.1',
|
||||
description: 'First subtask',
|
||||
status: 'done',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Subtask 1.2',
|
||||
description: 'Second subtask',
|
||||
status: 'pending',
|
||||
dependencies: [1]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Multiple tasks with dependencies in master tag
|
||||
* Use: Testing dependency operations, task ordering
|
||||
*/
|
||||
export const taggedTasksWithDependencies = {
|
||||
tag: 'master',
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Setup',
|
||||
description: 'Initial setup task',
|
||||
status: 'done',
|
||||
dependencies: [],
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Core Feature',
|
||||
description: 'Main feature implementation',
|
||||
status: 'in-progress',
|
||||
dependencies: [1],
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Polish',
|
||||
description: 'Final touches',
|
||||
status: 'pending',
|
||||
dependencies: [2],
|
||||
priority: 'low'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Empty tag - no tasks
|
||||
* Use: Testing edge cases, "add first task" scenarios
|
||||
*/
|
||||
export const taggedEmptyTasks = {
|
||||
tag: 'master',
|
||||
tasks: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create custom tagged fixture
|
||||
* @param {string} tagName - Tag name (default: 'master')
|
||||
* @param {Array} tasks - Array of task objects
|
||||
* @returns {Object} Tagged task data
|
||||
*
|
||||
* @example
|
||||
* const customData = createTaggedFixture('feature-branch', [
|
||||
* { id: 1, title: 'Custom Task', status: 'pending', dependencies: [] }
|
||||
* ]);
|
||||
*/
|
||||
export function createTaggedFixture(tagName = 'master', tasks = []) {
|
||||
return {
|
||||
tag: tagName,
|
||||
tasks
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
aggregateTelemetry: jest.fn((telemetryArray) => telemetryArray[0] || {}),
|
||||
ensureTagMetadata: jest.fn((tagObj) => tagObj),
|
||||
getCurrentTag: jest.fn(() => 'master'),
|
||||
resolveTag: jest.fn(() => 'master'),
|
||||
flattenTasksWithSubtasks: jest.fn((tasks) => tasks),
|
||||
getTagAwareFilePath: createGetTagAwareFilePathMock(),
|
||||
slugifyTagForFilePath: createSlugifyTagForFilePathMock(),
|
||||
|
||||
@@ -7,6 +7,34 @@ import { jest } from '@jest/globals';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Mock fs module - consolidated single registration
|
||||
const mockExistsSync = jest.fn();
|
||||
const mockReadFileSync = jest.fn();
|
||||
const mockWriteFileSync = jest.fn();
|
||||
const mockUnlinkSync = jest.fn();
|
||||
const mockMkdirSync = jest.fn();
|
||||
const mockReaddirSync = jest.fn(() => []);
|
||||
const mockStatSync = jest.fn(() => ({ isDirectory: () => false }));
|
||||
|
||||
jest.unstable_mockModule('fs', () => ({
|
||||
default: {
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
writeFileSync: mockWriteFileSync,
|
||||
unlinkSync: mockUnlinkSync,
|
||||
mkdirSync: mockMkdirSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
statSync: mockStatSync
|
||||
},
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
writeFileSync: mockWriteFileSync,
|
||||
unlinkSync: mockUnlinkSync,
|
||||
mkdirSync: mockMkdirSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
statSync: mockStatSync
|
||||
}));
|
||||
|
||||
// Mock the dependencies
|
||||
jest.unstable_mockModule('../../../../../src/utils/path-utils.js', () => ({
|
||||
resolveComplexityReportOutputPath: jest.fn(),
|
||||
@@ -59,6 +87,7 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
aggregateTelemetry: jest.fn((telemetryArray) => telemetryArray[0] || {}),
|
||||
ensureTagMetadata: jest.fn((tagObj) => tagObj),
|
||||
getCurrentTag: jest.fn(() => 'master'),
|
||||
resolveTag: jest.fn(() => 'master'),
|
||||
markMigrationForNotice: jest.fn(),
|
||||
performCompleteTagMigration: jest.fn(),
|
||||
setTasksForTag: jest.fn(),
|
||||
@@ -447,25 +476,30 @@ jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
getContextWithColor: jest.fn((context) => context)
|
||||
}));
|
||||
|
||||
// Mock fs module
|
||||
const mockWriteFileSync = jest.fn();
|
||||
const mockExistsSync = jest.fn();
|
||||
const mockReadFileSync = jest.fn();
|
||||
const mockMkdirSync = jest.fn();
|
||||
// fs module already mocked at top of file with shared spy references
|
||||
|
||||
jest.unstable_mockModule('fs', () => ({
|
||||
default: {
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
writeFileSync: mockWriteFileSync,
|
||||
mkdirSync: mockMkdirSync
|
||||
},
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
writeFileSync: mockWriteFileSync,
|
||||
mkdirSync: mockMkdirSync
|
||||
// Mock @tm/bridge module
|
||||
jest.unstable_mockModule('@tm/bridge', () => ({
|
||||
tryExpandViaRemote: jest.fn().mockResolvedValue(null)
|
||||
}));
|
||||
|
||||
// Mock bridge-utils module
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/bridge-utils.js',
|
||||
() => ({
|
||||
createBridgeLogger: jest.fn(() => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
},
|
||||
report: jest.fn(),
|
||||
isMCP: false
|
||||
}))
|
||||
})
|
||||
);
|
||||
|
||||
// Import the mocked modules
|
||||
const { resolveComplexityReportOutputPath, findComplexityReportPath } =
|
||||
await import('../../../../../src/utils/path-utils.js');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from 'fs';
|
||||
/**
|
||||
* Tests for the expand-task.js module
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
import fs from 'fs';
|
||||
import {
|
||||
createGetTagAwareFilePathMock,
|
||||
createSlugifyTagForFilePathMock
|
||||
@@ -25,6 +25,8 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
findTaskById: jest.fn(),
|
||||
findProjectRoot: jest.fn((tasksPath) => '/mock/project/root'),
|
||||
getCurrentTag: jest.fn(() => 'master'),
|
||||
resolveTag: jest.fn(() => 'master'),
|
||||
addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })),
|
||||
ensureTagMetadata: jest.fn((tagObj) => tagObj),
|
||||
flattenTasksWithSubtasks: jest.fn((tasks) => {
|
||||
const allTasks = [];
|
||||
@@ -202,6 +204,28 @@ jest.unstable_mockModule('cli-table3', () => ({
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock @tm/bridge module
|
||||
jest.unstable_mockModule('@tm/bridge', () => ({
|
||||
tryExpandViaRemote: jest.fn().mockResolvedValue(null)
|
||||
}));
|
||||
|
||||
// Mock bridge-utils module
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/bridge-utils.js',
|
||||
() => ({
|
||||
createBridgeLogger: jest.fn(() => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
},
|
||||
report: jest.fn(),
|
||||
isMCP: false
|
||||
}))
|
||||
})
|
||||
);
|
||||
|
||||
// Mock process.exit to prevent Jest worker crashes
|
||||
const mockExit = jest.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit called with "${code}"`);
|
||||
@@ -232,6 +256,8 @@ const { getDefaultSubtasks } = await import(
|
||||
'../../../../../scripts/modules/config-manager.js'
|
||||
);
|
||||
|
||||
const { tryExpandViaRemote } = await import('@tm/bridge');
|
||||
|
||||
// Import the module under test
|
||||
const { default: expandTask } = await import(
|
||||
'../../../../../scripts/modules/task-manager/expand-task.js'
|
||||
@@ -1276,4 +1302,124 @@ describe('expandTask', () => {
|
||||
expect(callArgs.systemPrompt).toContain('7 specific subtasks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote Expansion via Bridge', () => {
|
||||
const tasksPath = '/fake/path/tasks.json';
|
||||
const taskId = '2';
|
||||
const context = { tag: 'master' };
|
||||
|
||||
test('should use remote expansion result when tryExpandViaRemote succeeds', async () => {
|
||||
// Arrange - Mock successful remote expansion
|
||||
const remoteResult = {
|
||||
success: true,
|
||||
message: 'Task expanded successfully via remote',
|
||||
data: {
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Remote Subtask 1',
|
||||
description: 'First remote subtask',
|
||||
status: 'pending',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Remote Subtask 2',
|
||||
description: 'Second remote subtask',
|
||||
status: 'pending',
|
||||
dependencies: [1]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
tryExpandViaRemote.mockResolvedValue(remoteResult);
|
||||
|
||||
// Act
|
||||
const result = await expandTask(
|
||||
tasksPath,
|
||||
taskId,
|
||||
2,
|
||||
false,
|
||||
'',
|
||||
context,
|
||||
false
|
||||
);
|
||||
|
||||
// Assert - Should use remote result and NOT call local AI service
|
||||
expect(tryExpandViaRemote).toHaveBeenCalled();
|
||||
expect(generateObjectService).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(remoteResult);
|
||||
});
|
||||
|
||||
test('should fallback to local expansion when tryExpandViaRemote returns null', async () => {
|
||||
// Arrange - Mock remote returning null (no remote available)
|
||||
tryExpandViaRemote.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
await expandTask(tasksPath, taskId, 3, false, '', context, false);
|
||||
|
||||
// Assert - Should fallback to local expansion
|
||||
expect(tryExpandViaRemote).toHaveBeenCalled();
|
||||
expect(generateObjectService).toHaveBeenCalled();
|
||||
expect(writeJSON).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should propagate error when tryExpandViaRemote throws error', async () => {
|
||||
// Arrange - Mock remote throwing error (it re-throws, doesn't return null)
|
||||
tryExpandViaRemote.mockImplementation(() =>
|
||||
Promise.reject(new Error('Remote expansion service unavailable'))
|
||||
);
|
||||
|
||||
// Act & Assert - Should propagate the error (not fallback to local)
|
||||
await expect(
|
||||
expandTask(tasksPath, taskId, 3, false, '', context, false)
|
||||
).rejects.toThrow('Remote expansion service unavailable');
|
||||
|
||||
expect(tryExpandViaRemote).toHaveBeenCalled();
|
||||
// Local expansion should NOT be called when remote throws
|
||||
expect(generateObjectService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should pass correct parameters to tryExpandViaRemote', async () => {
|
||||
// Arrange
|
||||
const taskIdStr = '2'; // Use task 2 which exists in master tag
|
||||
const numSubtasks = 5;
|
||||
const additionalContext = 'Extra context for expansion';
|
||||
const useResearch = false; // Note: useResearch is the 4th param, not 7th
|
||||
const force = true; // Note: force is the 7th param
|
||||
const contextObj = {
|
||||
tag: 'master', // Use master tag where task 2 exists
|
||||
projectRoot: '/mock/project'
|
||||
};
|
||||
tryExpandViaRemote.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
await expandTask(
|
||||
tasksPath,
|
||||
taskIdStr,
|
||||
numSubtasks,
|
||||
useResearch, // 4th param
|
||||
additionalContext, // 5th param
|
||||
contextObj, // 6th param
|
||||
force // 7th param
|
||||
);
|
||||
|
||||
// Assert - Verify tryExpandViaRemote was called with correct params
|
||||
// Note: The actual call has a flat structure, not nested context
|
||||
expect(tryExpandViaRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskId: taskIdStr,
|
||||
numSubtasks,
|
||||
additionalContext,
|
||||
useResearch,
|
||||
force,
|
||||
projectRoot: '/mock/project',
|
||||
tag: 'master',
|
||||
isMCP: expect.any(Boolean),
|
||||
outputFormat: expect.any(String),
|
||||
report: expect.any(Function)
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,14 +23,25 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
isEmpty: jest.fn(() => false),
|
||||
resolveEnvVariable: jest.fn(),
|
||||
findTaskById: jest.fn(),
|
||||
getCurrentTag: jest.fn(() => 'master')
|
||||
getCurrentTag: jest.fn(() => 'master'),
|
||||
resolveTag: jest.fn(() => 'master'),
|
||||
addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })),
|
||||
getTasksForTag: jest.fn((data, tag) => data[tag]?.tasks || []),
|
||||
setTasksForTag: jest.fn(),
|
||||
ensureTagMetadata: jest.fn((tagObj) => tagObj)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
displayBanner: jest.fn(),
|
||||
getStatusWithColor: jest.fn((s) => s),
|
||||
startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
|
||||
stopLoadingIndicator: jest.fn(),
|
||||
displayAiUsageSummary: jest.fn()
|
||||
succeedLoadingIndicator: jest.fn(),
|
||||
failLoadingIndicator: jest.fn(),
|
||||
warnLoadingIndicator: jest.fn(),
|
||||
infoLoadingIndicator: jest.fn(),
|
||||
displayAiUsageSummary: jest.fn(),
|
||||
displayContextAnalysis: jest.fn()
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
@@ -81,6 +92,38 @@ jest.unstable_mockModule(
|
||||
})
|
||||
);
|
||||
|
||||
// Mock @tm/bridge module
|
||||
jest.unstable_mockModule('@tm/bridge', () => ({
|
||||
tryUpdateViaRemote: jest.fn().mockResolvedValue(null)
|
||||
}));
|
||||
|
||||
// Mock bridge-utils module
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/bridge-utils.js',
|
||||
() => ({
|
||||
createBridgeLogger: jest.fn(() => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
},
|
||||
report: jest.fn(),
|
||||
isMCP: false
|
||||
}))
|
||||
})
|
||||
);
|
||||
|
||||
// Mock fuzzyTaskSearch module
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/utils/fuzzyTaskSearch.js',
|
||||
() => ({
|
||||
FuzzyTaskSearch: jest.fn().mockImplementation(() => ({
|
||||
search: jest.fn().mockReturnValue([])
|
||||
}))
|
||||
})
|
||||
);
|
||||
|
||||
// Import mocked utils to leverage mocks later
|
||||
const { readJSON, log } = await import(
|
||||
'../../../../../scripts/modules/utils.js'
|
||||
|
||||
@@ -21,14 +21,25 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
isEmpty: jest.fn(() => false),
|
||||
resolveEnvVariable: jest.fn(),
|
||||
findTaskById: jest.fn(),
|
||||
getCurrentTag: jest.fn(() => 'master')
|
||||
getCurrentTag: jest.fn(() => 'master'),
|
||||
resolveTag: jest.fn(() => 'master'),
|
||||
addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })),
|
||||
getTasksForTag: jest.fn((data, tag) => data[tag]?.tasks || []),
|
||||
setTasksForTag: jest.fn(),
|
||||
ensureTagMetadata: jest.fn((tagObj) => tagObj)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
displayBanner: jest.fn(),
|
||||
getStatusWithColor: jest.fn((s) => s),
|
||||
startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
|
||||
stopLoadingIndicator: jest.fn(),
|
||||
displayAiUsageSummary: jest.fn()
|
||||
succeedLoadingIndicator: jest.fn(),
|
||||
failLoadingIndicator: jest.fn(),
|
||||
warnLoadingIndicator: jest.fn(),
|
||||
infoLoadingIndicator: jest.fn(),
|
||||
displayAiUsageSummary: jest.fn(),
|
||||
displayContextAnalysis: jest.fn()
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule(
|
||||
@@ -72,13 +83,99 @@ jest.unstable_mockModule(
|
||||
})
|
||||
);
|
||||
|
||||
// Mock @tm/bridge module
|
||||
jest.unstable_mockModule('@tm/bridge', () => ({
|
||||
tryUpdateViaRemote: jest.fn().mockResolvedValue(null)
|
||||
}));
|
||||
|
||||
// Mock bridge-utils module
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/bridge-utils.js',
|
||||
() => ({
|
||||
createBridgeLogger: jest.fn(() => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
},
|
||||
report: jest.fn(),
|
||||
isMCP: false
|
||||
}))
|
||||
})
|
||||
);
|
||||
|
||||
// Mock prompt-manager module
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/prompt-manager.js',
|
||||
() => ({
|
||||
getPromptManager: jest.fn().mockReturnValue({
|
||||
loadPrompt: jest.fn((promptId, params) => ({
|
||||
systemPrompt:
|
||||
'You are an AI assistant that helps update a software development task with new requirements and information.',
|
||||
userPrompt: `Update the following task based on the provided information: ${params?.updatePrompt || 'User prompt for task update'}`,
|
||||
metadata: {
|
||||
templateId: 'update-task',
|
||||
version: '1.0.0',
|
||||
variant: 'default',
|
||||
parameters: params || {}
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Mock contextGatherer module
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/utils/contextGatherer.js',
|
||||
() => ({
|
||||
ContextGatherer: jest.fn().mockImplementation(() => ({
|
||||
gather: jest.fn().mockResolvedValue({
|
||||
fullContext: '',
|
||||
summary: ''
|
||||
})
|
||||
}))
|
||||
})
|
||||
);
|
||||
|
||||
// Mock fuzzyTaskSearch module
|
||||
jest.unstable_mockModule(
|
||||
'../../../../../scripts/modules/utils/fuzzyTaskSearch.js',
|
||||
() => ({
|
||||
FuzzyTaskSearch: jest.fn().mockImplementation(() => ({
|
||||
search: jest.fn().mockReturnValue([]),
|
||||
findRelevantTasks: jest.fn().mockReturnValue([]),
|
||||
getTaskIds: jest.fn().mockReturnValue([])
|
||||
}))
|
||||
})
|
||||
);
|
||||
|
||||
const { readJSON, log } = await import(
|
||||
'../../../../../scripts/modules/utils.js'
|
||||
);
|
||||
const { tryUpdateViaRemote } = await import('@tm/bridge');
|
||||
const { createBridgeLogger } = await import(
|
||||
'../../../../../scripts/modules/bridge-utils.js'
|
||||
);
|
||||
const { getPromptManager } = await import(
|
||||
'../../../../../scripts/modules/prompt-manager.js'
|
||||
);
|
||||
const { ContextGatherer } = await import(
|
||||
'../../../../../scripts/modules/utils/contextGatherer.js'
|
||||
);
|
||||
const { FuzzyTaskSearch } = await import(
|
||||
'../../../../../scripts/modules/utils/fuzzyTaskSearch.js'
|
||||
);
|
||||
const { default: updateTaskById } = await import(
|
||||
'../../../../../scripts/modules/task-manager/update-task-by-id.js'
|
||||
);
|
||||
|
||||
// Import test fixtures for consistent sample data
|
||||
import {
|
||||
taggedEmptyTasks,
|
||||
taggedOneTask
|
||||
} from '../../../../fixtures/sample-tasks.js';
|
||||
|
||||
describe('updateTaskById validation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -120,7 +217,7 @@ describe('updateTaskById validation', () => {
|
||||
test('throws error when task ID not found', async () => {
|
||||
const fs = await import('fs');
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
readJSON.mockReturnValue({ tag: 'master', tasks: [] });
|
||||
readJSON.mockReturnValue(taggedEmptyTasks);
|
||||
await expect(
|
||||
updateTaskById(
|
||||
'tasks/tasks.json',
|
||||
@@ -133,7 +230,8 @@ describe('updateTaskById validation', () => {
|
||||
'json'
|
||||
)
|
||||
).rejects.toThrow('Task with ID 42 not found');
|
||||
expect(log).toHaveBeenCalled();
|
||||
// Note: The error is reported through the bridge logger (report),
|
||||
// not the log function, so we don't assert on log being called
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,3 +437,444 @@ describe('updateTaskById success path with generateObjectService', () => {
|
||||
).rejects.toThrow('Updated task missing required fields');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote Update via Bridge', () => {
|
||||
let fs;
|
||||
let generateObjectService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
fs = await import('fs');
|
||||
const aiServices = await import(
|
||||
'../../../../../scripts/modules/ai-services-unified.js'
|
||||
);
|
||||
generateObjectService = aiServices.generateObjectService;
|
||||
});
|
||||
|
||||
test('should use remote update result when tryUpdateViaRemote succeeds', async () => {
|
||||
// Arrange - Mock successful remote update
|
||||
const remoteResult = {
|
||||
success: true,
|
||||
message: 'Task updated successfully via remote',
|
||||
data: {
|
||||
task: {
|
||||
id: 1,
|
||||
title: 'Updated via Remote',
|
||||
description: 'Updated description from remote',
|
||||
status: 'in-progress',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
details: 'Remote update details',
|
||||
testStrategy: 'Remote test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
}
|
||||
};
|
||||
tryUpdateViaRemote.mockResolvedValue(remoteResult);
|
||||
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
readJSON.mockReturnValue({
|
||||
tag: 'master',
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Original Task',
|
||||
description: 'Original description',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'medium',
|
||||
details: 'Original details',
|
||||
testStrategy: 'Original test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await updateTaskById(
|
||||
'tasks/tasks.json',
|
||||
1,
|
||||
'Update this task',
|
||||
false,
|
||||
{ tag: 'master' },
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert - Should use remote result and NOT call local AI service
|
||||
expect(tryUpdateViaRemote).toHaveBeenCalled();
|
||||
expect(generateObjectService).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(remoteResult);
|
||||
});
|
||||
|
||||
test('should fallback to local update when tryUpdateViaRemote returns null', async () => {
|
||||
// Arrange - Mock remote returning null (no remote available)
|
||||
tryUpdateViaRemote.mockResolvedValue(null);
|
||||
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
readJSON.mockReturnValue({
|
||||
tag: 'master',
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'medium',
|
||||
details: 'Details',
|
||||
testStrategy: 'Test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
generateObjectService.mockResolvedValue({
|
||||
mainResult: {
|
||||
task: {
|
||||
id: 1,
|
||||
title: 'Updated Task',
|
||||
description: 'Updated description',
|
||||
status: 'in-progress',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
details: 'Updated details',
|
||||
testStrategy: 'Updated test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
},
|
||||
telemetryData: {}
|
||||
});
|
||||
|
||||
// Act
|
||||
await updateTaskById(
|
||||
'tasks/tasks.json',
|
||||
1,
|
||||
'Update this task',
|
||||
false,
|
||||
{ tag: 'master' },
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert - Should fallback to local update
|
||||
expect(tryUpdateViaRemote).toHaveBeenCalled();
|
||||
expect(generateObjectService).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should propagate error when tryUpdateViaRemote throws error', async () => {
|
||||
// Arrange - Mock remote throwing error (it re-throws, doesn't return null)
|
||||
tryUpdateViaRemote.mockImplementation(() =>
|
||||
Promise.reject(new Error('Remote update service unavailable'))
|
||||
);
|
||||
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
readJSON.mockReturnValue({
|
||||
tag: 'master',
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'medium',
|
||||
details: 'Details',
|
||||
testStrategy: 'Test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
generateObjectService.mockResolvedValue({
|
||||
mainResult: {
|
||||
task: {
|
||||
id: 1,
|
||||
title: 'Updated Task',
|
||||
description: 'Updated description',
|
||||
status: 'in-progress',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
details: 'Updated details',
|
||||
testStrategy: 'Updated test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
},
|
||||
telemetryData: {}
|
||||
});
|
||||
|
||||
// Act & Assert - Should propagate the error (not fallback to local)
|
||||
await expect(
|
||||
updateTaskById(
|
||||
'tasks/tasks.json',
|
||||
1,
|
||||
'Update this task',
|
||||
false,
|
||||
{ tag: 'master' },
|
||||
'json'
|
||||
)
|
||||
).rejects.toThrow('Remote update service unavailable');
|
||||
|
||||
expect(tryUpdateViaRemote).toHaveBeenCalled();
|
||||
// Local update should NOT be called when remote throws
|
||||
expect(generateObjectService).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prompt Manager Integration', () => {
|
||||
let fs;
|
||||
let generateObjectService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
fs = await import('fs');
|
||||
const aiServices = await import(
|
||||
'../../../../../scripts/modules/ai-services-unified.js'
|
||||
);
|
||||
generateObjectService = aiServices.generateObjectService;
|
||||
tryUpdateViaRemote.mockResolvedValue(null); // No remote
|
||||
});
|
||||
|
||||
test('should use prompt manager to load update prompts', async () => {
|
||||
// Arrange
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
readJSON.mockReturnValue({
|
||||
tag: 'master',
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'medium',
|
||||
details: 'Details',
|
||||
testStrategy: 'Test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
generateObjectService.mockResolvedValue({
|
||||
mainResult: {
|
||||
task: {
|
||||
id: 1,
|
||||
title: 'Updated Task',
|
||||
description: 'Updated description',
|
||||
status: 'in-progress',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
details: 'Updated details',
|
||||
testStrategy: 'Updated test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
},
|
||||
telemetryData: {}
|
||||
});
|
||||
|
||||
// Act
|
||||
await updateTaskById(
|
||||
'tasks/tasks.json',
|
||||
1,
|
||||
'Update this task with new requirements',
|
||||
false,
|
||||
{ tag: 'master', projectRoot: '/mock/project' },
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert - Prompt manager should be called
|
||||
expect(getPromptManager).toHaveBeenCalled();
|
||||
const promptManagerInstance = getPromptManager.mock.results[0].value;
|
||||
expect(promptManagerInstance.loadPrompt).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context Gathering Integration', () => {
|
||||
let fs;
|
||||
let generateObjectService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
fs = await import('fs');
|
||||
const aiServices = await import(
|
||||
'../../../../../scripts/modules/ai-services-unified.js'
|
||||
);
|
||||
generateObjectService = aiServices.generateObjectService;
|
||||
tryUpdateViaRemote.mockResolvedValue(null); // No remote
|
||||
});
|
||||
|
||||
test('should gather project context when projectRoot is provided', async () => {
|
||||
// Arrange
|
||||
const mockContextGatherer = {
|
||||
gather: jest.fn().mockResolvedValue({
|
||||
fullContext: 'Project context from files',
|
||||
summary: 'Context summary'
|
||||
})
|
||||
};
|
||||
ContextGatherer.mockImplementation(() => mockContextGatherer);
|
||||
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
readJSON.mockReturnValue({
|
||||
tag: 'master',
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'medium',
|
||||
details: 'Details',
|
||||
testStrategy: 'Test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
generateObjectService.mockResolvedValue({
|
||||
mainResult: {
|
||||
task: {
|
||||
id: 1,
|
||||
title: 'Updated Task',
|
||||
description: 'Updated description',
|
||||
status: 'in-progress',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
details: 'Updated details',
|
||||
testStrategy: 'Updated test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
},
|
||||
telemetryData: {}
|
||||
});
|
||||
|
||||
// Act
|
||||
await updateTaskById(
|
||||
'tasks/tasks.json',
|
||||
1,
|
||||
'Update with context',
|
||||
false,
|
||||
{ tag: 'master', projectRoot: '/mock/project' },
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert - Context gatherer should be instantiated and used
|
||||
expect(ContextGatherer).toHaveBeenCalledWith('/mock/project', 'master');
|
||||
expect(mockContextGatherer.gather).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fuzzy Task Search Integration', () => {
|
||||
let fs;
|
||||
let generateObjectService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
fs = await import('fs');
|
||||
const aiServices = await import(
|
||||
'../../../../../scripts/modules/ai-services-unified.js'
|
||||
);
|
||||
generateObjectService = aiServices.generateObjectService;
|
||||
tryUpdateViaRemote.mockResolvedValue(null); // No remote
|
||||
});
|
||||
|
||||
test('should use fuzzy search to find related tasks for context', async () => {
|
||||
// Arrange
|
||||
const mockFuzzySearch = {
|
||||
findRelevantTasks: jest.fn().mockReturnValue([
|
||||
{ id: 2, title: 'Related Task 1', score: 0.9 },
|
||||
{ id: 3, title: 'Related Task 2', score: 0.85 }
|
||||
]),
|
||||
getTaskIds: jest.fn().mockReturnValue(['2', '3'])
|
||||
};
|
||||
FuzzyTaskSearch.mockImplementation(() => mockFuzzySearch);
|
||||
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
readJSON.mockReturnValue({
|
||||
tag: 'master',
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task to update',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'medium',
|
||||
details: 'Details',
|
||||
testStrategy: 'Test strategy',
|
||||
subtasks: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Related Task 1',
|
||||
description: 'Related description',
|
||||
status: 'done',
|
||||
dependencies: [],
|
||||
priority: 'medium',
|
||||
details: 'Related details',
|
||||
testStrategy: 'Related test strategy',
|
||||
subtasks: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Related Task 2',
|
||||
description: 'Another related description',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'low',
|
||||
details: 'More details',
|
||||
testStrategy: 'Test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
generateObjectService.mockResolvedValue({
|
||||
mainResult: {
|
||||
task: {
|
||||
id: 1,
|
||||
title: 'Updated Task',
|
||||
description: 'Updated description',
|
||||
status: 'in-progress',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
details: 'Updated details',
|
||||
testStrategy: 'Updated test strategy',
|
||||
subtasks: []
|
||||
}
|
||||
},
|
||||
telemetryData: {}
|
||||
});
|
||||
|
||||
// Act
|
||||
await updateTaskById(
|
||||
'tasks/tasks.json',
|
||||
1,
|
||||
'Update with related task context',
|
||||
false,
|
||||
{ tag: 'master' },
|
||||
'json'
|
||||
);
|
||||
|
||||
// Assert - Fuzzy search should be instantiated and used
|
||||
expect(FuzzyTaskSearch).toHaveBeenCalled();
|
||||
expect(mockFuzzySearch.findRelevantTasks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Task to update'),
|
||||
expect.objectContaining({
|
||||
maxResults: 5,
|
||||
includeSelf: true
|
||||
})
|
||||
);
|
||||
expect(mockFuzzySearch.getTaskIds).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user