diff --git a/packages/tm-core/tests/integration/ai-operations/metadata-preservation.test.ts b/packages/tm-core/tests/integration/ai-operations/metadata-preservation.test.ts new file mode 100644 index 00000000..8057c100 --- /dev/null +++ b/packages/tm-core/tests/integration/ai-operations/metadata-preservation.test.ts @@ -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 { + 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 { + 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 = { + 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).githubIssue + ).toBe(42); + expect( + ( + (loadedTasks[0].metadata as Record).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\nImplementation notes from AI\n', + 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); + }); + }); +}); diff --git a/packages/tm-core/tests/integration/mcp-tools/metadata-updates.test.ts b/packages/tm-core/tests/integration/mcp-tools/metadata-updates.test.ts new file mode 100644 index 00000000..8685e00f --- /dev/null +++ b/packages/tm-core/tests/integration/mcp-tools/metadata-updates.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/tm-core/tests/integration/storage/file-storage-metadata.test.ts b/packages/tm-core/tests/integration/storage/file-storage-metadata.test.ts new file mode 100644 index 00000000..5f957eba --- /dev/null +++ b/packages/tm-core/tests/integration/storage/file-storage-metadata.test.ts @@ -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 { + 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 = { + 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 = { + 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 = { 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' + }); + }); + }); +}); diff --git a/scripts/modules/task-manager/update-subtask-by-id.js b/scripts/modules/task-manager/update-subtask-by-id.js index f5a3f6b8..6f550eb7 100644 --- a/scripts/modules/task-manager/update-subtask-by-id.js +++ b/scripts/modules/task-manager/update-subtask-by-id.js @@ -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 {