Files
claude-code/packages/builtin-tools/src/tools/WebSearchTool/__tests__/braveAdapter.test.ts
claude-code-best 6a182e45b3 feat: 注册所有新命令到命令系统和工具注册表
- commands.ts: 注册所有新命令(memory-stores、vault、schedule 等),
  移除 require() 动态加载,统一为 ESM import
- tools.ts: 注册 LocalMemoryRecallTool、VaultHttpFetchTool
- 补充命令测试(bridge-kick、commit、commit-push-pr、init-verifiers)
- 补充工具测试(AgentTool、RemoteTrigger、SkillTool、WebFetch、WebSearch)
- 集成测试:autonomy-lifecycle-user-flow 更新
- 探测脚本和功能文档

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-09 23:04:39 +08:00

308 lines
8.5 KiB
TypeScript

import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from 'bun:test'
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
// Each test below calls `mock.module('axios', ...)` per-test. Without an
// afterAll cleanup, the LAST per-test stub leaks into every test file that
// runs after this one (mock.module is process-global, last-write-wins). The
// spread-real mock registered here at the end re-routes axios to the real
// module, undoing the stub leakage so later suites see real axios.
afterAll(() => {
setupAxiosMock()
})
// Defensive mock: agent.test.ts mocks config.js which can corrupt Bun's
// src/* path alias resolution. Provide AbortError directly so the dynamic
// import in createAdapter() never needs to resolve the alias at runtime.
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)
const originalBraveSearchApiKey = process.env.BRAVE_SEARCH_API_KEY
const originalBraveApiKey = process.env.BRAVE_API_KEY
describe('BraveSearchAdapter.search', () => {
const createAdapter = async () => {
const { BraveSearchAdapter } = await import('../adapters/braveAdapter')
return new BraveSearchAdapter()
}
const SAMPLE_RESPONSE = {
grounding: {
generic: [
{
title: 'Result One',
url: 'https://example.com/result1',
snippets: ['Snippet one'],
},
{
title: 'Result Two',
url: 'https://example.com/result2',
snippets: ['Snippet two'],
},
],
},
}
beforeEach(() => {
process.env.BRAVE_SEARCH_API_KEY = 'test-brave-key'
delete process.env.BRAVE_API_KEY
})
afterEach(() => {
mock.restore()
if (originalBraveSearchApiKey === undefined) {
delete process.env.BRAVE_SEARCH_API_KEY
} else {
process.env.BRAVE_SEARCH_API_KEY = originalBraveSearchApiKey
}
if (originalBraveApiKey === undefined) {
delete process.env.BRAVE_API_KEY
} else {
process.env.BRAVE_API_KEY = originalBraveApiKey
}
})
test('returns parsed results from Brave LLM context payload', async () => {
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test query', {})
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
title: 'Result One',
url: 'https://example.com/result1',
snippet: 'Snippet 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_RESPONSE })),
isCancel: () => false,
},
}))
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]).toEqual({
type: 'query_update',
query: 'test',
})
expect(progressCalls[1]).toEqual({
type: 'search_results_received',
resultCount: 2,
query: 'test',
})
})
test('filters results by allowedDomains', async () => {
const mixedResponse = {
grounding: {
generic: [
{ title: 'Allowed', url: 'https://allowed.com/a' },
{ title: 'Blocked', url: 'https://blocked.com/b' },
],
},
}
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: mixedResponse })),
isCancel: () => false,
},
}))
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 mixedResponse = {
grounding: {
generic: [
{ title: 'Good', url: 'https://good.com/a' },
{ title: 'Spam', url: 'https://spam.com/b' },
],
},
}
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: mixedResponse })),
isCancel: () => false,
},
}))
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 response = {
grounding: {
generic: [
{ title: 'Subdomain', url: 'https://docs.example.com/page' },
{ title: 'Other', url: 'https://other.com/page' },
],
},
}
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: response })),
isCancel: () => false,
},
}))
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_RESPONSE })
}),
isCancel: (e: any) => e?.__CANCEL__ === true,
},
}))
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,
},
}))
const adapter = await createAdapter()
await expect(adapter.search('test', {})).rejects.toThrow('Network error')
})
test('sends the documented HTTPS endpoint with query params and auth header', async () => {
const axiosGet = mock(() => Promise.resolve({ data: SAMPLE_RESPONSE }))
mock.module('axios', () => ({
default: {
get: axiosGet,
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await adapter.search('hello world & special=chars', {})
expect(axiosGet.mock.calls).toHaveLength(1)
expect((axiosGet.mock.calls as any[][])[0][0]).toBe(
'https://api.search.brave.com/res/v1/llm/context',
)
expect((axiosGet.mock.calls as any[][])[0][1]).toMatchObject({
params: { q: 'hello world & special=chars' },
headers: {
Accept: 'application/json',
'X-Subscription-Token': 'test-brave-key',
},
})
})
test('accepts BRAVE_API_KEY as a fallback env var', async () => {
delete process.env.BRAVE_SEARCH_API_KEY
process.env.BRAVE_API_KEY = 'fallback-key'
const axiosGet = mock(() => Promise.resolve({ data: SAMPLE_RESPONSE }))
mock.module('axios', () => ({
default: {
get: axiosGet,
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await adapter.search('test', {})
expect((axiosGet.mock.calls as any[][])[0][1].headers).toMatchObject({
'X-Subscription-Token': 'fallback-key',
})
})
test('throws when no Brave API key is configured', async () => {
delete process.env.BRAVE_SEARCH_API_KEY
delete process.env.BRAVE_API_KEY
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await expect(adapter.search('test', {})).rejects.toThrow(
'BraveSearchAdapter requires BRAVE_SEARCH_API_KEY or BRAVE_API_KEY',
)
})
})