mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-11-16 18:14:43 +00:00
407 lines
9.9 KiB
JavaScript
407 lines
9.9 KiB
JavaScript
|
|
import {
|
||
|
|
jest,
|
||
|
|
beforeEach,
|
||
|
|
afterEach,
|
||
|
|
describe,
|
||
|
|
it,
|
||
|
|
expect
|
||
|
|
} from '@jest/globals';
|
||
|
|
import path from 'path';
|
||
|
|
import { fileURLToPath } from 'url';
|
||
|
|
|
||
|
|
// Create mock functions
|
||
|
|
const mockReadFileSync = jest.fn();
|
||
|
|
const mockReaddirSync = jest.fn();
|
||
|
|
const mockExistsSync = jest.fn();
|
||
|
|
|
||
|
|
// Set up default mock for supported-models.json to prevent config-manager from failing
|
||
|
|
mockReadFileSync.mockImplementation((filePath) => {
|
||
|
|
if (filePath.includes('supported-models.json')) {
|
||
|
|
return JSON.stringify({
|
||
|
|
anthropic: [{ id: 'claude-3-5-sonnet', max_tokens: 8192 }],
|
||
|
|
openai: [{ id: 'gpt-4', max_tokens: 8192 }]
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// Default return for other files
|
||
|
|
return '{}';
|
||
|
|
});
|
||
|
|
|
||
|
|
// Mock fs before importing modules that use it
|
||
|
|
jest.unstable_mockModule('fs', () => ({
|
||
|
|
default: {
|
||
|
|
readFileSync: mockReadFileSync,
|
||
|
|
readdirSync: mockReaddirSync,
|
||
|
|
existsSync: mockExistsSync
|
||
|
|
},
|
||
|
|
readFileSync: mockReadFileSync,
|
||
|
|
readdirSync: mockReaddirSync,
|
||
|
|
existsSync: mockExistsSync
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock process.exit to prevent tests from exiting
|
||
|
|
const mockExit = jest.fn();
|
||
|
|
jest.unstable_mockModule('process', () => ({
|
||
|
|
default: {
|
||
|
|
exit: mockExit,
|
||
|
|
env: {}
|
||
|
|
},
|
||
|
|
exit: mockExit
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Import after mocking
|
||
|
|
const { getPromptManager } = await import(
|
||
|
|
'../../scripts/modules/prompt-manager.js'
|
||
|
|
);
|
||
|
|
|
||
|
|
describe('PromptManager', () => {
|
||
|
|
let promptManager;
|
||
|
|
// Calculate expected templates directory
|
||
|
|
const __filename = fileURLToPath(import.meta.url);
|
||
|
|
const __dirname = path.dirname(__filename);
|
||
|
|
const expectedTemplatesDir = path.join(
|
||
|
|
__dirname,
|
||
|
|
'..',
|
||
|
|
'..',
|
||
|
|
'src',
|
||
|
|
'prompts'
|
||
|
|
);
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
// Clear all mocks
|
||
|
|
jest.clearAllMocks();
|
||
|
|
|
||
|
|
// Re-setup the default mock after clearing
|
||
|
|
mockReadFileSync.mockImplementation((filePath) => {
|
||
|
|
if (filePath.includes('supported-models.json')) {
|
||
|
|
return JSON.stringify({
|
||
|
|
anthropic: [{ id: 'claude-3-5-sonnet', max_tokens: 8192 }],
|
||
|
|
openai: [{ id: 'gpt-4', max_tokens: 8192 }]
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// Default return for other files
|
||
|
|
return '{}';
|
||
|
|
});
|
||
|
|
|
||
|
|
// Get the singleton instance
|
||
|
|
promptManager = getPromptManager();
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
jest.restoreAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('loadPrompt', () => {
|
||
|
|
it('should load and render a simple prompt template', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'test-prompt',
|
||
|
|
prompts: {
|
||
|
|
default: {
|
||
|
|
system: 'You are a helpful assistant',
|
||
|
|
user: 'Hello {{name}}, please {{action}}'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
const result = promptManager.loadPrompt('test-prompt', {
|
||
|
|
name: 'Alice',
|
||
|
|
action: 'help me'
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.systemPrompt).toBe('You are a helpful assistant');
|
||
|
|
expect(result.userPrompt).toBe('Hello Alice, please help me');
|
||
|
|
expect(mockReadFileSync).toHaveBeenCalledWith(
|
||
|
|
path.join(expectedTemplatesDir, 'test-prompt.json'),
|
||
|
|
'utf-8'
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle conditional content', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'conditional-prompt',
|
||
|
|
prompts: {
|
||
|
|
default: {
|
||
|
|
system: 'System prompt',
|
||
|
|
user: '{{#if useResearch}}Research and {{/if}}analyze the task'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
// Test with useResearch = true
|
||
|
|
let result = promptManager.loadPrompt('conditional-prompt', {
|
||
|
|
useResearch: true
|
||
|
|
});
|
||
|
|
expect(result.userPrompt).toBe('Research and analyze the task');
|
||
|
|
|
||
|
|
// Test with useResearch = false
|
||
|
|
result = promptManager.loadPrompt('conditional-prompt', {
|
||
|
|
useResearch: false
|
||
|
|
});
|
||
|
|
expect(result.userPrompt).toBe('analyze the task');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle array iteration with {{#each}}', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'loop-prompt',
|
||
|
|
prompts: {
|
||
|
|
default: {
|
||
|
|
system: 'System prompt',
|
||
|
|
user: 'Tasks:\n{{#each tasks}}- {{id}}: {{title}}\n{{/each}}'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
const result = promptManager.loadPrompt('loop-prompt', {
|
||
|
|
tasks: [
|
||
|
|
{ id: 1, title: 'First task' },
|
||
|
|
{ id: 2, title: 'Second task' }
|
||
|
|
]
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.userPrompt).toBe(
|
||
|
|
'Tasks:\n- 1: First task\n- 2: Second task\n'
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle JSON serialization with triple braces', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'json-prompt',
|
||
|
|
prompts: {
|
||
|
|
default: {
|
||
|
|
system: 'System prompt',
|
||
|
|
user: 'Analyze these tasks: {{{json tasks}}}'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
const tasks = [
|
||
|
|
{ id: 1, title: 'Task 1' },
|
||
|
|
{ id: 2, title: 'Task 2' }
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = promptManager.loadPrompt('json-prompt', { tasks });
|
||
|
|
|
||
|
|
expect(result.userPrompt).toBe(
|
||
|
|
`Analyze these tasks: ${JSON.stringify(tasks, null, 2)}`
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should select variants based on conditions', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'variant-prompt',
|
||
|
|
prompts: {
|
||
|
|
default: {
|
||
|
|
system: 'Default system',
|
||
|
|
user: 'Default user'
|
||
|
|
},
|
||
|
|
research: {
|
||
|
|
condition: 'useResearch === true',
|
||
|
|
system: 'Research system',
|
||
|
|
user: 'Research user'
|
||
|
|
},
|
||
|
|
highComplexity: {
|
||
|
|
condition: 'complexity >= 8',
|
||
|
|
system: 'Complex system',
|
||
|
|
user: 'Complex user'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
// Test default variant
|
||
|
|
let result = promptManager.loadPrompt('variant-prompt', {
|
||
|
|
useResearch: false,
|
||
|
|
complexity: 5
|
||
|
|
});
|
||
|
|
expect(result.systemPrompt).toBe('Default system');
|
||
|
|
|
||
|
|
// Test research variant
|
||
|
|
result = promptManager.loadPrompt('variant-prompt', {
|
||
|
|
useResearch: true,
|
||
|
|
complexity: 5
|
||
|
|
});
|
||
|
|
expect(result.systemPrompt).toBe('Research system');
|
||
|
|
|
||
|
|
// Test high complexity variant
|
||
|
|
result = promptManager.loadPrompt('variant-prompt', {
|
||
|
|
useResearch: false,
|
||
|
|
complexity: 9
|
||
|
|
});
|
||
|
|
expect(result.systemPrompt).toBe('Complex system');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should use specified variant key over conditions', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'variant-prompt',
|
||
|
|
prompts: {
|
||
|
|
default: {
|
||
|
|
system: 'Default system',
|
||
|
|
user: 'Default user'
|
||
|
|
},
|
||
|
|
research: {
|
||
|
|
condition: 'useResearch === true',
|
||
|
|
system: 'Research system',
|
||
|
|
user: 'Research user'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
// Force research variant even though useResearch is false
|
||
|
|
const result = promptManager.loadPrompt(
|
||
|
|
'variant-prompt',
|
||
|
|
{ useResearch: false },
|
||
|
|
'research'
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(result.systemPrompt).toBe('Research system');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle nested properties with dot notation', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'nested-prompt',
|
||
|
|
prompts: {
|
||
|
|
default: {
|
||
|
|
system: 'System',
|
||
|
|
user: 'Project: {{project.name}}, Version: {{project.version}}'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
const result = promptManager.loadPrompt('nested-prompt', {
|
||
|
|
project: {
|
||
|
|
name: 'TaskMaster',
|
||
|
|
version: '1.0.0'
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.userPrompt).toBe('Project: TaskMaster, Version: 1.0.0');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle complex nested structures', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'complex-prompt',
|
||
|
|
prompts: {
|
||
|
|
default: {
|
||
|
|
system: 'System',
|
||
|
|
user: '{{#if hasSubtasks}}Task has subtasks:\n{{#each subtasks}}- {{title}} ({{status}})\n{{/each}}{{/if}}'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
const result = promptManager.loadPrompt('complex-prompt', {
|
||
|
|
hasSubtasks: true,
|
||
|
|
subtasks: [
|
||
|
|
{ title: 'Subtask 1', status: 'pending' },
|
||
|
|
{ title: 'Subtask 2', status: 'done' }
|
||
|
|
]
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.userPrompt).toBe(
|
||
|
|
'Task has subtasks:\n- Subtask 1 (pending)\n- Subtask 2 (done)\n'
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should cache loaded templates', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'cached-prompt',
|
||
|
|
prompts: {
|
||
|
|
default: {
|
||
|
|
system: 'System',
|
||
|
|
user: 'User {{value}}'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
// First load
|
||
|
|
promptManager.loadPrompt('cached-prompt', { value: 'test1' });
|
||
|
|
expect(mockReadFileSync).toHaveBeenCalledTimes(1);
|
||
|
|
|
||
|
|
// Second load with same params should use cache
|
||
|
|
promptManager.loadPrompt('cached-prompt', { value: 'test1' });
|
||
|
|
expect(mockReadFileSync).toHaveBeenCalledTimes(1);
|
||
|
|
|
||
|
|
// Third load with different params should NOT use cache
|
||
|
|
promptManager.loadPrompt('cached-prompt', { value: 'test2' });
|
||
|
|
expect(mockReadFileSync).toHaveBeenCalledTimes(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should throw error for non-existent template', () => {
|
||
|
|
const error = new Error('File not found');
|
||
|
|
error.code = 'ENOENT';
|
||
|
|
mockReadFileSync.mockImplementation(() => {
|
||
|
|
throw error;
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(() => {
|
||
|
|
promptManager.loadPrompt('non-existent', {});
|
||
|
|
}).toThrow();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should throw error for invalid JSON', () => {
|
||
|
|
mockReadFileSync.mockReturnValue('{ invalid json');
|
||
|
|
|
||
|
|
expect(() => {
|
||
|
|
promptManager.loadPrompt('invalid-json', {});
|
||
|
|
}).toThrow();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle missing prompts section', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'no-prompts'
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
expect(() => {
|
||
|
|
promptManager.loadPrompt('no-prompts', {});
|
||
|
|
}).toThrow();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle special characters in templates', () => {
|
||
|
|
const mockTemplate = {
|
||
|
|
id: 'special-chars',
|
||
|
|
prompts: {
|
||
|
|
default: {
|
||
|
|
system: 'System with "quotes" and \'apostrophes\'',
|
||
|
|
user: 'User with newlines\nand\ttabs'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(mockTemplate));
|
||
|
|
|
||
|
|
const result = promptManager.loadPrompt('special-chars', {});
|
||
|
|
|
||
|
|
expect(result.systemPrompt).toBe(
|
||
|
|
'System with "quotes" and \'apostrophes\''
|
||
|
|
);
|
||
|
|
expect(result.userPrompt).toBe('User with newlines\nand\ttabs');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('singleton behavior', () => {
|
||
|
|
it('should return the same instance on multiple calls', () => {
|
||
|
|
const instance1 = getPromptManager();
|
||
|
|
const instance2 = getPromptManager();
|
||
|
|
|
||
|
|
expect(instance1).toBe(instance2);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|