client-handler.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. /**
  2. * Wire-protocol coverage over the isomorphic point: InProcessApiClient →
  3. * toFetchHandler(scripted impl) runs the real envelope wrap/unwrap, zod
  4. * two-level parse, and rpcId discipline with no network or browser. Each case
  5. * scripts its own minimal ApiProxy.
  6. */
  7. import { describe, expect, it, vi } from 'vitest'
  8. import type { ApiProxy, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
  9. import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
  10. function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>> {
  11. return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
  12. }
  13. /** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
  14. function scriptedApi(overrides: {
  15. host?: Partial<ApiProxy['host']>
  16. skills?: Partial<ApiProxy['skills']>
  17. agentPresets?: Partial<ApiProxy['agentPresets']>
  18. settings?: Partial<ApiProxy['settings']>
  19. llm?: Partial<ApiProxy['llm']>
  20. } = {}): ApiProxy {
  21. const err = <T>(r: RpcRequest<unknown>): Promise<RpcResponse<T>> =>
  22. Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } })
  23. return {
  24. host: {
  25. describe: r => ok(r, {
  26. version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
  27. }),
  28. openPath: r => ok(r, { opened: true as const }),
  29. ...overrides.host,
  30. },
  31. skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
  32. agentPresets: {
  33. openDocument: r => ok(r, { opened: true as const }),
  34. ...overrides.agentPresets,
  35. },
  36. settings: {
  37. openDocument: r => ok(r, { opened: true as const }),
  38. ...overrides.settings,
  39. },
  40. llm: {
  41. providers: r => ok(r, { providers: [] }),
  42. models: r => ok(r, {
  43. default: { provider: 'test', model: 'test' },
  44. routableProviders: [],
  45. groups: [],
  46. failures: [],
  47. }),
  48. discoverModels: err,
  49. ...overrides.llm,
  50. },
  51. downloads: { sessionLog: async () => new Response('stub', { status: 404 }) },
  52. }
  53. }
  54. function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient {
  55. return new InProcessApiClient(toFetchHandler(api), timeoutMs)
  56. }
  57. /** Wrap one scripted method to record its invocation into `seen` before responding. */
  58. function recorderInto(seen: { method: string; payload: unknown }[]) {
  59. return <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
  60. (r: RpcRequest<P>): Promise<RpcResponse<V>> => {
  61. seen.push({ method, payload: r.payload })
  62. return respond(r)
  63. }
  64. }
  65. describe('unary round trip', () => {
  66. it('carries payload out and value back through the full wire form', async () => {
  67. let seen: RpcRequest<{}> | undefined
  68. const api = scriptedApi({
  69. host: {
  70. describe: (request) => {
  71. seen = request
  72. return ok(request, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true })
  73. },
  74. },
  75. })
  76. const response = await client(api).host.describe({})
  77. expect(seen?.payload).toEqual({})
  78. expect(seen?.rpcId).toBeTruthy()
  79. expect(response.rpcId).toBe(seen?.rpcId)
  80. expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } })
  81. })
  82. it('routes the agent-preset document opener through the wire', async () => {
  83. const opened = await client(scriptedApi()).agentPresets.openDocument({ agentPreset: 'mine' })
  84. expect(opened.result).toEqual({ ok: true, value: { opened: true } })
  85. })
  86. it('passes business errors through as 200 + err result, not a throw', async () => {
  87. const api = scriptedApi({
  88. host: {
  89. describe: request => Promise.resolve({
  90. rpcId: request.rpcId,
  91. result: { ok: false, error: { code: 'internal', message: 'nope', details: {} } },
  92. }),
  93. },
  94. })
  95. const response = await client(api).host.describe({})
  96. expect(response.result).toEqual({ ok: false, error: { code: 'internal', message: 'nope', details: {} } })
  97. })
  98. it('throws on rpcId echo mismatch', async () => {
  99. const api = scriptedApi({
  100. host: {
  101. describe: () => Promise.resolve({
  102. rpcId: RpcId('forged'),
  103. result: { ok: true, value: { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true } },
  104. }),
  105. },
  106. })
  107. await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/)
  108. })
  109. it('rejects a method/path mismatch as bad-request', async () => {
  110. const handler = toFetchHandler(scriptedApi())
  111. const body = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} }
  112. const response = await handler.fetch('http://dsh.internal/api/skill.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
  113. expect(response.status).toBe(200)
  114. const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } }
  115. expect(parsed.result.ok).toBe(false)
  116. expect(parsed.result.error?.code).toBe('bad-request')
  117. expect(parsed.result.error?.message).toMatch(/does not match path/)
  118. })
  119. it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
  120. const handler = toFetchHandler(scriptedApi())
  121. // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
  122. const noId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) })
  123. expect(noId.status).toBe(200)
  124. const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
  125. expect(noIdParsed.result.ok).toBe(false)
  126. expect(noIdParsed.rpcId).toBe('invalid-request')
  127. // A string rpcId in the otherwise-bad body is salvaged for correlation.
  128. const withId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
  129. const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
  130. expect(withIdParsed.result.ok).toBe(false)
  131. expect(withIdParsed.rpcId).toBe('salvage-me')
  132. })
  133. it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
  134. const handler = toFetchHandler(scriptedApi())
  135. // Unknown method → 404.
  136. const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
  137. expect(notFound.status).toBe(404)
  138. // Non-JSON body → 400.
  139. const badBody = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' })
  140. expect(badBody.status).toBe(400)
  141. // Impl crash → 500, and through the client that is a throw, not an err result.
  142. const crashing = scriptedApi({ host: { describe: () => { throw new Error('impl exploded') } } })
  143. await expect(client(crashing).host.describe({})).rejects.toThrow(/transport failure .*500/)
  144. })
  145. it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => {
  146. const describe = vi.fn((request: RpcRequest<{}>) => ok(request, {
  147. version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
  148. }))
  149. const handler = toFetchHandler(scriptedApi({ host: { describe } }))
  150. const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} })
  151. // A "simple" browser POST (text/plain — sent with no CORS preflight) is
  152. // refused at the carrier before the impl runs.
  153. const plain = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'text/plain' }, body })
  154. expect(plain.status).toBe(415)
  155. // A string body with no explicit header defaults to text/plain — same fence.
  156. const unlabelled = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', body })
  157. expect(unlabelled.status).toBe(415)
  158. expect(describe).not.toHaveBeenCalled()
  159. // Media-type parameters pass: the fence checks the type, not the exact string.
  160. const charset = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body })
  161. expect(charset.status).toBe(200)
  162. expect(describe).toHaveBeenCalledTimes(1)
  163. })
  164. it('rejects when the transport never resolves within timeoutMs', async () => {
  165. // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
  166. const never = new InProcessApiClient({
  167. fetch: (_i: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
  168. init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) })
  169. }),
  170. }, 25)
  171. await expect(never.host.describe({})).rejects.toThrow()
  172. })
  173. it('aborts a unary call through the caller-supplied external signal', async () => {
  174. // Real-fetch semantics: on abort the rejection is the signal's reason, and the abort
  175. // works even when the transport ignores the signal entirely (hung impl).
  176. const gate = new AbortController()
  177. const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
  178. const call = hung.host.describe({}, gate.signal)
  179. gate.abort(new Error('externally aborted'))
  180. await expect(call).rejects.toThrow(/externally aborted/)
  181. })
  182. it('rejects an already-aborted signal before touching the transport, mapping a string reason to an Error', async () => {
  183. let touched = false
  184. const c = new InProcessApiClient({
  185. fetch: () => {
  186. touched = true
  187. return Promise.resolve(new Response('{}'))
  188. },
  189. }, 60_000)
  190. const gate = new AbortController()
  191. gate.abort('gone before start')
  192. await expect(c.host.describe({}, gate.signal)).rejects.toThrow('gone before start')
  193. expect(touched).toBe(false)
  194. })
  195. it('maps a non-Error, non-string abort reason to the default AbortError message', async () => {
  196. const gate = new AbortController()
  197. const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
  198. const call = hung.host.describe({}, gate.signal)
  199. gate.abort(42)
  200. await expect(call).rejects.toThrow('This operation was aborted')
  201. })
  202. it('passes a signal-less doFetch straight through to the handler', async () => {
  203. class Probe extends InProcessApiClient {
  204. direct(url: URL): Promise<Response> {
  205. return this.doFetch(url)
  206. }
  207. }
  208. const probe = new Probe({ fetch: () => Promise.resolve(new Response('raw')) })
  209. const response = await probe.direct(new URL('http://dsh.internal/probe'))
  210. expect(await response.text()).toBe('raw')
  211. })
  212. it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => {
  213. // Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject.
  214. const api = scriptedApi({
  215. host: { describe: request => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { version: 1 } } }) as never },
  216. })
  217. await expect(client(api).host.describe({})).rejects.toThrow()
  218. })
  219. })
  220. describe('envelope tap', () => {
  221. it('delivers one microtask batch of full forms per unary call', async () => {
  222. const api = scriptedApi()
  223. const tapped = client(api)
  224. const batches: (readonly RpcMessage[])[] = []
  225. tapped.subscribeEnvelopes(batch => batches.push(batch))
  226. await tapped.host.describe({})
  227. await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) })
  228. const all = batches.flat()
  229. expect(all.map(m => m.type)).toEqual(['client-request', 'server-response'])
  230. expect(all[0]?.rpcId).toBe(all[1]?.rpcId)
  231. })
  232. it('isolates a throwing listener and keeps serving the call', async () => {
  233. const api = scriptedApi()
  234. const tapped = client(api)
  235. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  236. try {
  237. const good: string[] = []
  238. tapped.subscribeEnvelopes(() => { throw new Error('listener bug') })
  239. tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type)))
  240. const response = await tapped.host.describe({})
  241. expect(response.result.ok).toBe(true)
  242. await vi.waitFor(() => { expect(good).toContain('server-response') })
  243. } finally {
  244. errorSpy.mockRestore()
  245. }
  246. })
  247. it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => {
  248. const api = scriptedApi()
  249. const tapped = client(api)
  250. await tapped.host.describe({}) // no subscribers: must not accumulate
  251. const batches: (readonly RpcMessage[])[] = []
  252. const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch))
  253. unsubscribe()
  254. await tapped.host.describe({})
  255. await new Promise(resolve => setTimeout(resolve, 0))
  256. expect(batches).toEqual([])
  257. })
  258. })
  259. describe('config unary surface', () => {
  260. it('round-trips every settings/llm method with its own payload and value shape', async () => {
  261. const seen: { method: string; payload: unknown }[] = []
  262. const record = recorderInto(seen)
  263. const providerRow = {
  264. provider: 'openai',
  265. displayName: 'openai',
  266. settingsNs: 'llm-pi-ai',
  267. settingsPath: ['providers', 'openai'],
  268. active: false,
  269. }
  270. const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
  271. const api = scriptedApi({
  272. settings: {
  273. openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })),
  274. },
  275. llm: {
  276. providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
  277. models: record('llm.models', r => ok(r, {
  278. default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  279. routableProviders: ['deepseek-official'],
  280. groups: [group],
  281. failures: [],
  282. })),
  283. discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })),
  284. },
  285. })
  286. const c = client(api)
  287. expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } })
  288. const providers = await c.llm.providers({})
  289. expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } })
  290. const models = await c.llm.models({})
  291. expect(models.result).toEqual({
  292. ok: true,
  293. value: {
  294. default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  295. routableProviders: ['deepseek-official'],
  296. groups: [group],
  297. failures: [],
  298. },
  299. })
  300. const discovered = await c.llm.discoverModels({
  301. settingsNs: 'llm-pi-ai',
  302. baseURL: 'https://gateway.acme.example/v1',
  303. api: 'openai-completions',
  304. apiKey: 'probe-key',
  305. })
  306. expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } })
  307. expect(seen.map(call => call.method)).toEqual([
  308. 'settings.openDocument',
  309. 'llm.providers', 'llm.models', 'llm.discoverModels',
  310. ])
  311. // The draft crosses whole, credential included: the host needs it for this
  312. // one interrogation and stores none of it.
  313. expect(seen[3]?.payload).toEqual({
  314. settingsNs: 'llm-pi-ai',
  315. baseURL: 'https://gateway.acme.example/v1',
  316. api: 'openai-completions',
  317. apiKey: 'probe-key',
  318. })
  319. })
  320. })