Skip to content

Commit 09259a8

Browse files
test: add browser agent integration tests (google-gemini#21151)
1 parent 173376b commit 09259a8

File tree

9 files changed

+241
-4
lines changed

9 files changed

+241
-4
lines changed

.github/workflows/chained_e2e.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,27 @@ jobs:
264264
run: 'npm run build'
265265
shell: 'pwsh'
266266

267+
- name: 'Ensure Chrome is available'
268+
shell: 'pwsh'
269+
run: |
270+
$chromePaths = @(
271+
"${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
272+
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe"
273+
)
274+
$chromeExists = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
275+
if (-not $chromeExists) {
276+
Write-Host 'Chrome not found, installing via Chocolatey...'
277+
choco install googlechrome -y --no-progress --ignore-checksums
278+
}
279+
$installed = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
280+
if ($installed) {
281+
Write-Host "Chrome found at: $installed"
282+
& $installed --version
283+
} else {
284+
Write-Error 'Chrome installation failed'
285+
exit 1
286+
}
287+
267288
- name: 'Run E2E tests'
268289
env:
269290
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
2+
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and analyze the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Go to https://example.com and find all links on the page, then describe them"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]}
2+
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"After analyzing https://example.com, I found the following links:\n\n1. **\"More information...\"** - This is the main link on the page that points to the IANA (Internet Assigned Numbers Authority) website for more details about reserved domains.\n\nThe page is quite minimal with just this single informational link, which is typical for example domains used in documentation."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":70,"totalTokenCount":270}}]}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you open https://example.com and analyze the page. Let me use the browser agent to navigate and capture the page information."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and capture the accessibility tree to get the page title and main content"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
2+
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Based on the browser analysis of https://example.com:\n\n**Page Title**: Example Domain\n\n**Main Content**: The page contains a simple heading \"Example Domain\" and explanatory text stating \"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\"\n\nThe page has a clean, minimal layout typical of placeholder domains used for documentation and examples."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and take a screenshot for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and take a screenshot of the page"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]}
2+
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully navigated to https://example.com and captured a screenshot. The page shows the familiar \"Example Domain\" header with explanatory text below it. The screenshot captures the clean, minimal layout of this demonstration website."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you navigate to https://example.com and perform both operations - capturing the accessibility tree and taking a screenshot."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":45,"totalTokenCount":145}}]}
2+
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully completed both operations on https://example.com:\n\n**Accessibility Tree Snapshot**: The page has a clear structure with the main heading \"Example Domain\" and descriptive text about the domain's purpose for documentation examples.\n\n**Screenshot**: Captured a visual representation of the page showing the clean, minimal layout with the heading and explanatory text.\n\nBoth the accessibility data and visual screenshot confirm this is the standard example domain page used for documentation purposes."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* Integration tests for the browser agent.
9+
*
10+
* These tests verify the complete end-to-end flow from CLI prompt through
11+
* browser_agent delegation to MCP/Chrome DevTools and back. Unlike the unit
12+
* tests in packages/core/src/agents/browser/ which mock all MCP components,
13+
* these tests launch real Chrome instances in headless mode.
14+
*
15+
* Tests are skipped on systems without Chrome/Chromium installed.
16+
*/
17+
18+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
19+
import { TestRig, assertModelHasOutput } from './test-helper.js';
20+
import { dirname, join } from 'node:path';
21+
import { fileURLToPath } from 'node:url';
22+
import { execSync } from 'node:child_process';
23+
import { existsSync } from 'node:fs';
24+
25+
const __filename = fileURLToPath(import.meta.url);
26+
const __dirname = dirname(__filename);
27+
28+
const chromeAvailable = (() => {
29+
try {
30+
if (process.platform === 'darwin') {
31+
execSync(
32+
'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"',
33+
{
34+
stdio: 'ignore',
35+
},
36+
);
37+
} else if (process.platform === 'linux') {
38+
execSync(
39+
'which google-chrome || which chromium-browser || which chromium',
40+
{ stdio: 'ignore' },
41+
);
42+
} else if (process.platform === 'win32') {
43+
// Check standard Windows installation paths using Node.js fs
44+
const chromePaths = [
45+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
46+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
47+
`${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`,
48+
];
49+
const found = chromePaths.some((p) => existsSync(p));
50+
if (!found) {
51+
// Fall back to PATH check
52+
execSync('where chrome || where chromium', { stdio: 'ignore' });
53+
}
54+
} else {
55+
return false;
56+
}
57+
return true;
58+
} catch {
59+
return false;
60+
}
61+
})();
62+
63+
describe.skipIf(!chromeAvailable)('browser-agent', () => {
64+
let rig: TestRig;
65+
66+
beforeEach(() => {
67+
rig = new TestRig();
68+
});
69+
70+
afterEach(async () => await rig.cleanup());
71+
72+
it('should navigate to a page and capture accessibility tree', async () => {
73+
rig.setup('browser-navigate-and-snapshot', {
74+
fakeResponsesPath: join(
75+
__dirname,
76+
'browser-agent.navigate-snapshot.responses',
77+
),
78+
settings: {
79+
agents: {
80+
browser_agent: {
81+
headless: true,
82+
sessionMode: 'isolated',
83+
},
84+
},
85+
},
86+
});
87+
88+
const result = await rig.run({
89+
args: 'Open https://example.com in the browser and tell me the page title and main content.',
90+
});
91+
92+
assertModelHasOutput(result);
93+
94+
const toolLogs = rig.readToolLogs();
95+
const browserAgentCall = toolLogs.find(
96+
(t) => t.toolRequest.name === 'browser_agent',
97+
);
98+
expect(
99+
browserAgentCall,
100+
'Expected browser_agent to be called',
101+
).toBeDefined();
102+
});
103+
104+
it('should take screenshots of web pages', async () => {
105+
rig.setup('browser-screenshot', {
106+
fakeResponsesPath: join(__dirname, 'browser-agent.screenshot.responses'),
107+
settings: {
108+
agents: {
109+
browser_agent: {
110+
headless: true,
111+
sessionMode: 'isolated',
112+
},
113+
},
114+
},
115+
});
116+
117+
const result = await rig.run({
118+
args: 'Navigate to https://example.com and take a screenshot.',
119+
});
120+
121+
const toolLogs = rig.readToolLogs();
122+
const browserCalls = toolLogs.filter(
123+
(t) => t.toolRequest.name === 'browser_agent',
124+
);
125+
expect(browserCalls.length).toBeGreaterThan(0);
126+
127+
assertModelHasOutput(result);
128+
});
129+
130+
it('should interact with page elements', async () => {
131+
rig.setup('browser-interaction', {
132+
fakeResponsesPath: join(__dirname, 'browser-agent.interaction.responses'),
133+
settings: {
134+
agents: {
135+
browser_agent: {
136+
headless: true,
137+
sessionMode: 'isolated',
138+
},
139+
},
140+
},
141+
});
142+
143+
const result = await rig.run({
144+
args: 'Go to https://example.com, find any links on the page, and describe them.',
145+
});
146+
147+
const toolLogs = rig.readToolLogs();
148+
const browserAgentCall = toolLogs.find(
149+
(t) => t.toolRequest.name === 'browser_agent',
150+
);
151+
expect(
152+
browserAgentCall,
153+
'Expected browser_agent to be called',
154+
).toBeDefined();
155+
156+
assertModelHasOutput(result);
157+
});
158+
159+
it('should clean up browser processes after completion', async () => {
160+
rig.setup('browser-cleanup', {
161+
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
162+
settings: {
163+
agents: {
164+
browser_agent: {
165+
headless: true,
166+
sessionMode: 'isolated',
167+
},
168+
},
169+
},
170+
});
171+
172+
await rig.run({
173+
args: 'Open https://example.com in the browser and check the page title.',
174+
});
175+
176+
// Test passes if we reach here, relying on Vitest's timeout mechanism
177+
// to detect hanging browser processes.
178+
});
179+
180+
it('should handle multiple browser operations in sequence', async () => {
181+
rig.setup('browser-sequential', {
182+
fakeResponsesPath: join(__dirname, 'browser-agent.sequential.responses'),
183+
settings: {
184+
agents: {
185+
browser_agent: {
186+
headless: true,
187+
sessionMode: 'isolated',
188+
},
189+
},
190+
},
191+
});
192+
193+
const result = await rig.run({
194+
args: 'Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot.',
195+
});
196+
197+
const toolLogs = rig.readToolLogs();
198+
const browserCalls = toolLogs.filter(
199+
(t) => t.toolRequest.name === 'browser_agent',
200+
);
201+
expect(browserCalls.length).toBeGreaterThan(0);
202+
203+
// Should successfully complete all operations
204+
assertModelHasOutput(result);
205+
});
206+
});

packages/core/src/agents/browser/browserManager.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe('BrowserManager', () => {
147147
// Verify StdioClientTransport was created with correct args
148148
expect(StdioClientTransport).toHaveBeenCalledWith(
149149
expect.objectContaining({
150-
command: 'npx',
150+
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
151151
args: expect.arrayContaining([
152152
'-y',
153153
expect.stringMatching(/chrome-devtools-mcp@/),
@@ -185,7 +185,7 @@ describe('BrowserManager', () => {
185185

186186
expect(StdioClientTransport).toHaveBeenCalledWith(
187187
expect.objectContaining({
188-
command: 'npx',
188+
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
189189
args: expect.arrayContaining(['--headless']),
190190
}),
191191
);
@@ -210,7 +210,7 @@ describe('BrowserManager', () => {
210210

211211
expect(StdioClientTransport).toHaveBeenCalledWith(
212212
expect.objectContaining({
213-
command: 'npx',
213+
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
214214
args: expect.arrayContaining(['--userDataDir', '/path/to/profile']),
215215
}),
216216
);

packages/core/src/agents/browser/browserManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ export class BrowserManager {
283283
// stderr is piped (not inherited) to prevent MCP server banners and
284284
// warnings from corrupting the UI in alternate buffer mode.
285285
this.mcpTransport = new StdioClientTransport({
286-
command: 'npx',
286+
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
287287
args: mcpArgs,
288288
stderr: 'pipe',
289289
});

0 commit comments

Comments
 (0)