client-handler.spec.ts 23 KB

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