mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
- Add AutoModeService integration tests for delegation verification - Test executePipeline delegation with context fields - Test detectPipelineStatus delegation for pipeline/non-pipeline status - Test resumePipeline delegation with autoLoadClaudeMd and useWorktrees - Add edge case tests for abort signals, missing context, deleted steps
1061 lines
34 KiB
TypeScript
1061 lines
34 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import type { Feature, PipelineStep, PipelineConfig } from '@automaker/types';
|
|
import {
|
|
PipelineOrchestrator,
|
|
type PipelineContext,
|
|
type PipelineStatusInfo,
|
|
type UpdateFeatureStatusFn,
|
|
type BuildFeaturePromptFn,
|
|
type ExecuteFeatureFn,
|
|
type RunAgentFn,
|
|
} from '../../../src/services/pipeline-orchestrator.js';
|
|
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
|
|
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
|
|
import type { AgentExecutor } from '../../../src/services/agent-executor.js';
|
|
import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js';
|
|
import type { SettingsService } from '../../../src/services/settings-service.js';
|
|
import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js';
|
|
import type { TestRunnerService } from '../../../src/services/test-runner-service.js';
|
|
import { pipelineService } from '../../../src/services/pipeline-service.js';
|
|
import * as secureFs from '../../../src/lib/secure-fs.js';
|
|
import { getFeatureDir } from '@automaker/platform';
|
|
import {
|
|
getPromptCustomization,
|
|
getAutoLoadClaudeMdSetting,
|
|
filterClaudeMdFromContext,
|
|
} from '../../../src/lib/settings-helpers.js';
|
|
|
|
// Mock pipelineService
|
|
vi.mock('../../../src/services/pipeline-service.js', () => ({
|
|
pipelineService: {
|
|
isPipelineStatus: vi.fn(),
|
|
getStepIdFromStatus: vi.fn(),
|
|
getPipelineConfig: vi.fn(),
|
|
getNextStatus: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock secureFs
|
|
vi.mock('../../../src/lib/secure-fs.js', () => ({
|
|
readFile: vi.fn(),
|
|
access: vi.fn(),
|
|
}));
|
|
|
|
// Mock settings helpers
|
|
vi.mock('../../../src/lib/settings-helpers.js', () => ({
|
|
getPromptCustomization: vi.fn().mockResolvedValue({
|
|
taskExecution: {
|
|
implementationInstructions: 'test instructions',
|
|
playwrightVerificationInstructions: 'test playwright',
|
|
},
|
|
}),
|
|
getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true),
|
|
filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'),
|
|
}));
|
|
|
|
// Mock validateWorkingDirectory
|
|
vi.mock('../../../src/lib/sdk-options.js', () => ({
|
|
validateWorkingDirectory: vi.fn(),
|
|
}));
|
|
|
|
// Mock platform
|
|
vi.mock('@automaker/platform', () => ({
|
|
getFeatureDir: vi
|
|
.fn()
|
|
.mockImplementation(
|
|
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
|
|
),
|
|
}));
|
|
|
|
// Mock model-resolver
|
|
vi.mock('@automaker/model-resolver', () => ({
|
|
resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'),
|
|
DEFAULT_MODELS: { claude: 'claude-sonnet-4' },
|
|
}));
|
|
|
|
describe('PipelineOrchestrator', () => {
|
|
// Mock dependencies
|
|
let mockEventBus: TypedEventBus;
|
|
let mockFeatureStateManager: FeatureStateManager;
|
|
let mockAgentExecutor: AgentExecutor;
|
|
let mockTestRunnerService: TestRunnerService;
|
|
let mockWorktreeResolver: WorktreeResolver;
|
|
let mockConcurrencyManager: ConcurrencyManager;
|
|
let mockSettingsService: SettingsService | null;
|
|
let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn;
|
|
let mockLoadContextFilesFn: vi.Mock;
|
|
let mockBuildFeaturePromptFn: BuildFeaturePromptFn;
|
|
let mockExecuteFeatureFn: ExecuteFeatureFn;
|
|
let mockRunAgentFn: RunAgentFn;
|
|
let orchestrator: PipelineOrchestrator;
|
|
|
|
// Test data
|
|
const testFeature: Feature = {
|
|
id: 'feature-1',
|
|
title: 'Test Feature',
|
|
category: 'test',
|
|
description: 'Test description',
|
|
status: 'pipeline_step-1',
|
|
branchName: 'feature/test-1',
|
|
};
|
|
|
|
const testSteps: PipelineStep[] = [
|
|
{
|
|
id: 'step-1',
|
|
name: 'Step 1',
|
|
order: 1,
|
|
instructions: 'Do step 1',
|
|
colorClass: 'blue',
|
|
createdAt: '',
|
|
updatedAt: '',
|
|
},
|
|
{
|
|
id: 'step-2',
|
|
name: 'Step 2',
|
|
order: 2,
|
|
instructions: 'Do step 2',
|
|
colorClass: 'green',
|
|
createdAt: '',
|
|
updatedAt: '',
|
|
},
|
|
];
|
|
|
|
const testConfig: PipelineConfig = {
|
|
version: 1,
|
|
steps: testSteps,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockEventBus = {
|
|
emitAutoModeEvent: vi.fn(),
|
|
} as unknown as TypedEventBus;
|
|
|
|
mockFeatureStateManager = {
|
|
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
|
|
loadFeature: vi.fn().mockResolvedValue(testFeature),
|
|
} as unknown as FeatureStateManager;
|
|
|
|
mockAgentExecutor = {
|
|
execute: vi.fn().mockResolvedValue({ success: true }),
|
|
} as unknown as AgentExecutor;
|
|
|
|
mockTestRunnerService = {
|
|
startTests: vi
|
|
.fn()
|
|
.mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }),
|
|
getSession: vi.fn().mockReturnValue({
|
|
status: 'passed',
|
|
exitCode: 0,
|
|
startedAt: new Date(),
|
|
finishedAt: new Date(),
|
|
}),
|
|
getSessionOutput: vi
|
|
.fn()
|
|
.mockReturnValue({ success: true, result: { output: 'All tests passed' } }),
|
|
} as unknown as TestRunnerService;
|
|
|
|
mockWorktreeResolver = {
|
|
findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'),
|
|
} as unknown as WorktreeResolver;
|
|
|
|
mockConcurrencyManager = {
|
|
acquire: vi.fn().mockReturnValue({
|
|
featureId: 'feature-1',
|
|
projectPath: '/test/project',
|
|
abortController: new AbortController(),
|
|
branchName: null,
|
|
worktreePath: null,
|
|
}),
|
|
release: vi.fn(),
|
|
} as unknown as ConcurrencyManager;
|
|
|
|
mockSettingsService = null;
|
|
|
|
mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined);
|
|
mockLoadContextFilesFn = vi.fn().mockResolvedValue({ contextPrompt: 'test context' });
|
|
mockBuildFeaturePromptFn = vi.fn().mockReturnValue('Feature prompt content');
|
|
mockExecuteFeatureFn = vi.fn().mockResolvedValue(undefined);
|
|
mockRunAgentFn = vi.fn().mockResolvedValue(undefined);
|
|
|
|
// Default mocks for secureFs
|
|
vi.mocked(secureFs.readFile).mockResolvedValue('Previous context');
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
// Re-setup platform mocks (clearAllMocks resets implementations)
|
|
vi.mocked(getFeatureDir).mockImplementation(
|
|
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
|
|
);
|
|
|
|
// Re-setup settings helpers mocks
|
|
vi.mocked(getPromptCustomization).mockResolvedValue({
|
|
taskExecution: {
|
|
implementationInstructions: 'test instructions',
|
|
playwrightVerificationInstructions: 'test playwright',
|
|
},
|
|
} as any);
|
|
vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true);
|
|
vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt');
|
|
|
|
orchestrator = new PipelineOrchestrator(
|
|
mockEventBus,
|
|
mockFeatureStateManager,
|
|
mockAgentExecutor,
|
|
mockTestRunnerService,
|
|
mockWorktreeResolver,
|
|
mockConcurrencyManager,
|
|
mockSettingsService,
|
|
mockUpdateFeatureStatusFn,
|
|
mockLoadContextFilesFn,
|
|
mockBuildFeaturePromptFn,
|
|
mockExecuteFeatureFn,
|
|
mockRunAgentFn
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should create instance with all dependencies', () => {
|
|
expect(orchestrator).toBeInstanceOf(PipelineOrchestrator);
|
|
});
|
|
|
|
it('should accept null settingsService', () => {
|
|
const orch = new PipelineOrchestrator(
|
|
mockEventBus,
|
|
mockFeatureStateManager,
|
|
mockAgentExecutor,
|
|
mockTestRunnerService,
|
|
mockWorktreeResolver,
|
|
mockConcurrencyManager,
|
|
null,
|
|
mockUpdateFeatureStatusFn,
|
|
mockLoadContextFilesFn,
|
|
mockBuildFeaturePromptFn,
|
|
mockExecuteFeatureFn,
|
|
mockRunAgentFn
|
|
);
|
|
expect(orch).toBeInstanceOf(PipelineOrchestrator);
|
|
});
|
|
});
|
|
|
|
describe('buildPipelineStepPrompt', () => {
|
|
const taskPrompts = {
|
|
implementationInstructions: 'impl instructions',
|
|
playwrightVerificationInstructions: 'playwright instructions',
|
|
};
|
|
|
|
it('should include step name and instructions', () => {
|
|
const prompt = orchestrator.buildPipelineStepPrompt(
|
|
testSteps[0],
|
|
testFeature,
|
|
'',
|
|
taskPrompts
|
|
);
|
|
expect(prompt).toContain('## Pipeline Step: Step 1');
|
|
expect(prompt).toContain('Do step 1');
|
|
});
|
|
|
|
it('should include feature context from callback', () => {
|
|
orchestrator.buildPipelineStepPrompt(testSteps[0], testFeature, '', taskPrompts);
|
|
expect(mockBuildFeaturePromptFn).toHaveBeenCalledWith(testFeature, taskPrompts);
|
|
});
|
|
|
|
it('should include previous context when available', () => {
|
|
const prompt = orchestrator.buildPipelineStepPrompt(
|
|
testSteps[0],
|
|
testFeature,
|
|
'Previous work content',
|
|
taskPrompts
|
|
);
|
|
expect(prompt).toContain('### Previous Work');
|
|
expect(prompt).toContain('Previous work content');
|
|
});
|
|
|
|
it('should omit previous context section when empty', () => {
|
|
const prompt = orchestrator.buildPipelineStepPrompt(
|
|
testSteps[0],
|
|
testFeature,
|
|
'',
|
|
taskPrompts
|
|
);
|
|
expect(prompt).not.toContain('### Previous Work');
|
|
});
|
|
});
|
|
|
|
describe('detectPipelineStatus', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(true);
|
|
vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('step-1');
|
|
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue(testConfig);
|
|
});
|
|
|
|
it('should return isPipeline false for non-pipeline status', async () => {
|
|
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false);
|
|
|
|
const result = await orchestrator.detectPipelineStatus(
|
|
'/test/project',
|
|
'feature-1',
|
|
'in_progress'
|
|
);
|
|
expect(result.isPipeline).toBe(false);
|
|
expect(result.stepId).toBeNull();
|
|
});
|
|
|
|
it('should return step info for valid pipeline status', async () => {
|
|
const result = await orchestrator.detectPipelineStatus(
|
|
'/test/project',
|
|
'feature-1',
|
|
'pipeline_step-1'
|
|
);
|
|
expect(result.isPipeline).toBe(true);
|
|
expect(result.stepId).toBe('step-1');
|
|
expect(result.stepIndex).toBe(0);
|
|
expect(result.step?.name).toBe('Step 1');
|
|
});
|
|
|
|
it('should return stepIndex -1 when step not found in config', async () => {
|
|
vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('nonexistent-step');
|
|
|
|
const result = await orchestrator.detectPipelineStatus(
|
|
'/test/project',
|
|
'feature-1',
|
|
'pipeline_nonexistent'
|
|
);
|
|
expect(result.isPipeline).toBe(true);
|
|
expect(result.stepIndex).toBe(-1);
|
|
expect(result.step).toBeNull();
|
|
});
|
|
|
|
it('should return config null when no pipeline config exists', async () => {
|
|
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue(null);
|
|
|
|
const result = await orchestrator.detectPipelineStatus(
|
|
'/test/project',
|
|
'feature-1',
|
|
'pipeline_step-1'
|
|
);
|
|
expect(result.isPipeline).toBe(true);
|
|
expect(result.config).toBeNull();
|
|
expect(result.stepIndex).toBe(-1);
|
|
});
|
|
});
|
|
|
|
describe('executeTestStep', () => {
|
|
const createTestContext = (): PipelineContext => ({
|
|
projectPath: '/test/project',
|
|
featureId: 'feature-1',
|
|
feature: testFeature,
|
|
steps: testSteps,
|
|
workDir: '/test/project',
|
|
worktreePath: null,
|
|
branchName: 'feature/test-1',
|
|
abortController: new AbortController(),
|
|
autoLoadClaudeMd: true,
|
|
testAttempts: 0,
|
|
maxTestAttempts: 5,
|
|
});
|
|
|
|
it('should return success when tests pass on first attempt', async () => {
|
|
const context = createTestContext();
|
|
const result = await orchestrator.executeTestStep(context, 'npm test');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.testsPassed).toBe(true);
|
|
expect(mockTestRunnerService.startTests).toHaveBeenCalledTimes(1);
|
|
}, 10000);
|
|
|
|
it('should retry with agent fix when tests fail', async () => {
|
|
vi.mocked(mockTestRunnerService.getSession)
|
|
.mockReturnValueOnce({
|
|
status: 'failed',
|
|
exitCode: 1,
|
|
startedAt: new Date(),
|
|
finishedAt: new Date(),
|
|
} as never)
|
|
.mockReturnValueOnce({
|
|
status: 'passed',
|
|
exitCode: 0,
|
|
startedAt: new Date(),
|
|
finishedAt: new Date(),
|
|
} as never);
|
|
|
|
const context = createTestContext();
|
|
const result = await orchestrator.executeTestStep(context, 'npm test');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
|
|
expect(mockTestRunnerService.startTests).toHaveBeenCalledTimes(2);
|
|
}, 15000);
|
|
|
|
it('should fail after max attempts', async () => {
|
|
vi.mocked(mockTestRunnerService.getSession).mockReturnValue({
|
|
status: 'failed',
|
|
exitCode: 1,
|
|
startedAt: new Date(),
|
|
finishedAt: new Date(),
|
|
} as never);
|
|
|
|
// Use smaller maxTestAttempts to speed up test
|
|
const context = { ...createTestContext(), maxTestAttempts: 2 };
|
|
const result = await orchestrator.executeTestStep(context, 'npm test');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.testsPassed).toBe(false);
|
|
expect(mockTestRunnerService.startTests).toHaveBeenCalledTimes(2);
|
|
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
|
|
}, 15000);
|
|
|
|
it('should emit pipeline_test_failed event on each failure', async () => {
|
|
vi.mocked(mockTestRunnerService.getSession).mockReturnValue({
|
|
status: 'failed',
|
|
exitCode: 1,
|
|
startedAt: new Date(),
|
|
finishedAt: new Date(),
|
|
} as never);
|
|
|
|
// Use smaller maxTestAttempts to speed up test
|
|
const context = { ...createTestContext(), maxTestAttempts: 2 };
|
|
await orchestrator.executeTestStep(context, 'npm test');
|
|
|
|
const testFailedCalls = vi
|
|
.mocked(mockEventBus.emitAutoModeEvent)
|
|
.mock.calls.filter((call) => call[0] === 'pipeline_test_failed');
|
|
expect(testFailedCalls.length).toBe(2);
|
|
}, 15000);
|
|
|
|
it('should build test failure summary for agent', async () => {
|
|
vi.mocked(mockTestRunnerService.getSession)
|
|
.mockReturnValueOnce({
|
|
status: 'failed',
|
|
exitCode: 1,
|
|
startedAt: new Date(),
|
|
finishedAt: new Date(),
|
|
} as never)
|
|
.mockReturnValueOnce({
|
|
status: 'passed',
|
|
exitCode: 0,
|
|
startedAt: new Date(),
|
|
finishedAt: new Date(),
|
|
} as never);
|
|
vi.mocked(mockTestRunnerService.getSessionOutput).mockReturnValue({
|
|
success: true,
|
|
result: { output: 'FAIL test.spec.ts\nExpected 1 to be 2' },
|
|
} as never);
|
|
|
|
const context = createTestContext();
|
|
await orchestrator.executeTestStep(context, 'npm test');
|
|
|
|
const fixPromptCall = vi.mocked(mockRunAgentFn).mock.calls[0];
|
|
expect(fixPromptCall[2]).toContain('Test Failures');
|
|
}, 15000);
|
|
});
|
|
|
|
describe('attemptMerge', () => {
|
|
const createMergeContext = (): PipelineContext => ({
|
|
projectPath: '/test/project',
|
|
featureId: 'feature-1',
|
|
feature: testFeature,
|
|
steps: testSteps,
|
|
workDir: '/test/project',
|
|
worktreePath: '/test/worktree',
|
|
branchName: 'feature/test-1',
|
|
abortController: new AbortController(),
|
|
autoLoadClaudeMd: true,
|
|
testAttempts: 0,
|
|
maxTestAttempts: 5,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
global.fetch = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.mocked(global.fetch).mockReset();
|
|
});
|
|
|
|
it('should call merge endpoint with correct parameters', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({ success: true }),
|
|
} as never);
|
|
|
|
const context = createMergeContext();
|
|
await orchestrator.attemptMerge(context);
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/api/worktree/merge'),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: expect.stringContaining('feature/test-1'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should return success on clean merge', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({ success: true }),
|
|
} as never);
|
|
|
|
const context = createMergeContext();
|
|
const result = await orchestrator.attemptMerge(context);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.hasConflicts).toBeUndefined();
|
|
});
|
|
|
|
it('should set merge_conflict status when hasConflicts is true', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: false,
|
|
json: vi.fn().mockResolvedValue({ success: false, hasConflicts: true }),
|
|
} as never);
|
|
|
|
const context = createMergeContext();
|
|
await orchestrator.attemptMerge(context);
|
|
|
|
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
|
'/test/project',
|
|
'feature-1',
|
|
'merge_conflict'
|
|
);
|
|
});
|
|
|
|
it('should emit pipeline_merge_conflict event on conflict', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: false,
|
|
json: vi.fn().mockResolvedValue({ success: false, hasConflicts: true }),
|
|
} as never);
|
|
|
|
const context = createMergeContext();
|
|
await orchestrator.attemptMerge(context);
|
|
|
|
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
|
'pipeline_merge_conflict',
|
|
expect.objectContaining({ featureId: 'feature-1', branchName: 'feature/test-1' })
|
|
);
|
|
});
|
|
|
|
it('should emit auto_mode_feature_complete on success', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({ success: true }),
|
|
} as never);
|
|
|
|
const context = createMergeContext();
|
|
await orchestrator.attemptMerge(context);
|
|
|
|
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
|
'auto_mode_feature_complete',
|
|
expect.objectContaining({ featureId: 'feature-1', passes: true })
|
|
);
|
|
});
|
|
|
|
it('should return needsAgentResolution true on conflict', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: false,
|
|
json: vi.fn().mockResolvedValue({ success: false, hasConflicts: true }),
|
|
} as never);
|
|
|
|
const context = createMergeContext();
|
|
const result = await orchestrator.attemptMerge(context);
|
|
|
|
expect(result.needsAgentResolution).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('buildTestFailureSummary', () => {
|
|
it('should extract pass/fail counts from test output', () => {
|
|
const scrollback = `
|
|
PASS tests/passing.test.ts
|
|
FAIL tests/failing.test.ts
|
|
FAIL tests/another.test.ts
|
|
`;
|
|
|
|
const summary = orchestrator.buildTestFailureSummary(scrollback);
|
|
expect(summary).toContain('1 passed');
|
|
expect(summary).toContain('2 failed');
|
|
});
|
|
|
|
it('should extract failed test names from output', () => {
|
|
const scrollback = `
|
|
FAIL tests/auth.test.ts
|
|
FAIL tests/user.test.ts
|
|
`;
|
|
|
|
const summary = orchestrator.buildTestFailureSummary(scrollback);
|
|
expect(summary).toContain('tests/auth.test.ts');
|
|
expect(summary).toContain('tests/user.test.ts');
|
|
});
|
|
|
|
it('should return concise summary for agent', () => {
|
|
const longOutput = 'x'.repeat(5000);
|
|
const summary = orchestrator.buildTestFailureSummary(longOutput);
|
|
|
|
expect(summary.length).toBeLessThan(5000);
|
|
expect(summary).toContain('Output (last 2000 chars)');
|
|
});
|
|
});
|
|
|
|
describe('resumePipeline', () => {
|
|
const validPipelineInfo: PipelineStatusInfo = {
|
|
isPipeline: true,
|
|
stepId: 'step-1',
|
|
stepIndex: 0,
|
|
totalSteps: 2,
|
|
step: testSteps[0],
|
|
config: testConfig,
|
|
};
|
|
|
|
it('should restart from beginning when no context file', async () => {
|
|
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
|
|
await orchestrator.resumePipeline('/test/project', testFeature, true, validPipelineInfo);
|
|
|
|
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
|
'/test/project',
|
|
'feature-1',
|
|
'in_progress'
|
|
);
|
|
expect(mockExecuteFeatureFn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should complete feature when step no longer exists', async () => {
|
|
const invalidPipelineInfo: PipelineStatusInfo = {
|
|
...validPipelineInfo,
|
|
stepIndex: -1,
|
|
step: null,
|
|
};
|
|
|
|
await orchestrator.resumePipeline('/test/project', testFeature, true, invalidPipelineInfo);
|
|
|
|
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
|
'/test/project',
|
|
'feature-1',
|
|
'verified'
|
|
);
|
|
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
|
'auto_mode_feature_complete',
|
|
expect.objectContaining({ message: expect.stringContaining('no longer exists') })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('resumeFromStep', () => {
|
|
it('should filter out excluded steps', async () => {
|
|
const featureWithExclusions: Feature = {
|
|
...testFeature,
|
|
excludedPipelineSteps: ['step-1'],
|
|
};
|
|
|
|
vi.mocked(pipelineService.getNextStatus).mockReturnValue('pipeline_step-2');
|
|
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(true);
|
|
vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('step-2');
|
|
|
|
await orchestrator.resumeFromStep(
|
|
'/test/project',
|
|
featureWithExclusions,
|
|
true,
|
|
0,
|
|
testConfig
|
|
);
|
|
|
|
expect(mockRunAgentFn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should complete feature when all remaining steps excluded', async () => {
|
|
const featureWithAllExcluded: Feature = {
|
|
...testFeature,
|
|
excludedPipelineSteps: ['step-1', 'step-2'],
|
|
};
|
|
|
|
vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified');
|
|
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false);
|
|
|
|
await orchestrator.resumeFromStep(
|
|
'/test/project',
|
|
featureWithAllExcluded,
|
|
true,
|
|
0,
|
|
testConfig
|
|
);
|
|
|
|
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
|
'auto_mode_feature_complete',
|
|
expect.objectContaining({ message: expect.stringContaining('excluded') })
|
|
);
|
|
});
|
|
|
|
it('should acquire running feature slot before execution', async () => {
|
|
await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig);
|
|
|
|
expect(mockConcurrencyManager.acquire).toHaveBeenCalledWith(
|
|
expect.objectContaining({ featureId: 'feature-1', allowReuse: true })
|
|
);
|
|
});
|
|
|
|
it('should release slot on completion', async () => {
|
|
await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig);
|
|
|
|
expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1');
|
|
});
|
|
|
|
it('should release slot on error', async () => {
|
|
mockRunAgentFn.mockRejectedValue(new Error('Test error'));
|
|
|
|
await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig);
|
|
|
|
expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1');
|
|
});
|
|
});
|
|
|
|
describe('executePipeline', () => {
|
|
const createPipelineContext = (): PipelineContext => ({
|
|
projectPath: '/test/project',
|
|
featureId: 'feature-1',
|
|
feature: testFeature,
|
|
steps: testSteps,
|
|
workDir: '/test/project',
|
|
worktreePath: null,
|
|
branchName: 'feature/test-1',
|
|
abortController: new AbortController(),
|
|
autoLoadClaudeMd: true,
|
|
testAttempts: 0,
|
|
maxTestAttempts: 5,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({ success: true }),
|
|
} as never);
|
|
});
|
|
|
|
it('should execute steps in sequence', async () => {
|
|
const context = createPipelineContext();
|
|
await orchestrator.executePipeline(context);
|
|
|
|
expect(mockRunAgentFn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should emit pipeline_step_started for each step', async () => {
|
|
const context = createPipelineContext();
|
|
await orchestrator.executePipeline(context);
|
|
|
|
const startedCalls = vi
|
|
.mocked(mockEventBus.emitAutoModeEvent)
|
|
.mock.calls.filter((call) => call[0] === 'pipeline_step_started');
|
|
expect(startedCalls.length).toBe(2);
|
|
});
|
|
|
|
it('should emit pipeline_step_complete after each step', async () => {
|
|
const context = createPipelineContext();
|
|
await orchestrator.executePipeline(context);
|
|
|
|
const completeCalls = vi
|
|
.mocked(mockEventBus.emitAutoModeEvent)
|
|
.mock.calls.filter((call) => call[0] === 'pipeline_step_complete');
|
|
expect(completeCalls.length).toBe(2);
|
|
});
|
|
|
|
it('should update feature status to pipeline_{stepId} for each step', async () => {
|
|
const context = createPipelineContext();
|
|
await orchestrator.executePipeline(context);
|
|
|
|
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
|
'/test/project',
|
|
'feature-1',
|
|
'pipeline_step-1'
|
|
);
|
|
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
|
'/test/project',
|
|
'feature-1',
|
|
'pipeline_step-2'
|
|
);
|
|
});
|
|
|
|
it('should respect abort signal between steps', async () => {
|
|
const context = createPipelineContext();
|
|
mockRunAgentFn.mockImplementation(async () => {
|
|
context.abortController.abort();
|
|
});
|
|
|
|
await expect(orchestrator.executePipeline(context)).rejects.toThrow(
|
|
'Pipeline execution aborted'
|
|
);
|
|
});
|
|
|
|
it('should call attemptMerge after successful completion', async () => {
|
|
const context = createPipelineContext();
|
|
await orchestrator.executePipeline(context);
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/api/worktree/merge'),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('AutoModeService integration (delegation verification)', () => {
|
|
describe('executePipeline delegation', () => {
|
|
const createPipelineContext = (): PipelineContext => ({
|
|
projectPath: '/test/project',
|
|
featureId: 'feature-1',
|
|
feature: testFeature,
|
|
steps: testSteps,
|
|
workDir: '/test/project',
|
|
worktreePath: '/test/worktree',
|
|
branchName: 'feature/test-1',
|
|
abortController: new AbortController(),
|
|
autoLoadClaudeMd: true,
|
|
testAttempts: 0,
|
|
maxTestAttempts: 5,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({ success: true }),
|
|
} as never);
|
|
});
|
|
|
|
it('builds PipelineContext with correct fields from executeFeature', async () => {
|
|
const context = createPipelineContext();
|
|
await orchestrator.executePipeline(context);
|
|
|
|
// Verify all context fields were used correctly
|
|
expect(context.projectPath).toBe('/test/project');
|
|
expect(context.featureId).toBe('feature-1');
|
|
expect(context.steps).toHaveLength(2);
|
|
expect(context.workDir).toBe('/test/project');
|
|
expect(context.worktreePath).toBe('/test/worktree');
|
|
expect(context.branchName).toBe('feature/test-1');
|
|
expect(context.autoLoadClaudeMd).toBe(true);
|
|
expect(context.testAttempts).toBe(0);
|
|
expect(context.maxTestAttempts).toBe(5);
|
|
});
|
|
|
|
it('passes worktreePath when worktree exists', async () => {
|
|
const context = createPipelineContext();
|
|
context.worktreePath = '/test/custom-worktree';
|
|
|
|
await orchestrator.executePipeline(context);
|
|
|
|
// Merge should receive the worktree path
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/api/worktree/merge'),
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('/test/custom-worktree'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('passes branchName from feature', async () => {
|
|
const context = createPipelineContext();
|
|
context.branchName = 'feature/custom-branch';
|
|
context.feature = { ...testFeature, branchName: 'feature/custom-branch' };
|
|
|
|
await orchestrator.executePipeline(context);
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/api/worktree/merge'),
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('feature/custom-branch'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('passes testAttempts and maxTestAttempts', async () => {
|
|
const context = createPipelineContext();
|
|
context.testAttempts = 2;
|
|
context.maxTestAttempts = 10;
|
|
|
|
// These values would be used by executeTestStep if called
|
|
expect(context.testAttempts).toBe(2);
|
|
expect(context.maxTestAttempts).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('detectPipelineStatus delegation', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(true);
|
|
vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('step-1');
|
|
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue(testConfig);
|
|
});
|
|
|
|
it('returns pipelineInfo from orchestrator for pipeline status', async () => {
|
|
const result = await orchestrator.detectPipelineStatus(
|
|
'/test/project',
|
|
'feature-1',
|
|
'pipeline_step-1'
|
|
);
|
|
|
|
expect(result.isPipeline).toBe(true);
|
|
expect(result.stepId).toBe('step-1');
|
|
expect(result.stepIndex).toBe(0);
|
|
expect(result.config).toEqual(testConfig);
|
|
});
|
|
|
|
it('returns isPipeline false for non-pipeline status', async () => {
|
|
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false);
|
|
|
|
const result = await orchestrator.detectPipelineStatus(
|
|
'/test/project',
|
|
'feature-1',
|
|
'in_progress'
|
|
);
|
|
|
|
expect(result.isPipeline).toBe(false);
|
|
expect(result.stepId).toBeNull();
|
|
expect(result.config).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resumePipeline delegation', () => {
|
|
const validPipelineInfo: PipelineStatusInfo = {
|
|
isPipeline: true,
|
|
stepId: 'step-1',
|
|
stepIndex: 0,
|
|
totalSteps: 2,
|
|
step: testSteps[0],
|
|
config: testConfig,
|
|
};
|
|
|
|
it('builds resumeContext with autoLoadClaudeMd setting', async () => {
|
|
vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true);
|
|
|
|
await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig);
|
|
|
|
// Verify autoLoadClaudeMd was fetched
|
|
expect(getAutoLoadClaudeMdSetting).toHaveBeenCalledWith(
|
|
'/test/project',
|
|
null,
|
|
'[AutoMode]'
|
|
);
|
|
});
|
|
|
|
it('passes useWorktrees flag to orchestrator', async () => {
|
|
await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig);
|
|
|
|
// When useWorktrees is true, it should look for worktree
|
|
expect(mockWorktreeResolver.findWorktreeForBranch).toHaveBeenCalledWith(
|
|
'/test/project',
|
|
'feature/test-1'
|
|
);
|
|
});
|
|
|
|
it('sets maxTestAttempts to 5', async () => {
|
|
// The default maxTestAttempts is 5 as per CONTEXT.md
|
|
await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig);
|
|
|
|
// Execution should proceed with maxTestAttempts = 5
|
|
expect(mockRunAgentFn).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
describe('abort signal handling', () => {
|
|
it('handles abort signal during step execution', async () => {
|
|
const context: PipelineContext = {
|
|
projectPath: '/test/project',
|
|
featureId: 'feature-1',
|
|
feature: testFeature,
|
|
steps: testSteps,
|
|
workDir: '/test/project',
|
|
worktreePath: null,
|
|
branchName: 'feature/test-1',
|
|
abortController: new AbortController(),
|
|
autoLoadClaudeMd: true,
|
|
testAttempts: 0,
|
|
maxTestAttempts: 5,
|
|
};
|
|
|
|
// Abort during first step
|
|
mockRunAgentFn.mockImplementationOnce(async () => {
|
|
context.abortController.abort();
|
|
});
|
|
|
|
await expect(orchestrator.executePipeline(context)).rejects.toThrow(
|
|
'Pipeline execution aborted'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('context file handling', () => {
|
|
it('handles missing context file during resume', async () => {
|
|
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
|
|
const pipelineInfo: PipelineStatusInfo = {
|
|
isPipeline: true,
|
|
stepId: 'step-1',
|
|
stepIndex: 0,
|
|
totalSteps: 2,
|
|
step: testSteps[0],
|
|
config: testConfig,
|
|
};
|
|
|
|
await orchestrator.resumePipeline('/test/project', testFeature, true, pipelineInfo);
|
|
|
|
// Should restart from beginning when no context
|
|
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
|
'/test/project',
|
|
'feature-1',
|
|
'in_progress'
|
|
);
|
|
expect(mockExecuteFeatureFn).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('step deletion handling', () => {
|
|
it('handles deleted step during resume', async () => {
|
|
const pipelineInfo: PipelineStatusInfo = {
|
|
isPipeline: true,
|
|
stepId: 'deleted-step',
|
|
stepIndex: -1,
|
|
totalSteps: 2,
|
|
step: null,
|
|
config: testConfig,
|
|
};
|
|
|
|
await orchestrator.resumePipeline('/test/project', testFeature, true, pipelineInfo);
|
|
|
|
// Should complete feature when step no longer exists
|
|
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
|
'/test/project',
|
|
'feature-1',
|
|
'verified'
|
|
);
|
|
});
|
|
|
|
it('handles all steps excluded during resume', async () => {
|
|
const featureWithAllExcluded: Feature = {
|
|
...testFeature,
|
|
excludedPipelineSteps: ['step-1', 'step-2'],
|
|
};
|
|
|
|
vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified');
|
|
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false);
|
|
|
|
await orchestrator.resumeFromStep(
|
|
'/test/project',
|
|
featureWithAllExcluded,
|
|
true,
|
|
0,
|
|
testConfig
|
|
);
|
|
|
|
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
|
'auto_mode_feature_complete',
|
|
expect.objectContaining({
|
|
message: expect.stringContaining('excluded'),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|