mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
test: add unit tests for exec-utils, feature verification, project analysis, and task execution
- Introduced comprehensive unit tests for exec-utils, covering execAsync, extendedPath, execEnv, and isENOENT functions. - Added tests for FeatureVerificationService to validate feature verification and commit processes. - Implemented tests for ProjectAnalyzer to ensure project analysis functionality. - Created tests for TaskExecutor to validate task execution and event emissions. These additions enhance test coverage and ensure the reliability of core functionalities.
This commit is contained in:
332
apps/server/tests/unit/services/auto-mode/task-executor.test.ts
Normal file
332
apps/server/tests/unit/services/auto-mode/task-executor.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { TaskExecutor } from '@/services/auto-mode/task-executor.js';
|
||||
import type { ParsedTask } from '@automaker/types';
|
||||
import type { TaskExecutionContext } from '@/services/auto-mode/types.js';
|
||||
|
||||
// Use vi.hoisted for mock functions
|
||||
const { mockBuildTaskPrompt, mockProcessStream } = vi.hoisted(() => ({
|
||||
mockBuildTaskPrompt: vi.fn(),
|
||||
mockProcessStream: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@automaker/prompts', () => ({
|
||||
buildTaskPrompt: mockBuildTaskPrompt,
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual('@automaker/utils');
|
||||
return {
|
||||
...actual,
|
||||
processStream: mockProcessStream,
|
||||
};
|
||||
});
|
||||
|
||||
describe('TaskExecutor', () => {
|
||||
let executor: TaskExecutor;
|
||||
let mockEvents: { emit: ReturnType<typeof vi.fn> };
|
||||
let mockProvider: { executeQuery: ReturnType<typeof vi.fn> };
|
||||
let mockContext: TaskExecutionContext;
|
||||
let mockTasks: ParsedTask[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockEvents = { emit: vi.fn() };
|
||||
mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'Task output' };
|
||||
})()
|
||||
),
|
||||
};
|
||||
|
||||
mockContext = {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project',
|
||||
workDir: '/project/worktree',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
maxTurns: 100,
|
||||
allowedTools: ['Read', 'Write'],
|
||||
abortController: new AbortController(),
|
||||
planContent: '# Plan\nTask list',
|
||||
userFeedback: undefined,
|
||||
};
|
||||
|
||||
mockTasks = [
|
||||
{ id: '1', description: 'Task 1', phase: 'Phase 1' },
|
||||
{ id: '2', description: 'Task 2', phase: 'Phase 1' },
|
||||
{ id: '3', description: 'Task 3', phase: 'Phase 2' },
|
||||
];
|
||||
|
||||
mockBuildTaskPrompt.mockReturnValue('Generated task prompt');
|
||||
mockProcessStream.mockResolvedValue({ text: 'Processed output', toolUses: [] });
|
||||
|
||||
executor = new TaskExecutor(mockEvents as any);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create executor instance', () => {
|
||||
expect(executor).toBeInstanceOf(TaskExecutor);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeAll', () => {
|
||||
it('should yield started and completed events for each task', async () => {
|
||||
const results: any[] = [];
|
||||
for await (const progress of executor.executeAll(
|
||||
mockTasks,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
)) {
|
||||
results.push(progress);
|
||||
}
|
||||
|
||||
// Should have 2 events per task (started + completed)
|
||||
expect(results).toHaveLength(6);
|
||||
expect(results[0]).toEqual({
|
||||
taskId: '1',
|
||||
taskIndex: 0,
|
||||
tasksTotal: 3,
|
||||
status: 'started',
|
||||
});
|
||||
expect(results[1]).toEqual({
|
||||
taskId: '1',
|
||||
taskIndex: 0,
|
||||
tasksTotal: 3,
|
||||
status: 'completed',
|
||||
output: 'Processed output',
|
||||
phaseComplete: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit task started events', async () => {
|
||||
const results: any[] = [];
|
||||
for await (const progress of executor.executeAll(
|
||||
mockTasks,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
)) {
|
||||
results.push(progress);
|
||||
}
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_task_started',
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project',
|
||||
taskId: '1',
|
||||
taskDescription: 'Task 1',
|
||||
taskIndex: 0,
|
||||
tasksTotal: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit task complete events', async () => {
|
||||
const results: any[] = [];
|
||||
for await (const progress of executor.executeAll(
|
||||
mockTasks,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
)) {
|
||||
results.push(progress);
|
||||
}
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_task_complete',
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project',
|
||||
taskId: '1',
|
||||
tasksCompleted: 1,
|
||||
tasksTotal: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on abort', async () => {
|
||||
mockContext.abortController.abort();
|
||||
|
||||
const results: any[] = [];
|
||||
await expect(async () => {
|
||||
for await (const progress of executor.executeAll(
|
||||
mockTasks,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
)) {
|
||||
results.push(progress);
|
||||
}
|
||||
}).rejects.toThrow('Feature execution aborted');
|
||||
});
|
||||
|
||||
it('should call provider executeQuery with correct options', async () => {
|
||||
const results: any[] = [];
|
||||
for await (const progress of executor.executeAll(
|
||||
mockTasks,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
)) {
|
||||
results.push(progress);
|
||||
}
|
||||
|
||||
expect(mockProvider.executeQuery).toHaveBeenCalledWith({
|
||||
prompt: 'Generated task prompt',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
maxTurns: 50, // Limited to 50 per task
|
||||
cwd: '/project/worktree',
|
||||
allowedTools: ['Read', 'Write'],
|
||||
abortController: mockContext.abortController,
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect phase completion', async () => {
|
||||
const results: any[] = [];
|
||||
for await (const progress of executor.executeAll(
|
||||
mockTasks,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
)) {
|
||||
results.push(progress);
|
||||
}
|
||||
|
||||
// Task 2 completes Phase 1 (next task is Phase 2)
|
||||
const task2Completed = results.find((r) => r.taskId === '2' && r.status === 'completed');
|
||||
expect(task2Completed?.phaseComplete).toBe(1);
|
||||
|
||||
// Task 3 completes Phase 2 (no more tasks)
|
||||
const task3Completed = results.find((r) => r.taskId === '3' && r.status === 'completed');
|
||||
expect(task3Completed?.phaseComplete).toBe(2);
|
||||
});
|
||||
|
||||
it('should emit phase complete events', async () => {
|
||||
const results: any[] = [];
|
||||
for await (const progress of executor.executeAll(
|
||||
mockTasks,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
)) {
|
||||
results.push(progress);
|
||||
}
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_phase_complete',
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project',
|
||||
phaseNumber: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should yield failed status on error', async () => {
|
||||
mockProcessStream.mockRejectedValueOnce(new Error('Task failed'));
|
||||
|
||||
const results: any[] = [];
|
||||
await expect(async () => {
|
||||
for await (const progress of executor.executeAll(
|
||||
mockTasks,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
)) {
|
||||
results.push(progress);
|
||||
}
|
||||
}).rejects.toThrow('Task failed');
|
||||
|
||||
expect(results).toContainEqual({
|
||||
taskId: '1',
|
||||
taskIndex: 0,
|
||||
tasksTotal: 3,
|
||||
status: 'failed',
|
||||
output: 'Task failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeOne', () => {
|
||||
it('should execute a single task and return output', async () => {
|
||||
const result = await executor.executeOne(
|
||||
mockTasks[0],
|
||||
mockTasks,
|
||||
0,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
expect(result).toBe('Processed output');
|
||||
});
|
||||
|
||||
it('should build prompt with correct parameters', async () => {
|
||||
await executor.executeOne(mockTasks[0], mockTasks, 0, mockContext, mockProvider as any);
|
||||
|
||||
expect(mockBuildTaskPrompt).toHaveBeenCalledWith(
|
||||
mockTasks[0],
|
||||
mockTasks,
|
||||
0,
|
||||
mockContext.planContent,
|
||||
mockContext.userFeedback
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit progress events for text output', async () => {
|
||||
mockProcessStream.mockImplementation(async (_stream, options) => {
|
||||
options.onText?.('Some output');
|
||||
return { text: 'Some output', toolUses: [] };
|
||||
});
|
||||
|
||||
await executor.executeOne(mockTasks[0], mockTasks, 0, mockContext, mockProvider as any);
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_progress',
|
||||
featureId: 'feature-1',
|
||||
content: 'Some output',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit tool events for tool use', async () => {
|
||||
mockProcessStream.mockImplementation(async (_stream, options) => {
|
||||
options.onToolUse?.('Read', { path: '/file.txt' });
|
||||
return { text: 'Output', toolUses: [] };
|
||||
});
|
||||
|
||||
await executor.executeOne(mockTasks[0], mockTasks, 0, mockContext, mockProvider as any);
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_tool',
|
||||
featureId: 'feature-1',
|
||||
tool: 'Read',
|
||||
input: { path: '/file.txt' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase detection', () => {
|
||||
it('should not detect phase completion for tasks without phase', async () => {
|
||||
const tasksNoPhase = [
|
||||
{ id: '1', description: 'Task 1' },
|
||||
{ id: '2', description: 'Task 2' },
|
||||
];
|
||||
|
||||
const results: any[] = [];
|
||||
for await (const progress of executor.executeAll(
|
||||
tasksNoPhase,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
)) {
|
||||
results.push(progress);
|
||||
}
|
||||
|
||||
const completedResults = results.filter((r) => r.status === 'completed');
|
||||
expect(completedResults.every((r) => r.phaseComplete === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect phase change when next task has different phase', async () => {
|
||||
const results: any[] = [];
|
||||
for await (const progress of executor.executeAll(
|
||||
mockTasks,
|
||||
mockContext,
|
||||
mockProvider as any
|
||||
)) {
|
||||
results.push(progress);
|
||||
}
|
||||
|
||||
// Task 2 (Phase 1) -> Task 3 (Phase 2) = phase complete
|
||||
const task2Completed = results.find((r) => r.taskId === '2' && r.status === 'completed');
|
||||
expect(task2Completed?.phaseComplete).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user