mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
refactor(06-04): delete auto-mode-service.ts monolith
- Delete the 2705-line auto-mode-service.ts monolith - Create AutoModeServiceCompat as compatibility layer for routes - Create GlobalAutoModeService for cross-project operations - Update all routes to use AutoModeServiceCompat type - Add SharedServices interface for state sharing across facades - Add getActiveProjects/getActiveWorktrees to AutoLoopCoordinator - Delete obsolete monolith test files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,346 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
|
||||
describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
let service: AutoModeService;
|
||||
const mockEvents = {
|
||||
subscribe: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new AutoModeService(mockEvents as any);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up any running processes
|
||||
await service.stopAutoLoop().catch(() => {});
|
||||
});
|
||||
|
||||
describe('getPlanningPromptPrefix', () => {
|
||||
// Access private method through any cast for testing
|
||||
const getPlanningPromptPrefix = (svc: any, feature: any) => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
it('should return empty string for skip mode', async () => {
|
||||
const feature = { id: 'test', planningMode: 'skip' as const };
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when planningMode is undefined', async () => {
|
||||
const feature = { id: 'test' };
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return lite prompt for lite mode without approval', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'lite' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Planning Phase (Lite Mode)');
|
||||
expect(result).toContain('[PLAN_GENERATED]');
|
||||
expect(result).toContain('Feature Request');
|
||||
});
|
||||
|
||||
it('should return lite_with_approval prompt for lite mode with approval', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'lite' as const,
|
||||
requirePlanApproval: true,
|
||||
};
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('## Planning Phase (Lite Mode)');
|
||||
expect(result).toContain('[SPEC_GENERATED]');
|
||||
expect(result).toContain(
|
||||
'DO NOT proceed with implementation until you receive explicit approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return spec prompt for spec mode', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'spec' as const,
|
||||
};
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('## Specification Phase (Spec Mode)');
|
||||
expect(result).toContain('```tasks');
|
||||
expect(result).toContain('T001');
|
||||
expect(result).toContain('[TASK_START]');
|
||||
expect(result).toContain('[TASK_COMPLETE]');
|
||||
});
|
||||
|
||||
it('should return full prompt for full mode', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'full' as const,
|
||||
};
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('## Full Specification Phase (Full SDD Mode)');
|
||||
expect(result).toContain('Phase 1: Foundation');
|
||||
expect(result).toContain('Phase 2: Core Implementation');
|
||||
expect(result).toContain('Phase 3: Integration & Testing');
|
||||
});
|
||||
|
||||
it('should include the separator and Feature Request header', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'spec' as const,
|
||||
};
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('---');
|
||||
expect(result).toContain('## Feature Request');
|
||||
});
|
||||
|
||||
it('should instruct agent to NOT output exploration text', async () => {
|
||||
const modes = ['lite', 'spec', 'full'] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: 'test', planningMode: mode };
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
// All modes should have the IMPORTANT instruction about not outputting exploration text
|
||||
expect(result).toContain('IMPORTANT: Do NOT output exploration text');
|
||||
expect(result).toContain('Silently analyze the codebase first');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTasksFromSpec (via module)', () => {
|
||||
// We need to test the module-level function
|
||||
// Import it directly for testing
|
||||
it('should parse tasks from a valid tasks block', async () => {
|
||||
// This tests the internal logic through integration
|
||||
// The function is module-level, so we verify behavior through the service
|
||||
const specContent = `
|
||||
## Specification
|
||||
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Create user model | File: src/models/user.ts
|
||||
- [ ] T002: Add API endpoint | File: src/routes/users.ts
|
||||
- [ ] T003: Write unit tests | File: tests/user.test.ts
|
||||
\`\`\`
|
||||
`;
|
||||
// Since parseTasksFromSpec is a module-level function,
|
||||
// we verify its behavior indirectly through plan parsing
|
||||
expect(specContent).toContain('T001');
|
||||
expect(specContent).toContain('T002');
|
||||
expect(specContent).toContain('T003');
|
||||
});
|
||||
|
||||
it('should handle tasks block with phases', () => {
|
||||
const specContent = `
|
||||
\`\`\`tasks
|
||||
## Phase 1: Setup
|
||||
- [ ] T001: Initialize project | File: package.json
|
||||
- [ ] T002: Configure TypeScript | File: tsconfig.json
|
||||
|
||||
## Phase 2: Implementation
|
||||
- [ ] T003: Create main module | File: src/index.ts
|
||||
\`\`\`
|
||||
`;
|
||||
expect(specContent).toContain('Phase 1');
|
||||
expect(specContent).toContain('Phase 2');
|
||||
expect(specContent).toContain('T001');
|
||||
expect(specContent).toContain('T003');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan approval flow', () => {
|
||||
it('should track pending approvals correctly', () => {
|
||||
expect(service.hasPendingApproval('test-feature')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow cancelling non-existent approval without error', () => {
|
||||
expect(() => service.cancelPlanApproval('non-existent')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should return running features count after stop', async () => {
|
||||
const count = await service.stopAutoLoop();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePlanApproval', () => {
|
||||
it('should return error when no pending approval exists', async () => {
|
||||
const result = await service.resolvePlanApproval(
|
||||
'non-existent-feature',
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No pending approval');
|
||||
});
|
||||
|
||||
it('should handle approval with edited plan', async () => {
|
||||
// Without a pending approval, this should fail gracefully
|
||||
const result = await service.resolvePlanApproval(
|
||||
'test-feature',
|
||||
true,
|
||||
'Edited plan content',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle rejection with feedback', async () => {
|
||||
const result = await service.resolvePlanApproval(
|
||||
'test-feature',
|
||||
false,
|
||||
undefined,
|
||||
'Please add more details',
|
||||
undefined
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFeaturePrompt', () => {
|
||||
const defaultTaskExecutionPrompts = {
|
||||
implementationInstructions: 'Test implementation instructions',
|
||||
playwrightVerificationInstructions: 'Test playwright instructions',
|
||||
};
|
||||
|
||||
const buildFeaturePrompt = (
|
||||
svc: any,
|
||||
feature: any,
|
||||
taskExecutionPrompts = defaultTaskExecutionPrompts
|
||||
) => {
|
||||
return svc.buildFeaturePrompt(feature, taskExecutionPrompts);
|
||||
};
|
||||
|
||||
it('should include feature ID and description', () => {
|
||||
const feature = {
|
||||
id: 'feat-123',
|
||||
description: 'Add user authentication',
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
expect(result).toContain('feat-123');
|
||||
expect(result).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
it('should include specification when present', () => {
|
||||
const feature = {
|
||||
id: 'feat-123',
|
||||
description: 'Test feature',
|
||||
spec: 'Detailed specification here',
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
expect(result).toContain('Specification:');
|
||||
expect(result).toContain('Detailed specification here');
|
||||
});
|
||||
|
||||
it('should include image paths when present', () => {
|
||||
const feature = {
|
||||
id: 'feat-123',
|
||||
description: 'Test feature',
|
||||
imagePaths: [
|
||||
{ path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' },
|
||||
'/tmp/image2.jpg',
|
||||
],
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
expect(result).toContain('Context Images Attached');
|
||||
expect(result).toContain('image1.png');
|
||||
expect(result).toContain('/tmp/image2.jpg');
|
||||
});
|
||||
|
||||
it('should include implementation instructions', () => {
|
||||
const feature = {
|
||||
id: 'feat-123',
|
||||
description: 'Test feature',
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
// The prompt should include the implementation instructions passed to it
|
||||
expect(result).toContain('Test implementation instructions');
|
||||
expect(result).toContain('Test playwright instructions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTitleFromDescription', () => {
|
||||
const extractTitle = (svc: any, description: string) => {
|
||||
return svc.extractTitleFromDescription(description);
|
||||
};
|
||||
|
||||
it("should return 'Untitled Feature' for empty description", () => {
|
||||
expect(extractTitle(service, '')).toBe('Untitled Feature');
|
||||
expect(extractTitle(service, ' ')).toBe('Untitled Feature');
|
||||
});
|
||||
|
||||
it('should return first line if under 60 characters', () => {
|
||||
const description = 'Add user login\nWith email validation';
|
||||
expect(extractTitle(service, description)).toBe('Add user login');
|
||||
});
|
||||
|
||||
it('should truncate long first lines to 60 characters', () => {
|
||||
const description =
|
||||
'This is a very long feature description that exceeds the sixty character limit significantly';
|
||||
const result = extractTitle(service, description);
|
||||
expect(result.length).toBe(60);
|
||||
expect(result).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PLANNING_PROMPTS structure', () => {
|
||||
const getPlanningPromptPrefix = (svc: any, feature: any) => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
it('should have all required planning modes', async () => {
|
||||
const modes = ['lite', 'spec', 'full'] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: 'test', planningMode: mode };
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result.length).toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('lite prompt should include correct structure', async () => {
|
||||
const feature = { id: 'test', planningMode: 'lite' as const };
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Goal');
|
||||
expect(result).toContain('Approach');
|
||||
expect(result).toContain('Files to Touch');
|
||||
expect(result).toContain('Tasks');
|
||||
expect(result).toContain('Risks');
|
||||
});
|
||||
|
||||
it('spec prompt should include task format instructions', async () => {
|
||||
const feature = { id: 'test', planningMode: 'spec' as const };
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Problem');
|
||||
expect(result).toContain('Solution');
|
||||
expect(result).toContain('Acceptance Criteria');
|
||||
expect(result).toContain('GIVEN-WHEN-THEN');
|
||||
expect(result).toContain('Implementation Tasks');
|
||||
expect(result).toContain('Verification');
|
||||
});
|
||||
|
||||
it('full prompt should include phases', async () => {
|
||||
const feature = { id: 'test', planningMode: 'full' as const };
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('1. **Problem Statement**');
|
||||
expect(result).toContain('2. **User Story**');
|
||||
expect(result).toContain('4. **Technical Context**');
|
||||
expect(result).toContain('5. **Non-Goals**');
|
||||
expect(result).toContain('Phase 1');
|
||||
expect(result).toContain('Phase 2');
|
||||
expect(result).toContain('Phase 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status management', () => {
|
||||
it('should report correct status', () => {
|
||||
const status = service.getStatus();
|
||||
expect(status.runningFeatures).toEqual([]);
|
||||
expect(status.isRunning).toBe(false);
|
||||
expect(status.runningCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,752 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
describe('auto-mode-service.ts', () => {
|
||||
let service: AutoModeService;
|
||||
const mockEvents = {
|
||||
subscribe: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new AutoModeService(mockEvents as any);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with event emitter', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startAutoLoop', () => {
|
||||
it('should throw if auto mode is already running', async () => {
|
||||
// Start first loop
|
||||
const promise1 = service.startAutoLoop('/test/project', 3);
|
||||
|
||||
// Try to start second loop
|
||||
await expect(service.startAutoLoop('/test/project', 3)).rejects.toThrow('already running');
|
||||
|
||||
// Cleanup
|
||||
await service.stopAutoLoop();
|
||||
await promise1.catch(() => {});
|
||||
});
|
||||
|
||||
it('should emit auto mode start event', async () => {
|
||||
const promise = service.startAutoLoop('/test/project', 3);
|
||||
|
||||
// Give it time to emit the event
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Auto mode started'),
|
||||
})
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
await service.stopAutoLoop();
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopAutoLoop', () => {
|
||||
it('should stop the auto loop', async () => {
|
||||
const promise = service.startAutoLoop('/test/project', 3);
|
||||
|
||||
const runningCount = await service.stopAutoLoop();
|
||||
|
||||
expect(runningCount).toBe(0);
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should return 0 when not running', async () => {
|
||||
const runningCount = await service.stopAutoLoop();
|
||||
expect(runningCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunningAgents', () => {
|
||||
// Helper to access private concurrencyManager
|
||||
const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager;
|
||||
|
||||
// Helper to add a running feature via concurrencyManager
|
||||
const addRunningFeature = (
|
||||
svc: AutoModeService,
|
||||
feature: { featureId: string; projectPath: string; isAutoMode: boolean }
|
||||
) => {
|
||||
getConcurrencyManager(svc).acquire(feature);
|
||||
};
|
||||
|
||||
// Helper to get the featureLoader and mock its get method
|
||||
const mockFeatureLoaderGet = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).featureLoader = { get: mockFn };
|
||||
};
|
||||
|
||||
it('should return empty array when no agents are running', async () => {
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return running agents with basic info when feature data is not available', async () => {
|
||||
// Arrange: Add a running feature via concurrencyManager
|
||||
addRunningFeature(service, {
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/test/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
// Mock featureLoader.get to return null (feature not found)
|
||||
const getMock = vi.fn().mockResolvedValue(null);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/test/project/path',
|
||||
projectName: 'path',
|
||||
isAutoMode: true,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return running agents with title and description when feature data is available', async () => {
|
||||
// Arrange
|
||||
addRunningFeature(service, {
|
||||
featureId: 'feature-456',
|
||||
projectPath: '/home/user/my-project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const mockFeature: Partial<Feature> = {
|
||||
id: 'feature-456',
|
||||
title: 'Implement user authentication',
|
||||
description: 'Add login and signup functionality',
|
||||
category: 'auth',
|
||||
};
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue(mockFeature);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-456',
|
||||
projectPath: '/home/user/my-project',
|
||||
projectName: 'my-project',
|
||||
isAutoMode: false,
|
||||
title: 'Implement user authentication',
|
||||
description: 'Add login and signup functionality',
|
||||
});
|
||||
expect(getMock).toHaveBeenCalledWith('/home/user/my-project', 'feature-456');
|
||||
});
|
||||
|
||||
it('should handle multiple running agents', async () => {
|
||||
// Arrange
|
||||
addRunningFeature(service, {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
addRunningFeature(service, {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'feature-1',
|
||||
title: 'Feature One',
|
||||
description: 'Description one',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'feature-2',
|
||||
title: 'Feature Two',
|
||||
description: 'Description two',
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(2);
|
||||
expect(getMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should silently handle errors when fetching feature data', async () => {
|
||||
// Arrange
|
||||
addRunningFeature(service, {
|
||||
featureId: 'feature-error',
|
||||
projectPath: '/project-error',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockRejectedValue(new Error('Database connection failed'));
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act - should not throw
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-error',
|
||||
projectPath: '/project-error',
|
||||
projectName: 'project-error',
|
||||
isAutoMode: true,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle feature with title but no description', async () => {
|
||||
// Arrange
|
||||
addRunningFeature(service, {
|
||||
featureId: 'feature-title-only',
|
||||
projectPath: '/project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue({
|
||||
id: 'feature-title-only',
|
||||
title: 'Only Title',
|
||||
// description is undefined
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].title).toBe('Only Title');
|
||||
expect(result[0].description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle feature with description but no title', async () => {
|
||||
// Arrange
|
||||
addRunningFeature(service, {
|
||||
featureId: 'feature-desc-only',
|
||||
projectPath: '/project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue({
|
||||
id: 'feature-desc-only',
|
||||
description: 'Only description, no title',
|
||||
// title is undefined
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].title).toBeUndefined();
|
||||
expect(result[0].description).toBe('Only description, no title');
|
||||
});
|
||||
|
||||
it('should extract projectName from nested paths correctly', async () => {
|
||||
// Arrange
|
||||
addRunningFeature(service, {
|
||||
featureId: 'feature-nested',
|
||||
projectPath: '/home/user/workspace/projects/my-awesome-project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue(null);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].projectName).toBe('my-awesome-project');
|
||||
});
|
||||
|
||||
it('should fetch feature data in parallel for multiple agents', async () => {
|
||||
// Arrange: Add multiple running features
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
addRunningFeature(service, {
|
||||
featureId: `feature-${i}`,
|
||||
projectPath: `/project-${i}`,
|
||||
isAutoMode: i % 2 === 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Track call order
|
||||
const callOrder: string[] = [];
|
||||
const getMock = vi.fn().mockImplementation(async (projectPath: string, featureId: string) => {
|
||||
callOrder.push(featureId);
|
||||
// Simulate async delay to verify parallel execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return { id: featureId, title: `Title for ${featureId}` };
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const startTime = Date.now();
|
||||
const result = await service.getRunningAgents();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(5);
|
||||
expect(getMock).toHaveBeenCalledTimes(5);
|
||||
// If executed in parallel, total time should be ~10ms (one batch)
|
||||
// If sequential, it would be ~50ms (5 * 10ms)
|
||||
// Allow some buffer for execution overhead
|
||||
expect(duration).toBeLessThan(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectOrphanedFeatures', () => {
|
||||
// Helper to mock featureLoader.getAll
|
||||
const mockFeatureLoaderGetAll = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).featureLoader = { getAll: mockFn };
|
||||
};
|
||||
|
||||
// Helper to mock getExistingBranches
|
||||
const mockGetExistingBranches = (svc: AutoModeService, branches: string[]) => {
|
||||
(svc as any).getExistingBranches = vi.fn().mockResolvedValue(new Set(branches));
|
||||
};
|
||||
|
||||
it('should return empty array when no features have branch names', async () => {
|
||||
const getAllMock = vi.fn().mockResolvedValue([
|
||||
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test' },
|
||||
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test' },
|
||||
] satisfies Feature[]);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'develop']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when all feature branches exist', async () => {
|
||||
const getAllMock = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Feature 1',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'feature-1',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'feature-2',
|
||||
},
|
||||
] satisfies Feature[]);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'feature-1', 'feature-2']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect orphaned features with missing branches', async () => {
|
||||
const features: Feature[] = [
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Feature 1',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'feature-1',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'deleted-branch',
|
||||
},
|
||||
{ id: 'f3', title: 'Feature 3', description: 'desc', category: 'test' }, // No branch
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'feature-1']); // deleted-branch not in list
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature.id).toBe('f2');
|
||||
expect(result[0].missingBranch).toBe('deleted-branch');
|
||||
});
|
||||
|
||||
it('should detect multiple orphaned features', async () => {
|
||||
const features: Feature[] = [
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Feature 1',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'orphan-1',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'orphan-2',
|
||||
},
|
||||
{
|
||||
id: 'f3',
|
||||
title: 'Feature 3',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'valid-branch',
|
||||
},
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'valid-branch']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((r) => r.feature.id)).toContain('f1');
|
||||
expect(result.map((r) => r.feature.id)).toContain('f2');
|
||||
});
|
||||
|
||||
it('should return empty array when getAll throws error', async () => {
|
||||
const getAllMock = vi.fn().mockRejectedValue(new Error('Failed to load features'));
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore empty branchName strings', async () => {
|
||||
const features: Feature[] = [
|
||||
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: '' },
|
||||
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test', branchName: ' ' },
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip features whose branchName matches the primary branch', async () => {
|
||||
const features: Feature[] = [
|
||||
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: 'main' },
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'orphaned',
|
||||
},
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'develop']);
|
||||
// Mock getCurrentBranch to return 'main'
|
||||
(service as any).getCurrentBranch = vi.fn().mockResolvedValue('main');
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
// Only f2 should be orphaned (orphaned branch doesn't exist)
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature.id).toBe('f2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markFeatureInterrupted', () => {
|
||||
// Helper to mock featureStateManager.markFeatureInterrupted
|
||||
const mockFeatureStateManagerMarkInterrupted = (
|
||||
svc: AutoModeService,
|
||||
mockFn: ReturnType<typeof vi.fn>
|
||||
) => {
|
||||
(svc as any).featureStateManager.markFeatureInterrupted = mockFn;
|
||||
};
|
||||
|
||||
it('should delegate to featureStateManager.markFeatureInterrupted', async () => {
|
||||
const markMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(markMock).toHaveBeenCalledWith('/test/project', 'feature-123', undefined);
|
||||
});
|
||||
|
||||
it('should pass reason to featureStateManager.markFeatureInterrupted', async () => {
|
||||
const markMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
|
||||
|
||||
expect(markMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'server shutdown');
|
||||
});
|
||||
|
||||
it('should propagate errors from featureStateManager', async () => {
|
||||
const markMock = vi.fn().mockRejectedValue(new Error('Update failed'));
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
await expect(service.markFeatureInterrupted('/test/project', 'feature-123')).rejects.toThrow(
|
||||
'Update failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllRunningFeaturesInterrupted', () => {
|
||||
// Helper to access private concurrencyManager
|
||||
const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager;
|
||||
|
||||
// Helper to add a running feature via concurrencyManager
|
||||
const addRunningFeatureForInterrupt = (
|
||||
svc: AutoModeService,
|
||||
feature: { featureId: string; projectPath: string; isAutoMode: boolean }
|
||||
) => {
|
||||
getConcurrencyManager(svc).acquire(feature);
|
||||
};
|
||||
|
||||
// Helper to mock featureStateManager.markFeatureInterrupted
|
||||
const mockFeatureStateManagerMarkInterrupted = (
|
||||
svc: AutoModeService,
|
||||
mockFn: ReturnType<typeof vi.fn>
|
||||
) => {
|
||||
(svc as any).featureStateManager.markFeatureInterrupted = mockFn;
|
||||
};
|
||||
|
||||
it('should do nothing when no features are running', async () => {
|
||||
const markMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(markMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark a single running feature as interrupted', async () => {
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const markMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'server shutdown');
|
||||
});
|
||||
|
||||
it('should mark multiple running features as interrupted', async () => {
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-3',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const markMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(markMock).toHaveBeenCalledTimes(3);
|
||||
expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'server shutdown');
|
||||
expect(markMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'server shutdown');
|
||||
expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-3', 'server shutdown');
|
||||
});
|
||||
|
||||
it('should mark features in parallel', async () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: `feature-${i}`,
|
||||
projectPath: `/project-${i}`,
|
||||
isAutoMode: true,
|
||||
});
|
||||
}
|
||||
|
||||
const callOrder: string[] = [];
|
||||
const markMock = vi
|
||||
.fn()
|
||||
.mockImplementation(async (_path: string, featureId: string, _reason?: string) => {
|
||||
callOrder.push(featureId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
const startTime = Date.now();
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(markMock).toHaveBeenCalledTimes(5);
|
||||
// If executed in parallel, total time should be ~10ms
|
||||
// If sequential, it would be ~50ms (5 * 10ms)
|
||||
expect(duration).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it('should continue marking other features when one fails', async () => {
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const markMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('Failed to update'));
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
// Should not throw even though one feature failed
|
||||
await expect(service.markAllRunningFeaturesInterrupted()).resolves.not.toThrow();
|
||||
|
||||
expect(markMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should use provided reason', async () => {
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const markMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted('manual stop');
|
||||
|
||||
expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'manual stop');
|
||||
});
|
||||
|
||||
it('should use default reason when none provided', async () => {
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const markMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'server shutdown');
|
||||
});
|
||||
|
||||
it('should call markFeatureInterrupted for all running features (pipeline status handling delegated to FeatureStateManager)', async () => {
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
addRunningFeatureForInterrupt(service, {
|
||||
featureId: 'feature-3',
|
||||
projectPath: '/project-c',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
// FeatureStateManager handles pipeline status preservation internally
|
||||
const markMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockFeatureStateManagerMarkInterrupted(service, markMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
// All running features should have markFeatureInterrupted called
|
||||
// (FeatureStateManager internally preserves pipeline statuses)
|
||||
expect(markMock).toHaveBeenCalledTimes(3);
|
||||
expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'server shutdown');
|
||||
expect(markMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'server shutdown');
|
||||
expect(markMock).toHaveBeenCalledWith('/project-c', 'feature-3', 'server shutdown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFeatureRunning', () => {
|
||||
// Helper to access private concurrencyManager
|
||||
const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager;
|
||||
|
||||
// Helper to add a running feature via concurrencyManager
|
||||
const addRunningFeatureForIsRunning = (
|
||||
svc: AutoModeService,
|
||||
feature: { featureId: string; projectPath: string; isAutoMode: boolean }
|
||||
) => {
|
||||
getConcurrencyManager(svc).acquire(feature);
|
||||
};
|
||||
|
||||
it('should return false when no features are running', () => {
|
||||
expect(service.isFeatureRunning('feature-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when the feature is running', () => {
|
||||
addRunningFeatureForIsRunning(service, {
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
expect(service.isFeatureRunning('feature-123')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-running feature when others are running', () => {
|
||||
addRunningFeatureForIsRunning(service, {
|
||||
featureId: 'feature-other',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
expect(service.isFeatureRunning('feature-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly track multiple running features', () => {
|
||||
addRunningFeatureForIsRunning(service, {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
addRunningFeatureForIsRunning(service, {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
expect(service.isFeatureRunning('feature-1')).toBe(true);
|
||||
expect(service.isFeatureRunning('feature-2')).toBe(true);
|
||||
expect(service.isFeatureRunning('feature-3')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user