1
0

mock-server.ts 3.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. import { createServer } from 'node:http'
  2. import type { IncomingMessage, Server, ServerResponse } from 'node:http'
  3. /** One scripted behavior for the next request the mock server receives. */
  4. export type Behavior =
  5. | { kind: 'sse'; events: string[]; delayMs?: number }
  6. | { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record<string, string> }
  7. | { kind: 'close-early'; events: string[] }
  8. export interface MockServer {
  9. url: string
  10. /** Bodies of received requests, in order. */
  11. requests: unknown[]
  12. /** Header bags of received requests, in order (parallel to `requests`). */
  13. headers: IncomingMessage['headers'][]
  14. script: Behavior[]
  15. close(): Promise<void>
  16. }
  17. const servers: Server[] = []
  18. /** Close every server opened since the last call; run from each spec's afterEach. */
  19. export async function closeMockServers(): Promise<void> {
  20. await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
  21. }
  22. /** A minimal complete text generation, reused by request-shape assertions. */
  23. export const textEvents = [
  24. '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
  25. '{"choices":[{"delta":{"content":"hello"}}]}',
  26. '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
  27. '[DONE]',
  28. ]
  29. /** Local chat-completions stand-in: replays scripted behaviors per request. */
  30. export async function mockServer(script: Behavior[]): Promise<MockServer> {
  31. const requests: unknown[] = []
  32. const headers: IncomingMessage['headers'][] = []
  33. const server = createServer((request: IncomingMessage, response: ServerResponse) => {
  34. let body = ''
  35. request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
  36. request.on('end', () => {
  37. requests.push(JSON.parse(body))
  38. headers.push(request.headers)
  39. const behavior = script.shift()
  40. if (!behavior) {
  41. response.writeHead(500).end('mock script exhausted')
  42. return
  43. }
  44. if (behavior.kind === 'http-error') {
  45. response.writeHead(behavior.status, {
  46. 'content-type': behavior.contentType ?? 'application/json',
  47. ...behavior.headers,
  48. })
  49. response.end(behavior.body)
  50. return
  51. }
  52. response.writeHead(200, { 'content-type': 'text/event-stream' })
  53. const write = (index: number): void => {
  54. if (index >= behavior.events.length) {
  55. if (behavior.kind === 'sse') response.end()
  56. else response.destroy() // close-early: drop the socket mid-stream
  57. return
  58. }
  59. response.write(`data: ${behavior.events[index]}\n\n`)
  60. setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5)
  61. }
  62. write(0)
  63. })
  64. })
  65. servers.push(server)
  66. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  67. const address = server.address()
  68. if (address === null || typeof address === 'string') throw new Error('no port')
  69. return {
  70. url: `http://127.0.0.1:${address.port}`,
  71. requests,
  72. headers,
  73. script,
  74. close: () => new Promise(resolve => server.close(() => { resolve() })),
  75. }
  76. }