client-handler.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  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, rpcId discipline, and SSE framing with no network and no
  5. * browser. Each case scripts its own minimal ApiProxy.
  6. */
  7. import { describe, expect, it, vi } from 'vitest'
  8. import type { SessionId } from '@deepseek-ai/dsh-session'
  9. import type { ApiProxy, GoalRef, GoalView, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
  10. import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
  11. const sid = (id: string): SessionId => id as SessionId
  12. function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>> {
  13. return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
  14. }
  15. /** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
  16. function scriptedApi(overrides: {
  17. sessions?: Partial<ApiProxy['sessions']>
  18. host?: Partial<ApiProxy['host']>
  19. events?: Partial<ApiProxy['events']>
  20. goals?: Partial<ApiProxy['goals']>
  21. respond?: ApiProxy['respond']
  22. } = {}): ApiProxy {
  23. async function *empty<F>(): AsyncGenerator<RpcRequest<F>> { /* no frames */ }
  24. const err = <T>(r: RpcRequest<unknown>): Promise<RpcResponse<T>> =>
  25. Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } })
  26. return {
  27. sessions: {
  28. list: r => ok(r, { items: [] }),
  29. create: r => ok(r, { sessionId: sid('s-new') }),
  30. history: r => ok(r, { events: [], hasMore: false }),
  31. prompt: r => ok(r, { accepted: true as const }),
  32. cancel: r => ok(r, { accepted: true as const }),
  33. ...overrides.sessions,
  34. },
  35. host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host },
  36. goals: {
  37. get: err,
  38. create: err,
  39. edit: err,
  40. pause: err,
  41. resume: err,
  42. complete: err,
  43. clear: err,
  44. ...overrides.goals,
  45. },
  46. events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
  47. respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
  48. }
  49. }
  50. function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient {
  51. return new InProcessApiClient(toFetchHandler(api), timeoutMs)
  52. }
  53. describe('unary round trip', () => {
  54. it('carries payload out and value back through the full wire form', async () => {
  55. let seen: RpcRequest<{ cursor?: string }> | undefined
  56. const api = scriptedApi({
  57. sessions: {
  58. list: (r) => {
  59. seen = r
  60. return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false }] })
  61. },
  62. },
  63. })
  64. const response = await client(api).sessions.list({ cursor: 'c1' })
  65. // Impl received the narrow form with a minted id; client returned the same id and value.
  66. expect(seen?.payload).toEqual({ cursor: 'c1' })
  67. expect(seen?.rpcId).toBeTruthy()
  68. expect(response.rpcId).toBe(seen?.rpcId)
  69. expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } })
  70. })
  71. it('passes business errors through as 200 + err result, not a throw', async () => {
  72. const api = scriptedApi({
  73. sessions: {
  74. cancel: r => Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: sid('sx') } } } }),
  75. },
  76. })
  77. const response = await client(api).sessions.cancel({ sessionId: sid('sx') })
  78. expect(response.result).toEqual({ ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: 'sx' } } })
  79. })
  80. it('throws on rpcId echo mismatch', async () => {
  81. const api = scriptedApi({
  82. sessions: { list: () => Promise.resolve({ rpcId: RpcId('forged'), result: { ok: true, value: { items: [] } } }) },
  83. })
  84. await expect(client(api).sessions.list({})).rejects.toThrow(/rpcId mismatch/)
  85. })
  86. it('rejects an invalid payload at the handler as 200 + bad-request with issues', async () => {
  87. const api = scriptedApi()
  88. const response = await client(api).sessions.history({ sessionId: 123 as unknown as SessionId })
  89. expect(response.result.ok).toBe(false)
  90. if (!response.result.ok) {
  91. expect(response.result.error.code).toBe('bad-request')
  92. expect((response.result.error.details as { issues: unknown[] }).issues.length).toBeGreaterThan(0)
  93. }
  94. })
  95. it('rejects a method/path mismatch as bad-request', async () => {
  96. const handler = toFetchHandler(scriptedApi())
  97. const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }
  98. const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify(body) })
  99. expect(response.status).toBe(200)
  100. const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } }
  101. expect(parsed.result.ok).toBe(false)
  102. expect(parsed.result.error?.code).toBe('bad-request')
  103. expect(parsed.result.error?.message).toMatch(/does not match path/)
  104. })
  105. it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
  106. const handler = toFetchHandler(scriptedApi())
  107. // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
  108. const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ nonsense: true }) })
  109. expect(noId.status).toBe(200)
  110. const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
  111. expect(noIdParsed.result.ok).toBe(false)
  112. expect(noIdParsed.rpcId).toBe('invalid-request')
  113. // A string rpcId in the otherwise-bad body is salvaged for correlation.
  114. const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
  115. const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
  116. expect(withIdParsed.result.ok).toBe(false)
  117. expect(withIdParsed.rpcId).toBe('salvage-me')
  118. })
  119. it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
  120. const handler = toFetchHandler(scriptedApi())
  121. // Unknown method → 404.
  122. const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', body: '{}' })
  123. expect(notFound.status).toBe(404)
  124. // Non-JSON body → 400.
  125. const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: '{oops' })
  126. expect(badBody.status).toBe(400)
  127. // Impl crash → 500, and through the client that is a throw, not an err result.
  128. const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } })
  129. await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/)
  130. })
  131. it('rejects when the transport never resolves within timeoutMs', async () => {
  132. // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
  133. const never = new InProcessApiClient({
  134. fetch: (_i: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
  135. init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) })
  136. }),
  137. }, 25)
  138. await expect(never.sessions.list({})).rejects.toThrow()
  139. })
  140. it('aborts a unary call through the caller-supplied external signal', async () => {
  141. // Real-fetch semantics: on abort the rejection is the signal's reason, and the abort
  142. // works even when the transport ignores the signal entirely (hung impl).
  143. const gate = new AbortController()
  144. const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
  145. const call = hung.sessions.list({}, gate.signal)
  146. gate.abort(new Error('externally aborted'))
  147. await expect(call).rejects.toThrow(/externally aborted/)
  148. })
  149. it('rejects an already-aborted signal before touching the transport, mapping a string reason to an Error', async () => {
  150. let touched = false
  151. const c = new InProcessApiClient({
  152. fetch: () => {
  153. touched = true
  154. return Promise.resolve(new Response('{}'))
  155. },
  156. }, 60_000)
  157. const gate = new AbortController()
  158. gate.abort('gone before start')
  159. await expect(c.sessions.list({}, gate.signal)).rejects.toThrow('gone before start')
  160. expect(touched).toBe(false)
  161. })
  162. it('maps a non-Error, non-string abort reason to the default AbortError message', async () => {
  163. const gate = new AbortController()
  164. const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
  165. const call = hung.sessions.list({}, gate.signal)
  166. gate.abort(42)
  167. await expect(call).rejects.toThrow('This operation was aborted')
  168. })
  169. it('passes a signal-less doFetch straight through to the handler', async () => {
  170. class Probe extends InProcessApiClient {
  171. direct(url: URL): Promise<Response> {
  172. return this.doFetch(url)
  173. }
  174. }
  175. const probe = new Probe({ fetch: () => Promise.resolve(new Response('raw')) })
  176. const response = await probe.direct(new URL('http://dsh.internal/probe'))
  177. expect(await response.text()).toBe('raw')
  178. })
  179. it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => {
  180. // Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject.
  181. const api = scriptedApi({
  182. sessions: { list: r => Promise.resolve({ rpcId: r.rpcId, result: { ok: true, value: { items: 'not-an-array' } } }) as never },
  183. })
  184. await expect(client(api).sessions.list({})).rejects.toThrow()
  185. })
  186. })
  187. describe('SSE stream path', () => {
  188. it('yields frames in order and skips the comment preamble', async () => {
  189. const frames: MuxFrame[] = [
  190. { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 3 },
  191. { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } },
  192. ]
  193. const api = scriptedApi({
  194. events: {
  195. async *mux(request) {
  196. let n = 0
  197. for (const frame of frames) yield { rpcId: RpcId(`push-${n++}-${request.rpcId}`), payload: frame }
  198. },
  199. },
  200. })
  201. const seen: MuxFrame[] = []
  202. for await (const envelope of client(api).events.mux({}, new AbortController().signal)) {
  203. seen.push(envelope.payload)
  204. }
  205. expect(seen).toEqual(frames)
  206. })
  207. it('reassembles frames across arbitrary chunk boundaries', async () => {
  208. // Two SSE frames split so one frame spans chunks and one chunk carries parts of both.
  209. const f1 = { type: 'server-request', rpcId: 'a', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's1', lastSeq: 1 } }
  210. const f2 = { type: 'server-request', rpcId: 'b', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's2', lastSeq: 2 } }
  211. const wire = `: connected\n\ndata: ${JSON.stringify(f1)}\n\ndata: ${JSON.stringify(f2)}\n\n`
  212. const cuts = [5, 40, wire.indexOf('data: ', 40) + 3]
  213. const encoder = new TextEncoder()
  214. const doFetch = (): Promise<Response> => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
  215. start(controller) {
  216. let prev = 0
  217. for (const cut of [...cuts, wire.length]) {
  218. controller.enqueue(encoder.encode(wire.slice(prev, cut)))
  219. prev = cut
  220. }
  221. controller.close()
  222. },
  223. }), { status: 200 }))
  224. const chopped = new InProcessApiClient({ fetch: doFetch })
  225. const seen: string[] = []
  226. for await (const envelope of chopped.events.mux({}, new AbortController().signal)) {
  227. seen.push((envelope.payload as { sessionId: string }).sessionId)
  228. expect(envelope.rpcId).toBe(seen.length === 1 ? 'a' : 'b')
  229. }
  230. expect(seen).toEqual(['s1', 's2'])
  231. })
  232. it('emits a stream/error frame then closes when the impl throws mid-stream', async () => {
  233. const api = scriptedApi({
  234. events: {
  235. async *host(request): AsyncGenerator<RpcRequest<HostFrame>> {
  236. yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1') } }
  237. throw new Error('impl died mid-stream')
  238. },
  239. },
  240. })
  241. const seen: HostFrame[] = []
  242. for await (const envelope of client(api).events.host({}, new AbortController().signal)) {
  243. seen.push(envelope.payload)
  244. }
  245. expect(seen.map(f => f.type)).toEqual(['host/session-added', 'stream/error'])
  246. const last = seen.at(-1)
  247. if (last?.type === 'stream/error') expect(last.error.message).toMatch(/impl died mid-stream/)
  248. })
  249. it('drops a malformed SSE frame and keeps the stream alive (S→C two-level parse)', async () => {
  250. const good = { type: 'server-request', rpcId: 'g1', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's1', lastSeq: 1 } }
  251. const badEnvelope = { type: 'server-response', rpcId: 'x' } // wrong quadrant for a stream
  252. const badFrame = { type: 'server-request', rpcId: 'b1', method: 'nope', payload: { type: 'no/such-frame' } }
  253. const wire = [
  254. 'data: {oops', // not JSON
  255. `data: ${JSON.stringify(badEnvelope)}`,
  256. `data: ${JSON.stringify(badFrame)}`,
  257. `data: ${JSON.stringify(good)}`,
  258. ].map(l => `${l}\n\n`).join('')
  259. const doFetch = (): Promise<Response> => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
  260. start(controller) {
  261. controller.enqueue(new TextEncoder().encode(wire))
  262. controller.close()
  263. },
  264. }), { status: 200 }))
  265. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  266. try {
  267. const seen: MuxFrame[] = []
  268. for await (const envelope of new InProcessApiClient({ fetch: doFetch }).events.mux({}, new AbortController().signal)) {
  269. seen.push(envelope.payload)
  270. }
  271. // The three corrupt frames are reported and skipped; the good one still arrives.
  272. expect(seen).toEqual([{ type: 'session/subscribed', sessionId: 's1', lastSeq: 1 }])
  273. expect(errorSpy.mock.calls.length).toBe(3)
  274. } finally {
  275. errorSpy.mockRestore()
  276. }
  277. })
  278. it('fires onOpen once headers are in, before the first frame, and not on transport failure', async () => {
  279. const api = scriptedApi({
  280. events: {
  281. async *mux(request): AsyncGenerator<RpcRequest<MuxFrame>> {
  282. yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 0 } }
  283. },
  284. },
  285. })
  286. const order: string[] = []
  287. const iterator = client(api).events.mux({}, new AbortController().signal, () => order.push('open'))
  288. expect(order).toEqual([]) // lazy generator: no fetch (and no onOpen) before iteration
  289. for await (const _ of iterator) order.push('frame')
  290. expect(order).toEqual(['open', 'frame'])
  291. // Transport failure path: onOpen must not fire.
  292. const failing = new InProcessApiClient({ fetch: () => Promise.resolve(new Response('down', { status: 503 })) })
  293. const failOrder: string[] = []
  294. await expect((async () => {
  295. for await (const _ of failing.events.mux({}, new AbortController().signal, () => failOrder.push('open'))) { /* unreachable */ }
  296. })()).rejects.toThrow(/transport failure/)
  297. expect(failOrder).toEqual([])
  298. })
  299. it('stops consuming when the caller aborts', async () => {
  300. let implSawAbort = false
  301. const api = scriptedApi({
  302. events: {
  303. async *mux(_request, signal): AsyncGenerator<RpcRequest<MuxFrame>> {
  304. try {
  305. let n = 0
  306. while (true) {
  307. yield { rpcId: RpcId(`p${n}`), payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: n++ } }
  308. await new Promise(resolve => setTimeout(resolve, 5))
  309. if (signal.aborted) return
  310. }
  311. } finally {
  312. implSawAbort = true
  313. }
  314. },
  315. },
  316. })
  317. const abort = new AbortController()
  318. let count = 0
  319. // In-process abort ends the stream (impl returns on signal.aborted); over a real
  320. // network fetch the same abort surfaces as a rejection — both stop the loop.
  321. await (async () => {
  322. for await (const _ of client(api).events.mux({}, abort.signal)) {
  323. if (++count === 2) abort.abort()
  324. }
  325. })().catch(() => undefined)
  326. expect(count).toBe(2)
  327. // Generator teardown may lag the abort by a microtask; poll briefly.
  328. await vi.waitFor(() => { expect(implSawAbort).toBe(true) })
  329. })
  330. })
  331. describe('goals unary surface', () => {
  332. /** A wire-valid GoalView the scripted impl hands back. */
  333. const view: GoalView = {
  334. id: 'goal-1' as GoalView['id'],
  335. revision: 2,
  336. objective: 'ship it',
  337. phase: 'active' as const,
  338. maxGoalRounds: 4,
  339. roundsStarted: 1,
  340. createdAt: 10,
  341. updatedAt: 20,
  342. activation: 'armed' as const,
  343. }
  344. const ref: GoalRef = { id: 'goal-1' as GoalRef['id'], revision: 1 }
  345. it('round-trips every goal method with its own payload and value shape', async () => {
  346. const seen: { method: string; payload: unknown }[] = []
  347. const record = <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
  348. (r: RpcRequest<P>): Promise<RpcResponse<V>> => {
  349. seen.push({ method, payload: r.payload })
  350. return respond(r)
  351. }
  352. const api = scriptedApi({
  353. goals: {
  354. get: record('goal.get', r => ok(r, { goal: view })),
  355. create: record('goal.create', r => ok(r, { goal: view })),
  356. edit: record('goal.edit', r => ok(r, { goal: { ...view, revision: 3 } })),
  357. pause: record('goal.pause', r => ok(r, { goal: { ...view, phase: 'paused' as const, activation: 'disarmed' as const } })),
  358. resume: record('goal.resume', r => ok(r, { goal: view })),
  359. complete: record('goal.complete', r => ok(r, { goal: { ...view, phase: 'complete' as const, activation: 'disarmed' as const } })),
  360. clear: record('goal.clear', r => ok(r, { cleared: true as const })),
  361. },
  362. })
  363. const c = client(api)
  364. const got = await c.goals.get({ sessionId: sid('s1') })
  365. expect(got.result).toEqual({ ok: true, value: { goal: view } })
  366. const created = await c.goals.create({ sessionId: sid('s1'), objective: 'ship it', maxGoalRounds: 4 })
  367. expect(created.result).toEqual({ ok: true, value: { goal: view } })
  368. const edited = await c.goals.edit({ sessionId: sid('s1'), ref, objective: 'ship v2' })
  369. expect(edited.result).toEqual({ ok: true, value: { goal: { ...view, revision: 3 } } })
  370. const paused = await c.goals.pause({ sessionId: sid('s1'), ref })
  371. expect(paused.result.ok && paused.result.value.goal.phase).toBe('paused')
  372. const resumed = await c.goals.resume({ sessionId: sid('s1'), ref })
  373. expect(resumed.result.ok && resumed.result.value.goal.phase).toBe('active')
  374. const completed = await c.goals.complete({ sessionId: sid('s1'), ref })
  375. expect(completed.result.ok && completed.result.value.goal.phase).toBe('complete')
  376. const cleared = await c.goals.clear({ sessionId: sid('s1'), ref })
  377. expect(cleared.result).toEqual({ ok: true, value: { cleared: true } })
  378. // The handler dispatched each call through its own route row: payload parsed per method.
  379. expect(seen.map(s => s.method)).toEqual(['goal.get', 'goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear'])
  380. expect(seen[1]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 })
  381. expect(seen[2]?.payload).toEqual({ sessionId: 's1', ref, objective: 'ship v2' })
  382. })
  383. it('passes a null goal and business errors through the goal.get route', async () => {
  384. const api = scriptedApi({ goals: { get: r => ok(r, { goal: null }) } })
  385. const response = await client(api).goals.get({ sessionId: sid('s-empty') })
  386. expect(response.result).toEqual({ ok: true, value: { goal: null } })
  387. // Default scripted goals impl answers an err result: it must arrive as a result, not a throw.
  388. const failed = await client(scriptedApi()).goals.pause({ sessionId: sid('s1'), ref })
  389. expect(failed.result.ok).toBe(false)
  390. if (!failed.result.ok) expect(failed.result.error.code).toBe('internal')
  391. })
  392. it('rejects an invalid goal payload at the handler as bad-request', async () => {
  393. const response = await client(scriptedApi()).goals.create({ sessionId: sid('s1'), objective: '' })
  394. expect(response.result.ok).toBe(false)
  395. if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
  396. })
  397. })
  398. describe('respond path', () => {
  399. it('round-trips a client-response to a receipt', async () => {
  400. const seen: unknown[] = []
  401. const api = scriptedApi({
  402. respond: (message) => {
  403. seen.push(message)
  404. return Promise.resolve({ accepted: true as const })
  405. },
  406. })
  407. const receipt = await client(api).respond({ type: 'client-response', rpcId: RpcId('req-1'), result: { ok: true, value: { behavior: 'allow' } } })
  408. expect(receipt).toEqual({ accepted: true })
  409. expect(seen).toEqual([{ type: 'client-response', rpcId: 'req-1', result: { ok: true, value: { behavior: 'allow' } } }])
  410. })
  411. it('returns bad-response for a malformed client-response without reaching the impl', async () => {
  412. const respond = vi.fn()
  413. const handler = toFetchHandler(scriptedApi({ respond }))
  414. const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', body: JSON.stringify({ type: 'client-response' }) })
  415. expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' })
  416. expect(respond).not.toHaveBeenCalled()
  417. })
  418. })
  419. describe('envelope tap', () => {
  420. it('delivers one microtask batch of full forms per unary call', async () => {
  421. const api = scriptedApi()
  422. const tapped = client(api)
  423. const batches: (readonly RpcMessage[])[] = []
  424. tapped.subscribeEnvelopes(batch => batches.push(batch))
  425. await tapped.sessions.list({})
  426. await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) })
  427. const all = batches.flat()
  428. expect(all.map(m => m.type)).toEqual(['client-request', 'server-response'])
  429. expect(all[0]?.rpcId).toBe(all[1]?.rpcId)
  430. })
  431. it('isolates a throwing listener and keeps serving the call', async () => {
  432. const api = scriptedApi()
  433. const tapped = client(api)
  434. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  435. try {
  436. const good: string[] = []
  437. tapped.subscribeEnvelopes(() => { throw new Error('listener bug') })
  438. tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type)))
  439. const response = await tapped.sessions.list({})
  440. expect(response.result.ok).toBe(true)
  441. await vi.waitFor(() => { expect(good).toContain('server-response') })
  442. } finally {
  443. errorSpy.mockRestore()
  444. }
  445. })
  446. it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => {
  447. const api = scriptedApi()
  448. const tapped = client(api)
  449. await tapped.sessions.list({}) // no subscribers: must not accumulate
  450. const batches: (readonly RpcMessage[])[] = []
  451. const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch))
  452. unsubscribe()
  453. await tapped.sessions.list({})
  454. await new Promise(resolve => setTimeout(resolve, 0))
  455. expect(batches).toEqual([])
  456. })
  457. })