feat: add Chrome DevTools Protocol browser tools
Add BrowserManager (puppeteer-core) with page pool and auto-detection of Chrome/Chromium. Six tools: browser.navigate, browser.screenshot, browser.click, browser.type, browser.content, browser.eval. Feature is opt-in (browser.enabled defaults to false). Add to coding tool profile. Includes 22 unit tests for manager and all tools.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createBrowserTools } from './tools.js';
|
||||
import type { BrowserManager } from './manager.js';
|
||||
|
||||
const mockGoto = vi.fn().mockResolvedValue(undefined);
|
||||
const mockTitle = vi.fn().mockResolvedValue('Test Page');
|
||||
const mockUrl = vi.fn().mockReturnValue('https://example.com');
|
||||
const mockClick = vi.fn().mockResolvedValue(undefined);
|
||||
const mockType = vi.fn().mockResolvedValue(undefined);
|
||||
const mock$eval = vi.fn().mockResolvedValue('Page content here');
|
||||
const mockEvaluate = vi.fn().mockResolvedValue({ result: 42 });
|
||||
const mockScreenshot = vi.fn().mockResolvedValue('base64data');
|
||||
const mock$ = vi.fn().mockResolvedValue({ screenshot: vi.fn().mockResolvedValue('element-base64') });
|
||||
const mockKeyboard = { press: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const mockPage = {
|
||||
goto: mockGoto,
|
||||
title: mockTitle,
|
||||
url: mockUrl,
|
||||
click: mockClick,
|
||||
type: mockType,
|
||||
$eval: mock$eval,
|
||||
evaluate: mockEvaluate,
|
||||
screenshot: mockScreenshot,
|
||||
$: mock$,
|
||||
keyboard: mockKeyboard,
|
||||
isClosed: () => false,
|
||||
};
|
||||
|
||||
const mockManager = {
|
||||
getPage: vi.fn().mockResolvedValue(mockPage),
|
||||
} as unknown as BrowserManager;
|
||||
|
||||
describe('Browser tools', () => {
|
||||
let tools: ReturnType<typeof createBrowserTools>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tools = createBrowserTools(mockManager);
|
||||
});
|
||||
|
||||
it('creates all browser tools', () => {
|
||||
const names = tools.map(t => t.name);
|
||||
expect(names).toContain('browser.navigate');
|
||||
expect(names).toContain('browser.screenshot');
|
||||
expect(names).toContain('browser.click');
|
||||
expect(names).toContain('browser.type');
|
||||
expect(names).toContain('browser.content');
|
||||
expect(names).toContain('browser.eval');
|
||||
expect(names).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('browser.navigate navigates to URL', async () => {
|
||||
const tool = tools.find(t => t.name === 'browser.navigate')!;
|
||||
const result = await tool.execute({ url: 'https://example.com' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('example.com');
|
||||
expect(mockGoto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
|
||||
it('browser.navigate respects custom waitUntil', async () => {
|
||||
const tool = tools.find(t => t.name === 'browser.navigate')!;
|
||||
await tool.execute({ url: 'https://example.com', waitUntil: 'networkidle0' });
|
||||
expect(mockGoto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'networkidle0' });
|
||||
});
|
||||
|
||||
it('browser.screenshot takes page screenshot', async () => {
|
||||
const tool = tools.find(t => t.name === 'browser.screenshot')!;
|
||||
const result = await tool.execute({});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('Screenshot captured');
|
||||
expect(mockScreenshot).toHaveBeenCalledWith({ encoding: 'base64', fullPage: false });
|
||||
});
|
||||
|
||||
it('browser.screenshot takes element screenshot', async () => {
|
||||
const tool = tools.find(t => t.name === 'browser.screenshot')!;
|
||||
const result = await tool.execute({ selector: '#header' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(mock$).toHaveBeenCalledWith('#header');
|
||||
});
|
||||
|
||||
it('browser.screenshot fails for missing element', async () => {
|
||||
mock$.mockResolvedValueOnce(null);
|
||||
const tool = tools.find(t => t.name === 'browser.screenshot')!;
|
||||
const result = await tool.execute({ selector: '#nonexistent' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Element not found');
|
||||
});
|
||||
|
||||
it('browser.click clicks element', async () => {
|
||||
const tool = tools.find(t => t.name === 'browser.click')!;
|
||||
const result = await tool.execute({ selector: '#submit' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockClick).toHaveBeenCalledWith('#submit');
|
||||
});
|
||||
|
||||
it('browser.type types into element', async () => {
|
||||
const tool = tools.find(t => t.name === 'browser.type')!;
|
||||
const result = await tool.execute({ selector: '#search', text: 'hello' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockType).toHaveBeenCalledWith('#search', 'hello');
|
||||
});
|
||||
|
||||
it('browser.type clears field before typing when clear=true', async () => {
|
||||
const tool = tools.find(t => t.name === 'browser.type')!;
|
||||
await tool.execute({ selector: '#search', text: 'hello', clear: true });
|
||||
expect(mockClick).toHaveBeenCalledWith('#search', { count: 3 });
|
||||
expect(mockKeyboard.press).toHaveBeenCalledWith('Backspace');
|
||||
expect(mockType).toHaveBeenCalledWith('#search', 'hello');
|
||||
});
|
||||
|
||||
it('browser.content returns page text', async () => {
|
||||
const tool = tools.find(t => t.name === 'browser.content')!;
|
||||
const result = await tool.execute({});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('Page content here');
|
||||
expect(result.output).toContain('example.com');
|
||||
expect(result.output).toContain('Test Page');
|
||||
});
|
||||
|
||||
it('browser.content uses custom selector', async () => {
|
||||
const tool = tools.find(t => t.name === 'browser.content')!;
|
||||
await tool.execute({ selector: '#main' });
|
||||
expect(mock$eval).toHaveBeenCalledWith('#main', expect.any(Function));
|
||||
});
|
||||
|
||||
it('browser.eval evaluates JS', async () => {
|
||||
const tool = tools.find(t => t.name === 'browser.eval')!;
|
||||
const result = await tool.execute({ expression: '1 + 1' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('42');
|
||||
});
|
||||
|
||||
it('browser.eval returns string results directly', async () => {
|
||||
mockEvaluate.mockResolvedValueOnce('hello world');
|
||||
const tool = tools.find(t => t.name === 'browser.eval')!;
|
||||
const result = await tool.execute({ expression: '"hello world"' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe('hello world');
|
||||
});
|
||||
|
||||
it('handles navigation errors gracefully', async () => {
|
||||
mockGoto.mockRejectedValueOnce(new Error('Navigation failed'));
|
||||
const tool = tools.find(t => t.name === 'browser.navigate')!;
|
||||
const result = await tool.execute({ url: 'https://broken.example.com' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Navigation failed');
|
||||
});
|
||||
|
||||
it('handles click errors gracefully', async () => {
|
||||
mockClick.mockRejectedValueOnce(new Error('Element not found'));
|
||||
const tool = tools.find(t => t.name === 'browser.click')!;
|
||||
const result = await tool.execute({ selector: '#missing' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Element not found');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user