Allow users to close leased browser tabs without cancelling the AI request or revoking its other tools. Closed-page operations report a recoverable error; explicit navigation creates a dedicated replacement tab for direct-model and MCP calls. Add regression coverage and real Electron lifecycle checks, and update browser behavior documentation. Release note: 浏览器标签页被 AI 请求占用时也可关闭,其他任务继续运行;再次导航会打开独立新页。
293 lines
9.6 KiB
TypeScript
293 lines
9.6 KiB
TypeScript
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<never>((_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()
|
|
})
|
|
})
|