Files
claude-code/packages/builtin-tools/src/tools/WebSearchTool/__tests__/bingAdapter.test.ts
claude-code-best bddd146f25 feat: 重构供应商层次 (#286)
* refactor: 创建 @anthropic-ai/model-provider 包骨架与类型定义

- 新建 workspace 包 packages/@anthropic-ai/model-provider
- 定义 ModelProviderHooks 接口(依赖注入:分析、成本、日志等)
- 定义 ClientFactories 接口(Anthropic/OpenAI/Gemini/Grok 客户端工厂)
- 搬入核心类型:Message 体系、NonNullableUsage、EMPTY_USAGE、SystemPrompt、错误常量
- 主项目 src/types/message.ts 等改为 re-export,保持向后兼容

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 提升 OpenAI 转换器和模型映射到 model-provider 包

- 搬入 OpenAI 消息转换(convertMessages)、工具转换(convertTools)、流适配(streamAdapter)
- 搬入 OpenAI 和 Grok 模型映射(resolveOpenAIModel、resolveGrokModel)
- 主项目文件改为 thin re-export proxy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 搬入 Gemini 兼容层到 model-provider 包

- 搬入 Gemini 类型定义、消息转换、工具转换、流适配、模型映射
- 主项目 gemini/ 目录下文件改为 thin re-export proxy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 搬入 errorUtils 并迁移消费者导入到 model-provider

- 搬入 formatAPIError、extractConnectionErrorDetails 等 errorUtils
- 迁移 10 个消费者文件直接从 @anthropic-ai/model-provider 导入
- 更新 emptyUsage、sdkUtilityTypes、systemPromptType 为 re-export proxy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: compact 模型降级为 -1 模式(Opus→Sonnet, Sonnet→Haiku)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 添加 agent-loop 绘图

* Revert "feat: compact 模型降级为 -1 模式(Opus→Sonnet, Sonnet→Haiku)"

This reverts commit e458d6391d.

* docs: 添加简化版 agent loop

* fix: 修复 n 快捷键导致关闭的问题

* fix: 修复 node 下 ws 没打包问题

* docs: 修复链接

* test: 添加测试支持

* fix: 修复类型问题(#267) (#271)

* fix: 修复 Bun 的 polyfill 问题

* fix: 类型修复完成

* feat: 统一所有包的类型文件

* fix: 修复构建问题

* test: 修复类型校验 (#279)

* fix: 修复 Bun 的 polyfill 问题

* fix: 类型修复完成

* feat: 统一所有包的类型文件

* fix: 修复构建问题

* fix(remote-control): harden self-hosted session flows (#278)

Co-authored-by: chengzifeng <chengzifeng@meituan.com>

* docs: update contributors

* build: 新增 vite 构建流程

* feat: 添加环境变量支持以覆盖 max_tokens 设置

* feat(langfuse): LLM generation 记录工具定义

将 Anthropic 格式的工具定义转换为 Langfuse 兼容的 OpenAI 格式,
并在 generation 的 input 中以 { messages, tools } 结构传入,
以便在 Langfuse UI 中查看完整的工具定义信息。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 添加对 ACP 协议的支持 (#284)

* feat: 适配 zed acp 协议

* docs: 完善 acp 文档

* chore: 1.4.0

* conflict: 解决冲突

* feat: 添加测试覆盖率上报

* style: 改名加移动文件夹位置

* refactor: 移动测试用例及实现

* test: 修复测试用例完成

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cheng Zi Feng <1154238323@qq.com>
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
Co-authored-by: claude-code-best <272536312+claude-code-best@users.noreply.github.com>
2026-04-17 09:33:14 +08:00

510 lines
16 KiB
TypeScript

import { describe, expect, mock, test } from 'bun:test'
const _abortMock = () => ({
AbortError: class AbortError extends Error {
constructor(message?: string) { super(message); this.name = 'AbortError' }
},
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
})
mock.module('src/utils/errors.js', _abortMock)
mock.module('src/utils/errors', _abortMock)
import { extractBingResults, decodeHtmlEntities } from '../adapters/bingAdapter'
// ---------------------------------------------------------------------------
// decodeHtmlEntities
// ---------------------------------------------------------------------------
describe('decodeHtmlEntities', () => {
test('decodes common named entities', () => {
expect(decodeHtmlEntities('&amp; &lt; &gt;')).toBe('& < >')
})
test('decodes quote entities', () => {
expect(decodeHtmlEntities('&quot;hello&quot;')).toBe('"hello"')
})
test('decodes numeric and hex apostrophe entities', () => {
expect(decodeHtmlEntities('&#39;it&#x27;s')).toBe("'it's")
})
test('decodes &nbsp; to non-breaking space (\\u00A0)', () => {
expect(decodeHtmlEntities('a&nbsp;b')).toBe('a\u00A0b')
})
test('returns plain text unchanged', () => {
expect(decodeHtmlEntities('hello world')).toBe('hello world')
})
test('handles empty string', () => {
expect(decodeHtmlEntities('')).toBe('')
})
test('decodes multiple occurrences of the same entity', () => {
expect(decodeHtmlEntities('a&amp;b&amp;c')).toBe('a&b&c')
})
test('handles mixed entities in one string', () => {
expect(decodeHtmlEntities('&lt;a&nbsp;href=&quot;x&quot;&gt;')).toBe('<a\u00A0href="x">')
})
})
// ---------------------------------------------------------------------------
// extractBingResults
// ---------------------------------------------------------------------------
describe('extractBingResults', () => {
test('extracts results from standard Bing HTML', () => {
const html = `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://example.com/page1" h="ID=SERP,1">Example Title 1</a></h2>
<div class="b_caption">
<p class="b_lineclamp">First result description</p>
</div>
</li>
<li class="b_algo">
<h2><a href="https://example.com/page2" h="ID=SERP,2">Example Title 2</a></h2>
<div class="b_caption">
<p class="b_lineclamp">Second result description</p>
</div>
</li>
</ol>
`
const results = extractBingResults(html)
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
title: 'Example Title 1',
url: 'https://example.com/page1',
snippet: 'First result description',
})
expect(results[1]).toEqual({
title: 'Example Title 2',
url: 'https://example.com/page2',
snippet: 'Second result description',
})
})
test('returns empty array when no b_algo blocks exist', () => {
const html = `
<ol id="b_results">
<li class="b_ad">Ad result</li>
<li class="b_ans">Answer card</li>
</ol>
`
expect(extractBingResults(html)).toEqual([])
})
test('returns empty array for empty HTML', () => {
expect(extractBingResults('')).toEqual([])
})
test('returns empty array for unrelated HTML', () => {
expect(extractBingResults('<html><body>Hello</body></html>')).toEqual([])
})
test('skips Bing-internal links', () => {
const html = `
<li class="b_algo">
<h2><a href="/search?q=more">More results</a></h2>
</li>
<li class="b_algo">
<h2><a href="https://www.bing.com/videos">Bing Videos</a></h2>
</li>
<li class="b_algo">
<h2><a href="#anchor">Jump link</a></h2>
</li>
`
expect(extractBingResults(html)).toEqual([])
})
test('strips HTML tags from titles', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Result with <strong>bold</strong> and <em>italic</em></a></h2>
</li>
`
const results = extractBingResults(html)
expect(results).toHaveLength(1)
expect(results[0].title).toBe('Result with bold and italic')
})
test('decodes HTML entities in titles', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Tom &amp; Jerry &lt;cartoon&gt;</a></h2>
</li>
`
const results = extractBingResults(html)
expect(results[0].title).toBe('Tom & Jerry <cartoon>')
})
test('extracts snippet from b_lineclamp class', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Title</a></h2>
<p class="b_lineclamp3 b_algo_slug">Lineclamp snippet text here</p>
</li>
`
const results = extractBingResults(html)
expect(results[0].snippet).toBe('Lineclamp snippet text here')
})
test('extracts snippet from b_caption paragraph fallback', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Title</a></h2>
<div class="b_caption">
<p>Caption paragraph text</p>
</div>
</li>
`
const results = extractBingResults(html)
expect(results[0].snippet).toBe('Caption paragraph text')
})
test('extracts snippet from b_caption div fallback', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Title</a></h2>
<div class="b_caption">Direct caption text without p tag</div>
</li>
`
const results = extractBingResults(html)
expect(results[0].snippet).toBe('Direct caption text without p tag')
})
test('returns undefined snippet when no caption exists', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Title Only</a></h2>
</li>
`
const results = extractBingResults(html)
expect(results[0].snippet).toBeUndefined()
})
test('handles mixed result types and only extracts b_algo', () => {
const html = `
<ol id="b_results">
<li class="b_ad"><h2><a href="https://ad.com">Ad Title</a></h2></li>
<li class="b_algo">
<h2><a href="https://real-result.com">Real Result</a></h2>
<p class="b_lineclamp">A real snippet</p>
</li>
<li class="b_ans"><div>People also ask</div></li>
<li class="b_algo">
<h2><a href="https://another.com">Another Result</a></h2>
</li>
</ol>
`
const results = extractBingResults(html)
expect(results).toHaveLength(2)
expect(results[0].title).toBe('Real Result')
expect(results[1].title).toBe('Another Result')
})
test('skips b_algo blocks without h2 > a structure', () => {
const html = `
<li class="b_algo">
<div>No link here</div>
</li>
<li class="b_algo">
<h2><a href="https://example.com">Valid Result</a></h2>
</li>
`
const results = extractBingResults(html)
expect(results).toHaveLength(1)
expect(results[0].title).toBe('Valid Result')
})
test('handles extra whitespace in h2 > a structure', () => {
const html = `
<li class="b_algo">
<h2>
<a href="https://example.com" h="ID=SERP,1" >
Whitespace Title
</a>
</h2>
</li>
`
const results = extractBingResults(html)
expect(results).toHaveLength(1)
expect(results[0].title).toBe('Whitespace Title')
})
test('handles snippet with HTML entities', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Title</a></h2>
<p class="b_lineclamp">5 &lt; 10 &amp; 10 &gt; 5</p>
</li>
`
const results = extractBingResults(html)
expect(results[0].snippet).toBe('5 < 10 & 10 > 5')
})
test('handles real-world Bing HTML structure', () => {
const html = `
<ol id="b_results" role="main">
<li class="b_algo" data-id="">
<div class="b_title">
<h2>
<a href="https://docs.python.org/3/tutorial/index.html" target="_blank" h="ID=SERP,5125.1">
Python Tutorial
</a>
</h2>
</div>
<div class="b_caption">
<div class="b_attribution" u="0|5125|4976674477245">
<cite>https://docs.python.org</cite>
</div>
<p class="b_lineclamp3">
Welcome to the Python Tutorial. This tutorial introduces you to the basic concepts and features...
</p>
</div>
</li>
<li class="b_algo">
<h2>
<a href="https://realpython.com/python-guide/" h="ID=SERP,5125.2">
Real Python Guide
</a>
</h2>
<div class="b_caption">
<div class="b_attribution">
<cite>https://realpython.com</cite>
</div>
<p>
The ultimate Python guide for beginners and experts alike.
</p>
</div>
</li>
</ol>
`
const results = extractBingResults(html)
expect(results).toHaveLength(2)
expect(results[0].title).toBe('Python Tutorial')
expect(results[0].url).toBe('https://docs.python.org/3/tutorial/index.html')
expect(results[0].snippet).toContain('Welcome to the Python Tutorial')
expect(results[1].title).toBe('Real Python Guide')
expect(results[1].snippet).toContain('ultimate Python guide')
})
})
// ---------------------------------------------------------------------------
// BingSearchAdapter.search (integration with mocked axios)
// ---------------------------------------------------------------------------
describe('BingSearchAdapter.search', () => {
// Dynamic import so mock.module() takes effect
const createAdapter = async () => {
const { BingSearchAdapter } = await import('../adapters/bingAdapter')
return new BingSearchAdapter()
}
const SAMPLE_HTML = `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://example.com/result1">Result One</a></h2>
<p class="b_lineclamp">Snippet one</p>
</li>
<li class="b_algo">
<h2><a href="https://example.com/result2">Result Two</a></h2>
<p class="b_lineclamp">Snippet two</p>
</li>
</ol>
`
test('returns parsed results from fetched HTML', async () => {
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_HTML })),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
const results = await adapter.search('test query', {})
expect(results).toHaveLength(2)
expect(results[0].title).toBe('Result One')
expect(results[1].title).toBe('Result Two')
})
test('calls onProgress with query_update and search_results_received', async () => {
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_HTML })),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const progressCalls: any[] = []
const onProgress = (p: any) => progressCalls.push(p)
const adapter = await createAdapter()
await adapter.search('test', { onProgress })
expect(progressCalls).toHaveLength(2)
expect(progressCalls[0].type).toBe('query_update')
expect(progressCalls[0].query).toBe('test')
expect(progressCalls[1].type).toBe('search_results_received')
expect(progressCalls[1].resultCount).toBe(2)
})
test('filters results by allowedDomains', async () => {
const mixedHtml = `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://allowed.com/a">Allowed Result</a></h2>
</li>
<li class="b_algo">
<h2><a href="https://blocked.com/b">Blocked Result</a></h2>
</li>
</ol>
`
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: mixedHtml })),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
allowedDomains: ['allowed.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://allowed.com/a')
})
test('filters results by blockedDomains', async () => {
const mixedHtml = `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://good.com/a">Good Result</a></h2>
</li>
<li class="b_algo">
<h2><a href="https://spam.com/b">Spam Result</a></h2>
</li>
</ol>
`
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: mixedHtml })),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
blockedDomains: ['spam.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://good.com/a')
})
test('filters subdomains with allowedDomains', async () => {
const html = `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://docs.example.com/page">Subdomain Result</a></h2>
</li>
<li class="b_algo">
<h2><a href="https://other.com/page">Other Result</a></h2>
</li>
</ol>
`
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: html })),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
allowedDomains: ['example.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://docs.example.com/page')
})
test('throws AbortError when signal is already aborted', async () => {
mock.module('axios', () => ({
default: {
get: mock((_url: string, config: any) => {
if (config?.signal?.aborted) {
const err = new Error('canceled')
;(err as any).__CANCEL__ = true
return Promise.reject(err)
}
return Promise.resolve({ data: SAMPLE_HTML })
}),
isCancel: (e: any) => e?.__CANCEL__ === true,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
const controller = new AbortController()
controller.abort()
const { AbortError } = await import('src/utils/errors')
await expect(
adapter.search('test', { signal: controller.signal }),
).rejects.toThrow(AbortError)
})
test('re-throws non-abort axios errors', async () => {
const networkError = new Error('Network error')
mock.module('axios', () => ({
default: {
get: mock(() => Promise.reject(networkError)),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
await expect(adapter.search('test', {})).rejects.toThrow('Network error')
})
test('encodes query parameter in URL', async () => {
const axiosGet = mock(() => Promise.resolve({ data: SAMPLE_HTML }))
mock.module('axios', () => ({
default: {
get: axiosGet,
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
await adapter.search('hello world & special=chars', {})
const calledUrl = (axiosGet.mock.calls as string[][])[0][0]
expect(calledUrl).toContain('q=hello%20world%20%26%20special%3Dchars')
})
})