exa.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import WebService from '@deepseek-ai/dsh-web'
  4. import { ExaSearchProvider, EXA_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-exa'
  5. import * as exaPlugin from '@deepseek-ai/dsh-web-search-exa'
  6. import { mapExaResponse, mapExaResult } from '../src/provider.ts'
  7. const options = { apiKey: 'exa-key', baseURL: 'https://api.exa.test', searchType: 'auto' as const, highlightsPerResult: 1 }
  8. function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
  9. return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init })
  10. }
  11. afterEach(() => {
  12. vi.unstubAllGlobals()
  13. })
  14. describe('Exa result mapping', () => {
  15. it('maps a full result entry', () => {
  16. expect(mapExaResult({
  17. url: 'https://a.test',
  18. title: 'A',
  19. publishedDate: '2026-01-01',
  20. highlights: ['salient sentence', 'second'],
  21. })).toEqual({ url: 'https://a.test', title: 'A', snippet: 'salient sentence', publishedAt: '2026-01-01' })
  22. })
  23. it('drops a result with no usable highlight', () => {
  24. expect(mapExaResult({ url: 'https://a.test', highlights: [] })).toBeUndefined()
  25. expect(mapExaResult({ url: 'https://a.test' })).toBeUndefined()
  26. expect(mapExaResult({ url: 'https://a.test', highlights: [' '] })).toBeUndefined()
  27. })
  28. it('omits null/empty optional fields rather than emitting them', () => {
  29. expect(mapExaResult({ url: 'https://a.test', title: null, publishedDate: null, highlights: ['hi'] }))
  30. .toEqual({ url: 'https://a.test', snippet: 'hi' })
  31. expect(mapExaResult({ url: 'https://a.test', title: '', publishedDate: '', highlights: ['hi'] }))
  32. .toEqual({ url: 'https://a.test', snippet: 'hi' })
  33. })
  34. it('maps a response to a result with no content and filtered sources', () => {
  35. const result = mapExaResponse({
  36. results: [
  37. { url: 'https://a.test', highlights: ['one'] },
  38. { url: 'https://b.test' },
  39. { url: 'https://c.test', title: 'C', highlights: ['three'] },
  40. ],
  41. })
  42. expect(result).toEqual({
  43. sources: [
  44. { url: 'https://a.test', snippet: 'one' },
  45. { url: 'https://c.test', title: 'C', snippet: 'three' },
  46. ],
  47. truncated: false,
  48. })
  49. expect(result.content).toBeUndefined()
  50. })
  51. it('tolerates a missing results array', () => {
  52. expect(mapExaResponse({}).sources).toEqual([])
  53. })
  54. })
  55. describe('ExaSearchProvider availability', () => {
  56. it('is unavailable without a key', () => {
  57. expect(new ExaSearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
  58. })
  59. it('is available with a key', () => {
  60. expect(new ExaSearchProvider(options).available()).toBe(true)
  61. })
  62. it('is misconfigured when the base URL is unparseable', () => {
  63. expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
  64. })
  65. it('is misconfigured when highlightsPerResult is not a positive integer', () => {
  66. expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).available()).toBe(false)
  67. expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).available()).toBe(false)
  68. })
  69. it('is misconfigured when numResults is set but not a positive integer', () => {
  70. expect(new ExaSearchProvider({ ...options, numResults: -1 }).available()).toBe(false)
  71. })
  72. })
  73. describe('ExaSearchProvider request mapping', () => {
  74. it('sends query, type, highlights, numResults and bearer auth', async () => {
  75. const fetchMock = vi.fn(async () => jsonResponse({ results: [{ url: 'https://a.test', highlights: ['hi'] }] }))
  76. vi.stubGlobal('fetch', fetchMock)
  77. const provider = new ExaSearchProvider({ ...options, searchType: 'neural', highlightsPerResult: 3 })
  78. await provider.search({ query: 'hello', maxResults: 5 })
  79. expect(fetchMock).toHaveBeenCalledOnce()
  80. const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
  81. expect(url).toBe('https://api.exa.test/search')
  82. expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
  83. expect((init.headers as Record<string, string>)['authorization']).toBe('Bearer exa-key')
  84. expect(JSON.parse(init.body as string)).toEqual({
  85. query: 'hello',
  86. type: 'neural',
  87. contents: { highlights: { highlightsPerUrl: 3 } },
  88. numResults: 5,
  89. })
  90. })
  91. it('falls back to the configured numResults when a request omits maxResults', async () => {
  92. const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
  93. vi.stubGlobal('fetch', fetchMock)
  94. await new ExaSearchProvider({ ...options, numResults: 7 }).search({ query: 'q' })
  95. const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
  96. expect(JSON.parse(init.body as string)).toMatchObject({ numResults: 7 })
  97. })
  98. it('lets a request maxResults win over the configured numResults', async () => {
  99. const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
  100. vi.stubGlobal('fetch', fetchMock)
  101. await new ExaSearchProvider({ ...options, numResults: 7 }).search({ query: 'q', maxResults: 2 })
  102. const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
  103. expect(JSON.parse(init.body as string)).toMatchObject({ numResults: 2 })
  104. })
  105. it('omits numResults when neither maxResults nor a configured default is set', async () => {
  106. const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
  107. vi.stubGlobal('fetch', fetchMock)
  108. await new ExaSearchProvider(options).search({ query: 'q' })
  109. const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
  110. expect(JSON.parse(init.body as string)).not.toHaveProperty('numResults')
  111. })
  112. it('forwards the abort signal', async () => {
  113. const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
  114. vi.stubGlobal('fetch', fetchMock)
  115. const controller = new AbortController()
  116. await new ExaSearchProvider(options).search({ query: 'q' }, controller.signal)
  117. const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
  118. expect(init.signal).toBe(controller.signal)
  119. })
  120. })
  121. describe('ExaSearchProvider error handling', () => {
  122. it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => {
  123. vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad key' }, { status: 401 })))
  124. await expect(new ExaSearchProvider(options).search({ query: 'q' }))
  125. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'bad key' }))
  126. })
  127. it('keeps a status-line message when the error body is not JSON', async () => {
  128. vi.stubGlobal('fetch', vi.fn(async () => new Response('gateway down', { status: 502 })))
  129. await expect(new ExaSearchProvider(options).search({ query: 'q' }))
  130. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'Exa API error (HTTP 502)' }))
  131. })
  132. it('keeps the status-line message when the JSON error body carries no detail', async () => {
  133. vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 })))
  134. await expect(new ExaSearchProvider(options).search({ query: 'q' }))
  135. .rejects.toThrow(expect.objectContaining({ message: 'Exa API error (HTTP 500)' }))
  136. })
  137. it('maps a network failure to WEB_PROVIDER_ERROR', async () => {
  138. vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused'))))
  139. await expect(new ExaSearchProvider(options).search({ query: 'q' }))
  140. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  141. })
  142. it('maps an abort to WEB_ABORTED', async () => {
  143. vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError'))))
  144. await expect(new ExaSearchProvider(options).search({ query: 'q' }))
  145. .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
  146. })
  147. it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => {
  148. vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 })))
  149. await expect(new ExaSearchProvider(options).search({ query: 'q' }))
  150. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  151. })
  152. it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => {
  153. vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: {} }, { status: 200 })))
  154. await expect(new ExaSearchProvider(options).search({ query: 'q' }))
  155. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  156. })
  157. it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => {
  158. const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 }
  159. vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response))
  160. await expect(new ExaSearchProvider(options).search({ query: 'q' }))
  161. .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
  162. })
  163. it('surfaces an abort during error-body parse as WEB_ABORTED', async () => {
  164. const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 }
  165. vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response))
  166. await expect(new ExaSearchProvider(options).search({ query: 'q' }))
  167. .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
  168. })
  169. })
  170. describe('web-search-exa plugin registration', () => {
  171. it('registers the provider into ctx.web (HMR-safe)', async () => {
  172. vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] })))
  173. const ctx = new Context()
  174. await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
  175. const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' })
  176. await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ sources: [], truncated: false })
  177. await fiber.dispose()
  178. await expect(ctx.web.search({ query: 'q' }))
  179. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
  180. })
  181. it('has no default export (namespace plugin export shape)', () => {
  182. expect('default' in exaPlugin).toBe(false)
  183. })
  184. it('threads searchType, highlightsPerResult and numResults config into the request', async () => {
  185. const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
  186. vi.stubGlobal('fetch', fetchMock)
  187. const ctx = new Context()
  188. await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
  189. const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key', searchType: 'keyword', highlightsPerResult: 2, numResults: 9 })
  190. await ctx.web.search({ query: 'q' })
  191. const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
  192. expect(JSON.parse(init.body as string)).toMatchObject({ type: 'keyword', contents: { highlights: { highlightsPerUrl: 2 } }, numResults: 9 })
  193. await fiber.dispose()
  194. })
  195. it('falls back to $EXA_API_KEY and the default base URL when config omits them', async () => {
  196. const prev = process.env.EXA_API_KEY
  197. process.env.EXA_API_KEY = 'env-key'
  198. try {
  199. const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
  200. vi.stubGlobal('fetch', fetchMock)
  201. const ctx = new Context()
  202. await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
  203. const fiber = await ctx.plugin(exaPlugin, {})
  204. await ctx.web.search({ query: 'q' })
  205. const [url] = fetchMock.mock.calls[0] as unknown as [string]
  206. expect(url).toBe('https://api.exa.ai/search')
  207. await fiber.dispose()
  208. } finally {
  209. if (prev === undefined) delete process.env.EXA_API_KEY
  210. else process.env.EXA_API_KEY = prev
  211. }
  212. })
  213. it('is unavailable when neither config nor env supplies a key', async () => {
  214. const prev = process.env.EXA_API_KEY
  215. delete process.env.EXA_API_KEY
  216. try {
  217. const ctx = new Context()
  218. await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
  219. await ctx.plugin(exaPlugin, {})
  220. await expect(ctx.web.search({ query: 'q' }))
  221. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
  222. } finally {
  223. if (prev !== undefined) process.env.EXA_API_KEY = prev
  224. }
  225. })
  226. })