Files
goodbuddy/src/main/browser/browser-model-tools.test.ts
T

212 lines
6.7 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
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'
describe('BrowserModelTools', () => {
it('publishes seven strict, bounded builtin tool definitions', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
})
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'
})
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'
})
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
)
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'
})
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')
})
it('rejects unknown tools, extra fields, malformed refs, and cancellation', async () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
})
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')
})
})