/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { CoreEventEmitter, CoreEvent, coreEvents, type UserFeedbackPayload, type McpProgressPayload, } from './events.js'; vi.mock('./debugLogger.js', () => ({ debugLogger: { log: vi.fn() }, })); describe('CoreEventEmitter', () => { let events: CoreEventEmitter; beforeEach(() => { events = new CoreEventEmitter(); }); it('should emit feedback immediately when a listener is present', () => { const listener = vi.fn(); events.on(CoreEvent.UserFeedback, listener); const payload = { severity: 'info' as const, message: 'Test message', }; events.emitFeedback(payload.severity, payload.message); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); }); it('should buffer feedback when no listener is present', () => { const listener = vi.fn(); const payload = { severity: 'warning' as const, message: 'Buffered message', }; // Emit while no listeners attached events.emitFeedback(payload.severity, payload.message); expect(listener).not.toHaveBeenCalled(); // Attach listener and drain events.on(CoreEvent.UserFeedback, listener); events.drainBacklogs(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); }); it('should respect the backlog size limit and maintain FIFO order', () => { const listener = vi.fn(); const MAX_BACKLOG_SIZE = 10000; for (let i = 0; i < MAX_BACKLOG_SIZE + 10; i++) { events.emitFeedback('info', `Message ${i}`); } events.on(CoreEvent.UserFeedback, listener); events.drainBacklogs(); expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE); // Verify strictly that the FIRST call was Message 10 (0-9 dropped) expect(listener.mock.calls[0][0]).toMatchObject({ message: 'Message 10' }); // Verify strictly that the LAST call was Message 109 expect(listener.mock.lastCall?.[0]).toMatchObject({ message: `Message ${MAX_BACKLOG_SIZE + 9}`, }); }); it('should clear the backlog after draining', () => { const listener = vi.fn(); events.emitFeedback('error', 'Test error'); events.on(CoreEvent.UserFeedback, listener); events.drainBacklogs(); expect(listener).toHaveBeenCalledTimes(1); listener.mockClear(); events.drainBacklogs(); expect(listener).not.toHaveBeenCalled(); }); it('should include optional error object in payload', () => { const listener = vi.fn(); events.on(CoreEvent.UserFeedback, listener); const error = new Error('Original error'); events.emitFeedback('error', 'Something went wrong', error); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ severity: 'error', message: 'Something went wrong', error, }), ); }); it('should handle multiple listeners correctly', () => { const listenerA = vi.fn(); const listenerB = vi.fn(); events.on(CoreEvent.UserFeedback, listenerA); events.on(CoreEvent.UserFeedback, listenerB); events.emitFeedback('info', 'Broadcast message'); expect(listenerA).toHaveBeenCalledTimes(1); expect(listenerB).toHaveBeenCalledTimes(1); }); it('should stop receiving events after off() is called', () => { const listener = vi.fn(); events.on(CoreEvent.UserFeedback, listener); events.emitFeedback('info', 'First message'); expect(listener).toHaveBeenCalledTimes(1); events.off(CoreEvent.UserFeedback, listener); events.emitFeedback('info', 'Second message'); expect(listener).toHaveBeenCalledTimes(1); // Still 1 }); it('should handle re-entrant feedback emission during draining safely', () => { events.emitFeedback('info', 'Buffered 1'); events.emitFeedback('info', 'Buffered 2'); const listener = vi.fn((payload: UserFeedbackPayload) => { // When 'Buffered 1' is received, immediately emit another event. if (payload.message === 'Buffered 1') { events.emitFeedback('warning', 'Re-entrant message'); } }); events.on(CoreEvent.UserFeedback, listener); events.drainBacklogs(); // Expectation with atomic snapshot: // 1. loop starts with ['Buffered 1', 'Buffered 2'] // 2. emits 'Buffered 1' // 3. listener fires for 'Buffered 1', calls emitFeedback('Re-entrant') // 4. emitFeedback sees listener attached, emits 'Re-entrant' synchronously // 5. listener fires for 'Re-entrant' // 6. loop continues, emits 'Buffered 2' // 7. listener fires for 'Buffered 2' expect(listener).toHaveBeenCalledTimes(3); expect(listener.mock.calls[0][0]).toMatchObject({ message: 'Buffered 1' }); expect(listener.mock.calls[1][0]).toMatchObject({ message: 'Re-entrant message', }); expect(listener.mock.calls[2][0]).toMatchObject({ message: 'Buffered 2' }); }); describe('ConsoleLog Event', () => { it('should emit console log immediately when a listener is present', () => { const listener = vi.fn(); events.on(CoreEvent.ConsoleLog, listener); const payload = { type: 'info' as const, content: 'Test log', }; events.emitConsoleLog(payload.type, payload.content); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); }); it('should buffer console logs when no listener is present', () => { const listener = vi.fn(); const payload = { type: 'warn' as const, content: 'Buffered log', }; // Emit while no listeners attached events.emitConsoleLog(payload.type, payload.content); expect(listener).not.toHaveBeenCalled(); // Attach listener and drain events.on(CoreEvent.ConsoleLog, listener); events.drainBacklogs(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); }); it('should respect the backlog size limit for console logs', () => { const listener = vi.fn(); const MAX_BACKLOG_SIZE = 10000; for (let i = 0; i < MAX_BACKLOG_SIZE + 10; i++) { events.emitConsoleLog('debug', `Log ${i}`); } events.on(CoreEvent.ConsoleLog, listener); events.drainBacklogs(); expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE); // Verify strictly that the FIRST call was Log 10 (0-9 dropped) expect(listener.mock.calls[0][0]).toMatchObject({ content: 'Log 10' }); }); }); describe('Output Event', () => { it('should emit output immediately when a listener is present', () => { const listener = vi.fn(); events.on(CoreEvent.Output, listener); const payload = { isStderr: false, chunk: 'Test output', encoding: 'utf8' as BufferEncoding, }; events.emitOutput(payload.isStderr, payload.chunk, payload.encoding); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); }); it('should buffer output when no listener is present', () => { const listener = vi.fn(); const payload = { isStderr: true, chunk: 'Buffered output', }; // Emit while no listeners attached events.emitOutput(payload.isStderr, payload.chunk); expect(listener).not.toHaveBeenCalled(); // Attach listener and drain events.on(CoreEvent.Output, listener); events.drainBacklogs(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); }); it('should respect the backlog size limit for output', () => { const listener = vi.fn(); const MAX_BACKLOG_SIZE = 10000; for (let i = 0; i < MAX_BACKLOG_SIZE + 10; i++) { events.emitOutput(false, `Output ${i}`); } events.on(CoreEvent.Output, listener); events.drainBacklogs(); expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE); // Verify strictly that the FIRST call was Output 10 (0-9 dropped) expect(listener.mock.calls[0][0]).toMatchObject({ chunk: 'Output 10' }); }); }); describe('ModelChanged Event', () => { it('should emit ModelChanged event with correct payload', () => { const listener = vi.fn(); events.on(CoreEvent.ModelChanged, listener); const newModel = 'gemini-2.5-pro'; events.emitModelChanged(newModel); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ model: newModel }); }); }); describe('Hook Events', () => { it('should emit HookStart event with correct payload using helper', () => { const listener = vi.fn(); events.on(CoreEvent.HookStart, listener); const payload = { hookName: 'test-hook', eventName: 'before-agent', hookIndex: 1, totalHooks: 1, }; events.emitHookStart(payload); expect(listener).toHaveBeenCalledWith(payload); }); it('should emit HookEnd event with correct payload using helper', () => { const listener = vi.fn(); events.on(CoreEvent.HookEnd, listener); const payload = { hookName: 'test-hook', eventName: 'before-agent', success: true, }; events.emitHookEnd(payload); expect(listener).toHaveBeenCalledWith(payload); }); }); describe('ConsentRequest Event', () => { it('should emit consent request immediately when a listener is present', () => { const listener = vi.fn(); events.on(CoreEvent.ConsentRequest, listener); const payload = { prompt: 'Do you consent?', onConfirm: vi.fn(), }; events.emitConsentRequest(payload); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith(payload); }); it('should buffer consent requests when no listener is present', () => { const listener = vi.fn(); const payload = { prompt: 'Buffered consent?', onConfirm: vi.fn(), }; // Emit while no listeners attached events.emitConsentRequest(payload); expect(listener).not.toHaveBeenCalled(); // Attach listener and drain events.on(CoreEvent.ConsentRequest, listener); events.drainBacklogs(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith(payload); }); it('should respect the backlog size limit for consent requests', () => { const listener = vi.fn(); const MAX_BACKLOG_SIZE = 10000; for (let i = 0; i < MAX_BACKLOG_SIZE + 10; i++) { events.emitConsentRequest({ prompt: `Consent ${i}`, onConfirm: vi.fn(), }); } events.on(CoreEvent.ConsentRequest, listener); events.drainBacklogs(); expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE); // Verify strictly that the FIRST call was Consent 10 (0-9 dropped) expect(listener.mock.calls[0][0]).toMatchObject({ prompt: 'Consent 10' }); }); }); describe('emitMcpProgress validation', () => { const basePayload: McpProgressPayload = { serverName: 'test-server', callId: 'call-1', progressToken: 'token-1', progress: 0, }; let listener: ReturnType; afterEach(() => { if (listener) { coreEvents.off(CoreEvent.McpProgress, listener); } }); it('rejects NaN progress', () => { listener = vi.fn(); coreEvents.on(CoreEvent.McpProgress, listener); coreEvents.emitMcpProgress({ ...basePayload, progress: NaN }); expect(listener).not.toHaveBeenCalled(); }); it('rejects negative progress', () => { listener = vi.fn(); coreEvents.on(CoreEvent.McpProgress, listener); coreEvents.emitMcpProgress({ ...basePayload, progress: -1 }); expect(listener).not.toHaveBeenCalled(); }); it('rejects Infinity progress', () => { listener = vi.fn(); coreEvents.on(CoreEvent.McpProgress, listener); coreEvents.emitMcpProgress({ ...basePayload, progress: Infinity }); expect(listener).not.toHaveBeenCalled(); }); it('emits valid progress payload', () => { listener = vi.fn(); coreEvents.on(CoreEvent.McpProgress, listener); const payload: McpProgressPayload = { ...basePayload, progress: 5, total: 10, message: 'test', }; coreEvents.emitMcpProgress(payload); expect(listener).toHaveBeenCalledExactlyOnceWith(payload); }); }); });