feat: 添加 builtin-tools 增强与测试覆盖

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
unraid
2026-04-22 22:38:10 +08:00
parent 7881cc617c
commit d208855f07
12 changed files with 1449 additions and 985 deletions

View File

@@ -9,19 +9,11 @@ const inputSchema = lazySchema(() =>
z.strictObject({
url: z
.string()
.describe('URL to navigate to in the browser.'),
.describe('URL to fetch and extract content from.'),
action: z
.enum(['navigate', 'screenshot', 'click', 'type', 'scroll'])
.enum(['navigate', 'screenshot'])
.optional()
.describe('Browser action to perform. Defaults to "navigate".'),
selector: z
.string()
.optional()
.describe('CSS selector for click/type actions.'),
text: z
.string()
.optional()
.describe('Text to type when action is "type".'),
.describe('Action to perform. "navigate" fetches page content (default). "screenshot" returns a text snapshot of the page.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
@@ -45,16 +37,24 @@ export const WebBrowserTool = buildTool({
},
async description() {
return 'Browse the web using an embedded browser'
return 'Fetch and read web page content via HTTP'
},
async prompt() {
return `Open and interact with web pages in an embedded browser. Supports navigation, screenshots, clicking, typing, and scrolling.
return `Fetch web pages via HTTP and extract their text content. This is a lightweight browser tool (HTTP fetch, not a full browser engine).
Supported actions:
- navigate: Fetch a URL and extract page title + text content
- screenshot: Same as navigate (returns text snapshot, not a visual screenshot)
Limitations:
- No JavaScript execution — only sees server-rendered HTML
- click/type/scroll require a full browser runtime (not available)
- For full browser interaction, use the Claude-in-Chrome MCP tools instead
Use this for:
- Viewing web pages and their content
- Taking screenshots of UI
- Interacting with web applications
- Testing web endpoints with full browser rendering`
- Reading web page content and documentation
- Checking API endpoints that return HTML
- Quick page title/content extraction`
},
isConcurrencySafe() {
@@ -85,12 +85,84 @@ Use this for:
},
async call(input: BrowserInput) {
// Browser integration requires the WEB_BROWSER_TOOL runtime (Bun WebView).
const action = input.action ?? 'navigate'
if (action === 'navigate' || action === 'screenshot') {
// Fetch the page content via HTTP
try {
const response = await fetch(input.url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
redirect: 'follow',
})
if (!response.ok) {
return {
data: {
title: `HTTP ${response.status}`,
url: input.url,
content: `Error: ${response.status} ${response.statusText}`,
},
}
}
const html = await response.text()
// Extract title
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i)
const title = titleMatch?.[1]?.trim() ?? ''
// Extract text content (strip HTML tags, scripts, styles)
let textContent = html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
// Truncate to reasonable size
if (textContent.length > 50_000) {
textContent = textContent.slice(0, 50_000) + '\n[truncated]'
}
if (action === 'screenshot') {
return {
data: {
title,
url: response.url,
content: `[Text snapshot — visual screenshots require Chrome browser tools]\n\n${textContent}`,
},
}
}
return {
data: {
title,
url: response.url,
content: textContent,
},
}
} catch (err) {
return {
data: {
title: 'Error',
url: input.url,
content: `Failed to fetch: ${err instanceof Error ? err.message : String(err)}`,
},
}
}
}
// Unreachable — schema only allows navigate/screenshot
return {
data: {
title: '',
url: input.url,
content: 'Web browser requires the WEB_BROWSER_TOOL runtime.',
content: `Unknown action "${action}".`,
},
}
},

View File

@@ -0,0 +1,94 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
// Mock fetch directly — avoids flaky dependency on external hosts AND
// pollution by other tests that call setGlobalDispatcher (proxy agents make
// localhost fetches return 500 in the full-suite run).
const realFetch = globalThis.fetch
beforeAll(() => {
globalThis.fetch = (async (
input: string | URL | Request,
_init?: RequestInit,
) => {
const url = typeof input === 'string' ? input : input.toString()
if (url === 'not-a-url' || !url.startsWith('http')) {
throw new TypeError('Failed to fetch')
}
const body =
'<!doctype html><html><head><title>Example Domain</title></head>' +
'<body><h1>Example Domain</h1><p>Sample content.</p></body></html>'
const res = new Response(body, {
status: 200,
headers: { 'content-type': 'text/html' },
})
// Make response.url match the request URL so tests can assert on it.
Object.defineProperty(res, 'url', { value: url, configurable: true })
return res
}) as typeof fetch
})
afterAll(() => {
globalThis.fetch = realFetch
})
describe('WebBrowserTool', () => {
test('tool exports and metadata', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
expect(WebBrowserTool).toBeDefined()
expect(WebBrowserTool.name).toBe('WebBrowser')
expect(typeof WebBrowserTool.call).toBe('function')
expect(WebBrowserTool.userFacingName()).toBe('Browser')
expect(WebBrowserTool.isReadOnly()).toBe(true)
})
test('description reflects browser-lite', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const desc = await WebBrowserTool.description()
expect(desc).toContain('HTTP')
expect(desc).not.toContain('embedded browser')
})
test('prompt mentions limitations', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const prompt = await WebBrowserTool.prompt()
expect(prompt).toContain('Limitations')
expect(prompt).toContain('No JavaScript')
expect(prompt).toContain('Claude-in-Chrome')
})
test('navigate fetches URL', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const result = await WebBrowserTool.call({
url: 'https://example.com',
} as any)
expect(result.data.title).toBe('Example Domain')
expect(result.data.url).toContain('example.com')
expect(result.data.content).toContain('Example Domain')
}, 15000)
test('screenshot returns text snapshot', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const result = await WebBrowserTool.call({
url: 'https://example.com',
action: 'screenshot',
} as any)
expect(result.data.content).toContain('Text snapshot')
expect(result.data.content).toContain('Example Domain')
}, 15000)
test('schema only allows navigate and screenshot', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const schema = WebBrowserTool.inputSchema
const parseResult = schema.safeParse({
url: 'https://example.com',
action: 'click',
})
expect(parseResult.success).toBe(false)
})
test('invalid URL returns error', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const result = await WebBrowserTool.call({ url: 'not-a-url' } as any)
expect(result.data.content).toContain('Failed to fetch')
})
})