fix(snapshot): keep fixture lifecycle tests source-clean

This commit is contained in:
Yichen Jiang 2026-08-29 11:50:13 +08:00
parent 1f8d7b73af
commit 7fbd33de00
4 changed files with 151 additions and 157 deletions

View file

@ -1,6 +1,5 @@
import { EventEmitter } from 'node:events' import { EventEmitter } from 'node:events'
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import { WebRuntime } from '@deepseek-ai/dsh-web'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
const httpMock = vi.hoisted(() => ({ createServer: vi.fn() })) const httpMock = vi.hoisted(() => ({ createServer: vi.fn() }))
@ -11,10 +10,9 @@ vi.mock('node:http', () => ({ createServer: httpMock.createServer }))
// @ts-expect-error The fixture intentionally has no declaration artifact. // @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' 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. // @ts-expect-error The fixture intentionally has no declaration artifact.
import * as webFetchFixtureModule from '../snapshots/session/web-fetch/web-fetch-fixture-server.mjs' import * as loopbackFixtureModule from '../snapshots/session/loopback-fixture-server.mjs'
const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages' const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages'
const RECORDED_URL = 'http://public.test:43117/menu.html'
interface FixturePlugin { interface FixturePlugin {
readonly name: string readonly name: string
@ -22,8 +20,18 @@ interface FixturePlugin {
apply(ctx: Context): Promise<void> 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 searchFixture = searchFixtureModule as unknown as FixturePlugin
const webFetchFixture = webFetchFixtureModule 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 const nativeFetch = globalThis.fetch
class FixtureServer extends EventEmitter { class FixtureServer extends EventEmitter {
@ -80,6 +88,17 @@ function captureErrors(ctx: Context): unknown[] {
return errors 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(() => { afterEach(() => {
globalThis.fetch = nativeFetch globalThis.fetch = nativeFetch
httpMock.createServer.mockReset() httpMock.createServer.mockReset()
@ -91,42 +110,33 @@ describe('snapshot HTTP fixture lifecycle', () => {
const ctx = new Context() const ctx = new Context()
const errors = captureErrors(ctx) const errors = captureErrors(ctx)
const fiber = ctx.plugin(searchFixture) const fiber = ctx.plugin(searchFixture)
await server.started.promise await disposeWhileStarting(fiber, server)
const disposal = fiber.dispose()
const settled = vi.fn()
void disposal.then(settled)
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
server.finishListening()
await disposal
expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true }) expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true })
expect(globalThis.fetch).toBe(nativeFetch) expect(globalThis.fetch).toBe(nativeFetch)
expect(errors).toEqual([]) expect(errors).toEqual([])
}) })
it('removes the fetch provider and closes its listener when disposal wins the startup race', async () => { it('runs owner cleanup and closes the listener when disposal wins the startup race', async () => {
const server = nextServer() const server = nextServer()
const ctx = new Context() const ctx = new Context()
await ctx.plugin(WebRuntime)
const errors = captureErrors(ctx) const errors = captureErrors(ctx)
const fiber = ctx.plugin(webFetchFixture) const onCleanup = vi.fn()
await server.started.promise const onListening = vi.fn()
const fiber = ctx.plugin({
const disposal = fiber.dispose() name: 'loopback-fixture-lifecycle-test',
const settled = vi.fn() apply: testCtx => applyLoopbackServerEffect(testCtx, {
void disposal.then(settled) label: 'loopback-fixture-lifecycle-test',
await Promise.resolve() onCleanup,
expect(settled).not.toHaveBeenCalled() onListening,
requestListener: () => {},
server.finishListening() }),
await disposal })
await disposeWhileStarting(fiber, server)
expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true }) expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true })
await expect(ctx.web.fetch({ url: RECORDED_URL })) expect(onListening).toHaveBeenCalledWith(expect.objectContaining({ port: 54321 }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) expect(onCleanup).toHaveBeenCalledOnce()
expect(errors).toEqual([]) expect(errors).toEqual([])
}) })

View file

@ -0,0 +1,75 @@
/** Shared lifecycle for snapshot HTTP fixtures that bind an ephemeral loopback port. */
import { createServer } from 'node:http'
function listen(server) {
return new Promise((resolve, reject) => {
const onError = (error) => {
server.off('error', onError)
reject(error)
}
server.once('error', onError)
try {
server.listen(0, '127.0.0.1', () => {
server.off('error', onError)
resolve(undefined)
})
} catch (error) {
server.off('error', onError)
reject(error)
}
})
}
async function close(server) {
if (!server.listening) return
await new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve(undefined))
server.closeAllConnections()
})
}
async function cleanup(server, onCleanup, label) {
const errors = []
try {
onCleanup()
} catch (error) {
errors.push(error)
}
try {
await close(server)
} catch (error) {
errors.push(error)
}
if (errors.length === 1) throw errors[0]
if (errors.length > 1) throw new AggregateError(errors, `${label}: cleanup failed`)
}
/**
* Start a loopback server as a Cordis effect and join cleanup with its setup.
* @param ctx - Cordis context that owns the listener effect.
* @param options - Fixture callbacks and the effect label used in diagnostics.
*/
export async function applyLoopbackServerEffect(ctx, options) {
const { label, onCleanup, onListening, requestListener } = options
await ctx.effect(async () => {
const server = createServer(requestListener)
try {
await listen(server)
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error(`${label}: loopback listener has no TCP address`)
}
onListening(address)
// Snapshot fixtures must never hold the process open past protocol shutdown.
server.unref()
return () => cleanup(server, onCleanup, label)
} catch (cause) {
try {
await cleanup(server, onCleanup, label)
} catch (cleanupError) {
throw new AggregateError([cause, cleanupError], `${label}: setup and cleanup failed`)
}
throw cause
}
}, label)
}

View file

@ -5,8 +5,8 @@
* and replay therefore exercise fetch and markdown rendering without external * and replay therefore exercise fetch and markdown rendering without external
* network while retaining the recorded request URL. * network while retaining the recorded request URL.
*/ */
import { createServer } from 'node:http'
import { HttpFetchProvider } from '@deepseek-ai/dsh-web-fetch-http' import { HttpFetchProvider } from '@deepseek-ai/dsh-web-fetch-http'
import { applyLoopbackServerEffect } from '../loopback-fixture-server.mjs'
/** Model-visible URL retained by the recorded session. */ /** Model-visible URL retained by the recorded session. */
const RECORDED_URL = 'http://public.test:43117/menu.html' const RECORDED_URL = 'http://public.test:43117/menu.html'
@ -36,33 +36,6 @@ const LIMITS = {
userAgent: 'deepseek-harness-snapshot/1.0', userAgent: 'deepseek-harness-snapshot/1.0',
} }
function listen(server) {
return new Promise((resolve, reject) => {
const onError = (error) => {
server.off('error', onError)
reject(error)
}
server.once('error', onError)
try {
server.listen(0, '127.0.0.1', () => {
server.off('error', onError)
resolve(undefined)
})
} catch (error) {
server.off('error', onError)
reject(error)
}
})
}
async function close(server) {
if (!server.listening) return
await new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve(undefined))
server.closeAllConnections()
})
}
/** /**
* Register the deterministic provider and start its loopback server. * Register the deterministic provider and start its loopback server.
* @param ctx - Cordis context; the effect disposes the server with the fiber. * @param ctx - Cordis context; the effect disposes the server with the fiber.
@ -89,41 +62,30 @@ export async function apply(ctx) {
return { ...result, url: RECORDED_URL } return { ...result, url: RECORDED_URL }
}, },
}) })
await ctx.effect(async () => { try {
const server = createServer((req, res) => { await applyLoopbackServerEffect(ctx, {
if (req.url === '/menu.html') { label: 'web-fetch-fixture-server',
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) requestListener: (req, res) => {
res.end(PAGE) if (req.url === '/menu.html') {
return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
} res.end(PAGE)
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) return
res.end('not found') }
}) res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
try { res.end('not found')
await listen(server) },
const address = server.address() onListening: (address) => {
if (address === null || typeof address === 'string') { transportUrl = new URL(RECORDED_URL)
throw new Error('web-fetch-fixture-server: loopback listener has no TCP address') transportUrl.port = String(address.port)
} readiness.resolve(undefined)
transportUrl = new URL(RECORDED_URL) },
transportUrl.port = String(address.port) onCleanup: () => {
// The fixture must never hold the process open past protocol shutdown.
server.unref()
readiness.resolve(undefined)
return async () => {
unregister() unregister()
await close(server) },
} })
} catch (cause) { } catch (cause) {
startupError = cause startupError = cause
readiness.resolve(undefined) readiness.resolve(undefined)
unregister() throw cause
try { }
await close(server)
} catch (cleanupError) {
throw new AggregateError([cause, cleanupError], 'web-fetch-fixture-server: setup and cleanup failed')
}
throw cause
}
}, 'web-fetch-fixture-server')
} }

View file

@ -1,5 +1,5 @@
/** Deterministic authentication failure for the search endpoint guidance snapshot. */ /** Deterministic authentication failure for the search endpoint guidance snapshot. */
import { createServer } from 'node:http' import { applyLoopbackServerEffect } from '../loopback-fixture-server.mjs'
/** Model-visible endpoint retained by the recorded session. */ /** Model-visible endpoint retained by the recorded session. */
const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages' const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages'
@ -8,33 +8,6 @@ const RECORDED_URL = new URL(RECORDED_ENDPOINT)
/** Cordis plugin name. */ /** Cordis plugin name. */
export const name = 'web-search-error-fixture' export const name = 'web-search-error-fixture'
function listen(server) {
return new Promise((resolve, reject) => {
const onError = (error) => {
server.off('error', onError)
reject(error)
}
server.once('error', onError)
try {
server.listen(0, '127.0.0.1', () => {
server.off('error', onError)
resolve(undefined)
})
} catch (error) {
server.off('error', onError)
reject(error)
}
})
}
async function close(server) {
if (!server.listening) return
await new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve(undefined))
server.closeAllConnections()
})
}
function requestUrl(input) { function requestUrl(input) {
if (typeof input === 'string') return input if (typeof input === 'string') return input
if (input instanceof URL) return input.href if (input instanceof URL) return input.href
@ -60,26 +33,12 @@ function transportInput(input, transportEndpoint) {
return input return input
} }
async function cleanup(server, restoreFetch) {
const errors = []
try {
restoreFetch()
} catch (error) {
errors.push(error)
}
try {
await close(server)
} catch (error) {
errors.push(error)
}
if (errors.length === 1) throw errors[0]
if (errors.length > 1) throw new AggregateError(errors, 'web-search-error-fixture: cleanup failed')
}
/** Start the local Messages endpoint and stop it with the plugin fiber. */ /** Start the local Messages endpoint and stop it with the plugin fiber. */
export async function apply(ctx) { export async function apply(ctx) {
await ctx.effect(async () => { let restoreFetch = () => {}
const server = createServer((request, response) => { await applyLoopbackServerEffect(ctx, {
label: 'web-search-error-fixture',
requestListener: (request, response) => {
if (request.method === 'POST' && request.url === '/anthropic/v1/messages') { if (request.method === 'POST' && request.url === '/anthropic/v1/messages') {
response.writeHead(401, { 'content-type': 'application/json' }) response.writeHead(401, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'invalid snapshot API key' } })) response.end(JSON.stringify({ error: { message: 'invalid snapshot API key' } }))
@ -87,31 +46,19 @@ export async function apply(ctx) {
} }
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
response.end('not found') response.end('not found')
}) },
try { onListening: (address) => {
await listen(server)
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('web-search-error-fixture: loopback listener has no TCP address')
}
const transportEndpoint = `http://127.0.0.1:${String(address.port)}/anthropic/v1/messages` const transportEndpoint = `http://127.0.0.1:${String(address.port)}/anthropic/v1/messages`
server.unref()
const originalFetch = globalThis.fetch const originalFetch = globalThis.fetch
const fixtureFetch = async (input, init) => originalFetch(transportInput(input, transportEndpoint), init) const fixtureFetch = async (input, init) => originalFetch(transportInput(input, transportEndpoint), init)
globalThis.fetch = fixtureFetch globalThis.fetch = fixtureFetch
return () => cleanup(server, () => { restoreFetch = () => {
if (globalThis.fetch !== fixtureFetch) { if (globalThis.fetch !== fixtureFetch) {
throw new Error('web-search-error-fixture: global fetch owner changed before cleanup') throw new Error('web-search-error-fixture: global fetch owner changed before cleanup')
} }
globalThis.fetch = originalFetch globalThis.fetch = originalFetch
})
} catch (cause) {
try {
await close(server)
} catch (cleanupError) {
throw new AggregateError([cause, cleanupError], 'web-search-error-fixture: setup and cleanup failed')
} }
throw cause },
} onCleanup: () => restoreFetch(),
}, 'web-search-error-fixture') })
} }