style: format metadata test files and update-subtask script

This commit is contained in:
Cedric Hurst
2025-12-31 01:36:50 -06:00
parent 9a6fa1bd2a
commit bf19b0c2c3
4 changed files with 1326 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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