| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- import { EventEmitter } from 'node:events'
- import { Context } from '@deepseek-ai/cordis'
- import { afterEach, describe, expect, it, vi } from 'vitest'
- const httpMock = vi.hoisted(() => ({ createServer: vi.fn() }))
- vi.mock('node:http', () => ({ createServer: httpMock.createServer }))
- // Snapshot plugins are plain runtime JavaScript loaded by cordis.yml.
- // @ts-expect-error The fixture intentionally has no declaration artifact.
- import * as searchFixtureModule from '../snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs'
- // @ts-expect-error The fixture intentionally has no declaration artifact.
- import * as loopbackFixtureModule from '../snapshots/session/loopback-fixture-server.mjs'
- const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages'
- interface FixturePlugin {
- readonly name: string
- readonly inject?: readonly string[]
- apply(ctx: Context): Promise<void>
- }
- interface LoopbackFixtureOptions {
- readonly label: string
- readonly onCleanup: () => void
- readonly onListening: (address: { port: number }) => void
- readonly requestListener: () => void
- }
- const searchFixture = searchFixtureModule as unknown as FixturePlugin
- const typedLoopbackFixtureModule = loopbackFixtureModule as unknown as {
- readonly applyLoopbackServerEffect: (ctx: Context, options: LoopbackFixtureOptions) => Promise<void>
- }
- const { applyLoopbackServerEffect } = typedLoopbackFixtureModule
- const nativeFetch = globalThis.fetch
- class FixtureServer extends EventEmitter {
- readonly started = Promise.withResolvers<undefined>()
- listening = false
- closed = false
- connectionsClosed = false
- unreferenced = false
- private listenCallback: (() => void) | undefined
- private port = 0
- listen(_port: number, _host: string, callback: () => void): this {
- this.listenCallback = callback
- this.started.resolve(undefined)
- return this
- }
- finishListening(port = 54321): void {
- this.port = port
- this.listening = true
- this.listenCallback?.()
- }
- address(): { address: string; family: string; port: number } | null {
- return this.listening ? { address: '127.0.0.1', family: 'IPv4', port: this.port } : null
- }
- unref(): this {
- this.unreferenced = true
- return this
- }
- close(callback: (error?: Error) => void): this {
- this.listening = false
- this.closed = true
- callback()
- return this
- }
- closeAllConnections(): void {
- this.connectionsClosed = true
- }
- }
- function nextServer(): FixtureServer {
- const server = new FixtureServer()
- httpMock.createServer.mockReturnValueOnce(server)
- return server
- }
- function captureErrors(ctx: Context): unknown[] {
- const errors: unknown[] = []
- ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
- return errors
- }
- async function disposeWhileStarting(fiber: { dispose(): Promise<unknown> }, server: FixtureServer): Promise<void> {
- await server.started.promise
- const disposal = fiber.dispose()
- const settled = vi.fn()
- void disposal.then(settled)
- await Promise.resolve()
- expect(settled).not.toHaveBeenCalled()
- server.finishListening()
- await disposal
- }
- afterEach(() => {
- globalThis.fetch = nativeFetch
- httpMock.createServer.mockReset()
- })
- describe('snapshot HTTP fixture lifecycle', () => {
- it('joins search listener setup and cleanup when disposal wins the startup race', async () => {
- const server = nextServer()
- const ctx = new Context()
- const errors = captureErrors(ctx)
- const fiber = ctx.plugin(searchFixture)
- await disposeWhileStarting(fiber, server)
- expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true })
- expect(globalThis.fetch).toBe(nativeFetch)
- expect(errors).toEqual([])
- })
- it('runs owner cleanup and closes the listener when disposal wins the startup race', async () => {
- const server = nextServer()
- const ctx = new Context()
- const errors = captureErrors(ctx)
- const onCleanup = vi.fn()
- const onListening = vi.fn()
- const fiber = ctx.plugin({
- name: 'loopback-fixture-lifecycle-test',
- apply: testCtx => applyLoopbackServerEffect(testCtx, {
- label: 'loopback-fixture-lifecycle-test',
- onCleanup,
- onListening,
- requestListener: () => {},
- }),
- })
- await disposeWhileStarting(fiber, server)
- expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true })
- expect(onListening).toHaveBeenCalledWith(expect.objectContaining({ port: 54321 }))
- expect(onCleanup).toHaveBeenCalledOnce()
- expect(errors).toEqual([])
- })
- it('maps every fetch input form and rejects another path on the recorded authority', async () => {
- const server = nextServer()
- const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => new Response('{}'))
- globalThis.fetch = fetchMock
- const ctx = new Context()
- const fiber = ctx.plugin(searchFixture)
- await server.started.promise
- server.finishListening(54322)
- await fiber
- try {
- await globalThis.fetch(RECORDED_ENDPOINT)
- expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/anthropic/v1/messages')
- await globalThis.fetch(new URL(RECORDED_ENDPOINT))
- expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/anthropic/v1/messages')
- const request = new Request(RECORDED_ENDPOINT, { method: 'POST', headers: { 'x-fixture': 'request' } })
- await globalThis.fetch(request)
- const mappedRequest = fetchMock.mock.calls.at(-1)?.[0]
- expect(mappedRequest).toBeInstanceOf(Request)
- if (!(mappedRequest instanceof Request)) throw new TypeError('mapped fetch input must be a Request')
- expect(mappedRequest.url).toBe('http://127.0.0.1:54322/anthropic/v1/messages')
- expect(mappedRequest.method).toBe('POST')
- expect(mappedRequest.headers.get('x-fixture')).toBe('request')
- const unrelated = new URL('https://example.test/')
- await globalThis.fetch(unrelated)
- expect(fetchMock.mock.calls.at(-1)?.[0]).toBe(unrelated)
- await expect(globalThis.fetch('http://127.0.0.1:43118/unexpected'))
- .rejects.toThrow('web-search-error-fixture: unexpected URL for recorded authority')
- } finally {
- await fiber.dispose()
- }
- expect(globalThis.fetch).toBe(fetchMock)
- expect(server.closed).toBe(true)
- })
- it('preserves a later fetch wrapper while still closing the listener and reporting the ownership error', async () => {
- const server = nextServer()
- const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => new Response('{}'))
- globalThis.fetch = fetchMock
- const ctx = new Context()
- const errors = captureErrors(ctx)
- const fiber = ctx.plugin(searchFixture)
- await server.started.promise
- server.finishListening()
- await fiber
- const fixtureFetch = globalThis.fetch
- const laterFetch = vi.fn((input: string | URL | Request, init?: RequestInit) => fixtureFetch(input, init))
- globalThis.fetch = laterFetch
- await fiber.dispose()
- expect(globalThis.fetch).toBe(laterFetch)
- expect(server).toMatchObject({ closed: true, connectionsClosed: true })
- expect(errors.map(String).join('\n')).toContain('web-search-error-fixture: global fetch owner changed before cleanup')
- })
- })
|