feat: add tm tags command to remote (#1386)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ralph Khreish
2025-11-12 20:08:27 +01:00
committed by GitHub
parent e59c16c707
commit 63134a222c
154 changed files with 6037 additions and 1391 deletions

View File

@@ -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
};
}

View File

@@ -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(),

View File

@@ -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');

View File

@@ -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)
})
);
});
});
});

View File

@@ -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'

View File

@@ -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();
});
});