mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
style: format metadata test files and update-subtask script
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* @fileoverview Integration tests for metadata preservation across AI operations
|
||||
*
|
||||
* Tests that user-defined metadata survives all AI operations including:
|
||||
* - update-task: AI updates task fields but doesn't include metadata in response
|
||||
* - expand-task: AI generates subtasks but parent task metadata is preserved
|
||||
* - parse-prd: AI generates new tasks without metadata field
|
||||
*
|
||||
* Key insight: AI schemas (base-schemas.js) intentionally EXCLUDE the metadata field.
|
||||
* This means AI responses never include metadata, and the spread operator in
|
||||
* storage/service layers preserves existing metadata during updates.
|
||||
*
|
||||
* These tests simulate what happens when AI operations update tasks - the AI
|
||||
* returns a task object without a metadata field, and we verify that the
|
||||
* existing metadata is preserved through the storage layer.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { FileStorage } from '../../../src/modules/storage/adapters/file-storage/file-storage.js';
|
||||
import type { Task, Subtask } from '../../../src/common/types/index.js';
|
||||
|
||||
/**
|
||||
* Creates a minimal valid task for testing
|
||||
*/
|
||||
function createTask(id: string, overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: `Description for task ${id}`,
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: '',
|
||||
testStrategy: '',
|
||||
subtasks: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a realistic metadata object like external integrations would produce
|
||||
*/
|
||||
function createRealisticMetadata(): Record<string, unknown> {
|
||||
return {
|
||||
uuid: '550e8400-e29b-41d4-a716-446655440000',
|
||||
githubIssue: 42,
|
||||
sprint: 'Q1-S3',
|
||||
jira: {
|
||||
key: 'PROJ-123',
|
||||
type: 'story',
|
||||
epic: 'EPIC-45'
|
||||
},
|
||||
importedAt: '2024-01-15T10:30:00Z',
|
||||
source: 'github-sync',
|
||||
labels: ['frontend', 'refactor', 'high-priority']
|
||||
};
|
||||
}
|
||||
|
||||
describe('AI Operation Metadata Preservation - Integration Tests', () => {
|
||||
let tempDir: string;
|
||||
let storage: FileStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temp directory for each test
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taskmaster-ai-test-'));
|
||||
// Create .taskmaster/tasks directory structure
|
||||
const taskmasterDir = path.join(tempDir, '.taskmaster', 'tasks');
|
||||
fs.mkdirSync(taskmasterDir, { recursive: true });
|
||||
storage = new FileStorage(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('update-task operation simulation', () => {
|
||||
it('should preserve metadata when AI returns task without metadata field', async () => {
|
||||
// Setup: Task with user metadata
|
||||
const originalMetadata = createRealisticMetadata();
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
title: 'Original Title',
|
||||
description: 'Original description',
|
||||
metadata: originalMetadata
|
||||
})
|
||||
];
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Simulate AI response: AI updates title/description but doesn't include metadata
|
||||
// This is the exact pattern from update-task-by-id.js
|
||||
const aiGeneratedUpdate: Partial<Task> = {
|
||||
title: 'AI Updated Title',
|
||||
description: 'AI refined description with more detail',
|
||||
details: 'AI generated implementation details',
|
||||
testStrategy: 'AI suggested test approach'
|
||||
// Note: NO metadata field - AI schemas don't include it
|
||||
};
|
||||
|
||||
// Apply update through FileStorage (simulating what AI operations do)
|
||||
await storage.updateTask('1', aiGeneratedUpdate);
|
||||
|
||||
// Verify: AI fields updated, metadata preserved
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].title).toBe('AI Updated Title');
|
||||
expect(loadedTasks[0].description).toBe(
|
||||
'AI refined description with more detail'
|
||||
);
|
||||
expect(loadedTasks[0].details).toBe(
|
||||
'AI generated implementation details'
|
||||
);
|
||||
expect(loadedTasks[0].testStrategy).toBe('AI suggested test approach');
|
||||
// Critical: metadata must be preserved
|
||||
expect(loadedTasks[0].metadata).toEqual(originalMetadata);
|
||||
});
|
||||
|
||||
it('should preserve metadata through multiple sequential AI updates', async () => {
|
||||
const metadata = { externalId: 'EXT-999', version: 1 };
|
||||
const tasks: Task[] = [createTask('1', { metadata })];
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// First AI update
|
||||
await storage.updateTask('1', { title: 'First AI Update' });
|
||||
|
||||
// Second AI update
|
||||
await storage.updateTask('1', {
|
||||
description: 'Second AI Update adds details'
|
||||
});
|
||||
|
||||
// Third AI update
|
||||
await storage.updateTask('1', { priority: 'high' });
|
||||
|
||||
// Verify metadata survived all updates
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].title).toBe('First AI Update');
|
||||
expect(loadedTasks[0].description).toBe('Second AI Update adds details');
|
||||
expect(loadedTasks[0].priority).toBe('high');
|
||||
expect(loadedTasks[0].metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should preserve realistic integration metadata during AI operations', async () => {
|
||||
const realisticMetadata = createRealisticMetadata();
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
title: 'Sync from GitHub',
|
||||
metadata: realisticMetadata
|
||||
})
|
||||
];
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// AI enriches the task
|
||||
await storage.updateTask('1', {
|
||||
title: 'Implement user authentication',
|
||||
description: 'Set up JWT-based authentication system',
|
||||
details: `
|
||||
## Implementation Plan
|
||||
1. Create auth middleware
|
||||
2. Implement JWT token generation
|
||||
3. Add refresh token logic
|
||||
4. Set up protected routes
|
||||
`.trim(),
|
||||
testStrategy:
|
||||
'Unit tests for JWT functions, integration tests for auth flow'
|
||||
});
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
// All AI updates applied
|
||||
expect(loadedTasks[0].title).toBe('Implement user authentication');
|
||||
expect(loadedTasks[0].details).toContain('Implementation Plan');
|
||||
// Realistic metadata preserved with all its nested structure
|
||||
expect(loadedTasks[0].metadata).toEqual(realisticMetadata);
|
||||
expect(
|
||||
(loadedTasks[0].metadata as Record<string, unknown>).githubIssue
|
||||
).toBe(42);
|
||||
expect(
|
||||
(
|
||||
(loadedTasks[0].metadata as Record<string, unknown>).jira as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
).key
|
||||
).toBe('PROJ-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expand-task operation simulation', () => {
|
||||
it('should preserve parent task metadata when adding AI-generated subtasks', async () => {
|
||||
const parentMetadata = { tracked: true, source: 'import' };
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
metadata: parentMetadata,
|
||||
subtasks: []
|
||||
})
|
||||
];
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Simulate expand-task: AI generates subtasks (without metadata)
|
||||
const aiGeneratedSubtasks: Subtask[] = [
|
||||
{
|
||||
id: 1,
|
||||
parentId: '1',
|
||||
title: 'AI Subtask 1',
|
||||
description: 'First step generated by AI',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: 'Implementation details',
|
||||
testStrategy: 'Test approach'
|
||||
// No metadata - AI doesn't generate it
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
parentId: '1',
|
||||
title: 'AI Subtask 2',
|
||||
description: 'Second step generated by AI',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [1],
|
||||
details: 'More details',
|
||||
testStrategy: 'More tests'
|
||||
}
|
||||
];
|
||||
|
||||
// Apply subtasks update
|
||||
await storage.updateTask('1', { subtasks: aiGeneratedSubtasks });
|
||||
|
||||
// Verify parent metadata preserved
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].metadata).toEqual(parentMetadata);
|
||||
expect(loadedTasks[0].subtasks).toHaveLength(2);
|
||||
// Subtasks don't inherit parent metadata
|
||||
expect(loadedTasks[0].subtasks[0].metadata).toBeUndefined();
|
||||
expect(loadedTasks[0].subtasks[1].metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve subtask metadata when parent is updated', async () => {
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
metadata: { parentMeta: 'parent-value' },
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
parentId: '1',
|
||||
title: 'Subtask with metadata',
|
||||
description: 'Has its own metadata',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: '',
|
||||
testStrategy: '',
|
||||
metadata: { subtaskMeta: 'subtask-value' }
|
||||
}
|
||||
]
|
||||
})
|
||||
];
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// AI updates parent task (not subtasks)
|
||||
await storage.updateTask('1', {
|
||||
title: 'Parent Updated by AI',
|
||||
description: 'New description'
|
||||
});
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
// Parent metadata preserved
|
||||
expect(loadedTasks[0].metadata).toEqual({ parentMeta: 'parent-value' });
|
||||
// Subtask and its metadata preserved
|
||||
expect(loadedTasks[0].subtasks[0].metadata).toEqual({
|
||||
subtaskMeta: 'subtask-value'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse-prd operation simulation', () => {
|
||||
it('should generate tasks without metadata field (as AI would)', async () => {
|
||||
// Simulate parse-prd output: AI generates tasks without metadata
|
||||
const aiGeneratedTasks: Task[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Set up project structure',
|
||||
description: 'Initialize the project with proper folder structure',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Create src/, tests/, docs/ directories',
|
||||
testStrategy: 'Verify directories exist',
|
||||
subtasks: []
|
||||
// No metadata - AI doesn't generate it
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Implement core functionality',
|
||||
description: 'Build the main features',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
dependencies: ['1'],
|
||||
details: 'Implement main modules',
|
||||
testStrategy: 'Unit tests for each module',
|
||||
subtasks: []
|
||||
}
|
||||
];
|
||||
|
||||
await storage.saveTasks(aiGeneratedTasks);
|
||||
|
||||
// Verify tasks saved correctly without metadata
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks).toHaveLength(2);
|
||||
expect(loadedTasks[0].metadata).toBeUndefined();
|
||||
expect(loadedTasks[1].metadata).toBeUndefined();
|
||||
// Later, user can add metadata
|
||||
await storage.updateTask('1', {
|
||||
metadata: { externalId: 'USER-ADDED-123' }
|
||||
});
|
||||
const updatedTasks = await storage.loadTasks();
|
||||
expect(updatedTasks[0].metadata).toEqual({
|
||||
externalId: 'USER-ADDED-123'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update-subtask operation simulation', () => {
|
||||
it('should preserve subtask metadata when appending info', async () => {
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
parentId: '1',
|
||||
title: 'Tracked subtask',
|
||||
description: 'Has metadata from import',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: 'Initial details',
|
||||
testStrategy: '',
|
||||
metadata: { importedFrom: 'jira', ticketId: 'JIRA-456' }
|
||||
}
|
||||
]
|
||||
})
|
||||
];
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Update subtask details (like update-subtask command does)
|
||||
const updatedSubtask: Subtask = {
|
||||
id: 1,
|
||||
parentId: '1',
|
||||
title: 'Tracked subtask',
|
||||
description: 'Has metadata from import',
|
||||
status: 'in-progress',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details:
|
||||
'Initial details\n\n<info added on 2024-01-20T10:00:00Z>\nImplementation notes from AI\n</info added on 2024-01-20T10:00:00Z>',
|
||||
testStrategy: 'AI suggested tests',
|
||||
metadata: { importedFrom: 'jira', ticketId: 'JIRA-456' }
|
||||
};
|
||||
|
||||
await storage.updateTask('1', { subtasks: [updatedSubtask] });
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].subtasks[0].metadata).toEqual({
|
||||
importedFrom: 'jira',
|
||||
ticketId: 'JIRA-456'
|
||||
});
|
||||
expect(loadedTasks[0].subtasks[0].details).toContain(
|
||||
'Implementation notes from AI'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed AI and storage metadata coexistence', () => {
|
||||
it('should preserve user metadata alongside AI-generated task fields', async () => {
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
// AI-generated fields
|
||||
relevantFiles: [
|
||||
{
|
||||
path: 'src/auth.ts',
|
||||
description: 'Auth module',
|
||||
action: 'modify'
|
||||
}
|
||||
],
|
||||
category: 'development',
|
||||
skills: ['TypeScript', 'Security'],
|
||||
acceptanceCriteria: ['Tests pass', 'Code reviewed'],
|
||||
// User-defined metadata (from import)
|
||||
metadata: {
|
||||
externalId: 'JIRA-789',
|
||||
storyPoints: 5,
|
||||
sprint: 'Sprint 10'
|
||||
}
|
||||
})
|
||||
];
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// AI updates the task (doesn't touch metadata)
|
||||
await storage.updateTask('1', {
|
||||
relevantFiles: [
|
||||
{ path: 'src/auth.ts', description: 'Auth module', action: 'modify' },
|
||||
{
|
||||
path: 'src/middleware.ts',
|
||||
description: 'Added middleware',
|
||||
action: 'create'
|
||||
}
|
||||
],
|
||||
skills: ['TypeScript', 'Security', 'JWT']
|
||||
});
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
// AI fields updated
|
||||
expect(loadedTasks[0].relevantFiles).toHaveLength(2);
|
||||
expect(loadedTasks[0].skills).toContain('JWT');
|
||||
// User metadata preserved
|
||||
expect(loadedTasks[0].metadata).toEqual({
|
||||
externalId: 'JIRA-789',
|
||||
storyPoints: 5,
|
||||
sprint: 'Sprint 10'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases for AI operations', () => {
|
||||
it('should handle task with only metadata being updated by AI', async () => {
|
||||
// Task has ONLY metadata set (sparse task)
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
metadata: { sparse: true, tracking: 'minimal' }
|
||||
})
|
||||
];
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// AI fills in all the other fields
|
||||
await storage.updateTask('1', {
|
||||
title: 'AI Generated Title',
|
||||
description: 'AI Generated Description',
|
||||
details: 'AI Generated Details',
|
||||
testStrategy: 'AI Generated Test Strategy',
|
||||
priority: 'high'
|
||||
});
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].title).toBe('AI Generated Title');
|
||||
expect(loadedTasks[0].priority).toBe('high');
|
||||
expect(loadedTasks[0].metadata).toEqual({
|
||||
sparse: true,
|
||||
tracking: 'minimal'
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve deeply nested metadata through AI operations', async () => {
|
||||
const deepMetadata = {
|
||||
integration: {
|
||||
source: {
|
||||
type: 'github',
|
||||
repo: {
|
||||
owner: 'org',
|
||||
name: 'repo',
|
||||
issue: {
|
||||
number: 123,
|
||||
labels: ['bug', 'priority-1']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const tasks: Task[] = [createTask('1', { metadata: deepMetadata })];
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Multiple AI operations
|
||||
await storage.updateTask('1', { title: 'Update 1' });
|
||||
await storage.updateTask('1', { description: 'Update 2' });
|
||||
await storage.updateTask('1', { status: 'in-progress' });
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].metadata).toEqual(deepMetadata);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @fileoverview Integration tests for MCP tool metadata updates
|
||||
*
|
||||
* Tests that metadata updates via update-task and update-subtask MCP tools
|
||||
* work correctly with the TASK_MASTER_ALLOW_METADATA_UPDATES flag.
|
||||
*
|
||||
* These tests validate the metadata flow from MCP tool layer through
|
||||
* direct functions to the legacy scripts and storage layer.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('MCP Tool Metadata Updates - Integration Tests', () => {
|
||||
let tempDir: string;
|
||||
let tasksJsonPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temp directory for each test
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taskmaster-mcp-test-'));
|
||||
// Create .taskmaster/tasks directory structure
|
||||
const taskmasterDir = path.join(tempDir, '.taskmaster', 'tasks');
|
||||
fs.mkdirSync(taskmasterDir, { recursive: true });
|
||||
tasksJsonPath = path.join(taskmasterDir, 'tasks.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
// Reset env vars
|
||||
delete process.env.TASK_MASTER_ALLOW_METADATA_UPDATES;
|
||||
});
|
||||
|
||||
describe('metadata JSON validation', () => {
|
||||
it('should validate metadata is a valid JSON object', () => {
|
||||
// Test valid JSON objects
|
||||
const validMetadata = [
|
||||
'{"key": "value"}',
|
||||
'{"githubIssue": 42, "sprint": "Q1"}',
|
||||
'{"nested": {"deep": true}}'
|
||||
];
|
||||
|
||||
for (const meta of validMetadata) {
|
||||
const parsed = JSON.parse(meta);
|
||||
expect(typeof parsed).toBe('object');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(Array.isArray(parsed)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid metadata formats', () => {
|
||||
const invalidMetadata = [
|
||||
'"string"', // Just a string
|
||||
'123', // Just a number
|
||||
'true', // Just a boolean
|
||||
'null', // Null
|
||||
'[1, 2, 3]' // Array
|
||||
];
|
||||
|
||||
for (const meta of invalidMetadata) {
|
||||
const parsed = JSON.parse(meta);
|
||||
const isValidObject =
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed);
|
||||
expect(isValidObject).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid JSON strings', () => {
|
||||
const invalidJson = [
|
||||
'{key: "value"}', // Missing quotes
|
||||
"{'key': 'value'}", // Single quotes
|
||||
'{"key": }' // Incomplete
|
||||
];
|
||||
|
||||
for (const json of invalidJson) {
|
||||
expect(() => JSON.parse(json)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('TASK_MASTER_ALLOW_METADATA_UPDATES flag', () => {
|
||||
it('should block metadata updates when flag is not set', () => {
|
||||
delete process.env.TASK_MASTER_ALLOW_METADATA_UPDATES;
|
||||
const allowMetadataUpdates =
|
||||
process.env.TASK_MASTER_ALLOW_METADATA_UPDATES === 'true';
|
||||
expect(allowMetadataUpdates).toBe(false);
|
||||
});
|
||||
|
||||
it('should block metadata updates when flag is set to false', () => {
|
||||
process.env.TASK_MASTER_ALLOW_METADATA_UPDATES = 'false';
|
||||
const allowMetadataUpdates =
|
||||
process.env.TASK_MASTER_ALLOW_METADATA_UPDATES === 'true';
|
||||
expect(allowMetadataUpdates).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow metadata updates when flag is set to true', () => {
|
||||
process.env.TASK_MASTER_ALLOW_METADATA_UPDATES = 'true';
|
||||
const allowMetadataUpdates =
|
||||
process.env.TASK_MASTER_ALLOW_METADATA_UPDATES === 'true';
|
||||
expect(allowMetadataUpdates).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-sensitive (TRUE should not work)', () => {
|
||||
process.env.TASK_MASTER_ALLOW_METADATA_UPDATES = 'TRUE';
|
||||
const allowMetadataUpdates =
|
||||
process.env.TASK_MASTER_ALLOW_METADATA_UPDATES === 'true';
|
||||
expect(allowMetadataUpdates).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata merge logic', () => {
|
||||
it('should merge new metadata with existing metadata', () => {
|
||||
const existingMetadata = { githubIssue: 42, sprint: 'Q1' };
|
||||
const newMetadata = { storyPoints: 5, reviewed: true };
|
||||
|
||||
const merged = {
|
||||
...(existingMetadata || {}),
|
||||
...(newMetadata || {})
|
||||
};
|
||||
|
||||
expect(merged).toEqual({
|
||||
githubIssue: 42,
|
||||
sprint: 'Q1',
|
||||
storyPoints: 5,
|
||||
reviewed: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should override existing keys with new values', () => {
|
||||
const existingMetadata = { githubIssue: 42, sprint: 'Q1' };
|
||||
const newMetadata = { sprint: 'Q2' }; // Override sprint
|
||||
|
||||
const merged = {
|
||||
...(existingMetadata || {}),
|
||||
...(newMetadata || {})
|
||||
};
|
||||
|
||||
expect(merged).toEqual({
|
||||
githubIssue: 42,
|
||||
sprint: 'Q2' // Overridden
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty existing metadata', () => {
|
||||
const existingMetadata = undefined;
|
||||
const newMetadata = { key: 'value' };
|
||||
|
||||
const merged = {
|
||||
...(existingMetadata || {}),
|
||||
...(newMetadata || {})
|
||||
};
|
||||
|
||||
expect(merged).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should handle empty new metadata', () => {
|
||||
const existingMetadata = { key: 'value' };
|
||||
const newMetadata = undefined;
|
||||
|
||||
const merged = {
|
||||
...(existingMetadata || {}),
|
||||
...(newMetadata || {})
|
||||
};
|
||||
|
||||
expect(merged).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should preserve nested objects in metadata', () => {
|
||||
const existingMetadata = {
|
||||
jira: { key: 'PROJ-123' },
|
||||
other: 'data'
|
||||
};
|
||||
const newMetadata = {
|
||||
jira: { key: 'PROJ-456', type: 'bug' } // Replace entire jira object
|
||||
};
|
||||
|
||||
const merged = {
|
||||
...(existingMetadata || {}),
|
||||
...(newMetadata || {})
|
||||
};
|
||||
|
||||
expect(merged).toEqual({
|
||||
jira: { key: 'PROJ-456', type: 'bug' }, // Entire jira object replaced
|
||||
other: 'data'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata-only update detection', () => {
|
||||
it('should detect metadata-only update when prompt is empty', () => {
|
||||
const prompt = '';
|
||||
const metadata = { key: 'value' };
|
||||
|
||||
const isMetadataOnly = metadata && (!prompt || prompt.trim() === '');
|
||||
expect(isMetadataOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect metadata-only update when prompt is whitespace', () => {
|
||||
const prompt = ' ';
|
||||
const metadata = { key: 'value' };
|
||||
|
||||
const isMetadataOnly = metadata && (!prompt || prompt.trim() === '');
|
||||
expect(isMetadataOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be metadata-only when prompt is provided', () => {
|
||||
const prompt = 'Update task details';
|
||||
const metadata = { key: 'value' };
|
||||
|
||||
const isMetadataOnly = metadata && (!prompt || prompt.trim() === '');
|
||||
expect(isMetadataOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be metadata-only when neither is provided', () => {
|
||||
const prompt = '';
|
||||
const metadata = null;
|
||||
|
||||
const isMetadataOnly = metadata && (!prompt || prompt.trim() === '');
|
||||
expect(isMetadataOnly).toBeFalsy(); // metadata is null, so falsy
|
||||
});
|
||||
});
|
||||
|
||||
describe('tasks.json file format with metadata', () => {
|
||||
it('should write and read metadata correctly in tasks.json', () => {
|
||||
const tasksData = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Test Task',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: '',
|
||||
testStrategy: '',
|
||||
subtasks: [],
|
||||
metadata: {
|
||||
githubIssue: 42,
|
||||
sprint: 'Q1-S3',
|
||||
storyPoints: 5
|
||||
}
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastModified: new Date().toISOString(),
|
||||
taskCount: 1,
|
||||
completedCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Write
|
||||
fs.writeFileSync(tasksJsonPath, JSON.stringify(tasksData, null, 2));
|
||||
|
||||
// Read and verify
|
||||
const rawContent = fs.readFileSync(tasksJsonPath, 'utf-8');
|
||||
const parsed = JSON.parse(rawContent);
|
||||
|
||||
expect(parsed.tasks[0].metadata).toEqual({
|
||||
githubIssue: 42,
|
||||
sprint: 'Q1-S3',
|
||||
storyPoints: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should write and read subtask metadata correctly', () => {
|
||||
const tasksData = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Parent Task',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: '',
|
||||
testStrategy: '',
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
parentId: 1,
|
||||
title: 'Subtask',
|
||||
description: 'Subtask description',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: '',
|
||||
testStrategy: '',
|
||||
metadata: {
|
||||
linkedTicket: 'JIRA-456',
|
||||
reviewed: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastModified: new Date().toISOString(),
|
||||
taskCount: 1,
|
||||
completedCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Write
|
||||
fs.writeFileSync(tasksJsonPath, JSON.stringify(tasksData, null, 2));
|
||||
|
||||
// Read and verify
|
||||
const rawContent = fs.readFileSync(tasksJsonPath, 'utf-8');
|
||||
const parsed = JSON.parse(rawContent);
|
||||
|
||||
expect(parsed.tasks[0].subtasks[0].metadata).toEqual({
|
||||
linkedTicket: 'JIRA-456',
|
||||
reviewed: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error message formatting', () => {
|
||||
it('should provide clear error for disabled metadata updates', () => {
|
||||
const errorMessage =
|
||||
'Metadata updates are disabled. Set TASK_MASTER_ALLOW_METADATA_UPDATES=true in your MCP server environment to enable metadata modifications.';
|
||||
|
||||
expect(errorMessage).toContain('TASK_MASTER_ALLOW_METADATA_UPDATES');
|
||||
expect(errorMessage).toContain('true');
|
||||
expect(errorMessage).toContain('MCP server environment');
|
||||
});
|
||||
|
||||
it('should provide clear error for invalid JSON', () => {
|
||||
const invalidJson = '{key: value}';
|
||||
const errorMessage = `Invalid metadata JSON: ${invalidJson}. Provide a valid JSON object string.`;
|
||||
|
||||
expect(errorMessage).toContain(invalidJson);
|
||||
expect(errorMessage).toContain('valid JSON object');
|
||||
});
|
||||
|
||||
it('should provide clear error for non-object JSON', () => {
|
||||
const errorMessage =
|
||||
'Invalid metadata: must be a JSON object (not null or array)';
|
||||
|
||||
expect(errorMessage).toContain('JSON object');
|
||||
expect(errorMessage).toContain('not null or array');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* @fileoverview Integration tests for FileStorage metadata preservation
|
||||
*
|
||||
* Tests that user-defined metadata survives all FileStorage CRUD operations
|
||||
* including load, save, update, and append.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { FileStorage } from '../../../src/modules/storage/adapters/file-storage/file-storage.js';
|
||||
import type { Task } from '../../../src/common/types/index.js';
|
||||
|
||||
/**
|
||||
* Creates a minimal valid task for testing
|
||||
*/
|
||||
function createTask(id: string, overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: `Description for task ${id}`,
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: '',
|
||||
testStrategy: '',
|
||||
subtasks: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('FileStorage Metadata Preservation - Integration Tests', () => {
|
||||
let tempDir: string;
|
||||
let storage: FileStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temp directory for each test
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taskmaster-test-'));
|
||||
// Create .taskmaster/tasks directory structure
|
||||
const taskmasterDir = path.join(tempDir, '.taskmaster', 'tasks');
|
||||
fs.mkdirSync(taskmasterDir, { recursive: true });
|
||||
storage = new FileStorage(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('saveTasks() and loadTasks() round-trip', () => {
|
||||
it('should preserve metadata through save and load cycle', async () => {
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
metadata: {
|
||||
externalId: 'JIRA-123',
|
||||
source: 'import',
|
||||
customField: { nested: 'value' }
|
||||
}
|
||||
}),
|
||||
createTask('2', {
|
||||
metadata: {
|
||||
score: 85,
|
||||
isUrgent: true
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
|
||||
expect(loadedTasks).toHaveLength(2);
|
||||
expect(loadedTasks[0].metadata).toEqual({
|
||||
externalId: 'JIRA-123',
|
||||
source: 'import',
|
||||
customField: { nested: 'value' }
|
||||
});
|
||||
expect(loadedTasks[1].metadata).toEqual({
|
||||
score: 85,
|
||||
isUrgent: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve empty metadata object', async () => {
|
||||
const tasks: Task[] = [createTask('1', { metadata: {} })];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
|
||||
expect(loadedTasks[0].metadata).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle tasks without metadata', async () => {
|
||||
const tasks: Task[] = [createTask('1')]; // No metadata
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
|
||||
expect(loadedTasks[0].metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve complex metadata with various types', async () => {
|
||||
const complexMetadata = {
|
||||
string: 'value',
|
||||
number: 42,
|
||||
float: 3.14,
|
||||
boolean: true,
|
||||
nullValue: null,
|
||||
array: [1, 'two', { three: 3 }],
|
||||
nested: {
|
||||
deep: {
|
||||
deeper: {
|
||||
value: 'found'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tasks: Task[] = [createTask('1', { metadata: complexMetadata })];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
|
||||
expect(loadedTasks[0].metadata).toEqual(complexMetadata);
|
||||
});
|
||||
|
||||
it('should preserve metadata on subtasks', async () => {
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
metadata: { parentMeta: 'value' },
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
parentId: '1',
|
||||
title: 'Subtask 1',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: '',
|
||||
testStrategy: '',
|
||||
metadata: { subtaskMeta: 'subtask-value' }
|
||||
}
|
||||
]
|
||||
})
|
||||
];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
|
||||
expect(loadedTasks[0].metadata).toEqual({ parentMeta: 'value' });
|
||||
expect(loadedTasks[0].subtasks[0].metadata).toEqual({
|
||||
subtaskMeta: 'subtask-value'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTask() metadata preservation', () => {
|
||||
it('should preserve existing metadata when updating other fields', async () => {
|
||||
const originalMetadata = { externalId: 'EXT-123', version: 1 };
|
||||
const tasks: Task[] = [createTask('1', { metadata: originalMetadata })];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Update title only, not metadata
|
||||
await storage.updateTask('1', { title: 'Updated Title' });
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].title).toBe('Updated Title');
|
||||
expect(loadedTasks[0].metadata).toEqual(originalMetadata);
|
||||
});
|
||||
|
||||
it('should allow updating metadata field directly', async () => {
|
||||
const tasks: Task[] = [createTask('1', { metadata: { original: true } })];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Update metadata
|
||||
await storage.updateTask('1', {
|
||||
metadata: { original: true, updated: true, newField: 'value' }
|
||||
});
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].metadata).toEqual({
|
||||
original: true,
|
||||
updated: true,
|
||||
newField: 'value'
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow replacing metadata entirely', async () => {
|
||||
const tasks: Task[] = [
|
||||
createTask('1', { metadata: { oldField: 'old' } })
|
||||
];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Replace metadata entirely
|
||||
await storage.updateTask('1', { metadata: { newField: 'new' } });
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].metadata).toEqual({ newField: 'new' });
|
||||
});
|
||||
|
||||
it('should preserve metadata when updating status', async () => {
|
||||
const tasks: Task[] = [createTask('1', { metadata: { tracked: true } })];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
await storage.updateTask('1', { status: 'in-progress' });
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].status).toBe('in-progress');
|
||||
expect(loadedTasks[0].metadata).toEqual({ tracked: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendTasks() metadata preservation', () => {
|
||||
it('should preserve metadata on existing tasks when appending', async () => {
|
||||
const existingTasks: Task[] = [
|
||||
createTask('1', { metadata: { existing: true } })
|
||||
];
|
||||
|
||||
await storage.saveTasks(existingTasks);
|
||||
|
||||
// Append new tasks
|
||||
const newTasks: Task[] = [
|
||||
createTask('2', { metadata: { newTask: true } })
|
||||
];
|
||||
|
||||
await storage.appendTasks(newTasks);
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks).toHaveLength(2);
|
||||
expect(loadedTasks.find((t) => t.id === '1')?.metadata).toEqual({
|
||||
existing: true
|
||||
});
|
||||
expect(loadedTasks.find((t) => t.id === '2')?.metadata).toEqual({
|
||||
newTask: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTask() single task metadata', () => {
|
||||
it('should preserve metadata when loading single task', async () => {
|
||||
const tasks: Task[] = [
|
||||
createTask('1', { metadata: { specific: 'metadata' } }),
|
||||
createTask('2', { metadata: { other: 'data' } })
|
||||
];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
const task = await storage.loadTask('1');
|
||||
|
||||
expect(task).toBeDefined();
|
||||
expect(task?.metadata).toEqual({ specific: 'metadata' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata alongside AI implementation metadata', () => {
|
||||
it('should preserve both user metadata and AI metadata', async () => {
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
// AI implementation metadata
|
||||
relevantFiles: [
|
||||
{
|
||||
path: 'src/test.ts',
|
||||
description: 'Test file',
|
||||
action: 'modify'
|
||||
}
|
||||
],
|
||||
category: 'development',
|
||||
skills: ['TypeScript'],
|
||||
acceptanceCriteria: ['Tests pass'],
|
||||
// User-defined metadata
|
||||
metadata: {
|
||||
externalId: 'JIRA-456',
|
||||
importedAt: '2024-01-15T10:00:00Z'
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
|
||||
// AI metadata preserved
|
||||
expect(loadedTasks[0].relevantFiles).toHaveLength(1);
|
||||
expect(loadedTasks[0].category).toBe('development');
|
||||
expect(loadedTasks[0].skills).toEqual(['TypeScript']);
|
||||
|
||||
// User metadata preserved
|
||||
expect(loadedTasks[0].metadata).toEqual({
|
||||
externalId: 'JIRA-456',
|
||||
importedAt: '2024-01-15T10:00:00Z'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI operation metadata preservation', () => {
|
||||
it('should preserve metadata when updating task with AI-like partial update', async () => {
|
||||
// Simulate existing task with user metadata
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
title: 'Original Title',
|
||||
metadata: { externalId: 'JIRA-123', version: 1 }
|
||||
})
|
||||
];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Simulate AI update - only updates specific fields, no metadata field
|
||||
// This mimics what happens when AI processes update-task
|
||||
const aiUpdate: Partial<Task> = {
|
||||
title: 'AI Updated Title',
|
||||
description: 'AI generated description',
|
||||
details: 'AI generated details'
|
||||
// Note: no metadata field - AI schemas don't include it
|
||||
};
|
||||
|
||||
await storage.updateTask('1', aiUpdate);
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].title).toBe('AI Updated Title');
|
||||
expect(loadedTasks[0].description).toBe('AI generated description');
|
||||
// User metadata must be preserved
|
||||
expect(loadedTasks[0].metadata).toEqual({
|
||||
externalId: 'JIRA-123',
|
||||
version: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve metadata when adding AI-generated subtasks', async () => {
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
metadata: { tracked: true, source: 'import' },
|
||||
subtasks: []
|
||||
})
|
||||
];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Simulate expand-task adding subtasks
|
||||
// Subtasks from AI don't have metadata field
|
||||
const updatedTask: Partial<Task> = {
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
parentId: '1',
|
||||
title: 'AI Generated Subtask',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: 'Details',
|
||||
testStrategy: 'Tests'
|
||||
// No metadata - AI doesn't generate it
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await storage.updateTask('1', updatedTask);
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
// Parent task metadata preserved
|
||||
expect(loadedTasks[0].metadata).toEqual({
|
||||
tracked: true,
|
||||
source: 'import'
|
||||
});
|
||||
// Subtask has no metadata (as expected from AI)
|
||||
expect(loadedTasks[0].subtasks[0].metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple sequential AI updates preserving metadata', async () => {
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
metadata: { originalField: 'preserved' }
|
||||
})
|
||||
];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// First AI update
|
||||
await storage.updateTask('1', { title: 'First Update' });
|
||||
// Second AI update
|
||||
await storage.updateTask('1', { description: 'Second Update' });
|
||||
// Third AI update
|
||||
await storage.updateTask('1', { priority: 'high' });
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
expect(loadedTasks[0].title).toBe('First Update');
|
||||
expect(loadedTasks[0].description).toBe('Second Update');
|
||||
expect(loadedTasks[0].priority).toBe('high');
|
||||
// Metadata preserved through all updates
|
||||
expect(loadedTasks[0].metadata).toEqual({ originalField: 'preserved' });
|
||||
});
|
||||
|
||||
it('should preserve metadata when update object omits metadata field entirely', async () => {
|
||||
// This is how AI operations work - they simply don't include metadata
|
||||
const tasks: Task[] = [
|
||||
createTask('1', {
|
||||
metadata: { important: 'data' }
|
||||
})
|
||||
];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Update WITHOUT metadata field (AI schemas don't include it)
|
||||
const updateWithoutMetadata: Partial<Task> = { title: 'Updated' };
|
||||
await storage.updateTask('1', updateWithoutMetadata);
|
||||
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
// When metadata field is absent from updates, existing metadata is preserved
|
||||
expect(loadedTasks[0].metadata).toEqual({ important: 'data' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('file format verification', () => {
|
||||
it('should write metadata to JSON file correctly', async () => {
|
||||
const tasks: Task[] = [createTask('1', { metadata: { written: true } })];
|
||||
|
||||
await storage.saveTasks(tasks);
|
||||
|
||||
// Read raw file to verify format
|
||||
const filePath = path.join(tempDir, '.taskmaster', 'tasks', 'tasks.json');
|
||||
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(rawContent);
|
||||
|
||||
expect(parsed.tasks[0].metadata).toEqual({ written: true });
|
||||
});
|
||||
|
||||
it('should load metadata from pre-existing JSON file', async () => {
|
||||
// Write a tasks.json file manually
|
||||
const tasksDir = path.join(tempDir, '.taskmaster', 'tasks');
|
||||
const filePath = path.join(tasksDir, 'tasks.json');
|
||||
|
||||
const fileContent = {
|
||||
tasks: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Pre-existing task',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: '',
|
||||
testStrategy: '',
|
||||
subtasks: [],
|
||||
metadata: {
|
||||
preExisting: true,
|
||||
importedFrom: 'external-system'
|
||||
}
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastModified: new Date().toISOString(),
|
||||
taskCount: 1,
|
||||
completedCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(fileContent, null, 2));
|
||||
|
||||
// Load through FileStorage
|
||||
const loadedTasks = await storage.loadTasks();
|
||||
|
||||
expect(loadedTasks).toHaveLength(1);
|
||||
expect(loadedTasks[0].metadata).toEqual({
|
||||
preExisting: true,
|
||||
importedFrom: 'external-system'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -164,6 +164,30 @@ async function updateSubtaskById(
|
||||
|
||||
const subtask = parentTask.subtasks[subtaskIndex];
|
||||
|
||||
// --- Metadata-Only Update (Fast Path) ---
|
||||
// If only metadata is provided (no prompt), skip AI and just update metadata
|
||||
if (metadata && (!prompt || prompt.trim() === '')) {
|
||||
report('info', `Metadata-only update for subtask ${subtaskId}`);
|
||||
// Merge new metadata with existing
|
||||
subtask.metadata = {
|
||||
...(subtask.metadata || {}),
|
||||
...metadata
|
||||
};
|
||||
parentTask.subtasks[subtaskIndex] = subtask;
|
||||
writeJSON(tasksPath, data, projectRoot, tag);
|
||||
report(
|
||||
'success',
|
||||
`Successfully updated metadata for subtask ${subtaskId}`
|
||||
);
|
||||
|
||||
return {
|
||||
updatedSubtask: subtask,
|
||||
telemetryData: null,
|
||||
tagInfo: { tag }
|
||||
};
|
||||
}
|
||||
// --- End Metadata-Only Update ---
|
||||
|
||||
// --- Context Gathering ---
|
||||
let gatheredContext = '';
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user