/** * ProcessManager Unit Tests * * Tests for ProcessManager class focusing on working directory handling, * command building, and spawn validation. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ProcessManager } from './process-manager.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; // Mock child_process.spawn vi.mock('node:child_process', () => ({ spawn: vi.fn(), })); // Mock fs operations vi.mock('node:fs', () => ({ openSync: vi.fn((path) => { // Return different fd numbers for stdout and stderr if (path.includes('output.jsonl')) return 99; if (path.includes('stderr.log')) return 100; return 101; }), closeSync: vi.fn(), existsSync: vi.fn(), })); vi.mock('node:fs/promises', () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), })); // Mock FileTailer vi.mock('./file-tailer.js', () => ({ FileTailer: class MockFileTailer { start = vi.fn().mockResolvedValue(undefined); stop = vi.fn().mockResolvedValue(undefined); }, })); // Mock SimpleGitWorktreeManager const mockCreate = vi.fn(); vi.mock('../git/manager.js', () => ({ SimpleGitWorktreeManager: class MockWorktreeManager { create = mockCreate; }, })); // Mock project clones vi.mock('../git/project-clones.js', () => ({ ensureProjectClone: vi.fn().mockResolvedValue('/mock/clone/path'), getProjectCloneDir: vi.fn().mockReturnValue('/mock/clone/path'), })); // Mock providers vi.mock('./providers/parsers/index.js', () => ({ getStreamParser: vi.fn().mockReturnValue({ parse: vi.fn() }), })); import { spawn } from 'node:child_process'; import { existsSync, openSync, closeSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { ensureProjectClone } from '../git/project-clones.js'; const mockSpawn = vi.mocked(spawn); const mockExistsSync = vi.mocked(existsSync); const mockMkdir = vi.mocked(mkdir); const mockWriteFile = vi.mocked(writeFile); const mockOpenSync = vi.mocked(openSync); const mockCloseSync = vi.mocked(closeSync); describe('ProcessManager', () => { let processManager: ProcessManager; let mockProjectRepository: ProjectRepository; const workspaceRoot = '/test/workspace'; beforeEach(() => { vi.clearAllMocks(); // Mock child process const mockChild = { pid: 12345, unref: vi.fn(), on: vi.fn(), kill: vi.fn(), }; mockSpawn.mockReturnValue(mockChild as any); // Mock project repository mockProjectRepository = { findProjectsByInitiativeId: vi.fn().mockResolvedValue([]), create: vi.fn(), findAll: vi.fn(), findById: vi.fn(), findByName: vi.fn(), update: vi.fn(), delete: vi.fn(), setInitiativeProjects: vi.fn(), addProjectToInitiative: vi.fn(), removeProjectFromInitiative: vi.fn(), }; processManager = new ProcessManager(workspaceRoot, mockProjectRepository); }); afterEach(() => { vi.resetAllMocks(); }); describe('getAgentWorkdir', () => { it('returns correct agent workdir path', () => { const alias = 'test-agent'; const expected = '/test/workspace/agent-workdirs/test-agent'; const result = processManager.getAgentWorkdir(alias); expect(result).toBe(expected); }); }); describe('createProjectWorktrees', () => { beforeEach(() => { // Mock the global worktree create function mockCreate.mockResolvedValue({ id: 'project1', path: '/test/workspace/agent-workdirs/test-agent/project1', branch: 'agent/test-agent', isMainWorktree: false, }); // Mock project repository vi.mocked(mockProjectRepository.findProjectsByInitiativeId).mockResolvedValue([ { id: '1', name: 'project1', url: 'https://github.com/user/project1.git', defaultBranch: 'main', lastFetchedAt: null, createdAt: new Date(), updatedAt: new Date() } ]); // Mock existsSync to return true for worktree paths mockExistsSync.mockImplementation((path) => { return path.toString().includes('/agent-workdirs/'); }); }); it('creates worktrees for initiative projects', async () => { const alias = 'test-agent'; const initiativeId = 'init-123'; const result = await processManager.createProjectWorktrees(alias, initiativeId); expect(result).toBe('/test/workspace/agent-workdirs/test-agent'); expect(mockProjectRepository.findProjectsByInitiativeId).toHaveBeenCalledWith('init-123'); expect(ensureProjectClone).toHaveBeenCalled(); }); it('throws error when worktree creation fails', async () => { // Mock worktree path to not exist after creation mockExistsSync.mockReturnValue(false); const alias = 'test-agent'; const initiativeId = 'init-123'; await expect(processManager.createProjectWorktrees(alias, initiativeId)) .rejects.toThrow('Worktree creation failed:'); }); }); describe('createStandaloneWorktree', () => { beforeEach(() => { mockCreate.mockResolvedValue({ id: 'workspace', path: '/test/workspace/agent-workdirs/test-agent/workspace', branch: 'agent/test-agent', isMainWorktree: false, }); mockExistsSync.mockImplementation((path) => { return path.toString().includes('/workspace'); }); }); it('creates standalone worktree', async () => { const alias = 'test-agent'; const result = await processManager.createStandaloneWorktree(alias); expect(result).toBe('/test/workspace/agent-workdirs/test-agent/workspace'); }); it('throws error when standalone worktree creation fails', async () => { mockExistsSync.mockReturnValue(false); const alias = 'test-agent'; await expect(processManager.createStandaloneWorktree(alias)) .rejects.toThrow('Standalone worktree creation failed:'); }); }); describe('spawnDetached', () => { beforeEach(() => { mockExistsSync.mockReturnValue(true); // CWD exists }); it('validates cwd exists before spawn', async () => { const agentId = 'agent-123'; const agentName = 'test-agent'; const command = 'claude'; const args = ['--help']; const cwd = '/test/workspace/agent-workdirs/test-agent'; const env = { TEST_VAR: 'value' }; const providerName = 'claude'; await processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName); expect(mockExistsSync).toHaveBeenCalledWith(cwd); expect(mockSpawn).toHaveBeenCalledWith(command, args, { cwd, env: expect.objectContaining(env), detached: true, stdio: ['ignore', 99, 100], }); }); it('throws error when cwd does not exist', async () => { mockExistsSync.mockReturnValue(false); const agentId = 'agent-123'; const agentName = 'test-agent'; const command = 'claude'; const args = ['--help']; const cwd = '/nonexistent/path'; const env = {}; const providerName = 'claude'; await expect( processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName), ).rejects.toThrow('Agent working directory does not exist: /nonexistent/path'); }); it('passes correct cwd parameter to spawn', async () => { const agentId = 'agent-123'; const agentName = 'test-agent'; const command = 'claude'; const args = ['--help']; const cwd = '/test/workspace/agent-workdirs/test-agent'; const env = { CLAUDE_CONFIG_DIR: '/config' }; const providerName = 'claude'; await processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName); expect(mockSpawn).toHaveBeenCalledTimes(1); const spawnCall = mockSpawn.mock.calls[0]; expect(spawnCall[0]).toBe(command); expect(spawnCall[1]).toEqual(args); expect(spawnCall[2]).toEqual({ cwd, env: expect.objectContaining({ ...process.env, CLAUDE_CONFIG_DIR: '/config', }), detached: true, stdio: ['ignore', 99, 100], }); }); it('writes prompt file when provided', async () => { const agentId = 'agent-123'; const agentName = 'test-agent'; const command = 'claude'; const args = ['--help']; const cwd = '/test/workspace/agent-workdirs/test-agent'; const env = {}; const providerName = 'claude'; const prompt = 'Test prompt'; await processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName, prompt); expect(mockWriteFile).toHaveBeenCalledWith( '/test/workspace/.cw/agent-logs/test-agent/PROMPT.md', 'Test prompt', 'utf-8' ); }); }); describe('buildSpawnCommand', () => { it('builds command with native prompt mode', () => { const provider = { name: 'claude', command: 'claude', args: ['--json-schema', 'schema.json'], env: {}, promptMode: 'native' as const, processNames: ['claude'], resumeStyle: 'flag' as const, resumeFlag: '--resume', nonInteractive: { subcommand: 'chat', promptFlag: '-p', outputFlag: '--output-format json', }, }; const prompt = 'Test prompt'; const result = processManager.buildSpawnCommand(provider, prompt); expect(result).toEqual({ command: 'claude', args: ['chat', '--json-schema', 'schema.json', '-p', 'Test prompt', '--output-format', 'json'], env: {}, }); }); it('builds command with flag prompt mode', () => { const provider = { name: 'codex', command: 'codex', args: ['--format', 'json'], env: {}, promptMode: 'flag' as const, processNames: ['codex'], resumeStyle: 'subcommand' as const, resumeFlag: 'resume', nonInteractive: { subcommand: 'run', promptFlag: '--prompt', outputFlag: '--json', }, }; const prompt = 'Test prompt'; const result = processManager.buildSpawnCommand(provider, prompt); expect(result).toEqual({ command: 'codex', args: ['run', '--format', 'json', '--prompt', 'Test prompt', '--json'], env: {}, }); }); }); describe('buildResumeCommand', () => { it('builds resume command with flag style', () => { const provider = { name: 'claude', command: 'claude', args: [], env: {}, promptMode: 'native' as const, processNames: ['claude'], resumeStyle: 'flag' as const, resumeFlag: '--resume', nonInteractive: { subcommand: 'chat', promptFlag: '-p', outputFlag: '--json', }, }; const sessionId = 'session-123'; const prompt = 'Continue working'; const result = processManager.buildResumeCommand(provider, sessionId, prompt); expect(result).toEqual({ command: 'claude', args: ['--resume', 'session-123', '-p', 'Continue working', '--json'], env: {}, }); }); it('throws error for providers without resume support', () => { const provider = { name: 'noresume', command: 'noresume', args: [], env: {}, promptMode: 'native' as const, processNames: ['noresume'], resumeStyle: 'none' as const, }; const sessionId = 'session-123'; const prompt = 'Continue working'; expect(() => { processManager.buildResumeCommand(provider, sessionId, prompt); }).toThrow("Provider 'noresume' does not support resume"); }); }); });