snapshot-http-fixtures.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import { EventEmitter } from 'node:events'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. const httpMock = vi.hoisted(() => ({ createServer: vi.fn() }))
  5. vi.mock('node:http', () => ({ createServer: httpMock.createServer }))
  6. // Snapshot plugins are plain runtime JavaScript loaded by cordis.yml.
  7. // @ts-expect-error The fixture intentionally has no declaration artifact.
  8. import * as searchFixtureModule from '../snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs'
  9. // @ts-expect-error The fixture intentionally has no declaration artifact.
  10. import * as loopbackFixtureModule from '../snapshots/session/loopback-fixture-server.mjs'
  11. const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages'
  12. interface FixturePlugin {
  13. readonly name: string
  14. readonly inject?: readonly string[]
  15. apply(ctx: Context): Promise<void>
  16. }
  17. interface LoopbackFixtureOptions {
  18. readonly label: string
  19. readonly onCleanup: () => void
  20. readonly onListening: (address: { port: number }) => void
  21. readonly requestListener: () => void
  22. }
  23. const searchFixture = searchFixtureModule as unknown as FixturePlugin
  24. const typedLoopbackFixtureModule = loopbackFixtureModule as unknown as {
  25. readonly applyLoopbackServerEffect: (ctx: Context, options: LoopbackFixtureOptions) => Promise<void>
  26. }
  27. const { applyLoopbackServerEffect } = typedLoopbackFixtureModule
  28. const nativeFetch = globalThis.fetch
  29. class FixtureServer extends EventEmitter {
  30. readonly started = Promise.withResolvers<undefined>()
  31. listening = false
  32. closed = false
  33. connectionsClosed = false
  34. unreferenced = false
  35. private listenCallback: (() => void) | undefined
  36. private port = 0
  37. listen(_port: number, _host: string, callback: () => void): this {
  38. this.listenCallback = callback
  39. this.started.resolve(undefined)
  40. return this
  41. }
  42. finishListening(port = 54321): void {
  43. this.port = port
  44. this.listening = true
  45. this.listenCallback?.()
  46. }
  47. address(): { address: string; family: string; port: number } | null {
  48. return this.listening ? { address: '127.0.0.1', family: 'IPv4', port: this.port } : null
  49. }
  50. unref(): this {
  51. this.unreferenced = true
  52. return this
  53. }
  54. close(callback: (error?: Error) => void): this {
  55. this.listening = false
  56. this.closed = true
  57. callback()
  58. return this
  59. }
  60. closeAllConnections(): void {
  61. this.connectionsClosed = true
  62. }
  63. }
  64. function nextServer(): FixtureServer {
  65. const server = new FixtureServer()
  66. httpMock.createServer.mockReturnValueOnce(server)
  67. return server
  68. }
  69. function captureErrors(ctx: Context): unknown[] {
  70. const errors: unknown[] = []
  71. ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
  72. return errors
  73. }
  74. async function disposeWhileStarting(fiber: { dispose(): Promise<unknown> }, server: FixtureServer): Promise<void> {
  75. await server.started.promise
  76. const disposal = fiber.dispose()
  77. const settled = vi.fn()
  78. void disposal.then(settled)
  79. await Promise.resolve()
  80. expect(settled).not.toHaveBeenCalled()
  81. server.finishListening()
  82. await disposal
  83. }
  84. afterEach(() => {
  85. globalThis.fetch = nativeFetch
  86. httpMock.createServer.mockReset()
  87. })
  88. describe('snapshot HTTP fixture lifecycle', () => {
  89. it('joins search listener setup and cleanup when disposal wins the startup race', async () => {
  90. const server = nextServer()
  91. const ctx = new Context()
  92. const errors = captureErrors(ctx)
  93. const fiber = ctx.plugin(searchFixture)
  94. await disposeWhileStarting(fiber, server)
  95. expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true })
  96. expect(globalThis.fetch).toBe(nativeFetch)
  97. expect(errors).toEqual([])
  98. })
  99. it('runs owner cleanup and closes the listener when disposal wins the startup race', async () => {
  100. const server = nextServer()
  101. const ctx = new Context()
  102. const errors = captureErrors(ctx)
  103. const onCleanup = vi.fn()
  104. const onListening = vi.fn()
  105. const fiber = ctx.plugin({
  106. name: 'loopback-fixture-lifecycle-test',
  107. apply: testCtx => applyLoopbackServerEffect(testCtx, {
  108. label: 'loopback-fixture-lifecycle-test',
  109. onCleanup,
  110. onListening,
  111. requestListener: () => {},
  112. }),
  113. })
  114. await disposeWhileStarting(fiber, server)
  115. expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true })
  116. expect(onListening).toHaveBeenCalledWith(expect.objectContaining({ port: 54321 }))
  117. expect(onCleanup).toHaveBeenCalledOnce()
  118. expect(errors).toEqual([])
  119. })
  120. it('maps every fetch input form and rejects another path on the recorded authority', async () => {
  121. const server = nextServer()
  122. const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => new Response('{}'))
  123. globalThis.fetch = fetchMock
  124. const ctx = new Context()
  125. const fiber = ctx.plugin(searchFixture)
  126. await server.started.promise
  127. server.finishListening(54322)
  128. await fiber
  129. try {
  130. await globalThis.fetch(RECORDED_ENDPOINT)
  131. expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/anthropic/v1/messages')
  132. await globalThis.fetch(new URL(RECORDED_ENDPOINT))
  133. expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/anthropic/v1/messages')
  134. const request = new Request(RECORDED_ENDPOINT, { method: 'POST', headers: { 'x-fixture': 'request' } })
  135. await globalThis.fetch(request)
  136. const mappedRequest = fetchMock.mock.calls.at(-1)?.[0]
  137. expect(mappedRequest).toBeInstanceOf(Request)
  138. if (!(mappedRequest instanceof Request)) throw new TypeError('mapped fetch input must be a Request')
  139. expect(mappedRequest.url).toBe('http://127.0.0.1:54322/anthropic/v1/messages')
  140. expect(mappedRequest.method).toBe('POST')
  141. expect(mappedRequest.headers.get('x-fixture')).toBe('request')
  142. const unrelated = new URL('https://example.test/')
  143. await globalThis.fetch(unrelated)
  144. expect(fetchMock.mock.calls.at(-1)?.[0]).toBe(unrelated)
  145. await expect(globalThis.fetch('http://127.0.0.1:43118/unexpected'))
  146. .rejects.toThrow('web-search-error-fixture: unexpected URL for recorded authority')
  147. } finally {
  148. await fiber.dispose()
  149. }
  150. expect(globalThis.fetch).toBe(fetchMock)
  151. expect(server.closed).toBe(true)
  152. })
  153. it('preserves a later fetch wrapper while still closing the listener and reporting the ownership error', async () => {
  154. const server = nextServer()
  155. const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => new Response('{}'))
  156. globalThis.fetch = fetchMock
  157. const ctx = new Context()
  158. const errors = captureErrors(ctx)
  159. const fiber = ctx.plugin(searchFixture)
  160. await server.started.promise
  161. server.finishListening()
  162. await fiber
  163. const fixtureFetch = globalThis.fetch
  164. const laterFetch = vi.fn((input: string | URL | Request, init?: RequestInit) => fixtureFetch(input, init))
  165. globalThis.fetch = laterFetch
  166. await fiber.dispose()
  167. expect(globalThis.fetch).toBe(laterFetch)
  168. expect(server).toMatchObject({ closed: true, connectionsClosed: true })
  169. expect(errors.map(String).join('\n')).toContain('web-search-error-fixture: global fetch owner changed before cleanup')
  170. })
  171. })