mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
Fix: Dev server detection bug fixes. Settings sync bug fixes. Cli provider fixes. Terminal background/foreground colors (#791)
* Changes from fix/dev-server-state-bug * feat: Add configurable max turns setting with user overrides. Address pr comments * fix: Update default behaviors and improve state management across server and UI * feat: Extract branch sync logic to separate service. Fix settings sync bug. Address pr comments * refactor: Extract magic numbers to named constants and improve branch tracking logic - Add DEFAULT_MAX_TURNS (1000) and MAX_ALLOWED_TURNS (2000) constants to settings-helpers - Replace hardcoded 1000 values with DEFAULT_MAX_TURNS constant throughout codebase - Improve max turns validation with explicit Number.isFinite check - Update getTrackingBranch to split on first slash instead of last for better remote parsing - Change isBranchCheckedOut return type from boolean to string|null to return worktree path - Add comments explaining skipFetch parameter in worktree creation - Fix cleanup order in AgentExecutor finally block to run before logging ``` * feat: Add comment refresh and improve model sync in PR dialog
This commit is contained in:
580
apps/server/tests/unit/services/event-hook-service.test.ts
Normal file
580
apps/server/tests/unit/services/event-hook-service.test.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventHookService } from '../../../src/services/event-hook-service.js';
|
||||
import type { EventEmitter, EventCallback, EventType } from '../../../src/lib/events.js';
|
||||
import type { SettingsService } from '../../../src/services/settings-service.js';
|
||||
import type { EventHistoryService } from '../../../src/services/event-history-service.js';
|
||||
import type { FeatureLoader } from '../../../src/services/feature-loader.js';
|
||||
|
||||
/**
|
||||
* Create a mock EventEmitter for testing
|
||||
*/
|
||||
function createMockEventEmitter(): EventEmitter & {
|
||||
subscribers: Set<EventCallback>;
|
||||
simulateEvent: (type: EventType, payload: unknown) => void;
|
||||
} {
|
||||
const subscribers = new Set<EventCallback>();
|
||||
|
||||
return {
|
||||
subscribers,
|
||||
emit(type: EventType, payload: unknown) {
|
||||
for (const callback of subscribers) {
|
||||
callback(type, payload);
|
||||
}
|
||||
},
|
||||
subscribe(callback: EventCallback) {
|
||||
subscribers.add(callback);
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
};
|
||||
},
|
||||
simulateEvent(type: EventType, payload: unknown) {
|
||||
for (const callback of subscribers) {
|
||||
callback(type, payload);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock SettingsService
|
||||
*/
|
||||
function createMockSettingsService(hooks: unknown[] = []): SettingsService {
|
||||
return {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }),
|
||||
} as unknown as SettingsService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock EventHistoryService
|
||||
*/
|
||||
function createMockEventHistoryService() {
|
||||
return {
|
||||
storeEvent: vi.fn().mockResolvedValue({ id: 'test-event-id' }),
|
||||
} as unknown as EventHistoryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock FeatureLoader
|
||||
*/
|
||||
function createMockFeatureLoader(features: Record<string, { title: string }> = {}) {
|
||||
return {
|
||||
get: vi.fn().mockImplementation((_projectPath: string, featureId: string) => {
|
||||
return Promise.resolve(features[featureId] || null);
|
||||
}),
|
||||
} as unknown as FeatureLoader;
|
||||
}
|
||||
|
||||
describe('EventHookService', () => {
|
||||
let service: EventHookService;
|
||||
let mockEmitter: ReturnType<typeof createMockEventEmitter>;
|
||||
let mockSettingsService: ReturnType<typeof createMockSettingsService>;
|
||||
let mockEventHistoryService: ReturnType<typeof createMockEventHistoryService>;
|
||||
let mockFeatureLoader: ReturnType<typeof createMockFeatureLoader>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new EventHookService();
|
||||
mockEmitter = createMockEventEmitter();
|
||||
mockSettingsService = createMockSettingsService();
|
||||
mockEventHistoryService = createMockEventHistoryService();
|
||||
mockFeatureLoader = createMockFeatureLoader();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.destroy();
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should subscribe to the event emitter', () => {
|
||||
service.initialize(mockEmitter, mockSettingsService, mockEventHistoryService);
|
||||
expect(mockEmitter.subscribers.size).toBe(1);
|
||||
});
|
||||
|
||||
it('should log initialization', () => {
|
||||
service.initialize(mockEmitter, mockSettingsService);
|
||||
expect(mockEmitter.subscribers.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should unsubscribe from the event emitter', () => {
|
||||
service.initialize(mockEmitter, mockSettingsService);
|
||||
expect(mockEmitter.subscribers.size).toBe(1);
|
||||
|
||||
service.destroy();
|
||||
expect(mockEmitter.subscribers.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event mapping - auto_mode_feature_complete', () => {
|
||||
it('should map to feature_success when passes is true', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed in 30s',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
// Allow async processing
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('feature_success');
|
||||
expect(storeCall.passes).toBe(true);
|
||||
});
|
||||
|
||||
it('should map to feature_error when passes is false', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: false,
|
||||
message: 'Feature stopped by user',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('feature_error');
|
||||
expect(storeCall.passes).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT populate error field for successful feature completion', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed in 30s - auto-verified',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('feature_success');
|
||||
// Critical: error should NOT contain the success message
|
||||
expect(storeCall.error).toBeUndefined();
|
||||
expect(storeCall.errorType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should populate error field for failed feature completion', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: false,
|
||||
message: 'Feature stopped by user',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('feature_error');
|
||||
// Error field should be populated for error triggers
|
||||
expect(storeCall.error).toBe('Feature stopped by user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event mapping - auto_mode_error', () => {
|
||||
it('should map to feature_error when featureId is present', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
featureId: 'feat-1',
|
||||
error: 'Network timeout',
|
||||
errorType: 'network',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('feature_error');
|
||||
expect(storeCall.error).toBe('Network timeout');
|
||||
expect(storeCall.errorType).toBe('network');
|
||||
});
|
||||
|
||||
it('should map to auto_mode_error when featureId is not present', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
error: 'System error',
|
||||
errorType: 'system',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('auto_mode_error');
|
||||
expect(storeCall.error).toBe('System error');
|
||||
expect(storeCall.errorType).toBe('system');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event mapping - auto_mode_idle', () => {
|
||||
it('should map to auto_mode_complete', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_idle',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('auto_mode_complete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event mapping - feature:created', () => {
|
||||
it('should trigger feature_created hook', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('feature:created', {
|
||||
featureId: 'feat-1',
|
||||
featureName: 'New Feature',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('feature_created');
|
||||
expect(storeCall.featureId).toBe('feat-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event mapping - unhandled events', () => {
|
||||
it('should ignore auto-mode events with unrecognized types', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_progress',
|
||||
featureId: 'feat-1',
|
||||
content: 'Working...',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
// Give it time to process
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore events without a type', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
featureId: 'feat-1',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook execution', () => {
|
||||
it('should execute matching enabled hooks for feature_success', async () => {
|
||||
const hooks = [
|
||||
{
|
||||
id: 'hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Success Hook',
|
||||
action: {
|
||||
type: 'shell',
|
||||
command: 'echo "success"',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'hook-2',
|
||||
enabled: true,
|
||||
trigger: 'feature_error',
|
||||
name: 'Error Hook',
|
||||
action: {
|
||||
type: 'shell',
|
||||
command: 'echo "error"',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed in 30s',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSettingsService.getGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The error hook should NOT have been triggered for a success event
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('feature_success');
|
||||
});
|
||||
|
||||
it('should NOT execute error hooks when feature completes successfully', async () => {
|
||||
// This is the key regression test for the bug:
|
||||
// "Error event hook fired when a feature completes successfully"
|
||||
const errorHookCommand = vi.fn();
|
||||
const hooks = [
|
||||
{
|
||||
id: 'hook-error',
|
||||
enabled: true,
|
||||
trigger: 'feature_error',
|
||||
name: 'Error Notification',
|
||||
action: {
|
||||
type: 'shell',
|
||||
command: 'echo "ERROR FIRED"',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed in 30s - auto-verified',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify the trigger was feature_success, not feature_error
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('feature_success');
|
||||
// And no error information should be present
|
||||
expect(storeCall.error).toBeUndefined();
|
||||
expect(storeCall.errorType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature name loading', () => {
|
||||
it('should load feature name from feature loader when not in payload', async () => {
|
||||
mockFeatureLoader = createMockFeatureLoader({
|
||||
'feat-1': { title: 'Loaded Feature Title' },
|
||||
});
|
||||
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feat-1',
|
||||
passes: true,
|
||||
message: 'Done',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.featureName).toBe('Loaded Feature Title');
|
||||
});
|
||||
|
||||
it('should fall back to payload featureName when loader fails', async () => {
|
||||
mockFeatureLoader = createMockFeatureLoader({}); // Empty - no features found
|
||||
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Fallback Name',
|
||||
passes: true,
|
||||
message: 'Done',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.featureName).toBe('Fallback Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error context for error events', () => {
|
||||
it('should use payload.error when available for error triggers', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
featureId: 'feat-1',
|
||||
error: 'Authentication failed',
|
||||
errorType: 'auth',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.error).toBe('Authentication failed');
|
||||
expect(storeCall.errorType).toBe('auth');
|
||||
});
|
||||
|
||||
it('should fall back to payload.message for error field in error triggers', async () => {
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
featureId: 'feat-1',
|
||||
passes: false,
|
||||
message: 'Feature stopped by user',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
expect(storeCall.trigger).toBe('feature_error');
|
||||
expect(storeCall.error).toBe('Feature stopped by user');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -175,7 +175,10 @@ describe('execution-service.ts', () => {
|
||||
} as unknown as TypedEventBus;
|
||||
|
||||
mockConcurrencyManager = {
|
||||
acquire: vi.fn().mockImplementation(({ featureId }) => createRunningFeature(featureId)),
|
||||
acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({
|
||||
...createRunningFeature(featureId),
|
||||
isAutoMode: isAutoMode ?? false,
|
||||
})),
|
||||
release: vi.fn(),
|
||||
getRunningFeature: vi.fn(),
|
||||
isRunning: vi.fn(),
|
||||
@@ -550,8 +553,8 @@ describe('execution-service.ts', () => {
|
||||
expect(mockRunAgentFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits feature_complete event on success', async () => {
|
||||
await service.executeFeature('/test/project', 'feature-1');
|
||||
it('emits feature_complete event on success when isAutoMode is true', async () => {
|
||||
await service.executeFeature('/test/project', 'feature-1', false, true);
|
||||
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||
'auto_mode_feature_complete',
|
||||
@@ -561,6 +564,15 @@ describe('execution-service.ts', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit feature_complete event on success when isAutoMode is false', async () => {
|
||||
await service.executeFeature('/test/project', 'feature-1', false, false);
|
||||
|
||||
const completeCalls = vi
|
||||
.mocked(mockEventBus.emitAutoModeEvent)
|
||||
.mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete');
|
||||
expect(completeCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeFeature - approved plan handling', () => {
|
||||
@@ -1110,7 +1122,7 @@ describe('execution-service.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('handles abort signal without error event', async () => {
|
||||
it('handles abort signal without error event (emits feature_complete when isAutoMode=true)', async () => {
|
||||
const abortError = new Error('abort');
|
||||
abortError.name = 'AbortError';
|
||||
mockRunAgentFn = vi.fn().mockRejectedValue(abortError);
|
||||
@@ -1136,7 +1148,7 @@ describe('execution-service.ts', () => {
|
||||
mockLoadContextFilesFn
|
||||
);
|
||||
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
await svc.executeFeature('/test/project', 'feature-1', false, true);
|
||||
|
||||
// Should emit feature_complete with stopped by user
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||
@@ -1155,6 +1167,47 @@ describe('execution-service.ts', () => {
|
||||
expect(errorCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('handles abort signal without emitting feature_complete when isAutoMode=false', async () => {
|
||||
const abortError = new Error('abort');
|
||||
abortError.name = 'AbortError';
|
||||
mockRunAgentFn = vi.fn().mockRejectedValue(abortError);
|
||||
|
||||
const svc = new ExecutionService(
|
||||
mockEventBus,
|
||||
mockConcurrencyManager,
|
||||
mockWorktreeResolver,
|
||||
mockSettingsService,
|
||||
mockRunAgentFn,
|
||||
mockExecutePipelineFn,
|
||||
mockUpdateFeatureStatusFn,
|
||||
mockLoadFeatureFn,
|
||||
mockGetPlanningPromptPrefixFn,
|
||||
mockSaveFeatureSummaryFn,
|
||||
mockRecordLearningsFn,
|
||||
mockContextExistsFn,
|
||||
mockResumeFeatureFn,
|
||||
mockTrackFailureFn,
|
||||
mockSignalPauseFn,
|
||||
mockRecordSuccessFn,
|
||||
mockSaveExecutionStateFn,
|
||||
mockLoadContextFilesFn
|
||||
);
|
||||
|
||||
await svc.executeFeature('/test/project', 'feature-1', false, false);
|
||||
|
||||
// Should NOT emit feature_complete when isAutoMode is false
|
||||
const completeCalls = vi
|
||||
.mocked(mockEventBus.emitAutoModeEvent)
|
||||
.mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete');
|
||||
expect(completeCalls.length).toBe(0);
|
||||
|
||||
// Should NOT emit error event (abort is not an error)
|
||||
const errorCalls = vi
|
||||
.mocked(mockEventBus.emitAutoModeEvent)
|
||||
.mock.calls.filter((call) => call[0] === 'auto_mode_error');
|
||||
expect(errorCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('releases running feature even on error', async () => {
|
||||
const testError = new Error('Test error');
|
||||
mockRunAgentFn = vi.fn().mockRejectedValue(testError);
|
||||
@@ -1339,8 +1392,8 @@ describe('execution-service.ts', () => {
|
||||
it('handles missing agent output gracefully', async () => {
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
// Should not throw
|
||||
await service.executeFeature('/test/project', 'feature-1');
|
||||
// Should not throw (isAutoMode=true so event is emitted)
|
||||
await service.executeFeature('/test/project', 'feature-1', false, true);
|
||||
|
||||
// Feature should still complete successfully
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||
|
||||
@@ -170,14 +170,16 @@ describe('PipelineOrchestrator', () => {
|
||||
} as unknown as WorktreeResolver;
|
||||
|
||||
mockConcurrencyManager = {
|
||||
acquire: vi.fn().mockReturnValue({
|
||||
featureId: 'feature-1',
|
||||
acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({
|
||||
featureId,
|
||||
projectPath: '/test/project',
|
||||
abortController: new AbortController(),
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
}),
|
||||
isAutoMode: isAutoMode ?? false,
|
||||
})),
|
||||
release: vi.fn(),
|
||||
getRunningFeature: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as ConcurrencyManager;
|
||||
|
||||
mockSettingsService = null;
|
||||
@@ -541,8 +543,18 @@ describe('PipelineOrchestrator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit auto_mode_feature_complete on success', async () => {
|
||||
it('should emit auto_mode_feature_complete on success when isAutoMode is true', async () => {
|
||||
vi.mocked(performMerge).mockResolvedValue({ success: true });
|
||||
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
abortController: new AbortController(),
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
isAutoMode: true,
|
||||
startTime: Date.now(),
|
||||
leaseCount: 1,
|
||||
});
|
||||
|
||||
const context = createMergeContext();
|
||||
await orchestrator.attemptMerge(context);
|
||||
@@ -553,6 +565,19 @@ describe('PipelineOrchestrator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not emit auto_mode_feature_complete on success when isAutoMode is false', async () => {
|
||||
vi.mocked(performMerge).mockResolvedValue({ success: true });
|
||||
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined);
|
||||
|
||||
const context = createMergeContext();
|
||||
await orchestrator.attemptMerge(context);
|
||||
|
||||
const completeCalls = vi
|
||||
.mocked(mockEventBus.emitAutoModeEvent)
|
||||
.mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete');
|
||||
expect(completeCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return needsAgentResolution true on conflict', async () => {
|
||||
vi.mocked(performMerge).mockResolvedValue({
|
||||
success: false,
|
||||
@@ -623,13 +648,24 @@ describe('PipelineOrchestrator', () => {
|
||||
expect(mockExecuteFeatureFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should complete feature when step no longer exists', async () => {
|
||||
it('should complete feature when step no longer exists and emit event when isAutoMode=true', async () => {
|
||||
const invalidPipelineInfo: PipelineStatusInfo = {
|
||||
...validPipelineInfo,
|
||||
stepIndex: -1,
|
||||
step: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
abortController: new AbortController(),
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
isAutoMode: true,
|
||||
startTime: Date.now(),
|
||||
leaseCount: 1,
|
||||
});
|
||||
|
||||
await orchestrator.resumePipeline('/test/project', testFeature, true, invalidPipelineInfo);
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
@@ -642,6 +678,28 @@ describe('PipelineOrchestrator', () => {
|
||||
expect.objectContaining({ message: expect.stringContaining('no longer exists') })
|
||||
);
|
||||
});
|
||||
|
||||
it('should not emit feature_complete when step no longer exists and isAutoMode=false', async () => {
|
||||
const invalidPipelineInfo: PipelineStatusInfo = {
|
||||
...validPipelineInfo,
|
||||
stepIndex: -1,
|
||||
step: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined);
|
||||
|
||||
await orchestrator.resumePipeline('/test/project', testFeature, true, invalidPipelineInfo);
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'verified'
|
||||
);
|
||||
const completeCalls = vi
|
||||
.mocked(mockEventBus.emitAutoModeEvent)
|
||||
.mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete');
|
||||
expect(completeCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeFromStep', () => {
|
||||
@@ -666,7 +724,7 @@ describe('PipelineOrchestrator', () => {
|
||||
expect(mockRunAgentFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should complete feature when all remaining steps excluded', async () => {
|
||||
it('should complete feature when all remaining steps excluded and emit event when isAutoMode=true', async () => {
|
||||
const featureWithAllExcluded: Feature = {
|
||||
...testFeature,
|
||||
excludedPipelineSteps: ['step-1', 'step-2'],
|
||||
@@ -674,6 +732,16 @@ describe('PipelineOrchestrator', () => {
|
||||
|
||||
vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified');
|
||||
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false);
|
||||
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
abortController: new AbortController(),
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
isAutoMode: true,
|
||||
startTime: Date.now(),
|
||||
leaseCount: 1,
|
||||
});
|
||||
|
||||
await orchestrator.resumeFromStep(
|
||||
'/test/project',
|
||||
@@ -1033,7 +1101,7 @@ describe('PipelineOrchestrator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('handles all steps excluded during resume', async () => {
|
||||
it('handles all steps excluded during resume and emits event when isAutoMode=true', async () => {
|
||||
const featureWithAllExcluded: Feature = {
|
||||
...testFeature,
|
||||
excludedPipelineSteps: ['step-1', 'step-2'],
|
||||
@@ -1041,6 +1109,16 @@ describe('PipelineOrchestrator', () => {
|
||||
|
||||
vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified');
|
||||
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false);
|
||||
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/test/project',
|
||||
abortController: new AbortController(),
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
isAutoMode: true,
|
||||
startTime: Date.now(),
|
||||
leaseCount: 1,
|
||||
});
|
||||
|
||||
await orchestrator.resumeFromStep(
|
||||
'/test/project',
|
||||
|
||||
Reference in New Issue
Block a user