2026-02-09 14:03:10 -08:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
|
|
|
|
import type { Config } from '@google/gemini-cli-core';
|
|
|
|
|
|
|
|
|
|
// --- Mocks (hoisted) ---
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
const mockActivityLoggerInstance = vi.hoisted(() => ({
|
|
|
|
|
disableNetworkLogging: vi.fn(),
|
|
|
|
|
enableNetworkLogging: vi.fn(),
|
|
|
|
|
drainBufferedLogs: vi.fn().mockReturnValue({ network: [], console: [] }),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-02-09 14:03:10 -08:00
|
|
|
vi.mock('./activityLogger.js', () => ({
|
|
|
|
|
initActivityLogger: mockInitActivityLogger,
|
|
|
|
|
addNetworkTransport: mockAddNetworkTransport,
|
2026-02-10 08:54:23 -08:00
|
|
|
ActivityLogger: {
|
|
|
|
|
getInstance: () => mockActivityLoggerInstance,
|
|
|
|
|
},
|
2026-02-09 14:03:10 -08:00
|
|
|
}));
|
|
|
|
|
|
2026-02-10 21:37:23 -08:00
|
|
|
const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true));
|
|
|
|
|
const mockOpenBrowserSecurely = vi.hoisted(() =>
|
|
|
|
|
vi.fn(() => Promise.resolve()),
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-09 14:03:10 -08:00
|
|
|
vi.mock('@google/gemini-cli-core', () => ({
|
|
|
|
|
debugLogger: {
|
|
|
|
|
log: vi.fn(),
|
|
|
|
|
debug: vi.fn(),
|
|
|
|
|
error: vi.fn(),
|
2026-02-10 21:37:23 -08:00
|
|
|
warn: vi.fn(),
|
2026-02-09 14:03:10 -08:00
|
|
|
},
|
2026-02-10 21:37:23 -08:00
|
|
|
shouldLaunchBrowser: mockShouldLaunchBrowser,
|
|
|
|
|
openBrowserSecurely: mockOpenBrowserSecurely,
|
2026-02-09 14:03:10 -08:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock('ws', () => ({
|
|
|
|
|
default: MockWebSocket,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock('gemini-cli-devtools', () => ({
|
|
|
|
|
DevTools: {
|
|
|
|
|
getInstance: () => mockDevToolsInstance,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// --- Import under test (after mocks) ---
|
2026-02-10 08:54:23 -08:00
|
|
|
import {
|
|
|
|
|
setupInitialActivityLogger,
|
|
|
|
|
startDevToolsServer,
|
2026-02-10 21:37:23 -08:00
|
|
|
toggleDevToolsPanel,
|
2026-02-10 08:54:23 -08:00
|
|
|
resetForTesting,
|
|
|
|
|
} from './devtoolsService.js';
|
2026-02-09 14:03:10 -08:00
|
|
|
|
|
|
|
|
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'];
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
describe('setupInitialActivityLogger', () => {
|
|
|
|
|
it('stays in buffer mode when no existing server found', async () => {
|
2026-02-09 14:03:10 -08:00
|
|
|
const config = createMockConfig();
|
2026-02-10 08:54:23 -08:00
|
|
|
const promise = setupInitialActivityLogger(config);
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
// Probe fires immediately — no server running
|
|
|
|
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
|
|
|
MockWebSocket.instances[0].simulateError();
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
await promise;
|
|
|
|
|
|
|
|
|
|
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
|
|
|
|
mode: 'buffer',
|
2026-02-09 14:03:10 -08:00
|
|
|
});
|
2026-02-10 08:54:23 -08:00
|
|
|
expect(mockAddNetworkTransport).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('attaches transport when existing server found at startup', async () => {
|
|
|
|
|
const config = createMockConfig();
|
|
|
|
|
const promise = setupInitialActivityLogger(config);
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
2026-02-09 14:03:10 -08:00
|
|
|
MockWebSocket.instances[0].simulateOpen();
|
|
|
|
|
|
|
|
|
|
await promise;
|
|
|
|
|
|
|
|
|
|
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
2026-02-10 08:54:23 -08:00
|
|
|
mode: 'buffer',
|
2026-02-09 14:03:10 -08:00
|
|
|
});
|
2026-02-10 08:54:23 -08:00
|
|
|
expect(mockAddNetworkTransport).toHaveBeenCalledWith(
|
|
|
|
|
config,
|
|
|
|
|
'127.0.0.1',
|
|
|
|
|
25417,
|
|
|
|
|
expect.any(Function),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
mockActivityLoggerInstance.enableNetworkLogging,
|
|
|
|
|
).toHaveBeenCalled();
|
2026-02-09 14:03:10 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
it('F12 short-circuits when startup already connected', async () => {
|
2026-02-09 14:03:10 -08:00
|
|
|
const config = createMockConfig();
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
// Startup: probe succeeds
|
|
|
|
|
const setupPromise = setupInitialActivityLogger(config);
|
|
|
|
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
|
|
|
MockWebSocket.instances[0].simulateOpen();
|
|
|
|
|
await setupPromise;
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
mockAddNetworkTransport.mockClear();
|
|
|
|
|
mockActivityLoggerInstance.enableNetworkLogging.mockClear();
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
// F12: should return URL immediately
|
|
|
|
|
const url = await startDevToolsServer(config);
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
expect(url).toBe('http://localhost:25417');
|
|
|
|
|
expect(mockAddNetworkTransport).not.toHaveBeenCalled();
|
|
|
|
|
expect(mockDevToolsInstance.start).not.toHaveBeenCalled();
|
2026-02-09 14:03:10 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
it('initializes in file mode when target env var is set', async () => {
|
2026-02-09 14:03:10 -08:00
|
|
|
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
|
|
|
|
|
const config = createMockConfig();
|
2026-02-10 08:54:23 -08:00
|
|
|
await setupInitialActivityLogger(config);
|
2026-02-09 14:03:10 -08:00
|
|
|
|
|
|
|
|
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
|
|
|
|
mode: 'file',
|
|
|
|
|
filePath: '/tmp/test.jsonl',
|
|
|
|
|
});
|
2026-02-10 08:54:23 -08:00
|
|
|
// No probe attempted
|
|
|
|
|
expect(MockWebSocket.instances.length).toBe(0);
|
2026-02-09 14:03:10 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does nothing in file mode when config.storage is missing', async () => {
|
|
|
|
|
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
|
|
|
|
|
const config = createMockConfig({ storage: undefined });
|
2026-02-10 08:54:23 -08:00
|
|
|
await setupInitialActivityLogger(config);
|
2026-02-09 14:03:10 -08:00
|
|
|
|
|
|
|
|
expect(mockInitActivityLogger).not.toHaveBeenCalled();
|
2026-02-10 08:54:23 -08:00
|
|
|
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();
|
2026-02-09 14:03:10 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
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');
|
|
|
|
|
// Only one probe + one server start
|
|
|
|
|
expect(mockAddNetworkTransport).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('throws when DevTools server fails to start', async () => {
|
2026-02-09 14:03:10 -08:00
|
|
|
const config = createMockConfig();
|
|
|
|
|
mockDevToolsInstance.start.mockRejectedValue(
|
|
|
|
|
new Error('MODULE_NOT_FOUND'),
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
const promise = startDevToolsServer(config);
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
// Probe fails first
|
|
|
|
|
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'),
|
|
|
|
|
);
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
const promise1 = startDevToolsServer(config);
|
|
|
|
|
|
|
|
|
|
// Probe fails
|
|
|
|
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
2026-02-09 14:03:10 -08:00
|
|
|
MockWebSocket.instances[0].simulateError();
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
await expect(promise1).rejects.toThrow('MODULE_NOT_FOUND');
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
// Second attempt should work (not return the cached rejected promise)
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
// Second call should short-circuit via connectedUrl
|
|
|
|
|
const url2 = await startDevToolsServer(config);
|
|
|
|
|
expect(url2).toBe('http://localhost:25417');
|
|
|
|
|
expect(mockAddNetworkTransport).not.toHaveBeenCalled();
|
|
|
|
|
expect(mockDevToolsInstance.start).not.toHaveBeenCalled();
|
2026-02-09 14:03:10 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('stops own server and connects to existing when losing port race', async () => {
|
|
|
|
|
const config = createMockConfig();
|
|
|
|
|
|
|
|
|
|
// Server starts on a different port (lost the race)
|
|
|
|
|
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418');
|
|
|
|
|
mockDevToolsInstance.getPort.mockReturnValue(25418);
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
const promise = startDevToolsServer(config);
|
2026-02-09 14:03:10 -08:00
|
|
|
|
|
|
|
|
// First: probe for existing server (fails)
|
|
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(MockWebSocket.instances.length).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
MockWebSocket.instances[0].simulateError();
|
|
|
|
|
|
|
|
|
|
// Second: after starting, probes the default port winner
|
|
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(MockWebSocket.instances.length).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
// Winner is alive
|
|
|
|
|
MockWebSocket.instances[1].simulateOpen();
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
const url = await promise;
|
2026-02-09 14:03:10 -08:00
|
|
|
|
|
|
|
|
expect(mockDevToolsInstance.stop).toHaveBeenCalled();
|
2026-02-10 08:54:23 -08:00
|
|
|
expect(url).toBe('http://localhost:25417');
|
|
|
|
|
expect(mockAddNetworkTransport).toHaveBeenCalledWith(
|
2026-02-09 14:03:10 -08:00
|
|
|
config,
|
2026-02-10 08:54:23 -08:00
|
|
|
'127.0.0.1',
|
|
|
|
|
25417,
|
|
|
|
|
expect.any(Function),
|
2026-02-09 14:03:10 -08:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
const promise = startDevToolsServer(config);
|
2026-02-09 14:03:10 -08:00
|
|
|
|
|
|
|
|
// Probe for existing (fails)
|
|
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(MockWebSocket.instances.length).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
MockWebSocket.instances[0].simulateError();
|
|
|
|
|
|
|
|
|
|
// Probe the winner (also fails)
|
|
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(MockWebSocket.instances.length).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
MockWebSocket.instances[1].simulateError();
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
const url = await promise;
|
2026-02-09 14:03:10 -08:00
|
|
|
|
|
|
|
|
expect(mockDevToolsInstance.stop).not.toHaveBeenCalled();
|
2026-02-10 08:54:23 -08:00
|
|
|
expect(url).toBe('http://localhost:25418');
|
|
|
|
|
expect(mockAddNetworkTransport).toHaveBeenCalledWith(
|
2026-02-09 14:03:10 -08:00
|
|
|
config,
|
2026-02-10 08:54:23 -08:00
|
|
|
'127.0.0.1',
|
|
|
|
|
25418,
|
|
|
|
|
expect.any(Function),
|
2026-02-09 14:03:10 -08:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
describe('handlePromotion (via startDevToolsServer)', () => {
|
2026-02-09 14:03:10 -08:00
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// First: set up the logger so we can grab onReconnectFailed
|
2026-02-10 08:54:23 -08:00
|
|
|
const promise = startDevToolsServer(config);
|
2026-02-09 14:03:10 -08:00
|
|
|
|
|
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(MockWebSocket.instances.length).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
MockWebSocket.instances[0].simulateError();
|
|
|
|
|
|
|
|
|
|
await promise;
|
|
|
|
|
|
|
|
|
|
// Extract onReconnectFailed callback
|
2026-02-10 08:54:23 -08:00
|
|
|
const initCall = mockAddNetworkTransport.mock.calls[0];
|
|
|
|
|
const onReconnectFailed = initCall[3];
|
2026-02-09 14:03:10 -08:00
|
|
|
expect(onReconnectFailed).toBeDefined();
|
|
|
|
|
|
|
|
|
|
// Trigger promotion MAX_PROMOTION_ATTEMPTS + 1 times
|
|
|
|
|
// Each call should succeed (addNetworkTransport called) until cap is hit
|
|
|
|
|
mockAddNetworkTransport.mockClear();
|
|
|
|
|
|
|
|
|
|
await onReconnectFailed(); // attempt 1
|
|
|
|
|
await onReconnectFailed(); // attempt 2
|
|
|
|
|
await onReconnectFailed(); // attempt 3
|
|
|
|
|
await onReconnectFailed(); // attempt 4 — should be capped
|
|
|
|
|
|
|
|
|
|
// Only 3 calls to addNetworkTransport (capped at MAX_PROMOTION_ATTEMPTS)
|
|
|
|
|
expect(mockAddNetworkTransport).toHaveBeenCalledTimes(3);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-10 21:37:23 -08:00
|
|
|
|
|
|
|
|
describe('toggleDevToolsPanel', () => {
|
|
|
|
|
it('calls toggle 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, toggle, setOpen);
|
|
|
|
|
|
|
|
|
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
|
|
|
MockWebSocket.instances[0].simulateError();
|
|
|
|
|
|
|
|
|
|
await promise;
|
|
|
|
|
|
|
|
|
|
expect(toggle).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(setOpen).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('calls toggle 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, toggle, setOpen);
|
|
|
|
|
|
|
|
|
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
|
|
|
MockWebSocket.instances[0].simulateError();
|
|
|
|
|
|
|
|
|
|
await promise;
|
|
|
|
|
|
|
|
|
|
expect(toggle).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(setOpen).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('calls toggle 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, toggle, setOpen);
|
|
|
|
|
|
|
|
|
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
|
|
|
MockWebSocket.instances[0].simulateError();
|
|
|
|
|
|
|
|
|
|
await promise;
|
|
|
|
|
|
|
|
|
|
expect(toggle).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(setOpen).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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, 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);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-09 14:03:10 -08:00
|
|
|
});
|