mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-05 09:33:07 +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