Files
deepseek-harness/packages/web/web/tests/web.spec.ts
T
Tianyi Cui de79553d4f refactor(web): drop the unconsumed observation surface
WebService exposed an observation surface nothing in production observes:
the web/providers-change event (declared, emitted on every provider
registration/disposal, rollback-yield ordered before the emit solely so a
throwing change listener unwinds the registration) and the aggregated
searchStatus()/fetchStatus() query with its WebCapabilityStatus union.
dsh-tool-web executes through ctx.web.search()/fetch() and routes on the
structured WebError codes selection throws at execution time; tool
registration follows product enablement, not provider availability. The
only listeners/callers were the web packages' own tests, and the
tool-web README / architecture.md prose claiming the tool 'reads only the
aggregated searchStatus()/fetchStatus()' had drifted from the call sites.

Remove the event declaration, both emits, and the rollback-before-emit
machinery (the plain ctx.effect disposer keeps HMR cleanup, matching
LlmService.registerAdapter). Remove searchStatus()/fetchStatus(),
resolveStatus(), and WebCapabilityStatus; the provider-private status()
stays as the execution-time selection input. Delete the listener-throw
rollback test, and rewrite every event/status assertion across the web
packages' tests onto caller-observable behavior: a successful
search()/fetch() or the structured WEB_PROVIDER_* codes. Regenerate the
cordis catalog; update the web/tool-web READMEs, the architecture.md web
paragraph, core-data-structures/web.md, and the type-equiv manifest; amend
the web capability seam RFC's facts to the shipped surface. This follows
the llm/adapter-change precedent: a boot-time backend-registry signal and
an availability probe distinct from executing both sit on the cut side of
its keep/cut criterion.

RFC: docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md
2026-07-04 15:42:56 +08:00

218 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import WebService, {
WebError,
type WebFetchProvider,
type WebFetchResult,
type WebProviderStatus,
type WebSearchProvider,
type WebSearchRequest,
type WebSearchResult,
} from '@deepseek-ai/dsh-web'
/** A scripted search provider for contract tests. */
function makeSearchProvider(
id: string,
status: WebProviderStatus,
search: (request: WebSearchRequest) => Promise<WebSearchResult>,
): WebSearchProvider {
return { id, status: () => status, search: request => search(request) }
}
function makeFetchProvider(id: string, status: WebProviderStatus, result: WebFetchResult): WebFetchProvider {
return { id, status: () => status, fetch: () => Promise.resolve(result) }
}
const available: WebProviderStatus = { available: true }
const unavailable: WebProviderStatus = { available: false, reason: 'missing-credential' }
function searchResult(providerId: string, overrides: Partial<WebSearchResult> = {}): WebSearchResult {
return { providerId, query: 'q', sources: [], truncated: false, ...overrides }
}
function fetchResult(providerId: string): WebFetchResult {
return { providerId, url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: 'hi' }, truncated: false }
}
/** Mount a WebService on a fresh root context with the given config. */
async function mountWeb(config: ConstructorParameters<typeof WebService>[1] = {}): Promise<{ ctx: Context; web: WebService }> {
const ctx = new Context()
await ctx.plugin(WebService, config)
return { ctx, web: ctx.web }
}
describe('WebService registration', () => {
it('registers a search provider and unregisters it via the returned disposer', async () => {
const { web } = await mountWeb()
const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
dispose()
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
})
it('throws WEB_DUPLICATE_PROVIDER on a duplicate search id', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
expect(() => web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))))
.toThrow(expect.objectContaining({ code: 'WEB_DUPLICATE_PROVIDER' }))
})
it('keeps search and fetch id namespaces independent', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('shared', available, () => Promise.resolve(searchResult('shared'))))
expect(() => web.registerFetchProvider(makeFetchProvider('shared', available, fetchResult('shared')))).not.toThrow()
})
it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => {
const { ctx, web } = await mountWeb()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
}, { inject: ['web'] }))
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
await fiber.dispose()
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
})
})
describe('WebService execution resolution', () => {
it('throws WEB_PROVIDER_UNAVAILABLE when nothing is registered', async () => {
const { web } = await mountWeb()
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
})
it('throws WEB_PROVIDER_UNAVAILABLE when providers exist but none are usable', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
})
it('throws WEB_PROVIDER_CONFIGURED_MISSING for an unregistered configured id', async () => {
const { web } = await mountWeb({ searchProvider: 'perplexity' })
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
})
it('throws WEB_PROVIDER_CONFIGURED_UNAVAILABLE for an unusable configured id', async () => {
const { web } = await mountWeb({ searchProvider: 'exa' })
web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
})
it('throws WEB_PROVIDER_AMBIGUOUS rather than picking by order', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_AMBIGUOUS' }))
})
it('runs the configured provider even when another usable provider is registered', async () => {
const { web } = await mountWeb({ searchProvider: 'perplexity' })
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
})
it('ignores unusable providers when auto-selecting', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity'))))
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
})
it('does not let registration order change auto-selection', async () => {
const a = await mountWeb()
a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
const b = await mountWeb()
b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
})
it('runs the selected provider and returns its result', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(
searchResult('exa', { content: 'answer', sources: [{ url: 'https://a' }] }),
)))
const result = await web.search({ query: 'q' })
expect(result.providerId).toBe('exa')
expect(result.content).toBe('answer')
expect(result.sources).toEqual([{ url: 'https://a' }])
})
it('propagates the abort signal to the provider', async () => {
const { web } = await mountWeb()
const seen: (AbortSignal | undefined)[] = []
web.registerSearchProvider({
id: 'exa',
status: () => available,
search: (_request, exec) => { seen.push(exec?.signal); return Promise.resolve(searchResult('exa')) },
})
const controller = new AbortController()
await web.search({ query: 'q' }, { signal: controller.signal })
expect(seen[0]).toBe(controller.signal)
})
})
describe('WebService maxResults enforcement', () => {
it('truncates sources and sets truncated when a provider over-returns', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa', {
sources: [{ url: 'https://1' }, { url: 'https://2' }, { url: 'https://3' }],
}))))
const result = await web.search({ query: 'q', maxResults: 2 })
expect(result.sources).toHaveLength(2)
expect(result.truncated).toBe(true)
})
it('leaves truncated false when within the bound', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa', {
sources: [{ url: 'https://1' }],
}))))
const result = await web.search({ query: 'q', maxResults: 8 })
expect(result.sources).toHaveLength(1)
expect(result.truncated).toBe(false)
})
it('does not bound when maxResults is omitted', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa', {
sources: [{ url: 'https://1' }, { url: 'https://2' }],
}))))
const result = await web.search({ query: 'q' })
expect(result.sources).toHaveLength(2)
expect(result.truncated).toBe(false)
})
})
describe('WebService fetch capability', () => {
it('resolves and runs the fetch provider independently of search', async () => {
const { web } = await mountWeb()
web.registerFetchProvider(makeFetchProvider('local-http', available, fetchResult('local-http')))
const result = await web.fetch({ url: 'https://example.com' })
expect(result.providerId).toBe('local-http')
expect(result.statusCode).toBe(200)
})
it('throws WEB_PROVIDER_UNAVAILABLE for fetch when no fetch provider is registered', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
await expect(web.fetch({ url: 'https://example.com' })).rejects.toThrow(
expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }),
)
})
})
describe('WebError', () => {
it('is a HarnessError carrying its code', () => {
const error = new WebError('boom', 'WEB_INVALID_URL')
expect(error.code).toBe('WEB_INVALID_URL')
expect(error.name).toBe('WebError')
})
})