import { describe, expect, it, vi } from 'vitest' import { browserTabIdSchema } from '../../shared/contracts' import { BrowserModelTools, browserBackInputSchema, browserClickInputSchema, browserNavigateInputSchema, browserScreenshotInputSchema, browserSelectInputSchema, browserSnapshotInputSchema, browserTypeInputSchema, type BrowserToolService } from './browser-model-tools' function createService(): BrowserToolService { return { getOrigin: vi.fn(() => 'https://example.com'), navigate: vi.fn(async (_conversationId, url) => ({ url, origin: 'https://example.com' })), snapshot: vi.fn(async () => ({ url: 'https://example.com/', title: 'Example', nodes: [], truncated: false })), click: vi.fn(async () => undefined), type: vi.fn(async () => undefined), select: vi.fn(async () => undefined), back: vi.fn(async () => ({ url: 'https://previous.example/', origin: 'https://previous.example' })), screenshot: vi.fn(async () => ({ type: 'image' as const, mimeType: 'image/jpeg' as const, data: '/9j/2Q==' })), releaseConversation: vi.fn(async () => undefined) } } const signal = new AbortController().signal const ref = 'b_abcdefghijklmnop' const browserTabId = browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000101' ) describe('BrowserModelTools', () => { it('publishes seven strict, bounded builtin tool definitions', () => { const tools = new BrowserModelTools({ service: createService(), conversationId: 'conversation', browserTabId }) const definitions = tools.listTools() expect(definitions.map((definition) => definition.name)).toEqual([ 'browser_navigate', 'browser_snapshot', 'browser_click', 'browser_type', 'browser_select', 'browser_back', 'browser_screenshot' ]) expect( definitions.every( (definition) => definition.source === 'builtin' && definition.inputSchema.additionalProperties === false ) ).toBe(true) expect(tools.ownsTool('browser_upload')).toBe(false) expect(tools.ownsTool('browser_download')).toBe(false) }) it('uses strict Zod parsing for every operation', () => { const cases: Array<[typeof browserSnapshotInputSchema, unknown]> = [ [browserNavigateInputSchema, { url: 'https://example.com', extra: true }], [browserSnapshotInputSchema, { extra: true }], [browserClickInputSchema, { ref: 'not-a-ref' }], [browserTypeInputSchema, { ref, text: '', extra: true }], [browserSelectInputSchema, { ref, value: '', extra: true }], [browserBackInputSchema, { extra: true }], [browserScreenshotInputSchema, { extra: true }] ] for (const [schema, value] of cases) { expect(() => schema.parse(value)).toThrow() } expect(() => browserNavigateInputSchema.parse({ url: 'file:///etc/passwd' }) ).not.toThrow() // Zod limits shape and size; the URL policy is deliberately applied by // approval/call handling so non-HTTP schemes still fail before execution. }) it('creates dynamic origin-scoped navigation approvals without exposing query values', () => { const tools = new BrowserModelTools({ service: createService(), conversationId: 'conversation', browserTabId }) const approval = tools.getApproval('browser_navigate', { url: 'https://example.com/path?token=top-secret' }) expect(approval).toMatchObject({ scopeKey: 'model:browser:navigate:https://example.com', allowPermanent: false }) expect(JSON.stringify(approval)).not.toContain('top-secret') expect(approval.argumentSummary).toContain('[查询参数已隐藏]') expect(() => tools.getApproval('browser_navigate', { url: 'file:///etc/passwd' }) ).toThrow('HTTP(S)') }) it('redacts typed and selected values and prevents session-grant reuse', async () => { const service = createService() const tools = new BrowserModelTools({ service, conversationId: 'conversation', browserTabId }) const first = tools.getApproval('browser_type', { ref, text: 'top-secret' }) const second = tools.getApproval('browser_type', { ref, text: 'top-secret' }) expect(first.scopeKey).not.toBe(second.scopeKey) expect(first.allowPermanent).toBe(false) expect(first.description).toContain('包括密码字段') expect(JSON.stringify(first)).not.toContain('top-secret') const result = await tools.callTool( 'browser_type', { ref, text: 'top-secret' }, signal ) expect(service.type).toHaveBeenCalledWith( 'conversation', ref, 'top-secret', signal, browserTabId ) expect(JSON.stringify(result)).not.toContain('top-secret') expect(result.parts[0]).toMatchObject({ type: 'text', text: expect.stringContaining('[已隐藏]') }) const selectApproval = tools.getApproval('browser_select', { ref, value: 'private-value' }) expect(JSON.stringify(selectApproval)).not.toContain('private-value') }) it('dispatches safe operations and returns correctly counted results', async () => { const service = createService() const tools = new BrowserModelTools({ service, conversationId: 'conversation', browserTabId }) const navigate = await tools.callTool( 'browser_navigate', { url: 'https://example.com/' }, signal ) const snapshot = await tools.callTool('browser_snapshot', {}, signal) const click = await tools.callTool('browser_click', { ref }, signal) const back = await tools.callTool('browser_back', {}, signal) for (const result of [navigate, snapshot, click, back]) { const part = result.parts[0] if (!part || part.type !== 'text') { throw new Error('expected text result') } expect(result.contextBytes).toBe(Buffer.byteLength(part.text)) } const screenshot = await tools.callTool('browser_screenshot', {}, signal) expect(screenshot).toEqual({ parts: [ { type: 'image', mimeType: 'image/jpeg', data: '/9j/2Q==' } ], contextBytes: Buffer.byteLength('/9j/2Q==') }) await tools.release() expect(service.releaseConversation).toHaveBeenCalledWith('conversation') expect(service.navigate).toHaveBeenCalledWith( 'conversation', 'https://example.com/', signal, browserTabId ) expect(service.snapshot).toHaveBeenCalledWith( 'conversation', signal, browserTabId ) expect(service.click).toHaveBeenCalledWith( 'conversation', ref, signal, browserTabId ) expect(service.back).toHaveBeenCalledWith( 'conversation', signal, browserTabId ) expect(service.screenshot).toHaveBeenCalledWith( 'conversation', signal, browserTabId ) }) it('rejects unknown tools, extra fields, malformed refs, and cancellation', async () => { const tools = new BrowserModelTools({ service: createService(), conversationId: 'conversation', browserTabId }) expect(() => tools.getApproval('browser_click', { ref, extra: true }) ).toThrow() await expect( tools.callTool('browser_upload', { path: 'secret.txt' }, signal) ).rejects.toThrow('未知浏览器工具') const controller = new AbortController() controller.abort() await expect( tools.callTool('browser_snapshot', {}, controller.signal) ).rejects.toHaveProperty('name', 'AbortError') }) it('validates explicit recovery navigation before creating a dedicated replacement', async () => { const service = createService() service.acquireTabUsage = vi.fn(() => { throw new Error('closed') }) service.createTab = vi.fn() const tools = new BrowserModelTools({ service, conversationId: 'conversation', browserTabId, recoverClosedTab: true }) for (const args of [{}, { url: 'file:///private' }, { url: 'https://example.com', extra: true }]) { await expect(tools.callTool('browser_navigate', args, signal)).rejects.toThrow() } await expect(tools.callTool('browser_snapshot', {}, signal)).rejects.toHaveProperty('name', 'BrowserTabClosedError') await expect(tools.callTool('browser_navigate', { url: 'https://example.com' }, AbortSignal.abort())) .rejects.toHaveProperty('name', 'AbortError') expect(service.createTab).not.toHaveBeenCalled() expect(service.navigate).not.toHaveBeenCalled() }) it('preserves request cancellation and releases the per-call lease', async () => { const service = createService() const leaseController = new AbortController() const release = vi.fn() service.acquireTabUsage = vi.fn(() => ({ conversationId: 'conversation', tabId: browserTabId, owner: 'request', signal: leaseController.signal, release })) service.createTab = vi.fn() vi.mocked(service.snapshot).mockImplementation(async (_conversationId, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }) }) ) const tools = new BrowserModelTools({ service, conversationId: 'conversation', browserTabId, recoverClosedTab: true }) const controller = new AbortController() const reason = new Error('cancel request') const pending = tools.callTool('browser_snapshot', {}, controller.signal) const cancelled = expect(pending).rejects.toBe(reason) controller.abort(reason) await cancelled expect(release).toHaveBeenCalledOnce() expect(service.createTab).not.toHaveBeenCalled() }) })