integration.spec.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. /**
  2. * Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search
  3. * provider (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool
  4. * (`dsh-tool-web`), exercised through `ctx.tools.execute()` — nothing bypasses
  5. * the tool registry. Fetch hits a real loopback HTTP server (verifying the
  6. * WORLD); search runs the real Exa provider over a stubbed global `fetch` (the
  7. * network is the one boundary we mock).
  8. */
  9. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  10. import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
  11. import { AddressInfo } from 'node:net'
  12. import { Context } from 'cordis'
  13. import { CallId } from '@deepseek-ai/dsh-llm'
  14. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  15. import ToolRegistry from '@deepseek-ai/dsh-tools'
  16. import WebService from '@deepseek-ai/dsh-web'
  17. import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
  18. import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
  19. import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
  20. type Handler = (req: IncomingMessage, res: ServerResponse) => void
  21. let server: Server
  22. let base: string
  23. let handler: Handler
  24. let ctx: Context
  25. let fiber: Awaited<ReturnType<Context['plugin']>>
  26. beforeEach(async () => {
  27. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('<h1>Hello</h1><p>World</p>') }
  28. server = createServer((req, res) => { handler(req, res) })
  29. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  30. base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
  31. ctx = new Context()
  32. await ctx.plugin(SystemPrompt)
  33. await ctx.plugin(ToolRegistry)
  34. await ctx.plugin(WebService, { searchProvider: WebSearchExa.EXA_PROVIDER_ID, fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
  35. await ctx.plugin(WebFetchLocal, {})
  36. await ctx.plugin(WebSearchExa, { apiKey: 'exa-key', baseURL: 'https://api.exa.test' })
  37. fiber = await ctx.plugin(ToolWeb)
  38. })
  39. afterEach(async () => {
  40. await fiber.dispose()
  41. vi.unstubAllGlobals()
  42. await new Promise<void>(resolve => server.close(() => { resolve() }))
  43. })
  44. let counter = 0
  45. type ToolResult = { isError: boolean; content: { type: string; text?: string }[]; error?: { code: string } }
  46. function call(name: string, args: unknown): Promise<ToolResult> {
  47. return ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args })
  48. }
  49. describe('web_fetch integration over the real backend', () => {
  50. it('fetches an html page and renders it to markdown', async () => {
  51. const out = await call('web_fetch', { url: base })
  52. expect(out.isError).toBe(false)
  53. const text = out.content.map(b => b.text).join('')
  54. expect(text).toContain(`Fetched ${base}`)
  55. expect(text).toContain('# Hello')
  56. expect(text).toContain('World')
  57. })
  58. it('reports a 404 as a result, not an error', async () => {
  59. handler = (_req, res) => { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('missing') }
  60. const out = await call('web_fetch', { url: base })
  61. expect(out.isError).toBe(false)
  62. expect(out.content.map(b => b.text).join('')).toContain('HTTP 404')
  63. })
  64. it('surfaces WEB_INVALID_URL as a structured tool error', async () => {
  65. const out = await call('web_fetch', { url: 'ftp://example.com' })
  66. expect(out.isError).toBe(true)
  67. expect(out.error?.code).toBe('WEB_INVALID_URL')
  68. })
  69. it('surfaces a blocked cross-origin redirect as WEB_REDIRECT_BLOCKED', async () => {
  70. handler = (_req, res) => { res.writeHead(302, { location: 'https://example.com/' }); res.end() }
  71. const out = await call('web_fetch', { url: base })
  72. expect(out.isError).toBe(true)
  73. expect(out.error?.code).toBe('WEB_REDIRECT_BLOCKED')
  74. })
  75. })
  76. describe('web_search integration over the real Exa provider', () => {
  77. it('runs web_search end-to-end and formats the provider result', async () => {
  78. vi.stubGlobal('fetch', vi.fn(async () => new Response(
  79. JSON.stringify({ results: [{ url: 'https://result.test', title: 'Result', highlights: ['a highlight'] }] }),
  80. { status: 200, headers: { 'content-type': 'application/json' } },
  81. )))
  82. const out = await call('web_search', { query: 'deepseek' })
  83. expect(out.isError).toBe(false)
  84. expect(out.content.map(b => b.text).join('')).toContain('[Result](https://result.test)')
  85. })
  86. })