/** * web_browser 工具测试(v0.7.5 新建覆盖) * * 通过 mock browser-window-manager(BrowserWindowManager 假实现)与 * ssrf-guard(validateSSRF)锁定 9 种 action 的路由契约: * open/screenshot/evaluate/extract/click/type/scroll/wait/close * 及参数校验(缺 selector / 缺 text / 缺 script)与错误传播。 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const ssrfMock = vi.hoisted(() => ({ validateSSRF: vi.fn(async () => undefined), })); vi.mock('../ssrf-guard', () => ({ validateSSRF: ssrfMock.validateSSRF, })); const managerMock = vi.hoisted(() => { const methods: Record> = {}; for (const m of [ 'open', 'screenshot', 'evaluate', 'extract', 'click', 'type', 'scroll', 'wait', 'close', ]) { methods[m] = vi.fn(); } return { methods }; }); vi.mock('../browser-window-manager', () => ({ BrowserWindowManager: class { open = managerMock.methods.open; screenshot = managerMock.methods.screenshot; evaluate = managerMock.methods.evaluate; extract = managerMock.methods.extract; click = managerMock.methods.click; type = managerMock.methods.type; scroll = managerMock.methods.scroll; wait = managerMock.methods.wait; close = managerMock.methods.close; static cleanup = vi.fn(async () => undefined); }, })); import { WebBrowserTool } from '../browser'; import type { ToolExecutionContext } from '../../../types/metona-tool'; const context: ToolExecutionContext = { sessionId: 't', workspacePath: process.cwd(), iteration: 1, requestId: 'r', }; describe('web_browser — 入口与 open', () => { let tool: WebBrowserTool; beforeEach(() => { tool = new WebBrowserTool(); vi.clearAllMocks(); }); afterEach(() => vi.clearAllMocks()); it('缺 action → 报错', async () => { const r = (await tool.execute({}, context)) as { success: boolean; error?: string }; expect(r.success).toBe(false); expect(String(r.error)).toContain('action'); }); it('未知 action → 报错', async () => { const r = (await tool.execute({ action: 'frobnicate' }, context)) as { success: boolean; error?: string; }; expect(r.success).toBe(false); expect(String(r.error)).toContain('Unknown action'); }); it('open 缺 url / 非法协议 → 拒绝', async () => { const noUrl = (await tool.execute({ action: 'open' }, context)) as { success: boolean }; expect(noUrl.success).toBe(false); const fileUrl = (await tool.execute({ action: 'open', url: 'file:///x' }, context)) as { success: boolean; error?: string; }; expect(fileUrl.success).toBe(false); expect(String(fileUrl.error)).toContain('URL must start with'); }); it('open SSRF 拦截(内网 IP)→ 拒绝且不创建窗口', async () => { ssrfMock.validateSSRF.mockRejectedValueOnce( new Error('Blocked SSRF: private/loopback address'), ); const r = (await tool.execute({ action: 'open', url: 'http://127.0.0.1:1/' }, context)) as { success: boolean; error?: string; }; expect(r.success).toBe(false); expect(String(r.error)).toContain('Blocked SSRF'); expect(managerMock.methods.open).not.toHaveBeenCalled(); }); it('open 成功返回 title/url(manager.open 结果展开)', async () => { managerMock.methods.open.mockResolvedValue({ title: 'Page', url: 'https://ok.test/' }); const r = (await tool.execute({ action: 'open', url: 'https://ok.test/' }, context)) as { success: boolean; action: string; title?: string; url?: string; }; expect(r.success).toBe(true); expect(r.action).toBe('open'); expect(r.title).toBe('Page'); expect(r.url).toBe('https://ok.test/'); expect(managerMock.methods.open).toHaveBeenCalledWith({ url: 'https://ok.test/', waitSelector: undefined, }); }); it('open 透传 wait_selector', async () => { managerMock.methods.open.mockResolvedValue({ title: 'P', url: 'https://ok.test/' }); await tool.execute({ action: 'open', url: 'https://ok.test/', wait_selector: '#app' }, context); expect(managerMock.methods.open).toHaveBeenCalledWith({ url: 'https://ok.test/', waitSelector: '#app', }); }); it('open 管理器抛错 → success:false', async () => { managerMock.methods.open.mockRejectedValue(new Error('navigation failed')); const r = (await tool.execute({ action: 'open', url: 'https://ok.test/' }, context)) as { success: boolean; error?: string; }; expect(r.success).toBe(false); expect(String(r.error)).toContain('navigation failed'); }); }); describe('web_browser — screenshot / evaluate / extract', () => { let tool: WebBrowserTool; beforeEach(() => { tool = new WebBrowserTool(); vi.clearAllMocks(); }); afterEach(() => vi.clearAllMocks()); it('screenshot 成功返回 image/width/height/mime_type', async () => { managerMock.methods.screenshot.mockResolvedValue({ data: 'iVBORw0KGgoAAAA', width: 800, height: 600, }); const r = (await tool.execute({ action: 'screenshot' }, context)) as { success: boolean; image?: string; width?: number; height?: number; mime_type?: string; }; expect(r.success).toBe(true); expect(r.image).toBe('iVBORw0KGgoAAAA'); expect(r.width).toBe(800); expect(r.height).toBe(600); expect(r.mime_type).toBe('image/png'); }); it('screenshot 支持 full_page 与 selector 透传', async () => { managerMock.methods.screenshot.mockResolvedValue({ data: 'x', width: 1, height: 1 }); await tool.execute({ action: 'screenshot', full_page: true, selector: '#main' }, context); expect(managerMock.methods.screenshot).toHaveBeenCalledWith({ fullPage: true, selector: '#main', }); }); it('screenshot 管理器抛错 → success:false', async () => { managerMock.methods.screenshot.mockRejectedValue(new Error('capture failed')); const r = (await tool.execute({ action: 'screenshot' }, context)) as { success: boolean; error?: string; }; expect(r.success).toBe(false); }); it('evaluate 成功返回脚本结果', async () => { managerMock.methods.evaluate.mockResolvedValue(42); const r = (await tool.execute({ action: 'evaluate', script: '1+1' }, context)) as { success: boolean; result?: unknown; }; expect(r.success).toBe(true); expect(r.result).toBe(42); }); it('evaluate 缺 script → 拒绝', async () => { const r = (await tool.execute({ action: 'evaluate' }, context)) as { success: boolean }; expect(r.success).toBe(false); expect(managerMock.methods.evaluate).not.toHaveBeenCalled(); }); it('extract 成功返回 text/links/link_count', async () => { managerMock.methods.extract.mockResolvedValue({ text: '页面文本', links: ['https://a.test/', 'https://b.test/'], }); const r = (await tool.execute({ action: 'extract' }, context)) as { success: boolean; text?: string; links?: string[]; link_count?: number; }; expect(r.success).toBe(true); expect(r.text).toBe('页面文本'); expect(r.link_count).toBe(2); }); it('extract 透传 selector', async () => { managerMock.methods.extract.mockResolvedValue({ text: 't', links: [] }); await tool.execute({ action: 'extract', selector: 'article' }, context); expect(managerMock.methods.extract).toHaveBeenCalledWith('article'); }); }); describe('web_browser — click / type / scroll / wait / close', () => { let tool: WebBrowserTool; beforeEach(() => { tool = new WebBrowserTool(); vi.clearAllMocks(); }); afterEach(() => vi.clearAllMocks()); it('click 成功返回 clicked:true', async () => { managerMock.methods.click.mockResolvedValue(undefined); const r = (await tool.execute({ action: 'click', selector: '#btn' }, context)) as { success: boolean; clicked?: boolean; }; expect(r.success).toBe(true); expect(r.clicked).toBe(true); expect(managerMock.methods.click).toHaveBeenCalledWith('#btn', false); }); it('click 缺 selector → 拒绝', async () => { const r = (await tool.execute({ action: 'click' }, context)) as { success: boolean }; expect(r.success).toBe(false); }); it('click wait 参数透传', async () => { managerMock.methods.click.mockResolvedValue(undefined); await tool.execute({ action: 'click', selector: '#x', wait: true }, context); expect(managerMock.methods.click).toHaveBeenCalledWith('#x', true); }); it('type 成功返回 typed 长度', async () => { managerMock.methods.type.mockResolvedValue(undefined); const r = (await tool.execute( { action: 'type', selector: '#input', text: 'hello world' }, context, )) as { success: boolean; typed?: number }; expect(r.success).toBe(true); expect(r.typed).toBe(11); expect(managerMock.methods.type).toHaveBeenCalledWith('#input', 'hello world', { clear: true, submit: false, }); }); it('type 缺 selector 或 text → 拒绝', async () => { const noSel = (await tool.execute({ action: 'type', text: 'x' }, context)) as { success: boolean; }; expect(noSel.success).toBe(false); const noText = (await tool.execute({ action: 'type', selector: '#i' }, context)) as { success: boolean; }; expect(noText.success).toBe(false); expect(managerMock.methods.type).not.toHaveBeenCalled(); }); it('type 支持 clear/submit 透传', async () => { managerMock.methods.type.mockResolvedValue(undefined); await tool.execute( { action: 'type', selector: '#f', text: 'v', clear: false, submit: true }, context, ); expect(managerMock.methods.type).toHaveBeenCalledWith('#f', 'v', { clear: false, submit: true, }); }); it('scroll 成功返回 direction/selector', async () => { managerMock.methods.scroll.mockResolvedValue(undefined); const r = (await tool.execute({ action: 'scroll', direction: 'bottom' }, context)) as { success: boolean; direction?: string; }; expect(r.success).toBe(true); expect(r.direction).toBe('bottom'); expect(managerMock.methods.scroll).toHaveBeenCalledWith({ direction: 'bottom', selector: undefined, }); }); it('scroll 缺省 direction=down', async () => { managerMock.methods.scroll.mockResolvedValue(undefined); const r = (await tool.execute({ action: 'scroll' }, context)) as { success: boolean }; expect(r.success).toBe(true); expect(managerMock.methods.scroll).toHaveBeenCalledWith({ direction: 'down', selector: undefined, }); }); it('wait 成功返回 waited_for(selector 形态)', async () => { managerMock.methods.wait.mockResolvedValue(undefined); const r = (await tool.execute({ action: 'wait', selector: '.ready' }, context)) as { success: boolean; waited_for?: string; }; expect(r.success).toBe(true); expect(r.waited_for).toBe('.ready'); expect(managerMock.methods.wait).toHaveBeenCalledWith({ selector: '.ready', timeMs: 1000 }); }); it('wait 缺 selector → waited_for 为时间形态,time_ms 透传', async () => { managerMock.methods.wait.mockResolvedValue(undefined); const r = (await tool.execute({ action: 'wait', time_ms: 500 }, context)) as { success: boolean; waited_for?: string; }; expect(r.success).toBe(true); expect(r.waited_for).toBe('500ms'); expect(managerMock.methods.wait).toHaveBeenCalledWith({ selector: undefined, timeMs: 500 }); }); it('close 成功返回 closed:true', async () => { managerMock.methods.close.mockResolvedValue(undefined); const r = (await tool.execute({ action: 'close' }, context)) as { success: boolean; closed?: boolean; }; expect(r.success).toBe(true); expect(r.closed).toBe(true); expect(managerMock.methods.close).toHaveBeenCalled(); }); it('wait 管理器抛错 → success:false', async () => { managerMock.methods.wait.mockRejectedValue(new Error('timeout waiting')); const r = (await tool.execute({ action: 'wait', selector: '.x' }, context)) as { success: boolean; error?: string; }; expect(r.success).toBe(false); expect(String(r.error)).toContain('timeout waiting'); }); });