mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-29 23:41:29 -07:00
854 lines
27 KiB
TypeScript
854 lines
27 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';
|
|
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<typeof import('node:fs')>();
|
|
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<typeof Client>,
|
|
);
|
|
});
|
|
|
|
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<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/,
|
|
);
|
|
});
|
|
|
|
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<typeof Client>,
|
|
);
|
|
|
|
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\)/,
|
|
);
|
|
});
|
|
});
|
|
});
|