From 3bcdc883e60b027070f17416e1fad2698b707f71 Mon Sep 17 00:00:00 2001 From: Shirone Date: Tue, 27 Jan 2026 14:48:36 +0100 Subject: [PATCH] feat(01-03): create TypedEventBus class with tests - Add TypedEventBus as wrapper around EventEmitter - Implement emitAutoModeEvent method for auto-mode event format - Add emit, subscribe, getUnderlyingEmitter methods - Create comprehensive test suite (20 tests) - Verify exact event format for frontend compatibility --- apps/server/src/services/typed-event-bus.ts | 108 +++++++ .../unit/services/typed-event-bus.test.ts | 299 ++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 apps/server/src/services/typed-event-bus.ts create mode 100644 apps/server/tests/unit/services/typed-event-bus.test.ts diff --git a/apps/server/src/services/typed-event-bus.ts b/apps/server/src/services/typed-event-bus.ts new file mode 100644 index 00000000..11424826 --- /dev/null +++ b/apps/server/src/services/typed-event-bus.ts @@ -0,0 +1,108 @@ +/** + * TypedEventBus - Type-safe event emission wrapper for AutoModeService + * + * This class wraps the existing EventEmitter to provide type-safe event emission, + * specifically encapsulating the `emitAutoModeEvent` pattern used throughout AutoModeService. + * + * Key behavior: + * - emitAutoModeEvent wraps events in 'auto-mode:event' format for frontend consumption + * - Preserves all existing event emission patterns for backward compatibility + * - Frontend receives events in the exact same format as before (no breaking changes) + */ + +import type { EventEmitter, EventType, EventCallback } from '../lib/events.js'; + +/** + * Auto-mode event types that can be emitted through the TypedEventBus. + * These correspond to the event types expected by the frontend. + */ +export type AutoModeEventType = + | 'auto_mode_started' + | 'auto_mode_stopped' + | 'auto_mode_idle' + | 'auto_mode_error' + | 'auto_mode_paused_failures' + | 'auto_mode_feature_start' + | 'auto_mode_feature_complete' + | 'auto_mode_feature_resuming' + | 'auto_mode_progress' + | 'auto_mode_tool' + | 'auto_mode_task_started' + | 'auto_mode_task_complete' + | 'auto_mode_task_status' + | 'auto_mode_phase_complete' + | 'auto_mode_summary' + | 'auto_mode_resuming_features' + | 'planning_started' + | 'plan_approval_required' + | 'plan_approved' + | 'plan_auto_approved' + | 'plan_rejected' + | 'plan_revision_requested' + | 'plan_revision_warning' + | 'pipeline_step_started' + | 'pipeline_step_complete' + | string; // Allow other strings for extensibility + +/** + * TypedEventBus wraps an EventEmitter to provide type-safe event emission + * with the auto-mode event wrapping pattern. + */ +export class TypedEventBus { + private events: EventEmitter; + + /** + * Create a TypedEventBus wrapping an existing EventEmitter. + * @param events - The underlying EventEmitter to wrap + */ + constructor(events: EventEmitter) { + this.events = events; + } + + /** + * Emit a raw event directly to subscribers. + * Use this for non-auto-mode events that don't need wrapping. + * @param type - The event type + * @param payload - The event payload + */ + emit(type: EventType, payload: unknown): void { + this.events.emit(type, payload); + } + + /** + * Emit an auto-mode event wrapped in the correct format for the client. + * All auto-mode events are sent as type "auto-mode:event" with the actual + * event type and data in the payload. + * + * This produces the exact same event format that the frontend expects: + * { type: eventType, ...data } + * + * @param eventType - The auto-mode event type (e.g., 'auto_mode_started') + * @param data - Additional data to include in the event payload + */ + emitAutoModeEvent(eventType: AutoModeEventType, data: Record): void { + // Wrap the event in auto-mode:event format expected by the client + this.events.emit('auto-mode:event', { + type: eventType, + ...data, + }); + } + + /** + * Subscribe to all events from the underlying emitter. + * @param callback - Function called with (type, payload) for each event + * @returns Unsubscribe function + */ + subscribe(callback: EventCallback): () => void { + return this.events.subscribe(callback); + } + + /** + * Get the underlying EventEmitter for cases where direct access is needed. + * Use sparingly - prefer the typed methods when possible. + * @returns The wrapped EventEmitter + */ + getUnderlyingEmitter(): EventEmitter { + return this.events; + } +} diff --git a/apps/server/tests/unit/services/typed-event-bus.test.ts b/apps/server/tests/unit/services/typed-event-bus.test.ts new file mode 100644 index 00000000..85c5b202 --- /dev/null +++ b/apps/server/tests/unit/services/typed-event-bus.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { EventEmitter, EventCallback, EventType } from '../../../src/lib/events.js'; + +/** + * Create a mock EventEmitter for testing + */ +function createMockEventEmitter(): EventEmitter & { + emitCalls: Array<{ type: EventType; payload: unknown }>; + subscribers: Set; +} { + const subscribers = new Set(); + const emitCalls: Array<{ type: EventType; payload: unknown }> = []; + + return { + emitCalls, + subscribers, + emit(type: EventType, payload: unknown) { + emitCalls.push({ type, payload }); + // Also call subscribers to simulate real behavior + for (const callback of subscribers) { + callback(type, payload); + } + }, + subscribe(callback: EventCallback) { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + }; +} + +describe('TypedEventBus', () => { + let mockEmitter: ReturnType; + let eventBus: TypedEventBus; + + beforeEach(() => { + mockEmitter = createMockEventEmitter(); + eventBus = new TypedEventBus(mockEmitter); + }); + + describe('constructor', () => { + it('should wrap an EventEmitter', () => { + expect(eventBus).toBeInstanceOf(TypedEventBus); + }); + + it('should store the underlying emitter', () => { + expect(eventBus.getUnderlyingEmitter()).toBe(mockEmitter); + }); + }); + + describe('emit', () => { + it('should pass events directly to the underlying emitter', () => { + const payload = { test: 'data' }; + eventBus.emit('feature:created', payload); + + expect(mockEmitter.emitCalls).toHaveLength(1); + expect(mockEmitter.emitCalls[0]).toEqual({ + type: 'feature:created', + payload: { test: 'data' }, + }); + }); + + it('should handle various event types', () => { + eventBus.emit('feature:updated', { id: '1' }); + eventBus.emit('agent:streaming', { chunk: 'data' }); + eventBus.emit('error', { message: 'error' }); + + expect(mockEmitter.emitCalls).toHaveLength(3); + expect(mockEmitter.emitCalls[0].type).toBe('feature:updated'); + expect(mockEmitter.emitCalls[1].type).toBe('agent:streaming'); + expect(mockEmitter.emitCalls[2].type).toBe('error'); + }); + }); + + describe('emitAutoModeEvent', () => { + it('should wrap events in auto-mode:event format', () => { + eventBus.emitAutoModeEvent('auto_mode_started', { projectPath: '/test' }); + + expect(mockEmitter.emitCalls).toHaveLength(1); + expect(mockEmitter.emitCalls[0].type).toBe('auto-mode:event'); + }); + + it('should include event type in payload', () => { + eventBus.emitAutoModeEvent('auto_mode_started', { projectPath: '/test' }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload.type).toBe('auto_mode_started'); + }); + + it('should spread additional data into payload', () => { + eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId: 'feat-1', + featureName: 'Test Feature', + projectPath: '/project', + }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload).toEqual({ + type: 'auto_mode_feature_start', + featureId: 'feat-1', + featureName: 'Test Feature', + projectPath: '/project', + }); + }); + + it('should handle empty data object', () => { + eventBus.emitAutoModeEvent('auto_mode_idle', {}); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload).toEqual({ type: 'auto_mode_idle' }); + }); + + it('should preserve exact event format for frontend compatibility', () => { + // This test verifies the exact format that the frontend expects + eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId: 'feat-123', + progress: 50, + message: 'Processing...', + }); + + expect(mockEmitter.emitCalls[0]).toEqual({ + type: 'auto-mode:event', + payload: { + type: 'auto_mode_progress', + featureId: 'feat-123', + progress: 50, + message: 'Processing...', + }, + }); + }); + + it('should handle all standard auto-mode event types', () => { + const eventTypes = [ + 'auto_mode_started', + 'auto_mode_stopped', + 'auto_mode_idle', + 'auto_mode_error', + 'auto_mode_paused_failures', + 'auto_mode_feature_start', + 'auto_mode_feature_complete', + 'auto_mode_feature_resuming', + 'auto_mode_progress', + 'auto_mode_tool', + 'auto_mode_task_started', + 'auto_mode_task_complete', + 'planning_started', + 'plan_approval_required', + 'plan_approved', + 'plan_rejected', + ] as const; + + for (const eventType of eventTypes) { + eventBus.emitAutoModeEvent(eventType, { test: true }); + } + + expect(mockEmitter.emitCalls).toHaveLength(eventTypes.length); + mockEmitter.emitCalls.forEach((call, index) => { + expect(call.type).toBe('auto-mode:event'); + const payload = call.payload as Record; + expect(payload.type).toBe(eventTypes[index]); + }); + }); + + it('should allow custom event types (string extensibility)', () => { + eventBus.emitAutoModeEvent('custom_event_type', { custom: 'data' }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload.type).toBe('custom_event_type'); + }); + }); + + describe('subscribe', () => { + it('should pass subscriptions to the underlying emitter', () => { + const callback = vi.fn(); + eventBus.subscribe(callback); + + expect(mockEmitter.subscribers.has(callback)).toBe(true); + }); + + it('should return an unsubscribe function', () => { + const callback = vi.fn(); + const unsubscribe = eventBus.subscribe(callback); + + expect(mockEmitter.subscribers.has(callback)).toBe(true); + + unsubscribe(); + + expect(mockEmitter.subscribers.has(callback)).toBe(false); + }); + + it('should receive events when subscribed', () => { + const callback = vi.fn(); + eventBus.subscribe(callback); + + eventBus.emit('feature:created', { id: '1' }); + + expect(callback).toHaveBeenCalledWith('feature:created', { id: '1' }); + }); + + it('should receive auto-mode events when subscribed', () => { + const callback = vi.fn(); + eventBus.subscribe(callback); + + eventBus.emitAutoModeEvent('auto_mode_started', { projectPath: '/test' }); + + expect(callback).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_started', + projectPath: '/test', + }); + }); + + it('should not receive events after unsubscribe', () => { + const callback = vi.fn(); + const unsubscribe = eventBus.subscribe(callback); + + eventBus.emit('event1', {}); + expect(callback).toHaveBeenCalledTimes(1); + + unsubscribe(); + + eventBus.emit('event2', {}); + expect(callback).toHaveBeenCalledTimes(1); // Still 1, not called again + }); + }); + + describe('getUnderlyingEmitter', () => { + it('should return the wrapped EventEmitter', () => { + const emitter = eventBus.getUnderlyingEmitter(); + expect(emitter).toBe(mockEmitter); + }); + + it('should allow direct access for special cases', () => { + const emitter = eventBus.getUnderlyingEmitter(); + + // Verify we can use it directly + emitter.emit('direct:event', { direct: true }); + + expect(mockEmitter.emitCalls).toHaveLength(1); + expect(mockEmitter.emitCalls[0].type).toBe('direct:event'); + }); + }); + + describe('integration with real EventEmitter pattern', () => { + it('should produce the exact payload format used by AutoModeService', () => { + // This test documents the exact format that was in AutoModeService.emitAutoModeEvent + // before extraction, ensuring backward compatibility + + const receivedEvents: Array<{ type: EventType; payload: unknown }> = []; + + eventBus.subscribe((type, payload) => { + receivedEvents.push({ type, payload }); + }); + + // Simulate the exact call pattern from AutoModeService + eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId: 'abc-123', + featureName: 'Add user authentication', + projectPath: '/home/user/project', + }); + + expect(receivedEvents).toHaveLength(1); + expect(receivedEvents[0]).toEqual({ + type: 'auto-mode:event', + payload: { + type: 'auto_mode_feature_start', + featureId: 'abc-123', + featureName: 'Add user authentication', + projectPath: '/home/user/project', + }, + }); + }); + + it('should handle complex nested data in events', () => { + eventBus.emitAutoModeEvent('auto_mode_tool', { + featureId: 'feat-1', + tool: { + name: 'write_file', + input: { + path: '/src/index.ts', + content: 'const x = 1;', + }, + }, + timestamp: 1234567890, + }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload.type).toBe('auto_mode_tool'); + expect(payload.tool).toEqual({ + name: 'write_file', + input: { + path: '/src/index.ts', + content: 'const x = 1;', + }, + }); + }); + }); +});