340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
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 mock$$eval = vi.fn().mockResolvedValue(['Row 1', 'Row 2']);
|
|
const mockEvaluate = vi.fn().mockResolvedValue({ result: 42 });
|
|
const mockWaitForSelector = vi.fn().mockResolvedValue(undefined);
|
|
const mockWaitForFunction = vi.fn().mockResolvedValue(undefined);
|
|
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,
|
|
$$eval: mock$$eval,
|
|
evaluate: mockEvaluate,
|
|
waitForSelector: mockWaitForSelector,
|
|
waitForFunction: mockWaitForFunction,
|
|
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>;
|
|
|
|
function getTool(name: string) {
|
|
const tool = tools.find(t => t.name === name);
|
|
if (!tool) {
|
|
throw new Error(`Tool not found: ${name}`);
|
|
}
|
|
return tool;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockUrl.mockReturnValue('https://example.com');
|
|
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.wait_for');
|
|
expect(names).toContain('browser.assert');
|
|
expect(names).toContain('browser.extract');
|
|
expect(names).toContain('browser.checkpoint.save');
|
|
expect(names).toContain('browser.checkpoint.resume');
|
|
expect(names).toContain('browser.eval');
|
|
expect(names).toContain('browser.evaluate');
|
|
expect(names).toHaveLength(12);
|
|
});
|
|
|
|
it('browser.navigate navigates to URL', async () => {
|
|
const tool = getTool('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 = getTool('browser.navigate');
|
|
await tool.execute({ url: 'https://example.com', waitUntil: 'networkidle0' });
|
|
expect(mockGoto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'networkidle0' });
|
|
});
|
|
|
|
it('browser.navigate retries on transient errors', async () => {
|
|
mockGoto.mockRejectedValueOnce(new Error('temporary down'));
|
|
const tool = getTool('browser.navigate');
|
|
const result = await tool.execute({
|
|
url: 'https://example.com',
|
|
retry: { attempts: 2, delay_ms: 0 },
|
|
});
|
|
expect(result.success).toBe(true);
|
|
expect(mockGoto).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('browser.screenshot takes page screenshot', async () => {
|
|
const tool = getTool('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 = getTool('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 = getTool('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 = getTool('browser.click');
|
|
const result = await tool.execute({ selector: '#submit' });
|
|
expect(result.success).toBe(true);
|
|
expect(mockClick).toHaveBeenCalledWith('#submit');
|
|
});
|
|
|
|
it('browser.click retries on transient failures', async () => {
|
|
mockClick.mockRejectedValueOnce(new Error('click miss'));
|
|
const tool = getTool('browser.click');
|
|
const result = await tool.execute({
|
|
selector: '#submit',
|
|
retry: { attempts: 2, delay_ms: 0 },
|
|
});
|
|
expect(result.success).toBe(true);
|
|
expect(mockClick).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('browser.type types into element', async () => {
|
|
const tool = getTool('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 = getTool('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 = getTool('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 = getTool('browser.content');
|
|
await tool.execute({ selector: '#main' });
|
|
expect(mock$eval).toHaveBeenCalledWith('#main', expect.any(Function));
|
|
});
|
|
|
|
it('browser.wait_for waits on selector and text', async () => {
|
|
const tool = getTool('browser.wait_for');
|
|
const result = await tool.execute({
|
|
selector: '#loaded',
|
|
text: 'Ready',
|
|
timeout_ms: 5000,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
expect(mockWaitForSelector).toHaveBeenCalledWith('#loaded', { timeout: 5000, visible: false });
|
|
expect(mockWaitForFunction).toHaveBeenCalled();
|
|
});
|
|
|
|
it('browser.assert validates selector/text/url conditions', async () => {
|
|
const tool = getTool('browser.assert');
|
|
const result = await tool.execute({
|
|
selector: '#main',
|
|
exists: true,
|
|
text: 'Page content here',
|
|
url_includes: 'example.com',
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('browser.assert fails when conditions are not met', async () => {
|
|
mockUrl.mockReturnValue('https://example.com/path');
|
|
mock$eval.mockResolvedValueOnce('different content');
|
|
mock$.mockResolvedValueOnce(null);
|
|
const tool = getTool('browser.assert');
|
|
const result = await tool.execute({
|
|
selector: '#missing',
|
|
exists: true,
|
|
text: 'not present',
|
|
url_includes: 'nope',
|
|
retry: { attempts: 1 },
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('failed after retries');
|
|
});
|
|
|
|
it('browser.extract returns a single value by selector', async () => {
|
|
mock$eval.mockResolvedValueOnce('Primary value');
|
|
const tool = getTool('browser.extract');
|
|
const result = await tool.execute({ selector: '#value' });
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain('"value": "Primary value"');
|
|
});
|
|
|
|
it('browser.extract returns array values when all=true', async () => {
|
|
mock$$eval.mockResolvedValueOnce(['A', 'B', 'C']);
|
|
const tool = getTool('browser.extract');
|
|
const result = await tool.execute({ selector: '.row', all: true });
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain('"count": 3');
|
|
expect(result.output).toContain('"values"');
|
|
});
|
|
|
|
it('browser.checkpoint.save and resume navigates to saved url', async () => {
|
|
const saveTool = getTool('browser.checkpoint.save');
|
|
const resumeTool = getTool('browser.checkpoint.resume');
|
|
|
|
const saved = await saveTool.execute({ checkpoint_id: 'cp-1' });
|
|
expect(saved.success).toBe(true);
|
|
expect(saved.output).toContain('cp-1');
|
|
|
|
const resumed = await resumeTool.execute({ checkpoint_id: 'cp-1' });
|
|
expect(resumed.success).toBe(true);
|
|
expect(mockGoto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'domcontentloaded' });
|
|
});
|
|
|
|
it('browser.eval evaluates JS', async () => {
|
|
const tool = getTool('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 = getTool('browser.eval');
|
|
const result = await tool.execute({ expression: '"hello world"' });
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toBe('hello world');
|
|
});
|
|
|
|
it('browser.evaluate aliases browser.eval behavior', async () => {
|
|
const tool = getTool('browser.evaluate');
|
|
const result = await tool.execute({ expression: '1 + 1' });
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain('42');
|
|
});
|
|
|
|
it('enforces allowed domain guardrail for navigation', async () => {
|
|
const restrictedTools = createBrowserTools(mockManager, {
|
|
allowedDomains: ['example.com'],
|
|
});
|
|
const navigate = restrictedTools.find((tool) => tool.name === 'browser.navigate');
|
|
if (!navigate) {
|
|
throw new Error('missing navigate tool');
|
|
}
|
|
const result = await navigate.execute({ url: 'https://blocked.test' });
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('allowed_domains');
|
|
});
|
|
|
|
it('requires explicit high-risk confirmation for configured domains', async () => {
|
|
const guardedTools = createBrowserTools(mockManager, {
|
|
highRiskDomains: ['bank.example.com'],
|
|
requireHighRiskConfirmation: true,
|
|
});
|
|
const navigate = guardedTools.find((tool) => tool.name === 'browser.navigate');
|
|
if (!navigate) {
|
|
throw new Error('missing navigate tool');
|
|
}
|
|
|
|
const denied = await navigate.execute({ url: 'https://bank.example.com' });
|
|
expect(denied.success).toBe(false);
|
|
expect(denied.error).toContain('confirm_high_risk=true');
|
|
|
|
const allowed = await navigate.execute({
|
|
url: 'https://bank.example.com',
|
|
confirm_high_risk: true,
|
|
});
|
|
expect(allowed.success).toBe(true);
|
|
});
|
|
|
|
it('enforces workflow step budget', async () => {
|
|
const budgetedTools = createBrowserTools(mockManager, {
|
|
maxWorkflowSteps: 1,
|
|
});
|
|
const navigate = budgetedTools.find((tool) => tool.name === 'browser.navigate');
|
|
const click = budgetedTools.find((tool) => tool.name === 'browser.click');
|
|
if (!navigate || !click) {
|
|
throw new Error('missing browser tools');
|
|
}
|
|
|
|
const first = await navigate.execute({ url: 'https://example.com' });
|
|
expect(first.success).toBe(true);
|
|
const second = await click.execute({ selector: '#submit' });
|
|
expect(second.success).toBe(false);
|
|
expect(second.error).toContain('budget exhausted');
|
|
});
|
|
|
|
it('handles navigation errors gracefully', async () => {
|
|
mockGoto.mockRejectedValueOnce(new Error('Navigation failed'));
|
|
const tool = getTool('browser.navigate');
|
|
const result = await tool.execute({ url: 'https://broken.example.com', retry: { attempts: 1 } });
|
|
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 = getTool('browser.click');
|
|
const result = await tool.execute({ selector: '#missing', retry: { attempts: 1 } });
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Element not found');
|
|
});
|
|
|
|
it('returns aborted error when signal is already aborted', async () => {
|
|
const tool = getTool('browser.navigate');
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
|
|
const result = await tool.execute({ url: 'https://example.com' }, { signal: controller.signal });
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('aborted');
|
|
expect(mockManager.getPage).not.toHaveBeenCalled();
|
|
expect(mockGoto).not.toHaveBeenCalled();
|
|
});
|
|
});
|