Files
gemini-cli/packages/core/src/agents/browser/browserManager.test.ts

601 lines
19 KiB
TypeScript

/**
* @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';
import { injectAutomationOverlay } from './automationOverlay.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(),
},
}));
vi.mock('./automationOverlay.js', () => ({
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
}));
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
describe('BrowserManager', () => {
let mockConfig: Config;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(injectAutomationOverlay).mockClear();
// 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,
});
});
it('should block navigate_page to disallowed domain', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('navigate_page', {
url: 'https://evil.com',
});
expect(result.isError).toBe(true);
expect((result.content || [])[0]?.text).toContain('not permitted');
expect(Client).not.toHaveBeenCalled();
});
it('should allow navigate_page to allowed domain', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('navigate_page', {
url: 'https://google.com/search',
});
expect(result.isError).toBe(false);
expect((result.content || [])[0]?.text).toBe('Tool result');
});
it('should allow navigate_page to subdomain when wildcard is used', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['*.google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('navigate_page', {
url: 'https://mail.google.com',
});
expect(result.isError).toBe(false);
expect((result.content || [])[0]?.text).toBe('Tool result');
});
it('should block new_page to disallowed domain', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('new_page', {
url: 'https://evil.com',
});
expect(result.isError).toBe(true);
expect((result.content || [])[0]?.text).toContain('not permitted');
});
});
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: process.platform === 'win32' ? 'npx.cmd' : '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 --host-rules when allowedDomains is configured', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com', '*.openai.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
expect(args).toContain(
'--chromeArg="--host-rules=MAP * 127.0.0.1, EXCLUDE google.com, EXCLUDE *.openai.com, EXCLUDE 127.0.0.1"',
);
});
it('should throw error when invalid domain is configured in allowedDomains', async () => {
const invalidConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['invalid domain!'],
},
},
});
const manager = new BrowserManager(invalidConfig);
await expect(manager.ensureConnection()).rejects.toThrow(
'Invalid domain in allowedDomains: invalid domain!',
);
});
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: process.platform === 'win32' ? 'npx.cmd' : '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: process.platform === 'win32' ? 'npx.cmd' : '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();
});
});
describe('overlay re-injection in callTool', () => {
it('should re-inject overlay after click in non-headless mode', async () => {
const manager = new BrowserManager(mockConfig);
await manager.callTool('click', { uid: '1_2' });
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
});
it('should re-inject overlay after navigate_page in non-headless mode', async () => {
const manager = new BrowserManager(mockConfig);
await manager.callTool('navigate_page', { url: 'https://example.com' });
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
});
it('should re-inject overlay after click_at, new_page, press_key, handle_dialog', async () => {
const manager = new BrowserManager(mockConfig);
for (const tool of [
'click_at',
'new_page',
'press_key',
'handle_dialog',
]) {
vi.mocked(injectAutomationOverlay).mockClear();
await manager.callTool(tool, {});
expect(injectAutomationOverlay).toHaveBeenCalledTimes(1);
}
});
it('should NOT re-inject overlay after read-only tools', async () => {
const manager = new BrowserManager(mockConfig);
for (const tool of [
'take_snapshot',
'take_screenshot',
'get_console_message',
'fill',
]) {
vi.mocked(injectAutomationOverlay).mockClear();
await manager.callTool(tool, {});
expect(injectAutomationOverlay).not.toHaveBeenCalled();
}
});
it('should NOT re-inject overlay when headless is true', async () => {
const headlessConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { headless: true },
},
});
const manager = new BrowserManager(headlessConfig);
await manager.callTool('click', { uid: '1_2' });
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
it('should NOT re-inject overlay when tool returns an error result', async () => {
vi.mocked(Client).mockImplementation(
() =>
({
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
listTools: vi.fn().mockResolvedValue({ tools: [] }),
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Element not found' }],
isError: true,
}),
}) as unknown as InstanceType<typeof Client>,
);
const manager = new BrowserManager(mockConfig);
await manager.callTool('click', { uid: 'bad' });
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
});
});