mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 13:34:15 -07:00
feat(browser): implement experimental browser agent (#19284)
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { BrowserManager } from './browserManager.js';
|
||||
import { makeFakeConfig } from '../../test-utils/config.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
|
||||
// Mock the MCP SDK
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: vi.fn().mockImplementation(() => ({
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
listTools: vi.fn().mockResolvedValue({
|
||||
tools: [
|
||||
{ name: 'take_snapshot', description: 'Take a snapshot' },
|
||||
{ name: 'click', description: 'Click an element' },
|
||||
{ name: 'click_at', description: 'Click at coordinates' },
|
||||
{ name: 'take_screenshot', description: 'Take a screenshot' },
|
||||
],
|
||||
}),
|
||||
callTool: vi.fn().mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'Tool result' }],
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: vi.fn().mockImplementation(() => ({
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
stderr: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/debugLogger.js', () => ({
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
describe('BrowserManager', () => {
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Setup mock config
|
||||
mockConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Re-setup Client mock after reset
|
||||
vi.mocked(Client).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
listTools: vi.fn().mockResolvedValue({
|
||||
tools: [
|
||||
{ name: 'take_snapshot', description: 'Take a snapshot' },
|
||||
{ name: 'click', description: 'Click an element' },
|
||||
{ name: 'click_at', description: 'Click at coordinates' },
|
||||
{ name: 'take_screenshot', description: 'Take a screenshot' },
|
||||
],
|
||||
}),
|
||||
callTool: vi.fn().mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'Tool result' }],
|
||||
}),
|
||||
}) as unknown as InstanceType<typeof Client>,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getRawMcpClient', () => {
|
||||
it('should ensure connection and return raw MCP client', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
const client = await manager.getRawMcpClient();
|
||||
|
||||
expect(client).toBeDefined();
|
||||
expect(Client).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return cached client if already connected', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
|
||||
// First call
|
||||
const client1 = await manager.getRawMcpClient();
|
||||
|
||||
// Second call should use cache
|
||||
const client2 = await manager.getRawMcpClient();
|
||||
|
||||
expect(client1).toBe(client2);
|
||||
// Client constructor should only be called once
|
||||
expect(Client).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDiscoveredTools', () => {
|
||||
it('should return tools discovered from MCP server including visual tools', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
const tools = await manager.getDiscoveredTools();
|
||||
|
||||
expect(tools).toHaveLength(4);
|
||||
expect(tools.map((t) => t.name)).toContain('take_snapshot');
|
||||
expect(tools.map((t) => t.name)).toContain('click');
|
||||
expect(tools.map((t) => t.name)).toContain('click_at');
|
||||
expect(tools.map((t) => t.name)).toContain('take_screenshot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callTool', () => {
|
||||
it('should call tool on MCP client and return result', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
const result = await manager.callTool('take_snapshot', { verbose: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
content: [{ type: 'text', text: 'Tool result' }],
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP connection', () => {
|
||||
it('should spawn npx chrome-devtools-mcp with --experimental-vision (persistent mode by default)', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
// Verify StdioClientTransport was created with correct args
|
||||
expect(StdioClientTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'npx',
|
||||
args: expect.arrayContaining([
|
||||
'-y',
|
||||
expect.stringMatching(/chrome-devtools-mcp@/),
|
||||
'--experimental-vision',
|
||||
]),
|
||||
}),
|
||||
);
|
||||
// Persistent mode should NOT include --isolated or --autoConnect
|
||||
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
|
||||
?.args as string[];
|
||||
expect(args).not.toContain('--isolated');
|
||||
expect(args).not.toContain('--autoConnect');
|
||||
// Persistent mode should set the default --userDataDir under ~/.gemini
|
||||
expect(args).toContain('--userDataDir');
|
||||
const userDataDirIndex = args.indexOf('--userDataDir');
|
||||
expect(args[userDataDirIndex + 1]).toMatch(/cli-browser-profile$/);
|
||||
});
|
||||
|
||||
it('should pass headless flag when configured', async () => {
|
||||
const headlessConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(headlessConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
expect(StdioClientTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'npx',
|
||||
args: expect.arrayContaining(['--headless']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass profilePath as --userDataDir when configured', async () => {
|
||||
const profileConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
profilePath: '/path/to/profile',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(profileConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
expect(StdioClientTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'npx',
|
||||
args: expect.arrayContaining(['--userDataDir', '/path/to/profile']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --isolated when sessionMode is isolated', async () => {
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
sessionMode: 'isolated',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(isolatedConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
|
||||
?.args as string[];
|
||||
expect(args).toContain('--isolated');
|
||||
expect(args).not.toContain('--autoConnect');
|
||||
});
|
||||
|
||||
it('should pass --autoConnect when sessionMode is existing', async () => {
|
||||
const existingConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
sessionMode: 'existing',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(existingConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
|
||||
?.args as string[];
|
||||
expect(args).toContain('--autoConnect');
|
||||
expect(args).not.toContain('--isolated');
|
||||
});
|
||||
|
||||
it('should throw actionable error when existing mode connection fails', async () => {
|
||||
// Make the Client mock's connect method reject
|
||||
vi.mocked(Client).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: vi.fn().mockRejectedValue(new Error('Connection refused')),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
listTools: vi.fn(),
|
||||
callTool: vi.fn(),
|
||||
}) as unknown as InstanceType<typeof Client>,
|
||||
);
|
||||
|
||||
const existingConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
sessionMode: 'existing',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(existingConfig);
|
||||
|
||||
await expect(manager.ensureConnection()).rejects.toThrow(
|
||||
/Failed to connect to existing Chrome instance/,
|
||||
);
|
||||
// Create a fresh manager to verify the error message includes remediation steps
|
||||
const manager2 = new BrowserManager(existingConfig);
|
||||
await expect(manager2.ensureConnection()).rejects.toThrow(
|
||||
/chrome:\/\/inspect\/#remote-debugging/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw profile-lock remediation when persistent mode hits "already running"', async () => {
|
||||
vi.mocked(Client).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error(
|
||||
'Could not connect to Chrome. The browser is already running for the current profile.',
|
||||
),
|
||||
),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
listTools: vi.fn(),
|
||||
callTool: vi.fn(),
|
||||
}) as unknown as InstanceType<typeof Client>,
|
||||
);
|
||||
|
||||
// Default config = persistent mode
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
|
||||
await expect(manager.ensureConnection()).rejects.toThrow(
|
||||
/Close all Chrome windows using this profile/,
|
||||
);
|
||||
const manager2 = new BrowserManager(mockConfig);
|
||||
await expect(manager2.ensureConnection()).rejects.toThrow(
|
||||
/Set sessionMode to "isolated"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw timeout-specific remediation for persistent mode', async () => {
|
||||
vi.mocked(Client).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error('Timed out connecting to chrome-devtools-mcp'),
|
||||
),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
listTools: vi.fn(),
|
||||
callTool: vi.fn(),
|
||||
}) as unknown as InstanceType<typeof Client>,
|
||||
);
|
||||
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
|
||||
await expect(manager.ensureConnection()).rejects.toThrow(
|
||||
/Chrome is not installed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should include sessionMode in generic fallback error', async () => {
|
||||
vi.mocked(Client).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Some unexpected error')),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
listTools: vi.fn(),
|
||||
callTool: vi.fn(),
|
||||
}) as unknown as InstanceType<typeof Client>,
|
||||
);
|
||||
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
|
||||
await expect(manager.ensureConnection()).rejects.toThrow(
|
||||
/sessionMode: persistent/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP isolation', () => {
|
||||
it('should use raw MCP SDK Client, not McpClient wrapper', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
// Verify we're using the raw Client from MCP SDK
|
||||
expect(Client).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'gemini-cli-browser-agent',
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not use McpClientManager from config', async () => {
|
||||
// Spy on config method to verify isolation
|
||||
const getMcpClientManagerSpy = vi.spyOn(
|
||||
mockConfig,
|
||||
'getMcpClientManager',
|
||||
);
|
||||
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
// Config's getMcpClientManager should NOT be called
|
||||
// This ensures isolation from main registry
|
||||
expect(getMcpClientManagerSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('should close MCP connections', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
const client = await manager.getRawMcpClient();
|
||||
|
||||
await manager.close();
|
||||
|
||||
expect(client.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user