mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 17:41:24 -07:00
429 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|