| |
| |
| |
| |
| |
|
|
| import { vi, describe, it, expect, beforeEach } from 'vitest'; |
| import type { Config } from '@google/gemini-cli-core'; |
|
|
| |
|
|
| const mockInitActivityLogger = vi.hoisted(() => vi.fn()); |
| const mockAddNetworkTransport = vi.hoisted(() => vi.fn()); |
|
|
| type Listener = (...args: unknown[]) => void; |
|
|
| const { MockWebSocket } = vi.hoisted(() => { |
| class MockWebSocket { |
| close = vi.fn(); |
| url: string; |
| static instances: MockWebSocket[] = []; |
| private listeners = new Map<string, Listener[]>(); |
|
|
| constructor(url: string) { |
| this.url = url; |
| MockWebSocket.instances.push(this); |
| } |
|
|
| on(event: string, fn: Listener) { |
| const fns = this.listeners.get(event) || []; |
| fns.push(fn); |
| this.listeners.set(event, fns); |
| return this; |
| } |
|
|
| emit(event: string, ...args: unknown[]) { |
| for (const fn of this.listeners.get(event) || []) { |
| fn(...args); |
| } |
| } |
|
|
| simulateOpen() { |
| this.emit('open'); |
| } |
|
|
| simulateError() { |
| this.emit('error', new Error('ECONNREFUSED')); |
| } |
| } |
| return { MockWebSocket }; |
| }); |
|
|
| const mockDevToolsInstance = vi.hoisted(() => ({ |
| start: vi.fn(), |
| stop: vi.fn(), |
| getPort: vi.fn(), |
| })); |
|
|
| const mockActivityLoggerInstance = vi.hoisted(() => ({ |
| disableNetworkLogging: vi.fn(), |
| enableNetworkLogging: vi.fn(), |
| drainBufferedLogs: vi.fn().mockReturnValue({ network: [], console: [] }), |
| })); |
|
|
| vi.mock('./activityLogger.js', () => ({ |
| initActivityLogger: mockInitActivityLogger, |
| addNetworkTransport: mockAddNetworkTransport, |
| ActivityLogger: { |
| getInstance: () => mockActivityLoggerInstance, |
| }, |
| })); |
|
|
| const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true)); |
| const mockOpenBrowserSecurely = vi.hoisted(() => |
| vi.fn(() => Promise.resolve()), |
| ); |
|
|
| vi.mock('@google/gemini-cli-core', () => ({ |
| debugLogger: { |
| log: vi.fn(), |
| debug: vi.fn(), |
| error: vi.fn(), |
| warn: vi.fn(), |
| }, |
| shouldLaunchBrowser: mockShouldLaunchBrowser, |
| openBrowserSecurely: mockOpenBrowserSecurely, |
| })); |
|
|
| vi.mock('ws', () => ({ |
| default: MockWebSocket, |
| })); |
|
|
| vi.mock('@google/gemini-cli-devtools', () => ({ |
| DevTools: { |
| getInstance: () => mockDevToolsInstance, |
| }, |
| })); |
|
|
| |
| import { |
| setupInitialActivityLogger, |
| startDevToolsServer, |
| toggleDevToolsPanel, |
| resetForTesting, |
| } from './devtoolsService.js'; |
|
|
| function createMockConfig(overrides: Record<string, unknown> = {}) { |
| return { |
| isInteractive: vi.fn().mockReturnValue(true), |
| getSessionId: vi.fn().mockReturnValue('test-session'), |
| getDebugMode: vi.fn().mockReturnValue(false), |
| storage: { getProjectTempLogsDir: vi.fn().mockReturnValue('/tmp/logs') }, |
| ...overrides, |
| } as unknown as Config; |
| } |
|
|
| describe('devtoolsService', () => { |
| beforeEach(() => { |
| vi.clearAllMocks(); |
| MockWebSocket.instances = []; |
| resetForTesting(); |
| delete process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']; |
| }); |
|
|
| describe('setupInitialActivityLogger', () => { |
| it('stays in buffer mode (no probe attempted)', () => { |
| const config = createMockConfig(); |
| setupInitialActivityLogger(config); |
|
|
| expect(mockInitActivityLogger).toHaveBeenCalledWith(config, { |
| mode: 'buffer', |
| }); |
| expect(mockAddNetworkTransport).not.toHaveBeenCalled(); |
| |
| expect(MockWebSocket.instances.length).toBe(0); |
| }); |
|
|
| it('initializes in file mode when target env var is set', () => { |
| process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl'; |
| const config = createMockConfig(); |
| setupInitialActivityLogger(config); |
|
|
| expect(mockInitActivityLogger).toHaveBeenCalledWith(config, { |
| mode: 'file', |
| filePath: '/tmp/test.jsonl', |
| }); |
| |
| expect(MockWebSocket.instances.length).toBe(0); |
| }); |
|
|
| it('does nothing in file mode when config.storage is missing', () => { |
| process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl'; |
| const config = createMockConfig({ storage: undefined }); |
| setupInitialActivityLogger(config); |
|
|
| expect(mockInitActivityLogger).not.toHaveBeenCalled(); |
| expect(MockWebSocket.instances.length).toBe(0); |
| }); |
| }); |
|
|
| describe('startDevToolsServer', () => { |
| it('starts new server when none exists and enables logging', async () => { |
| const config = createMockConfig(); |
| mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); |
| mockDevToolsInstance.getPort.mockReturnValue(25417); |
|
|
| const promise = startDevToolsServer(config); |
|
|
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| const url = await promise; |
|
|
| expect(url).toBe('http://localhost:25417'); |
| expect(mockAddNetworkTransport).toHaveBeenCalledWith( |
| config, |
| '127.0.0.1', |
| 25417, |
| expect.any(Function), |
| ); |
| expect( |
| mockActivityLoggerInstance.enableNetworkLogging, |
| ).toHaveBeenCalled(); |
| }); |
|
|
| it('connects to existing server if one is found', async () => { |
| const config = createMockConfig(); |
|
|
| const promise = startDevToolsServer(config); |
|
|
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); |
| MockWebSocket.instances[0].simulateOpen(); |
|
|
| const url = await promise; |
|
|
| expect(url).toBe('http://localhost:25417'); |
| expect(mockAddNetworkTransport).toHaveBeenCalled(); |
| expect( |
| mockActivityLoggerInstance.enableNetworkLogging, |
| ).toHaveBeenCalled(); |
| }); |
|
|
| it('deduplicates concurrent calls (returns same promise)', async () => { |
| const config = createMockConfig(); |
| mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); |
| mockDevToolsInstance.getPort.mockReturnValue(25417); |
|
|
| const promise1 = startDevToolsServer(config); |
| const promise2 = startDevToolsServer(config); |
|
|
| expect(promise1).toBe(promise2); |
|
|
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| const [url1, url2] = await Promise.all([promise1, promise2]); |
| expect(url1).toBe('http://localhost:25417'); |
| expect(url2).toBe('http://localhost:25417'); |
| |
| expect(mockAddNetworkTransport).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('throws when DevTools server fails to start', async () => { |
| const config = createMockConfig(); |
| mockDevToolsInstance.start.mockRejectedValue( |
| new Error('MODULE_NOT_FOUND'), |
| ); |
|
|
| const promise = startDevToolsServer(config); |
|
|
| |
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| await expect(promise).rejects.toThrow('MODULE_NOT_FOUND'); |
| expect(mockAddNetworkTransport).not.toHaveBeenCalled(); |
| }); |
|
|
| it('allows retry after server start failure', async () => { |
| const config = createMockConfig(); |
| mockDevToolsInstance.start.mockRejectedValueOnce( |
| new Error('MODULE_NOT_FOUND'), |
| ); |
|
|
| const promise1 = startDevToolsServer(config); |
|
|
| |
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| await expect(promise1).rejects.toThrow('MODULE_NOT_FOUND'); |
|
|
| |
| mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); |
| mockDevToolsInstance.getPort.mockReturnValue(25417); |
|
|
| const promise2 = startDevToolsServer(config); |
|
|
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(2)); |
| MockWebSocket.instances[1].simulateError(); |
|
|
| const url = await promise2; |
| expect(url).toBe('http://localhost:25417'); |
| expect(mockAddNetworkTransport).toHaveBeenCalled(); |
| }); |
|
|
| it('short-circuits on second F12 after successful start', async () => { |
| const config = createMockConfig(); |
| mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); |
| mockDevToolsInstance.getPort.mockReturnValue(25417); |
|
|
| const promise1 = startDevToolsServer(config); |
|
|
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| const url1 = await promise1; |
| expect(url1).toBe('http://localhost:25417'); |
|
|
| mockAddNetworkTransport.mockClear(); |
| mockDevToolsInstance.start.mockClear(); |
|
|
| |
| const url2 = await startDevToolsServer(config); |
| expect(url2).toBe('http://localhost:25417'); |
| expect(mockAddNetworkTransport).not.toHaveBeenCalled(); |
| expect(mockDevToolsInstance.start).not.toHaveBeenCalled(); |
| }); |
|
|
| it('stops own server and connects to existing when losing port race', async () => { |
| const config = createMockConfig(); |
|
|
| |
| mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418'); |
| mockDevToolsInstance.getPort.mockReturnValue(25418); |
|
|
| const promise = startDevToolsServer(config); |
|
|
| |
| await vi.waitFor(() => { |
| expect(MockWebSocket.instances.length).toBe(1); |
| }); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| |
| await vi.waitFor(() => { |
| expect(MockWebSocket.instances.length).toBe(2); |
| }); |
| |
| MockWebSocket.instances[1].simulateOpen(); |
|
|
| const url = await promise; |
|
|
| expect(mockDevToolsInstance.stop).toHaveBeenCalled(); |
| expect(url).toBe('http://localhost:25417'); |
| expect(mockAddNetworkTransport).toHaveBeenCalledWith( |
| config, |
| '127.0.0.1', |
| 25417, |
| expect.any(Function), |
| ); |
| }); |
|
|
| it('keeps own server when winner is not responding', async () => { |
| const config = createMockConfig(); |
|
|
| mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418'); |
| mockDevToolsInstance.getPort.mockReturnValue(25418); |
|
|
| const promise = startDevToolsServer(config); |
|
|
| |
| await vi.waitFor(() => { |
| expect(MockWebSocket.instances.length).toBe(1); |
| }); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| |
| await vi.waitFor(() => { |
| expect(MockWebSocket.instances.length).toBe(2); |
| }); |
| MockWebSocket.instances[1].simulateError(); |
|
|
| const url = await promise; |
|
|
| expect(mockDevToolsInstance.stop).not.toHaveBeenCalled(); |
| expect(url).toBe('http://localhost:25418'); |
| expect(mockAddNetworkTransport).toHaveBeenCalledWith( |
| config, |
| '127.0.0.1', |
| 25418, |
| expect.any(Function), |
| ); |
| }); |
| }); |
|
|
| describe('handlePromotion (via startDevToolsServer)', () => { |
| it('caps promotion attempts at MAX_PROMOTION_ATTEMPTS', async () => { |
| const config = createMockConfig(); |
| mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); |
| mockDevToolsInstance.getPort.mockReturnValue(25417); |
|
|
| |
| const promise = startDevToolsServer(config); |
|
|
| await vi.waitFor(() => { |
| expect(MockWebSocket.instances.length).toBe(1); |
| }); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| await promise; |
|
|
| |
| const initCall = mockAddNetworkTransport.mock.calls[0]; |
| const onReconnectFailed = initCall[3]; |
| expect(onReconnectFailed).toBeDefined(); |
|
|
| |
| |
| mockAddNetworkTransport.mockClear(); |
|
|
| await onReconnectFailed(); |
| await onReconnectFailed(); |
| await onReconnectFailed(); |
| await onReconnectFailed(); |
|
|
| |
| expect(mockAddNetworkTransport).toHaveBeenCalledTimes(3); |
| }); |
| }); |
|
|
| describe('toggleDevToolsPanel', () => { |
| it('calls toggle (to close) when already open', async () => { |
| const config = createMockConfig(); |
| const toggle = vi.fn(); |
| const setOpen = vi.fn(); |
|
|
| const promise = toggleDevToolsPanel(config, true, toggle, setOpen); |
| await promise; |
|
|
| expect(toggle).toHaveBeenCalledTimes(1); |
| expect(setOpen).not.toHaveBeenCalled(); |
| }); |
|
|
| it('does NOT call toggle or setOpen when browser opens successfully', async () => { |
| const config = createMockConfig(); |
| const toggle = vi.fn(); |
| const setOpen = vi.fn(); |
|
|
| mockShouldLaunchBrowser.mockReturnValue(true); |
| mockOpenBrowserSecurely.mockResolvedValue(undefined); |
| mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); |
| mockDevToolsInstance.getPort.mockReturnValue(25417); |
|
|
| const promise = toggleDevToolsPanel(config, false, toggle, setOpen); |
|
|
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| await promise; |
|
|
| expect(toggle).not.toHaveBeenCalled(); |
| expect(setOpen).not.toHaveBeenCalled(); |
| }); |
|
|
| it('calls setOpen when browser fails to open', async () => { |
| const config = createMockConfig(); |
| const toggle = vi.fn(); |
| const setOpen = vi.fn(); |
|
|
| mockShouldLaunchBrowser.mockReturnValue(true); |
| mockOpenBrowserSecurely.mockRejectedValue(new Error('no browser')); |
| mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); |
| mockDevToolsInstance.getPort.mockReturnValue(25417); |
|
|
| const promise = toggleDevToolsPanel(config, false, toggle, setOpen); |
|
|
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| await promise; |
|
|
| expect(toggle).not.toHaveBeenCalled(); |
| expect(setOpen).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('calls setOpen when shouldLaunchBrowser returns false', async () => { |
| const config = createMockConfig(); |
| const toggle = vi.fn(); |
| const setOpen = vi.fn(); |
|
|
| mockShouldLaunchBrowser.mockReturnValue(false); |
| mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); |
| mockDevToolsInstance.getPort.mockReturnValue(25417); |
|
|
| const promise = toggleDevToolsPanel(config, false, toggle, setOpen); |
|
|
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| await promise; |
|
|
| expect(toggle).not.toHaveBeenCalled(); |
| expect(setOpen).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('calls setOpen when DevTools server fails to start', async () => { |
| const config = createMockConfig(); |
| const toggle = vi.fn(); |
| const setOpen = vi.fn(); |
|
|
| mockDevToolsInstance.start.mockRejectedValue(new Error('fail')); |
|
|
| const promise = toggleDevToolsPanel(config, false, toggle, setOpen); |
|
|
| await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); |
| MockWebSocket.instances[0].simulateError(); |
|
|
| await promise; |
|
|
| expect(toggle).not.toHaveBeenCalled(); |
| expect(setOpen).toHaveBeenCalledTimes(1); |
| }); |
| }); |
| }); |
|
|