diff --git a/apps/server/tests/unit/lib/exec-utils.test.ts b/apps/server/tests/unit/lib/exec-utils.test.ts new file mode 100644 index 00000000..7e2e636e --- /dev/null +++ b/apps/server/tests/unit/lib/exec-utils.test.ts @@ -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'); + }); + }); +}); diff --git a/apps/server/tests/unit/services/auto-mode/feature-verification.test.ts b/apps/server/tests/unit/services/auto-mode/feature-verification.test.ts new file mode 100644 index 00000000..019789c8 --- /dev/null +++ b/apps/server/tests/unit/services/auto-mode/feature-verification.test.ts @@ -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 }; + + 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(); + }); + }); +}); diff --git a/apps/server/tests/unit/services/auto-mode/project-analyzer.test.ts b/apps/server/tests/unit/services/auto-mode/project-analyzer.test.ts new file mode 100644 index 00000000..3f2896b3 --- /dev/null +++ b/apps/server/tests/unit/services/auto-mode/project-analyzer.test.ts @@ -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 }; + + 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), + }) + ); + }); + }); +}); diff --git a/apps/server/tests/unit/services/auto-mode/task-executor.test.ts b/apps/server/tests/unit/services/auto-mode/task-executor.test.ts new file mode 100644 index 00000000..5f7325e5 --- /dev/null +++ b/apps/server/tests/unit/services/auto-mode/task-executor.test.ts @@ -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 }; + let mockProvider: { executeQuery: ReturnType }; + 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); + }); + }); +});