/** * @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'; import { injectInputBlocker } from './inputBlocker.js'; import { coreEvents } from '../../utils/events.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(), warn: vi.fn(), error: vi.fn(), }, })); // Mock browser consent to always grant consent by default vi.mock('../../utils/browserConsent.js', () => ({ getBrowserConsentIfNeeded: vi.fn().mockResolvedValue(true), })); vi.mock('./automationOverlay.js', () => ({ injectAutomationOverlay: vi.fn().mockResolvedValue(undefined), })); vi.mock('./inputBlocker.js', () => ({ injectInputBlocker: vi.fn().mockResolvedValue(undefined), removeInputBlocker: vi.fn().mockResolvedValue(undefined), suspendInputBlocker: vi.fn().mockResolvedValue(undefined), resumeInputBlocker: vi.fn().mockResolvedValue(undefined), })); vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, existsSync: vi.fn((p: string) => { if (p.endsWith('bundled/chrome-devtools-mcp.mjs')) { return false; // Default } return actual.existsSync(p); }), }; }); import * as fs from 'node:fs'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { getBrowserConsentIfNeeded } from '../../utils/browserConsent.js'; describe('BrowserManager', () => { let mockConfig: Config; beforeEach(() => { vi.resetAllMocks(); vi.mocked(injectAutomationOverlay).mockClear(); vi.mocked(injectInputBlocker).mockClear(); vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {}); // Re-establish consent mock after resetAllMocks vi.mocked(getBrowserConsentIfNeeded).mockResolvedValue(true); // 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, ); }); afterEach(() => { vi.restoreAllMocks(); }); describe('MCP bundled path resolution', () => { it('should use bundled path if it exists (handles bundled CLI)', async () => { vi.mocked(fs.existsSync).mockReturnValue(true); const manager = new BrowserManager(mockConfig); await manager.ensureConnection(); expect(StdioClientTransport).toHaveBeenCalledWith( expect.objectContaining({ command: 'node', args: expect.arrayContaining([ expect.stringMatching(/bundled\/chrome-devtools-mcp\.mjs$/), ]), }), ); }); it('should fall back to development path if bundled path does not exist', async () => { vi.mocked(fs.existsSync).mockReturnValue(false); const manager = new BrowserManager(mockConfig); await manager.ensureConnection(); expect(StdioClientTransport).toHaveBeenCalledWith( expect.objectContaining({ command: 'node', args: expect.arrayContaining([ expect.stringMatching( /(dist\/)?bundled\/chrome-devtools-mcp\.mjs$/, ), ]), }), ); }); }); 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'); }); it('should block proxy URL with embedded disallowed domain in query params', async () => { const restrictedConfig = makeFakeConfig({ agents: { browser: { allowedDomains: ['*.google.com'], }, }, }); const manager = new BrowserManager(restrictedConfig); const result = await manager.callTool('new_page', { url: 'https://translate.google.com/translate?sl=en&tl=en&u=https://blocked.org/page', }); expect(result.isError).toBe(true); expect((result.content || [])[0]?.text).toContain( 'an embedded URL targets a disallowed domain', ); }); it('should block proxy URL with embedded disallowed domain in URL fragment (hash)', async () => { const restrictedConfig = makeFakeConfig({ agents: { browser: { allowedDomains: ['*.google.com'], }, }, }); const manager = new BrowserManager(restrictedConfig); const result = await manager.callTool('new_page', { url: 'https://translate.google.com/#view=home&op=translate&sl=en&tl=zh-CN&u=https://blocked.org', }); expect(result.isError).toBe(true); expect((result.content || [])[0]?.text).toContain( 'an embedded URL targets a disallowed domain', ); }); it('should allow proxy URL when embedded domain is also allowed', async () => { const restrictedConfig = makeFakeConfig({ agents: { browser: { allowedDomains: ['*.google.com', 'github.com'], }, }, }); const manager = new BrowserManager(restrictedConfig); const result = await manager.callTool('new_page', { url: 'https://translate.google.com/translate?u=https://github.com/repo', }); expect(result.isError).toBe(false); }); it('should allow navigation to allowed domain without proxy params', async () => { const restrictedConfig = makeFakeConfig({ agents: { browser: { allowedDomains: ['*.google.com'], }, }, }); const manager = new BrowserManager(restrictedConfig); const result = await manager.callTool('new_page', { url: 'https://translate.google.com/?sl=en&tl=zh', }); expect(result.isError).toBe(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: 'node', args: expect.arrayContaining([ expect.stringMatching(/chrome-devtools-mcp\.mjs$/), '--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'); expect(args).not.toContain('-y'); // 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: 'node', 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: 'node', 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'); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', expect.stringContaining('saved logins will be visible'), ); }); 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, ); 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, ); // 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, ); 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, ); const manager = new BrowserManager(mockConfig); await expect(manager.ensureConnection()).rejects.toThrow( /sessionMode: persistent/, ); }); it('should pass --no-usage-statistics and --no-performance-crux when privacy is disabled', async () => { const privacyDisabledConfig = makeFakeConfig({ agents: { overrides: { browser_agent: { enabled: true, }, }, browser: { headless: false, }, }, usageStatisticsEnabled: false, }); const manager = new BrowserManager(privacyDisabledConfig); await manager.ensureConnection(); const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0] ?.args as string[]; expect(args).toContain('--no-usage-statistics'); expect(args).toContain('--no-performance-crux'); }); it('should NOT pass privacy flags when usage statistics are enabled', async () => { // Default config has usageStatisticsEnabled: true (or undefined) const manager = new BrowserManager(mockConfig); await manager.ensureConnection(); const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0] ?.args as string[]; expect(args).not.toContain('--no-usage-statistics'); expect(args).not.toContain('--no-performance-crux'); }); }); 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 and input blocker after click in non-headless mode when input disabling is enabled', async () => { // Enable input disabling in config mockConfig = makeFakeConfig({ agents: { overrides: { browser_agent: { enabled: true, }, }, browser: { headless: false, disableUserInput: true, }, }, }); const manager = new BrowserManager(mockConfig); await manager.callTool('click', { uid: '1_2' }); expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined); expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined); }); it('should re-inject overlay and input blocker after navigate_page in non-headless mode when input disabling is enabled', async () => { mockConfig = makeFakeConfig({ agents: { overrides: { browser_agent: { enabled: true, }, }, browser: { headless: false, disableUserInput: true, }, }, }); const manager = new BrowserManager(mockConfig); await manager.callTool('navigate_page', { url: 'https://example.com' }); expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined); expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined); }); it('should re-inject overlay and input blocker after click_at, new_page, press_key, handle_dialog when input disabling is enabled', async () => { mockConfig = makeFakeConfig({ agents: { overrides: { browser_agent: { enabled: true, }, }, browser: { headless: false, disableUserInput: true, }, }, }); const manager = new BrowserManager(mockConfig); for (const tool of [ 'click_at', 'new_page', 'press_key', 'handle_dialog', ]) { vi.mocked(injectAutomationOverlay).mockClear(); vi.mocked(injectInputBlocker).mockClear(); await manager.callTool(tool, {}); expect(injectAutomationOverlay).toHaveBeenCalledTimes(1); expect(injectInputBlocker).toHaveBeenCalledTimes(1); expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined); } }); it('should NOT re-inject overlay or input blocker 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(); vi.mocked(injectInputBlocker).mockClear(); await manager.callTool(tool, {}); expect(injectAutomationOverlay).not.toHaveBeenCalled(); expect(injectInputBlocker).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, ); const manager = new BrowserManager(mockConfig); await manager.callTool('click', { uid: 'bad' }); expect(injectAutomationOverlay).not.toHaveBeenCalled(); }); }); describe('Rate limiting', () => { it('should terminate task when maxActionsPerTask is reached', async () => { const limitedConfig = makeFakeConfig({ agents: { browser: { maxActionsPerTask: 3, }, }, }); const manager = new BrowserManager(limitedConfig); // First 3 calls should succeed await manager.callTool('take_snapshot', {}); await manager.callTool('take_snapshot', { some: 'args' }); await manager.callTool('take_snapshot', { other: 'args' }); await manager.callTool('take_snapshot', { other: 'new args' }); // 4th call should throw await expect(manager.callTool('take_snapshot', {})).rejects.toThrow( /maximum action limit \(3\)/, ); }); }); });