mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
test(01-01): add characterization tests for ConcurrencyManager
- Test lease counting basics (acquire/release semantics) - Test running count queries (project and worktree level) - Test feature state queries (isRunning, getRunningFeature, getAllRunning) - Test edge cases (multiple features, multiple worktrees) - 36 test cases documenting expected behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
612
apps/server/tests/unit/services/concurrency-manager.test.ts
Normal file
612
apps/server/tests/unit/services/concurrency-manager.test.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ConcurrencyManager, type RunningFeature } from '@/services/concurrency-manager.js';
|
||||
|
||||
// Mock git-utils to control getCurrentBranch behavior
|
||||
vi.mock('@automaker/git-utils', () => ({
|
||||
getCurrentBranch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getCurrentBranch } from '@automaker/git-utils';
|
||||
const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
|
||||
|
||||
describe('ConcurrencyManager', () => {
|
||||
let manager: ConcurrencyManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
manager = new ConcurrencyManager();
|
||||
// Default: primary branch is 'main'
|
||||
mockGetCurrentBranch.mockResolvedValue('main');
|
||||
});
|
||||
|
||||
describe('acquire', () => {
|
||||
it('should create new entry with leaseCount: 1 on first acquire', () => {
|
||||
const result = manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
expect(result.featureId).toBe('feature-1');
|
||||
expect(result.projectPath).toBe('/test/project');
|
||||
expect(result.isAutoMode).toBe(true);
|
||||
expect(result.leaseCount).toBe(1);
|
||||
expect(result.worktreePath).toBeNull();
|
||||
expect(result.branchName).toBeNull();
|
||||
expect(result.startTime).toBeDefined();
|
||||
expect(result.abortController).toBeInstanceOf(AbortController);
|
||||
});
|
||||
|
||||
it('should increment leaseCount when allowReuse is true for existing feature', () => {
|
||||
// First acquire
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
// Second acquire with allowReuse
|
||||
const result = manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
allowReuse: true,
|
||||
});
|
||||
|
||||
expect(result.leaseCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw "already running" when allowReuse is false for existing feature', () => {
|
||||
// First acquire
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
// Second acquire without allowReuse
|
||||
expect(() =>
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
})
|
||||
).toThrow('already running');
|
||||
});
|
||||
|
||||
it('should throw "already running" when allowReuse is explicitly false', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
allowReuse: false,
|
||||
})
|
||||
).toThrow('already running');
|
||||
});
|
||||
|
||||
it('should use provided abortController', () => {
|
||||
const customAbortController = new AbortController();
|
||||
|
||||
const result = manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
abortController: customAbortController,
|
||||
});
|
||||
|
||||
expect(result.abortController).toBe(customAbortController);
|
||||
});
|
||||
|
||||
it('should return the existing entry when allowReuse is true', () => {
|
||||
const first = manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const second = manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
allowReuse: true,
|
||||
});
|
||||
|
||||
// Should be the same object reference
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('should allow multiple nested acquire calls with allowReuse', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
allowReuse: true,
|
||||
});
|
||||
|
||||
const result = manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
allowReuse: true,
|
||||
});
|
||||
|
||||
expect(result.leaseCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('release', () => {
|
||||
it('should decrement leaseCount on release', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
allowReuse: true,
|
||||
});
|
||||
|
||||
manager.release('feature-1');
|
||||
|
||||
const entry = manager.getRunningFeature('feature-1');
|
||||
expect(entry?.leaseCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should delete entry when leaseCount reaches 0', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.release('feature-1');
|
||||
|
||||
expect(manager.isRunning('feature-1')).toBe(false);
|
||||
expect(manager.getRunningFeature('feature-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should delete entry immediately when force is true regardless of leaseCount', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
allowReuse: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
allowReuse: true,
|
||||
});
|
||||
|
||||
// leaseCount is 3, but force should still delete
|
||||
manager.release('feature-1', { force: true });
|
||||
|
||||
expect(manager.isRunning('feature-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should do nothing when releasing non-existent feature', () => {
|
||||
// Should not throw
|
||||
manager.release('non-existent-feature');
|
||||
manager.release('non-existent-feature', { force: true });
|
||||
});
|
||||
|
||||
it('should only delete entry after all leases are released', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
allowReuse: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
allowReuse: true,
|
||||
});
|
||||
|
||||
// leaseCount is 3
|
||||
manager.release('feature-1');
|
||||
expect(manager.isRunning('feature-1')).toBe(true);
|
||||
|
||||
manager.release('feature-1');
|
||||
expect(manager.isRunning('feature-1')).toBe(true);
|
||||
|
||||
manager.release('feature-1');
|
||||
expect(manager.isRunning('feature-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRunning', () => {
|
||||
it('should return false when feature is not running', () => {
|
||||
expect(manager.isRunning('feature-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when feature is running', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
expect(manager.isRunning('feature-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false after feature is released', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.release('feature-1');
|
||||
|
||||
expect(manager.isRunning('feature-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunningFeature', () => {
|
||||
it('should return undefined for non-existent feature', () => {
|
||||
expect(manager.getRunningFeature('feature-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the RunningFeature entry', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const entry = manager.getRunningFeature('feature-1');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.featureId).toBe('feature-1');
|
||||
expect(entry?.projectPath).toBe('/test/project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunningCount (project-level)', () => {
|
||||
it('should return 0 when no features are running', () => {
|
||||
expect(manager.getRunningCount('/test/project')).toBe(0);
|
||||
});
|
||||
|
||||
it('should count features for specific project', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
expect(manager.getRunningCount('/test/project')).toBe(2);
|
||||
});
|
||||
|
||||
it('should only count features for the specified project', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-3',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
expect(manager.getRunningCount('/project-a')).toBe(2);
|
||||
expect(manager.getRunningCount('/project-b')).toBe(1);
|
||||
expect(manager.getRunningCount('/project-c')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunningCountForWorktree', () => {
|
||||
it('should return 0 when no features are running', async () => {
|
||||
const count = await manager.getRunningCountForWorktree('/test/project', null);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should count features with null branchName as main worktree', async () => {
|
||||
const entry = manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
// entry.branchName is null by default
|
||||
|
||||
const count = await manager.getRunningCountForWorktree('/test/project', null);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('should count features matching primary branch as main worktree', async () => {
|
||||
mockGetCurrentBranch.mockResolvedValue('main');
|
||||
|
||||
const entry = manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
manager.updateRunningFeature('feature-1', { branchName: 'main' });
|
||||
|
||||
const count = await manager.getRunningCountForWorktree('/test/project', null);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('should count features with exact branch match for feature worktrees', async () => {
|
||||
const entry = manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
manager.updateRunningFeature('feature-1', { branchName: 'feature-branch' });
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
// feature-2 has null branchName
|
||||
|
||||
const featureBranchCount = await manager.getRunningCountForWorktree(
|
||||
'/test/project',
|
||||
'feature-branch'
|
||||
);
|
||||
expect(featureBranchCount).toBe(1);
|
||||
|
||||
const mainWorktreeCount = await manager.getRunningCountForWorktree('/test/project', null);
|
||||
expect(mainWorktreeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should respect branch normalization (main is treated as null)', async () => {
|
||||
mockGetCurrentBranch.mockResolvedValue('main');
|
||||
|
||||
// Feature with branchName 'main' should count as main worktree
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
manager.updateRunningFeature('feature-1', { branchName: 'main' });
|
||||
|
||||
// Feature with branchName null should also count as main worktree
|
||||
manager.acquire({
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const mainCount = await manager.getRunningCountForWorktree('/test/project', null);
|
||||
expect(mainCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter by both projectPath and branchName', async () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
manager.updateRunningFeature('feature-1', { branchName: 'feature-x' });
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: true,
|
||||
});
|
||||
manager.updateRunningFeature('feature-2', { branchName: 'feature-x' });
|
||||
|
||||
const countA = await manager.getRunningCountForWorktree('/project-a', 'feature-x');
|
||||
const countB = await manager.getRunningCountForWorktree('/project-b', 'feature-x');
|
||||
|
||||
expect(countA).toBe(1);
|
||||
expect(countB).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllRunning', () => {
|
||||
it('should return empty array when no features are running', () => {
|
||||
expect(manager.getAllRunning()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return array with all running features', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const running = manager.getAllRunning();
|
||||
expect(running).toHaveLength(2);
|
||||
expect(running.map((r) => r.featureId)).toContain('feature-1');
|
||||
expect(running.map((r) => r.featureId)).toContain('feature-2');
|
||||
});
|
||||
|
||||
it('should include feature metadata', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
manager.updateRunningFeature('feature-1', { model: 'claude-sonnet-4', provider: 'claude' });
|
||||
|
||||
const running = manager.getAllRunning();
|
||||
expect(running[0].model).toBe('claude-sonnet-4');
|
||||
expect(running[0].provider).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRunningFeature', () => {
|
||||
it('should update worktreePath and branchName', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.updateRunningFeature('feature-1', {
|
||||
worktreePath: '/worktrees/feature-1',
|
||||
branchName: 'feature-1-branch',
|
||||
});
|
||||
|
||||
const entry = manager.getRunningFeature('feature-1');
|
||||
expect(entry?.worktreePath).toBe('/worktrees/feature-1');
|
||||
expect(entry?.branchName).toBe('feature-1-branch');
|
||||
});
|
||||
|
||||
it('should update model and provider', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.updateRunningFeature('feature-1', {
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
provider: 'claude',
|
||||
});
|
||||
|
||||
const entry = manager.getRunningFeature('feature-1');
|
||||
expect(entry?.model).toBe('claude-opus-4-5-20251101');
|
||||
expect(entry?.provider).toBe('claude');
|
||||
});
|
||||
|
||||
it('should do nothing for non-existent feature', () => {
|
||||
// Should not throw
|
||||
manager.updateRunningFeature('non-existent', { model: 'test' });
|
||||
});
|
||||
|
||||
it('should preserve other properties when updating partial fields', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const original = manager.getRunningFeature('feature-1');
|
||||
const originalStartTime = original?.startTime;
|
||||
|
||||
manager.updateRunningFeature('feature-1', { model: 'claude-sonnet-4' });
|
||||
|
||||
const updated = manager.getRunningFeature('feature-1');
|
||||
expect(updated?.startTime).toBe(originalStartTime);
|
||||
expect(updated?.projectPath).toBe('/test/project');
|
||||
expect(updated?.isAutoMode).toBe(true);
|
||||
expect(updated?.model).toBe('claude-sonnet-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle multiple features for same project', () => {
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
manager.acquire({
|
||||
featureId: 'feature-3',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
expect(manager.getRunningCount('/test/project')).toBe(3);
|
||||
expect(manager.isRunning('feature-1')).toBe(true);
|
||||
expect(manager.isRunning('feature-2')).toBe(true);
|
||||
expect(manager.isRunning('feature-3')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle features across different worktrees', async () => {
|
||||
// Main worktree feature
|
||||
manager.acquire({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
// Worktree A feature
|
||||
manager.acquire({
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
manager.updateRunningFeature('feature-2', {
|
||||
worktreePath: '/worktrees/a',
|
||||
branchName: 'branch-a',
|
||||
});
|
||||
|
||||
// Worktree B feature
|
||||
manager.acquire({
|
||||
featureId: 'feature-3',
|
||||
projectPath: '/test/project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
manager.updateRunningFeature('feature-3', {
|
||||
worktreePath: '/worktrees/b',
|
||||
branchName: 'branch-b',
|
||||
});
|
||||
|
||||
expect(await manager.getRunningCountForWorktree('/test/project', null)).toBe(1);
|
||||
expect(await manager.getRunningCountForWorktree('/test/project', 'branch-a')).toBe(1);
|
||||
expect(await manager.getRunningCountForWorktree('/test/project', 'branch-b')).toBe(1);
|
||||
expect(manager.getRunningCount('/test/project')).toBe(3);
|
||||
});
|
||||
|
||||
it('should return 0 counts and empty arrays for empty state', () => {
|
||||
expect(manager.getRunningCount('/any/project')).toBe(0);
|
||||
expect(manager.getAllRunning()).toEqual([]);
|
||||
expect(manager.isRunning('any-feature')).toBe(false);
|
||||
expect(manager.getRunningFeature('any-feature')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user