Files
Codewalkers/apps/server/agent/process-manager.test.ts
Lukas May 5e77bf104c feat: Add remote sync for project clones
Fetch remote changes before agents start working so they build on
up-to-date code. Adds ProjectSyncManager with git fetch + ff-only
merge of defaultBranch, integrated into phase dispatch to sync
before branch creation.

- Schema: lastFetchedAt column on projects table (migration 0029)
- Events: project:synced, project:sync_failed
- Phase dispatch: sync all linked projects before creating branches
- tRPC: syncProject, syncAllProjects, getProjectSyncStatus
- CLI: cw project sync [name] --all, cw project status [name]
- Frontend: sync button + ahead/behind badge on projects settings
2026-03-05 11:45:09 +01:00

390 lines
12 KiB
TypeScript

/**
* 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");
});
});
});