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
390 lines
12 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
}); |