Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .cursor/rules/tests.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,43 @@ When testing ES modules (`"type": "module"` in package.json), traditional mockin
- ❌ **DON'T**: Write tests that depend on execution order
- ❌ **DON'T**: Define mock variables before `jest.mock()` calls (they won't be accessible due to hoisting)


- **Task File Operations**
- ✅ DO: Use test-specific file paths (e.g., 'test-tasks.json') for all operations
- ✅ DO: Mock `readJSON` and `writeJSON` to avoid real file system interactions
- ✅ DO: Verify file operations use the correct paths in `expect` statements
- ✅ DO: Use different paths for each test to avoid test interdependence
- ✅ DO: Verify modifications on the in-memory task objects passed to `writeJSON`
- ❌ DON'T: Modify real task files (tasks.json) during tests
- ❌ DON'T: Skip testing file operations because they're "just I/O"

```javascript
// ✅ DO: Test file operations without real file system changes
test('should update task status in tasks.json', async () => {
// Setup mock to return sample data
readJSON.mockResolvedValue(JSON.parse(JSON.stringify(sampleTasks)));

// Use test-specific file path
await setTaskStatus('test-tasks.json', '2', 'done');

// Verify correct file path was read
expect(readJSON).toHaveBeenCalledWith('test-tasks.json');

// Verify correct file path was written with updated content
expect(writeJSON).toHaveBeenCalledWith(
'test-tasks.json',
expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({
id: 2,
status: 'done'
})
])
})
);
});
```

## Running Tests

```bash
Expand Down Expand Up @@ -396,6 +433,111 @@ npm test -- -t "pattern to match"
- Reset state in `beforeEach` and `afterEach` hooks
- Avoid global state modifications

## Reliable Testing Techniques

- **Create Simplified Test Functions**
- Create simplified versions of complex functions that focus only on core logic
- Remove file system operations, API calls, and other external dependencies
- Pass all dependencies as parameters to make testing easier

```javascript
// Original function (hard to test)
const setTaskStatus = async (taskId, newStatus) => {
const tasksPath = 'tasks/tasks.json';
const data = await readJSON(tasksPath);
// Update task status logic
await writeJSON(tasksPath, data);
return data;
};

// Test-friendly simplified function (easy to test)
const testSetTaskStatus = (tasksData, taskIdInput, newStatus) => {
// Same core logic without file operations
// Update task status logic on provided tasksData object
return tasksData; // Return updated data for assertions
};
```

- **Avoid Real File System Operations**
- Never write to real files during tests
- Create test-specific versions of file operation functions
- Mock all file system operations including read, write, exists, etc.
- Verify function behavior using the in-memory data structures

```javascript
// Mock file operations
const mockReadJSON = jest.fn();
const mockWriteJSON = jest.fn();

jest.mock('../../scripts/modules/utils.js', () => ({
readJSON: mockReadJSON,
writeJSON: mockWriteJSON,
}));

test('should update task status correctly', () => {
// Setup mock data
const testData = JSON.parse(JSON.stringify(sampleTasks));
mockReadJSON.mockReturnValue(testData);

// Call the function that would normally modify files
const result = testSetTaskStatus(testData, '1', 'done');

// Assert on the in-memory data structure
expect(result.tasks[0].status).toBe('done');
});
```

- **Data Isolation Between Tests**
- Always create fresh copies of test data for each test
- Use `JSON.parse(JSON.stringify(original))` for deep cloning
- Reset all mocks before each test with `jest.clearAllMocks()`
- Avoid state that persists between tests

```javascript
beforeEach(() => {
jest.clearAllMocks();
// Deep clone the test data
testTasksData = JSON.parse(JSON.stringify(sampleTasks));
});
```

- **Test All Path Variations**
- Regular tasks and subtasks
- Single items and multiple items
- Success paths and error paths
- Edge cases (empty data, invalid inputs, etc.)

```javascript
// Multiple test cases covering different scenarios
test('should update regular task status', () => {
/* test implementation */
});

test('should update subtask status', () => {
/* test implementation */
});

test('should update multiple tasks when given comma-separated IDs', () => {
/* test implementation */
});

test('should throw error for non-existent task ID', () => {
/* test implementation */
});
```

- **Stabilize Tests With Predictable Input/Output**
- Use consistent, predictable test fixtures
- Avoid random values or time-dependent data
- Make tests deterministic for reliable CI/CD
- Control all variables that might affect test outcomes

```javascript
// Use a specific known date instead of current date
const fixedDate = new Date('2023-01-01T12:00:00Z');
jest.spyOn(global, 'Date').mockImplementation(() => fixedDate);
```

See [tests/README.md](mdc:tests/README.md) for more details on the testing approach.

Refer to [jest.config.js](mdc:jest.config.js) for Jest configuration options.
26 changes: 12 additions & 14 deletions bin/task-master.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { spawn } from 'child_process';
import { Command } from 'commander';
import { displayHelp, displayBanner } from '../scripts/modules/ui.js';
import { registerCommands } from '../scripts/modules/commands.js';
import { detectCamelCaseFlags } from '../scripts/modules/utils.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -53,28 +54,18 @@ function runDevScript(args) {
});
}

// Helper function to detect camelCase and convert to kebab-case
const toKebabCase = (str) => str.replace(/([A-Z])/g, '-$1').toLowerCase();

/**
* Create a wrapper action that passes the command to dev.js
* @param {string} commandName - The name of the command
* @returns {Function} Wrapper action function
*/
function createDevScriptAction(commandName) {
return (options, cmd) => {
// Helper function to detect camelCase and convert to kebab-case
const toKebabCase = (str) => str.replace(/([A-Z])/g, '-$1').toLowerCase();

// Check for camelCase flags and error out with helpful message
const camelCaseFlags = [];
for (const arg of process.argv) {
if (arg.startsWith('--') && /[A-Z]/.test(arg)) {
const flagName = arg.split('=')[0].slice(2); // Remove -- and anything after =
const kebabVersion = toKebabCase(flagName);
camelCaseFlags.push({
original: flagName,
kebabCase: kebabVersion
});
}
}
const camelCaseFlags = detectCamelCaseFlags(process.argv);

// If camelCase flags were found, show error and exit
if (camelCaseFlags.length > 0) {
Expand Down Expand Up @@ -306,4 +297,11 @@ if (process.argv.length <= 2) {
displayBanner();
displayHelp();
process.exit(0);
}

// Add exports at the end of the file
if (typeof module !== 'undefined') {
module.exports = {
detectCamelCaseFlags
};
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "task-master-ai",
"version": "0.9.28",
"version": "0.9.29",
"description": "A task management system for ambitious AI-driven development that doesn't overwhelm and confuse Cursor.",
"main": "index.js",
"type": "module",
Expand Down
79 changes: 71 additions & 8 deletions scripts/modules/ai-services.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,25 @@
* AI service interactions for the Task Master CLI
*/

// NOTE/TODO: Include the beta header output-128k-2025-02-19 in your API request to increase the maximum output token length to 128k tokens for Claude 3.7 Sonnet.

import { Anthropic } from '@anthropic-ai/sdk';
import OpenAI from 'openai';
import dotenv from 'dotenv';
import { CONFIG, log, sanitizePrompt } from './utils.js';
import { startLoadingIndicator, stopLoadingIndicator } from './ui.js';
import chalk from 'chalk';

// Load environment variables
dotenv.config();

// Configure Anthropic client
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
// Add beta header for 128k token output
defaultHeaders: {
'anthropic-beta': 'output-128k-2025-02-19'
}
});

// Lazy-loaded Perplexity client
Expand All @@ -37,6 +44,38 @@ function getPerplexityClient() {
return perplexity;
}

/**
* Handle Claude API errors with user-friendly messages
* @param {Error} error - The error from Claude API
* @returns {string} User-friendly error message
*/
function handleClaudeError(error) {
// Check if it's a structured error response
if (error.type === 'error' && error.error) {
switch (error.error.type) {
case 'overloaded_error':
return 'Claude is currently experiencing high demand and is overloaded. Please wait a few minutes and try again.';
case 'rate_limit_error':
return 'You have exceeded the rate limit. Please wait a few minutes before making more requests.';
case 'invalid_request_error':
return 'There was an issue with the request format. If this persists, please report it as a bug.';
default:
return `Claude API error: ${error.error.message}`;
}
}

// Check for network/timeout errors
if (error.message?.toLowerCase().includes('timeout')) {
return 'The request to Claude timed out. Please try again.';
}
if (error.message?.toLowerCase().includes('network')) {
return 'There was a network error connecting to Claude. Please check your internet connection and try again.';
}

// Default error message
return `Error communicating with Claude: ${error.message}`;
}

/**
* Call Claude to generate tasks from a PRD
* @param {string} prdContent - PRD content
Expand Down Expand Up @@ -99,14 +138,27 @@ Important: Your response must be valid JSON only, with no additional explanation
// Use streaming request to handle large responses and show progress
return await handleStreamingRequest(prdContent, prdPath, numTasks, CONFIG.maxTokens, systemPrompt);
} catch (error) {
log('error', 'Error calling Claude:', error.message);

// Retry logic
if (retryCount < 2) {
log('info', `Retrying (${retryCount + 1}/2)...`);
// Get user-friendly error message
const userMessage = handleClaudeError(error);
log('error', userMessage);

// Retry logic for certain errors
if (retryCount < 2 && (
error.error?.type === 'overloaded_error' ||
error.error?.type === 'rate_limit_error' ||
error.message?.toLowerCase().includes('timeout') ||
error.message?.toLowerCase().includes('network')
)) {
const waitTime = (retryCount + 1) * 5000; // 5s, then 10s
log('info', `Waiting ${waitTime/1000} seconds before retry ${retryCount + 1}/2...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
return await callClaude(prdContent, prdPath, numTasks, retryCount + 1);
} else {
throw error;
console.error(chalk.red(userMessage));
if (CONFIG.debug) {
log('debug', 'Full error:', error);
}
throw new Error(userMessage);
}
}
}
Expand Down Expand Up @@ -166,7 +218,17 @@ async function handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens,
} catch (error) {
if (streamingInterval) clearInterval(streamingInterval);
stopLoadingIndicator(loadingIndicator);
throw error;

// Get user-friendly error message
const userMessage = handleClaudeError(error);
log('error', userMessage);
console.error(chalk.red(userMessage));

if (CONFIG.debug) {
log('debug', 'Full error:', error);
}

throw new Error(userMessage);
}
}

Expand Down Expand Up @@ -613,5 +675,6 @@ export {
generateSubtasks,
generateSubtasksWithPerplexity,
parseSubtasksFromText,
generateComplexityAnalysisPrompt
generateComplexityAnalysisPrompt,
handleClaudeError
};
4 changes: 2 additions & 2 deletions scripts/modules/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ async function displayTaskById(tasksPath, taskId) {
chalk.magenta.bold('Title'),
chalk.magenta.bold('Deps')
],
colWidths: [6, 12, Math.min(50, process.stdout.columns - 65 || 30), 30],
colWidths: [10, 15, Math.min(50, process.stdout.columns - 40 || 30), 20],
style: {
head: [],
border: [],
Expand Down Expand Up @@ -945,7 +945,7 @@ async function displayComplexityReport(reportPath) {
const terminalWidth = process.stdout.columns || 100; // Default to 100 if can't detect

// Calculate dynamic column widths
const idWidth = 5;
const idWidth = 12;
const titleWidth = Math.floor(terminalWidth * 0.25); // 25% of width
const scoreWidth = 8;
const subtasksWidth = 8;
Expand Down
Loading