fix(snapshot): keep fixture lifecycle tests source-clean
This commit is contained in:
parent
1f8d7b73af
commit
7fbd33de00
4 changed files with 151 additions and 157 deletions
|
|
@ -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([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
75
snapshots/session/loopback-fixture-server.mjs
Normal file
75
snapshots/session/loopback-fixture-server.mjs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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')
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue