mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +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:
143
apps/server/tests/unit/lib/exec-utils.test.ts
Normal file
143
apps/server/tests/unit/lib/exec-utils.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Store original platform and env
|
||||
const originalPlatform = process.platform;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
describe('exec-utils.ts', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original values
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe('execAsync', () => {
|
||||
it('should be a promisified exec function', async () => {
|
||||
const { execAsync } = await import('@/lib/exec-utils.js');
|
||||
expect(typeof execAsync).toBe('function');
|
||||
});
|
||||
|
||||
it('should execute shell commands successfully', async () => {
|
||||
const { execAsync } = await import('@/lib/exec-utils.js');
|
||||
const result = await execAsync('echo "hello"');
|
||||
expect(result.stdout.trim()).toBe('hello');
|
||||
});
|
||||
|
||||
it('should reject on invalid commands', async () => {
|
||||
const { execAsync } = await import('@/lib/exec-utils.js');
|
||||
await expect(execAsync('nonexistent-command-12345')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extendedPath', () => {
|
||||
it('should include the original PATH', async () => {
|
||||
const { extendedPath } = await import('@/lib/exec-utils.js');
|
||||
expect(extendedPath).toContain(process.env.PATH);
|
||||
});
|
||||
|
||||
it('should include additional Unix paths on non-Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
vi.resetModules();
|
||||
|
||||
const { extendedPath } = await import('@/lib/exec-utils.js');
|
||||
expect(extendedPath).toContain('/opt/homebrew/bin');
|
||||
expect(extendedPath).toContain('/usr/local/bin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execEnv', () => {
|
||||
it('should have PATH set to extendedPath', async () => {
|
||||
const { execEnv, extendedPath } = await import('@/lib/exec-utils.js');
|
||||
expect(execEnv.PATH).toBe(extendedPath);
|
||||
});
|
||||
|
||||
it('should include all original environment variables', async () => {
|
||||
const { execEnv } = await import('@/lib/exec-utils.js');
|
||||
// Should have common env vars
|
||||
expect(execEnv.HOME || execEnv.USERPROFILE).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isENOENT', () => {
|
||||
it('should return true for ENOENT errors', async () => {
|
||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
||||
const error = { code: 'ENOENT' };
|
||||
expect(isENOENT(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other error codes', async () => {
|
||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
||||
const error = { code: 'EACCES' };
|
||||
expect(isENOENT(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null', async () => {
|
||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
||||
expect(isENOENT(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', async () => {
|
||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
||||
expect(isENOENT(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-objects', async () => {
|
||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
||||
expect(isENOENT('ENOENT')).toBe(false);
|
||||
expect(isENOENT(123)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for objects without code property', async () => {
|
||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
||||
expect(isENOENT({})).toBe(false);
|
||||
expect(isENOENT({ message: 'error' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle Error objects with code', async () => {
|
||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
||||
const error = new Error('File not found') as Error & { code: string };
|
||||
error.code = 'ENOENT';
|
||||
expect(isENOENT(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows platform handling', () => {
|
||||
it('should use semicolon as path separator on Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
process.env.LOCALAPPDATA = 'C:\\Users\\Test\\AppData\\Local';
|
||||
process.env.PROGRAMFILES = 'C:\\Program Files';
|
||||
vi.resetModules();
|
||||
|
||||
const { extendedPath } = await import('@/lib/exec-utils.js');
|
||||
// Windows uses semicolon separator
|
||||
expect(extendedPath).toContain(';');
|
||||
expect(extendedPath).toContain('\\Git\\cmd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unix platform handling', () => {
|
||||
it('should use colon as path separator on Unix', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.HOME = '/home/testuser';
|
||||
vi.resetModules();
|
||||
|
||||
const { extendedPath } = await import('@/lib/exec-utils.js');
|
||||
// Unix uses colon separator
|
||||
expect(extendedPath).toContain(':');
|
||||
expect(extendedPath).toContain('/home/linuxbrew/.linuxbrew/bin');
|
||||
});
|
||||
|
||||
it('should include HOME/.local/bin path', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
process.env.HOME = '/Users/testuser';
|
||||
vi.resetModules();
|
||||
|
||||
const { extendedPath } = await import('@/lib/exec-utils.js');
|
||||
expect(extendedPath).toContain('/Users/testuser/.local/bin');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FeatureVerificationService } from '@/services/auto-mode/feature-verification.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
secureFs: {
|
||||
access: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
getFeatureDir: vi.fn(
|
||||
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/git-utils', () => ({
|
||||
runVerificationChecks: vi.fn(),
|
||||
hasUncommittedChanges: vi.fn(),
|
||||
commitAll: vi.fn(),
|
||||
shortHash: vi.fn((hash: string) => hash.substring(0, 7)),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/prompts', () => ({
|
||||
extractTitleFromDescription: vi.fn((desc: string) => desc.split('\n')[0]),
|
||||
}));
|
||||
|
||||
import { secureFs, getFeatureDir } from '@automaker/platform';
|
||||
import { runVerificationChecks, hasUncommittedChanges, commitAll } from '@automaker/git-utils';
|
||||
|
||||
describe('FeatureVerificationService', () => {
|
||||
let service: FeatureVerificationService;
|
||||
let mockEvents: { emit: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEvents = { emit: vi.fn() };
|
||||
service = new FeatureVerificationService(mockEvents as any);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create service instance', () => {
|
||||
expect(service).toBeInstanceOf(FeatureVerificationService);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWorkDir', () => {
|
||||
it('should return worktree path if it exists', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.resolveWorkDir('/project', 'feature-1');
|
||||
|
||||
expect(result).toBe('/project/.worktrees/feature-1');
|
||||
expect(secureFs.access).toHaveBeenCalledWith('/project/.worktrees/feature-1');
|
||||
});
|
||||
|
||||
it('should return project path if worktree does not exist', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await service.resolveWorkDir('/project', 'feature-1');
|
||||
|
||||
expect(result).toBe('/project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify', () => {
|
||||
it('should emit success event when verification passes', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(runVerificationChecks).mockResolvedValue({ success: true });
|
||||
|
||||
const result = await service.verify('/project', 'feature-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feature-1',
|
||||
passes: true,
|
||||
message: 'All verification checks passed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit failure event when verification fails', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(runVerificationChecks).mockResolvedValue({
|
||||
success: false,
|
||||
failedCheck: 'lint',
|
||||
});
|
||||
|
||||
const result = await service.verify('/project', 'feature-1');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedCheck).toBe('lint');
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feature-1',
|
||||
passes: false,
|
||||
message: 'Verification failed: lint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use worktree path if available', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(runVerificationChecks).mockResolvedValue({ success: true });
|
||||
|
||||
await service.verify('/project', 'feature-1');
|
||||
|
||||
expect(runVerificationChecks).toHaveBeenCalledWith('/project/.worktrees/feature-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commit', () => {
|
||||
it('should return null hash when no changes', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(false);
|
||||
|
||||
const result = await service.commit('/project', 'feature-1', null);
|
||||
|
||||
expect(result.hash).toBeNull();
|
||||
expect(commitAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should commit changes and return hash', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
|
||||
vi.mocked(commitAll).mockResolvedValue('abc123def456');
|
||||
|
||||
const result = await service.commit('/project', 'feature-1', {
|
||||
id: 'feature-1',
|
||||
description: 'Add login button\nWith authentication',
|
||||
} as any);
|
||||
|
||||
expect(result.hash).toBe('abc123def456');
|
||||
expect(result.shortHash).toBe('abc123d');
|
||||
expect(commitAll).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
expect.stringContaining('feat: Add login button')
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided worktree path', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
|
||||
vi.mocked(commitAll).mockResolvedValue('abc123');
|
||||
|
||||
await service.commit('/project', 'feature-1', null, '/custom/worktree');
|
||||
|
||||
expect(hasUncommittedChanges).toHaveBeenCalledWith('/custom/worktree');
|
||||
});
|
||||
|
||||
it('should fall back to project path if provided worktree does not exist', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(false);
|
||||
|
||||
await service.commit('/project', 'feature-1', null, '/nonexistent/worktree');
|
||||
|
||||
expect(hasUncommittedChanges).toHaveBeenCalledWith('/project');
|
||||
});
|
||||
|
||||
it('should use feature ID in commit message when no feature provided', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
|
||||
vi.mocked(commitAll).mockResolvedValue('abc123');
|
||||
|
||||
await service.commit('/project', 'feature-123', null);
|
||||
|
||||
expect(commitAll).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
expect.stringContaining('feat: Feature feature-123')
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit event on successful commit', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
|
||||
vi.mocked(commitAll).mockResolvedValue('abc123def');
|
||||
|
||||
await service.commit('/project', 'feature-1', null);
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feature-1',
|
||||
passes: true,
|
||||
message: expect.stringContaining('Changes committed:'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null hash when commit fails', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
|
||||
vi.mocked(commitAll).mockResolvedValue(null);
|
||||
|
||||
const result = await service.commit('/project', 'feature-1', null);
|
||||
|
||||
expect(result.hash).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contextExists', () => {
|
||||
it('should return true if context file exists', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.contextExists('/project', 'feature-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(secureFs.access).toHaveBeenCalledWith(
|
||||
'/project/.automaker/features/feature-1/agent-output.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false if context file does not exist', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await service.contextExists('/project', 'feature-1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadContext', () => {
|
||||
it('should return context content if file exists', async () => {
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('# Agent Output\nSome content');
|
||||
|
||||
const result = await service.loadContext('/project', 'feature-1');
|
||||
|
||||
expect(result).toBe('# Agent Output\nSome content');
|
||||
expect(secureFs.readFile).toHaveBeenCalledWith(
|
||||
'/project/.automaker/features/feature-1/agent-output.md',
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null if file does not exist', async () => {
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await service.loadContext('/project', 'feature-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Use vi.hoisted for mock functions that need to be used in vi.mock factories
|
||||
const {
|
||||
mockExecuteQuery,
|
||||
mockProcessStream,
|
||||
mockMkdir,
|
||||
mockWriteFile,
|
||||
mockValidateWorkingDirectory,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExecuteQuery: vi.fn(),
|
||||
mockProcessStream: vi.fn(),
|
||||
mockMkdir: vi.fn(),
|
||||
mockWriteFile: vi.fn(),
|
||||
mockValidateWorkingDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
secureFs: {
|
||||
mkdir: mockMkdir,
|
||||
writeFile: mockWriteFile,
|
||||
},
|
||||
getAutomakerDir: (projectPath: string) => `${projectPath}/.automaker`,
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual('@automaker/utils');
|
||||
return {
|
||||
...actual,
|
||||
processStream: mockProcessStream,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@automaker/model-resolver', () => ({
|
||||
resolveModelString: () => 'claude-sonnet-4-20250514',
|
||||
DEFAULT_MODELS: { claude: 'claude-sonnet-4-20250514' },
|
||||
}));
|
||||
|
||||
vi.mock('@/providers/provider-factory.js', () => ({
|
||||
ProviderFactory: {
|
||||
getProviderForModel: () => ({
|
||||
executeQuery: mockExecuteQuery,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/sdk-options.js', () => ({
|
||||
validateWorkingDirectory: mockValidateWorkingDirectory,
|
||||
}));
|
||||
|
||||
import { ProjectAnalyzer } from '@/services/auto-mode/project-analyzer.js';
|
||||
|
||||
describe('ProjectAnalyzer', () => {
|
||||
let analyzer: ProjectAnalyzer;
|
||||
let mockEvents: { emit: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEvents = { emit: vi.fn() };
|
||||
|
||||
mockExecuteQuery.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'Analysis result' };
|
||||
})()
|
||||
);
|
||||
|
||||
mockProcessStream.mockResolvedValue({
|
||||
text: '# Project Analysis\nThis is a test project.',
|
||||
toolUses: [],
|
||||
});
|
||||
|
||||
analyzer = new ProjectAnalyzer(mockEvents as any);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create analyzer instance', () => {
|
||||
expect(analyzer).toBeInstanceOf(ProjectAnalyzer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyze', () => {
|
||||
it('should validate working directory', async () => {
|
||||
await analyzer.analyze('/project');
|
||||
|
||||
expect(mockValidateWorkingDirectory).toHaveBeenCalledWith('/project');
|
||||
});
|
||||
|
||||
it('should emit start event', async () => {
|
||||
await analyzer.analyze('/project');
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'auto-mode:event',
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_feature_start',
|
||||
projectPath: '/project',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should call provider executeQuery with correct options', async () => {
|
||||
await analyzer.analyze('/project');
|
||||
|
||||
expect(mockExecuteQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/project',
|
||||
maxTurns: 5,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should save analysis to file', async () => {
|
||||
await analyzer.analyze('/project');
|
||||
|
||||
expect(mockMkdir).toHaveBeenCalledWith('/project/.automaker', { recursive: true });
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
'/project/.automaker/project-analysis.md',
|
||||
'# Project Analysis\nThis is a test project.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit complete event on success', async () => {
|
||||
await analyzer.analyze('/project');
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'auto-mode:event',
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: true,
|
||||
message: 'Project analysis completed',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit error event on failure', async () => {
|
||||
mockProcessStream.mockRejectedValue(new Error('Analysis failed'));
|
||||
|
||||
await analyzer.analyze('/project');
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'auto-mode:event',
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_error',
|
||||
error: expect.stringContaining('Analysis failed'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle stream with onText callback', async () => {
|
||||
await analyzer.analyze('/project');
|
||||
|
||||
expect(mockProcessStream).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
onText: expect.any(Function),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
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