Files
gemini-cli/packages/core/src/utils/events.test.ts

429 lines
13 KiB
TypeScript

/**
* @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<typeof vi.fn>;
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);
});
});
});