feat: CLI & MCP progress tracking for parse-prd command (#1048)
* initial cutover * update log to debug * update tracker to pass units * update test to match new base tracker format * add streamTextService mocks * remove unused imports * Ensure the CLI waits for async main() completion * refactor to reduce code duplication * update comment * reuse function * ensure targetTag is defined in streaming mode * avoid throwing inside process.exit spy * check for null * remove reference to generate * fix formatting * fix textStream assignment * ensure no division by 0 * fix jest chalk mocks * refactor for maintainability * Improve bar chart calculation logic for consistent visual representation * use custom streaming error types; fix mocks * Update streamText extraction in parse-prd.js to match actual service response * remove check - doesn't belong here * update mocks * remove streaming test that wasn't really doing anything * add comment * make parsing logic more DRY * fix formatting * Fix textStream extraction to match actual service response * fix mock * Add a cleanup method to ensure proper resource disposal and prevent memory leaks * debounce progress updates to reduce UI flicker during rapid updates * Implement timeout protection for streaming operations (60-second timeout) with automatic fallback to non-streaming mode. * clear timeout properly * Add a maximum buffer size limit (1MB) to prevent unbounded memory growth with very large streaming responses. * fix formatting * remove duplicate mock * better docs * fix formatting * sanitize the dynamic property name * Fix incorrect remaining progress calculation * Use onError callback instead of console.warn * Remove unused chalk import * Add missing custom validator in fallback parsing configuration * add custom validator parameter in fallback parsing * chore: fix package-lock.json * chore: large code refactor * chore: increase timeout from 1 minute to 3 minutes * fix: refactor and fix streaming * Merge remote-tracking branch 'origin/next' into joedanz/parse-prd-progress * fix: cleanup and fix unit tests * chore: fix unit tests * chore: fix format * chore: run format * chore: fix weird CI unit test error * chore: fix format --------- Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
This commit is contained in:
@@ -1,68 +1,470 @@
|
||||
// In tests/unit/parse-prd.test.js
|
||||
// Testing that parse-prd.js handles both .txt and .md files the same way
|
||||
// Testing parse-prd.js file extension compatibility with real files
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import os from 'os';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Mock the AI services to avoid real API calls
|
||||
jest.unstable_mockModule(
|
||||
'../../scripts/modules/ai-services-unified.js',
|
||||
() => ({
|
||||
streamTextService: jest.fn(),
|
||||
generateObjectService: jest.fn(),
|
||||
streamObjectService: jest.fn().mockImplementation(async () => {
|
||||
return {
|
||||
get partialObjectStream() {
|
||||
return (async function* () {
|
||||
yield { tasks: [] };
|
||||
yield { tasks: [{ id: 1, title: 'Test Task', priority: 'high' }] };
|
||||
})();
|
||||
},
|
||||
object: Promise.resolve({
|
||||
tasks: [{ id: 1, title: 'Test Task', priority: 'high' }]
|
||||
})
|
||||
};
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Mock all config-manager exports comprehensively
|
||||
jest.unstable_mockModule('../../scripts/modules/config-manager.js', () => ({
|
||||
getDebugFlag: jest.fn(() => false),
|
||||
getDefaultPriority: jest.fn(() => 'medium'),
|
||||
getMainModelId: jest.fn(() => 'test-model'),
|
||||
getResearchModelId: jest.fn(() => 'test-research-model'),
|
||||
getParametersForRole: jest.fn(() => ({ maxTokens: 1000, temperature: 0.7 })),
|
||||
getMainProvider: jest.fn(() => 'anthropic'),
|
||||
getResearchProvider: jest.fn(() => 'perplexity'),
|
||||
getFallbackProvider: jest.fn(() => 'anthropic'),
|
||||
getResponseLanguage: jest.fn(() => 'English'),
|
||||
getDefaultNumTasks: jest.fn(() => 10),
|
||||
getDefaultSubtasks: jest.fn(() => 5),
|
||||
getLogLevel: jest.fn(() => 'info'),
|
||||
getConfig: jest.fn(() => ({})),
|
||||
getAllProviders: jest.fn(() => ['anthropic', 'perplexity']),
|
||||
MODEL_MAP: {},
|
||||
VALID_PROVIDERS: ['anthropic', 'perplexity'],
|
||||
validateProvider: jest.fn(() => true),
|
||||
validateProviderModelCombination: jest.fn(() => true),
|
||||
isApiKeySet: jest.fn(() => true)
|
||||
}));
|
||||
|
||||
// Mock utils comprehensively to prevent CLI behavior
|
||||
jest.unstable_mockModule('../../scripts/modules/utils.js', () => ({
|
||||
log: jest.fn(),
|
||||
writeJSON: jest.fn(),
|
||||
enableSilentMode: jest.fn(),
|
||||
disableSilentMode: jest.fn(),
|
||||
isSilentMode: jest.fn(() => false),
|
||||
getCurrentTag: jest.fn(() => 'master'),
|
||||
ensureTagMetadata: jest.fn(),
|
||||
readJSON: jest.fn(() => ({ master: { tasks: [] } })),
|
||||
findProjectRoot: jest.fn(() => '/tmp/test'),
|
||||
resolveEnvVariable: jest.fn(() => 'mock-key'),
|
||||
findTaskById: jest.fn(() => null),
|
||||
findTaskByPattern: jest.fn(() => []),
|
||||
validateTaskId: jest.fn(() => true),
|
||||
createTask: jest.fn(() => ({ id: 1, title: 'Mock Task' })),
|
||||
sortByDependencies: jest.fn((tasks) => tasks),
|
||||
isEmpty: jest.fn(() => false),
|
||||
truncate: jest.fn((text) => text),
|
||||
slugify: jest.fn((text) => text.toLowerCase()),
|
||||
getTagFromPath: jest.fn(() => 'master'),
|
||||
isValidTag: jest.fn(() => true),
|
||||
migrateToTaggedFormat: jest.fn(() => ({ master: { tasks: [] } })),
|
||||
performCompleteTagMigration: jest.fn(),
|
||||
resolveCurrentTag: jest.fn(() => 'master'),
|
||||
getDefaultTag: jest.fn(() => 'master'),
|
||||
performMigrationIfNeeded: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock prompt manager
|
||||
jest.unstable_mockModule('../../scripts/modules/prompt-manager.js', () => ({
|
||||
getPromptManager: jest.fn(() => ({
|
||||
loadPrompt: jest.fn(() => ({
|
||||
systemPrompt: 'Test system prompt',
|
||||
userPrompt: 'Test user prompt'
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock progress/UI components to prevent real CLI UI
|
||||
jest.unstable_mockModule('../../src/progress/parse-prd-tracker.js', () => ({
|
||||
createParsePrdTracker: jest.fn(() => ({
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
cleanup: jest.fn(),
|
||||
addTaskLine: jest.fn(),
|
||||
updateTokens: jest.fn(),
|
||||
complete: jest.fn(),
|
||||
getSummary: jest.fn().mockReturnValue({
|
||||
taskPriorities: { high: 0, medium: 0, low: 0 },
|
||||
elapsedTime: 0,
|
||||
actionVerb: 'generated'
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../src/ui/parse-prd.js', () => ({
|
||||
displayParsePrdStart: jest.fn(),
|
||||
displayParsePrdSummary: jest.fn()
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../scripts/modules/ui.js', () => ({
|
||||
displayAiUsageSummary: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock task generation to prevent file operations
|
||||
jest.unstable_mockModule(
|
||||
'../../scripts/modules/task-manager/generate-task-files.js',
|
||||
() => ({
|
||||
default: jest.fn()
|
||||
})
|
||||
);
|
||||
|
||||
// Mock stream parser
|
||||
jest.unstable_mockModule('../../src/utils/stream-parser.js', () => {
|
||||
// Define mock StreamingError class
|
||||
class StreamingError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'StreamingError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// Define mock error codes
|
||||
const STREAMING_ERROR_CODES = {
|
||||
NOT_ASYNC_ITERABLE: 'STREAMING_NOT_SUPPORTED',
|
||||
STREAM_PROCESSING_FAILED: 'STREAM_PROCESSING_FAILED',
|
||||
STREAM_NOT_ITERABLE: 'STREAM_NOT_ITERABLE'
|
||||
};
|
||||
|
||||
return {
|
||||
parseStream: jest.fn(),
|
||||
StreamingError,
|
||||
STREAMING_ERROR_CODES
|
||||
};
|
||||
});
|
||||
|
||||
// Mock other potential UI elements
|
||||
jest.unstable_mockModule('ora', () => ({
|
||||
default: jest.fn(() => ({
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
fail: jest.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('chalk', () => ({
|
||||
default: {
|
||||
red: jest.fn((text) => text),
|
||||
green: jest.fn((text) => text),
|
||||
blue: jest.fn((text) => text),
|
||||
yellow: jest.fn((text) => text),
|
||||
cyan: jest.fn((text) => text),
|
||||
white: {
|
||||
bold: jest.fn((text) => text)
|
||||
}
|
||||
},
|
||||
red: jest.fn((text) => text),
|
||||
green: jest.fn((text) => text),
|
||||
blue: jest.fn((text) => text),
|
||||
yellow: jest.fn((text) => text),
|
||||
cyan: jest.fn((text) => text),
|
||||
white: {
|
||||
bold: jest.fn((text) => text)
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock boxen
|
||||
jest.unstable_mockModule('boxen', () => ({
|
||||
default: jest.fn((content) => content)
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
jest.unstable_mockModule('../../src/constants/task-priority.js', () => ({
|
||||
DEFAULT_TASK_PRIORITY: 'medium',
|
||||
TASK_PRIORITY_OPTIONS: ['low', 'medium', 'high']
|
||||
}));
|
||||
|
||||
// Mock UI indicators
|
||||
jest.unstable_mockModule('../../src/ui/indicators.js', () => ({
|
||||
getPriorityIndicators: jest.fn(() => ({
|
||||
high: '🔴',
|
||||
medium: '🟡',
|
||||
low: '🟢'
|
||||
}))
|
||||
}));
|
||||
|
||||
// Import modules after mocking
|
||||
const { generateObjectService } = await import(
|
||||
'../../scripts/modules/ai-services-unified.js'
|
||||
);
|
||||
const parsePRD = (
|
||||
await import('../../scripts/modules/task-manager/parse-prd/parse-prd.js')
|
||||
).default;
|
||||
|
||||
describe('parse-prd file extension compatibility', () => {
|
||||
// Test directly that the parse-prd functionality works with different extensions
|
||||
// by examining the parameter handling in mcp-server/src/tools/parse-prd.js
|
||||
let tempDir;
|
||||
let testFiles;
|
||||
|
||||
test('Parameter description mentions support for .md files', () => {
|
||||
// The parameter description for 'input' in parse-prd.js includes .md files
|
||||
const description =
|
||||
'Absolute path to the PRD document file (.txt, .md, etc.)';
|
||||
|
||||
// Verify the description explicitly mentions .md files
|
||||
expect(description).toContain('.md');
|
||||
});
|
||||
|
||||
test('File extension validation is not restricted to .txt files', () => {
|
||||
// Check for absence of extension validation
|
||||
const fileValidator = (filePath) => {
|
||||
// Return a boolean value to ensure the test passes
|
||||
if (!filePath || filePath.length === 0) {
|
||||
return false;
|
||||
const mockTasksResponse = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Test Task 1',
|
||||
description: 'First test task',
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
priority: 'high',
|
||||
details: 'Implementation details for task 1',
|
||||
testStrategy: 'Unit tests for task 1'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Test Task 2',
|
||||
description: 'Second test task',
|
||||
status: 'pending',
|
||||
dependencies: [1],
|
||||
priority: 'medium',
|
||||
details: 'Implementation details for task 2',
|
||||
testStrategy: 'Integration tests for task 2'
|
||||
}
|
||||
return true;
|
||||
],
|
||||
metadata: {
|
||||
projectName: 'Test Project',
|
||||
totalTasks: 2,
|
||||
sourceFile: 'test-prd',
|
||||
generatedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
const samplePRDContent = `# Test Project PRD
|
||||
|
||||
## Overview
|
||||
Build a simple task management application.
|
||||
|
||||
## Features
|
||||
1. Create and manage tasks
|
||||
2. Set task priorities
|
||||
3. Track task dependencies
|
||||
|
||||
## Technical Requirements
|
||||
- React frontend
|
||||
- Node.js backend
|
||||
- PostgreSQL database
|
||||
|
||||
## Success Criteria
|
||||
- Users can create tasks successfully
|
||||
- Task dependencies work correctly`;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create temporary directory for test files
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parse-prd-test-'));
|
||||
|
||||
// Create test files with different extensions
|
||||
testFiles = {
|
||||
txt: path.join(tempDir, 'test-prd.txt'),
|
||||
md: path.join(tempDir, 'test-prd.md'),
|
||||
rst: path.join(tempDir, 'test-prd.rst'),
|
||||
noExt: path.join(tempDir, 'test-prd')
|
||||
};
|
||||
|
||||
// Test with different extensions
|
||||
expect(fileValidator('/path/to/prd.txt')).toBe(true);
|
||||
expect(fileValidator('/path/to/prd.md')).toBe(true);
|
||||
// Write the same content to all test files
|
||||
Object.values(testFiles).forEach((filePath) => {
|
||||
fs.writeFileSync(filePath, samplePRDContent);
|
||||
});
|
||||
|
||||
// Invalid cases should still fail regardless of extension
|
||||
expect(fileValidator('')).toBe(false);
|
||||
// Mock process.exit to prevent actual exit
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => undefined);
|
||||
|
||||
// Mock console methods to prevent output
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
test('Implementation handles all file types the same way', () => {
|
||||
// This test confirms that the implementation treats all file types equally
|
||||
// by simulating the core functionality
|
||||
afterAll(() => {
|
||||
// Clean up temporary directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
const mockImplementation = (filePath) => {
|
||||
// The parse-prd.js implementation only checks file existence,
|
||||
// not the file extension, which is what we want to verify
|
||||
// Restore mocks
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return { success: false, error: { code: 'MISSING_INPUT_FILE' } };
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock successful AI response
|
||||
generateObjectService.mockResolvedValue({
|
||||
mainResult: { object: mockTasksResponse },
|
||||
telemetryData: {
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: 'test-user',
|
||||
commandName: 'parse-prd',
|
||||
modelUsed: 'test-model',
|
||||
providerName: 'test-provider',
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
totalTokens: 300,
|
||||
totalCost: 0.01,
|
||||
currency: 'USD'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// In the real implementation, this would check if the file exists
|
||||
// But for our test, we're verifying that the same logic applies
|
||||
// regardless of file extension
|
||||
test('should accept and parse .txt files', async () => {
|
||||
const outputPath = path.join(tempDir, 'tasks-txt.json');
|
||||
|
||||
// No special handling for different extensions
|
||||
return { success: true };
|
||||
};
|
||||
const result = await parsePRD(testFiles.txt, outputPath, 2, {
|
||||
force: true,
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
},
|
||||
projectRoot: tempDir
|
||||
});
|
||||
|
||||
// Verify same behavior for different extensions
|
||||
const txtResult = mockImplementation('/path/to/prd.txt');
|
||||
const mdResult = mockImplementation('/path/to/prd.md');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tasksPath).toBe(outputPath);
|
||||
expect(fs.existsSync(outputPath)).toBe(true);
|
||||
|
||||
// Both should succeed since there's no extension-specific logic
|
||||
expect(txtResult.success).toBe(true);
|
||||
expect(mdResult.success).toBe(true);
|
||||
// Verify the content was parsed correctly
|
||||
const tasksData = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
||||
expect(tasksData.master.tasks).toHaveLength(2);
|
||||
expect(tasksData.master.tasks[0].title).toBe('Test Task 1');
|
||||
});
|
||||
|
||||
// Both should have the same structure
|
||||
expect(Object.keys(txtResult)).toEqual(Object.keys(mdResult));
|
||||
test('should accept and parse .md files', async () => {
|
||||
const outputPath = path.join(tempDir, 'tasks-md.json');
|
||||
|
||||
const result = await parsePRD(testFiles.md, outputPath, 2, {
|
||||
force: true,
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
},
|
||||
projectRoot: tempDir
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tasksPath).toBe(outputPath);
|
||||
expect(fs.existsSync(outputPath)).toBe(true);
|
||||
|
||||
// Verify the content was parsed correctly
|
||||
const tasksData = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
||||
expect(tasksData.master.tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should accept and parse files with other text extensions', async () => {
|
||||
const outputPath = path.join(tempDir, 'tasks-rst.json');
|
||||
|
||||
const result = await parsePRD(testFiles.rst, outputPath, 2, {
|
||||
force: true,
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
},
|
||||
projectRoot: tempDir
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tasksPath).toBe(outputPath);
|
||||
expect(fs.existsSync(outputPath)).toBe(true);
|
||||
});
|
||||
|
||||
test('should accept and parse files with no extension', async () => {
|
||||
const outputPath = path.join(tempDir, 'tasks-noext.json');
|
||||
|
||||
const result = await parsePRD(testFiles.noExt, outputPath, 2, {
|
||||
force: true,
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
},
|
||||
projectRoot: tempDir
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tasksPath).toBe(outputPath);
|
||||
expect(fs.existsSync(outputPath)).toBe(true);
|
||||
});
|
||||
|
||||
test('should produce identical results regardless of file extension', async () => {
|
||||
const outputs = {};
|
||||
|
||||
// Parse each file type with a unique project root to avoid ID conflicts
|
||||
for (const [ext, filePath] of Object.entries(testFiles)) {
|
||||
// Create a unique subdirectory for each test to isolate them
|
||||
const testSubDir = path.join(tempDir, `test-${ext}`);
|
||||
fs.mkdirSync(testSubDir, { recursive: true });
|
||||
|
||||
const outputPath = path.join(testSubDir, `tasks.json`);
|
||||
|
||||
await parsePRD(filePath, outputPath, 2, {
|
||||
force: true,
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
},
|
||||
projectRoot: testSubDir
|
||||
});
|
||||
|
||||
const tasksData = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
||||
outputs[ext] = tasksData;
|
||||
}
|
||||
|
||||
// Compare all outputs - they should be identical (except metadata timestamps)
|
||||
const baseOutput = outputs.txt;
|
||||
Object.values(outputs).forEach((output) => {
|
||||
expect(output.master.tasks).toEqual(baseOutput.master.tasks);
|
||||
expect(output.master.metadata.projectName).toEqual(
|
||||
baseOutput.master.metadata.projectName
|
||||
);
|
||||
expect(output.master.metadata.totalTasks).toEqual(
|
||||
baseOutput.master.metadata.totalTasks
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle non-existent files gracefully', async () => {
|
||||
const nonExistentFile = path.join(tempDir, 'does-not-exist.txt');
|
||||
const outputPath = path.join(tempDir, 'tasks-error.json');
|
||||
|
||||
await expect(
|
||||
parsePRD(nonExistentFile, outputPath, 2, {
|
||||
force: true,
|
||||
mcpLog: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
success: jest.fn()
|
||||
},
|
||||
projectRoot: tempDir
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user