ts-procedures 5.9.1 → 5.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +1 -1
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +46 -101
  3. package/agent_config/claude-code/skills/guide/SKILL.md +49 -34
  4. package/agent_config/claude-code/skills/guide/anti-patterns.md +6 -5
  5. package/agent_config/claude-code/skills/guide/api-reference.md +60 -49
  6. package/agent_config/claude-code/skills/review/SKILL.md +12 -17
  7. package/agent_config/claude-code/skills/scaffold/SKILL.md +18 -23
  8. package/agent_config/claude-code/skills/scaffold/templates/client.md +115 -0
  9. package/agent_config/lib/install-claude.mjs +22 -22
  10. package/docs/core.md +5 -9
  11. package/docs/streaming.md +9 -9
  12. package/package.json +2 -13
  13. package/src/client/call.test.ts +162 -0
  14. package/src/client/errors.test.ts +43 -0
  15. package/src/client/fetch-adapter.test.ts +340 -0
  16. package/src/client/hooks.test.ts +191 -0
  17. package/src/client/index.test.ts +290 -0
  18. package/src/client/request-builder.test.ts +184 -0
  19. package/src/client/stream.test.ts +331 -0
  20. package/src/codegen/bin/cli.test.ts +260 -0
  21. package/src/codegen/bin/cli.ts +282 -0
  22. package/src/codegen/constants.ts +1 -0
  23. package/src/codegen/e2e.test.ts +565 -0
  24. package/src/codegen/emit-client-runtime.test.ts +93 -0
  25. package/src/codegen/emit-client-runtime.ts +114 -0
  26. package/src/codegen/emit-client-types.test.ts +39 -0
  27. package/src/codegen/emit-client-types.ts +27 -0
  28. package/src/codegen/emit-errors.test.ts +202 -0
  29. package/src/codegen/emit-errors.ts +80 -0
  30. package/src/codegen/emit-index.test.ts +127 -0
  31. package/src/codegen/emit-index.ts +58 -0
  32. package/src/codegen/emit-scope.test.ts +624 -0
  33. package/src/codegen/emit-scope.ts +389 -0
  34. package/src/codegen/emit-types.test.ts +205 -0
  35. package/src/codegen/emit-types.ts +158 -0
  36. package/src/codegen/group-routes.test.ts +159 -0
  37. package/src/codegen/group-routes.ts +61 -0
  38. package/src/codegen/index.ts +30 -0
  39. package/src/codegen/naming.test.ts +50 -0
  40. package/src/codegen/naming.ts +25 -0
  41. package/src/codegen/pipeline.test.ts +316 -0
  42. package/src/codegen/pipeline.ts +108 -0
  43. package/src/codegen/resolve-envelope.test.ts +76 -0
  44. package/src/codegen/resolve-envelope.ts +61 -0
  45. package/src/errors.test.ts +163 -0
  46. package/src/errors.ts +107 -0
  47. package/src/exports.ts +7 -0
  48. package/src/implementations/http/doc-registry.test.ts +415 -0
  49. package/src/implementations/http/doc-registry.ts +143 -0
  50. package/src/implementations/http/express-rpc/README.md +6 -6
  51. package/src/implementations/http/express-rpc/index.test.ts +957 -0
  52. package/src/implementations/http/express-rpc/index.ts +266 -0
  53. package/src/implementations/http/express-rpc/types.ts +16 -0
  54. package/src/implementations/http/hono-api/index.test.ts +1341 -0
  55. package/src/implementations/http/hono-api/index.ts +463 -0
  56. package/src/implementations/http/hono-api/types.ts +16 -0
  57. package/src/implementations/http/hono-rpc/README.md +6 -6
  58. package/src/implementations/http/hono-rpc/index.test.ts +1075 -0
  59. package/src/implementations/http/hono-rpc/index.ts +238 -0
  60. package/src/implementations/http/hono-rpc/types.ts +16 -0
  61. package/src/implementations/http/hono-stream/README.md +12 -12
  62. package/src/implementations/http/hono-stream/index.test.ts +1768 -0
  63. package/src/implementations/http/hono-stream/index.ts +456 -0
  64. package/src/implementations/http/hono-stream/types.ts +20 -0
  65. package/src/implementations/types.ts +174 -0
  66. package/src/index.test.ts +1185 -0
  67. package/src/index.ts +522 -0
  68. package/src/schema/compute-schema.test.ts +128 -0
  69. package/src/schema/compute-schema.ts +88 -0
  70. package/src/schema/extract-json-schema.test.ts +25 -0
  71. package/src/schema/extract-json-schema.ts +15 -0
  72. package/src/schema/parser.test.ts +182 -0
  73. package/src/schema/parser.ts +215 -0
  74. package/src/schema/resolve-schema-lib.test.ts +19 -0
  75. package/src/schema/resolve-schema-lib.ts +29 -0
  76. package/src/schema/types.ts +20 -0
  77. package/src/stack-utils.test.ts +94 -0
  78. package/src/stack-utils.ts +129 -0
  79. package/docs/superpowers/plans/2026-03-30-client-codegen.md +0 -2833
  80. package/docs/superpowers/specs/2026-03-30-client-codegen-design.md +0 -632
@@ -0,0 +1,340 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { createFetchAdapter } from './fetch-adapter.js'
3
+
4
+ // ── helpers ───────────────────────────────────────────────
5
+
6
+ function mockSSEResponse(sseText: string): Response {
7
+ const encoder = new TextEncoder()
8
+ const stream = new ReadableStream({
9
+ start(controller) {
10
+ controller.enqueue(encoder.encode(sseText))
11
+ controller.close()
12
+ },
13
+ })
14
+ return new Response(stream, {
15
+ status: 200,
16
+ headers: { 'content-type': 'text/event-stream' },
17
+ })
18
+ }
19
+
20
+ function mockSSEResponseChunked(chunks: string[]): Response {
21
+ const encoder = new TextEncoder()
22
+ const stream = new ReadableStream({
23
+ start(controller) {
24
+ for (const chunk of chunks) {
25
+ controller.enqueue(encoder.encode(chunk))
26
+ }
27
+ controller.close()
28
+ },
29
+ })
30
+ return new Response(stream, {
31
+ status: 200,
32
+ headers: { 'content-type': 'text/event-stream' },
33
+ })
34
+ }
35
+
36
+ async function collectStream(stream: AsyncIterable<unknown>): Promise<unknown[]> {
37
+ const items: unknown[] = []
38
+ for await (const item of stream) {
39
+ items.push(item)
40
+ }
41
+ return items
42
+ }
43
+
44
+ // ── request() tests ───────────────────────────────────────
45
+
46
+ describe('createFetchAdapter — request()', () => {
47
+ beforeEach(() => {
48
+ vi.stubGlobal('fetch', vi.fn())
49
+ })
50
+
51
+ afterEach(() => {
52
+ vi.unstubAllGlobals()
53
+ })
54
+
55
+ it('makes fetch call with correct URL, method, body', async () => {
56
+ const mockFetch = vi.mocked(globalThis.fetch)
57
+ mockFetch.mockResolvedValueOnce(
58
+ new Response(JSON.stringify({ id: 1 }), { status: 200 })
59
+ )
60
+
61
+ const adapter = createFetchAdapter()
62
+ await adapter.request({
63
+ url: 'https://api.example.com/users',
64
+ method: 'POST',
65
+ body: { name: 'Alice' },
66
+ })
67
+
68
+ expect(mockFetch).toHaveBeenCalledOnce()
69
+ const [url, init] = mockFetch.mock.calls[0]!
70
+ expect(url).toBe('https://api.example.com/users')
71
+ expect((init as Record<string, unknown>).method).toBe('POST')
72
+ expect((init as Record<string, unknown>).body).toBe(JSON.stringify({ name: 'Alice' }))
73
+ })
74
+
75
+ it('merges config headers with request headers (request wins)', async () => {
76
+ const mockFetch = vi.mocked(globalThis.fetch)
77
+ mockFetch.mockResolvedValueOnce(
78
+ new Response(JSON.stringify({}), { status: 200 })
79
+ )
80
+
81
+ const adapter = createFetchAdapter({
82
+ headers: {
83
+ 'x-api-key': 'default-key',
84
+ 'x-source': 'config',
85
+ },
86
+ })
87
+
88
+ await adapter.request({
89
+ url: 'https://api.example.com/data',
90
+ method: 'GET',
91
+ headers: {
92
+ 'x-api-key': 'override-key', // should win
93
+ 'x-request-id': 'abc123',
94
+ },
95
+ })
96
+
97
+ const [, init] = mockFetch.mock.calls[0]!
98
+ const headers = (init as Record<string, unknown>).headers as Record<string, string>
99
+ expect(headers['x-api-key']).toBe('override-key')
100
+ expect(headers['x-source']).toBe('config')
101
+ expect(headers['x-request-id']).toBe('abc123')
102
+ })
103
+
104
+ it('parses JSON response body', async () => {
105
+ const mockFetch = vi.mocked(globalThis.fetch)
106
+ mockFetch.mockResolvedValueOnce(
107
+ new Response(JSON.stringify({ result: 'ok' }), { status: 200 })
108
+ )
109
+
110
+ const adapter = createFetchAdapter()
111
+ const response = await adapter.request({
112
+ url: 'https://api.example.com/ping',
113
+ method: 'GET',
114
+ })
115
+
116
+ expect(response.status).toBe(200)
117
+ expect(response.body).toEqual({ result: 'ok' })
118
+ })
119
+
120
+ it('handles empty/non-JSON response body gracefully (e.g. 204 No Content)', async () => {
121
+ const mockFetch = vi.mocked(globalThis.fetch)
122
+ mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 }))
123
+
124
+ const adapter = createFetchAdapter()
125
+ const response = await adapter.request({
126
+ url: 'https://api.example.com/delete',
127
+ method: 'DELETE',
128
+ })
129
+
130
+ expect(response.status).toBe(204)
131
+ // body should be null (or empty string) — not throw
132
+ expect(response.body === null || response.body === '').toBe(true)
133
+ })
134
+
135
+ it('passes signal to fetch', async () => {
136
+ const mockFetch = vi.mocked(globalThis.fetch)
137
+ mockFetch.mockResolvedValueOnce(
138
+ new Response(JSON.stringify({}), { status: 200 })
139
+ )
140
+
141
+ const controller = new AbortController()
142
+ const adapter = createFetchAdapter()
143
+ await adapter.request({
144
+ url: 'https://api.example.com/slow',
145
+ method: 'GET',
146
+ signal: controller.signal,
147
+ })
148
+
149
+ const [, init] = mockFetch.mock.calls[0]!
150
+ expect((init as Record<string, unknown>).signal).toBe(controller.signal)
151
+ })
152
+
153
+ it('returns response headers as Record<string, string>', async () => {
154
+ const mockFetch = vi.mocked(globalThis.fetch)
155
+ mockFetch.mockResolvedValueOnce(
156
+ new Response(JSON.stringify({}), {
157
+ status: 200,
158
+ headers: { 'content-type': 'application/json', 'x-request-id': 'xyz' },
159
+ })
160
+ )
161
+
162
+ const adapter = createFetchAdapter()
163
+ const response = await adapter.request({
164
+ url: 'https://api.example.com/data',
165
+ method: 'GET',
166
+ })
167
+
168
+ expect(response.headers['content-type']).toBe('application/json')
169
+ expect(response.headers['x-request-id']).toBe('xyz')
170
+ })
171
+
172
+ it('sends no body when body is undefined', async () => {
173
+ const mockFetch = vi.mocked(globalThis.fetch)
174
+ mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }))
175
+
176
+ const adapter = createFetchAdapter()
177
+ await adapter.request({
178
+ url: 'https://api.example.com/items',
179
+ method: 'GET',
180
+ })
181
+
182
+ const [, init] = mockFetch.mock.calls[0]!
183
+ expect((init as Record<string, unknown>).body).toBeUndefined()
184
+ })
185
+ })
186
+
187
+ // ── stream() tests ────────────────────────────────────────
188
+
189
+ describe('createFetchAdapter — stream()', () => {
190
+ beforeEach(() => {
191
+ vi.stubGlobal('fetch', vi.fn())
192
+ })
193
+
194
+ afterEach(() => {
195
+ vi.unstubAllGlobals()
196
+ })
197
+
198
+ it('parses SSE events from stream', async () => {
199
+ const mockFetch = vi.mocked(globalThis.fetch)
200
+ const sseText =
201
+ 'event: message\ndata: {"count":1}\nid: 0\n\nevent: message\ndata: {"count":2}\nid: 1\n\n'
202
+ mockFetch.mockResolvedValueOnce(mockSSEResponse(sseText))
203
+
204
+ const adapter = createFetchAdapter()
205
+ const response = await adapter.stream({
206
+ url: 'https://api.example.com/stream',
207
+ method: 'GET',
208
+ })
209
+
210
+ expect(response.status).toBe(200)
211
+ const items = await collectStream(response.body)
212
+ expect(items).toEqual([
213
+ { data: { count: 1 }, event: 'message', id: '0' },
214
+ { data: { count: 2 }, event: 'message', id: '1' },
215
+ ])
216
+ })
217
+
218
+ it('handles multi-line data fields (multiple data: lines joined with newline)', async () => {
219
+ const mockFetch = vi.mocked(globalThis.fetch)
220
+ // Multi-line data: per SSE spec, multiple data: lines are joined with \n
221
+ const sseText = 'event: multi\ndata: {"line":\ndata: "hello"}\n\n'
222
+ mockFetch.mockResolvedValueOnce(mockSSEResponse(sseText))
223
+
224
+ const adapter = createFetchAdapter()
225
+ const response = await adapter.stream({
226
+ url: 'https://api.example.com/stream',
227
+ method: 'GET',
228
+ })
229
+
230
+ const items = await collectStream(response.body)
231
+ expect(items).toHaveLength(1)
232
+ const item = items[0] as { data: unknown; event: string }
233
+ expect(item.event).toBe('multi')
234
+ // data is parsed from joined lines: '{"line":\n"hello"}'
235
+ expect(item.data).toEqual({ line: 'hello' })
236
+ })
237
+
238
+ it('handles chunked SSE data (message split across multiple chunks)', async () => {
239
+ const mockFetch = vi.mocked(globalThis.fetch)
240
+ // Split the SSE message across 3 chunks
241
+ const chunks = [
242
+ 'event: tick\nda',
243
+ 'ta: {"n":42}\n',
244
+ '\n',
245
+ ]
246
+ mockFetch.mockResolvedValueOnce(mockSSEResponseChunked(chunks))
247
+
248
+ const adapter = createFetchAdapter()
249
+ const response = await adapter.stream({
250
+ url: 'https://api.example.com/stream',
251
+ method: 'GET',
252
+ })
253
+
254
+ const items = await collectStream(response.body)
255
+ expect(items).toEqual([{ data: { n: 42 }, event: 'tick', id: undefined }])
256
+ })
257
+
258
+ it('yields parsed objects with event and id', async () => {
259
+ const mockFetch = vi.mocked(globalThis.fetch)
260
+ const sseText = 'event: update\ndata: {"value":7}\nid: evt-99\n\n'
261
+ mockFetch.mockResolvedValueOnce(mockSSEResponse(sseText))
262
+
263
+ const adapter = createFetchAdapter()
264
+ const response = await adapter.stream({
265
+ url: 'https://api.example.com/stream',
266
+ method: 'GET',
267
+ })
268
+
269
+ const items = await collectStream(response.body)
270
+ expect(items).toEqual([{ data: { value: 7 }, event: 'update', id: 'evt-99' }])
271
+ })
272
+
273
+ it('handles event: return correctly (just another SSE event)', async () => {
274
+ const mockFetch = vi.mocked(globalThis.fetch)
275
+ const sseText =
276
+ 'event: message\ndata: {"n":1}\n\nevent: return\ndata: {"total":10}\n\n'
277
+ mockFetch.mockResolvedValueOnce(mockSSEResponse(sseText))
278
+
279
+ const adapter = createFetchAdapter()
280
+ const response = await adapter.stream({
281
+ url: 'https://api.example.com/stream',
282
+ method: 'GET',
283
+ })
284
+
285
+ // The adapter yields all SSE events as-is — createTypedStream handles 'return' semantics
286
+ const items = await collectStream(response.body)
287
+ expect(items).toEqual([
288
+ { data: { n: 1 }, event: 'message', id: undefined },
289
+ { data: { total: 10 }, event: 'return', id: undefined },
290
+ ])
291
+ })
292
+
293
+ it('returns response headers from stream response', async () => {
294
+ const mockFetch = vi.mocked(globalThis.fetch)
295
+ mockFetch.mockResolvedValueOnce(
296
+ mockSSEResponse('event: ping\ndata: {}\n\n')
297
+ )
298
+
299
+ const adapter = createFetchAdapter()
300
+ const response = await adapter.stream({
301
+ url: 'https://api.example.com/stream',
302
+ method: 'GET',
303
+ })
304
+
305
+ expect(response.headers['content-type']).toContain('text/event-stream')
306
+ })
307
+
308
+ it('passes signal to fetch for streams', async () => {
309
+ const mockFetch = vi.mocked(globalThis.fetch)
310
+ mockFetch.mockResolvedValueOnce(mockSSEResponse('event: ping\ndata: {}\n\n'))
311
+
312
+ const controller = new AbortController()
313
+ const adapter = createFetchAdapter()
314
+ await adapter.stream({
315
+ url: 'https://api.example.com/stream',
316
+ method: 'GET',
317
+ signal: controller.signal,
318
+ })
319
+
320
+ const [, init] = mockFetch.mock.calls[0]!
321
+ expect((init as Record<string, unknown>).signal).toBe(controller.signal)
322
+ })
323
+
324
+ it('handles SSE events without data field gracefully (skips them)', async () => {
325
+ const mockFetch = vi.mocked(globalThis.fetch)
326
+ // Comment-only message (no data field) — should be skipped
327
+ const sseText = ': keep-alive\n\nevent: message\ndata: {"ok":true}\n\n'
328
+ mockFetch.mockResolvedValueOnce(mockSSEResponse(sseText))
329
+
330
+ const adapter = createFetchAdapter()
331
+ const response = await adapter.stream({
332
+ url: 'https://api.example.com/stream',
333
+ method: 'GET',
334
+ })
335
+
336
+ const items = await collectStream(response.body)
337
+ // Only the message with data is yielded; comment-only block is skipped
338
+ expect(items).toEqual([{ data: { ok: true }, event: 'message', id: undefined }])
339
+ })
340
+ })
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
3
+ import type {
4
+ BeforeRequestContext,
5
+ AfterResponseContext,
6
+ ErrorContext,
7
+ ClientHooks,
8
+ } from './types.js'
9
+
10
+ // ── helpers ───────────────────────────────────────────────
11
+
12
+ function makeBeforeCtx(overrides?: Partial<BeforeRequestContext>): BeforeRequestContext {
13
+ return {
14
+ procedureName: 'TestProcedure',
15
+ scope: 'test',
16
+ request: { url: 'https://example.com', method: 'GET' },
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ function makeAfterCtx(overrides?: Partial<AfterResponseContext>): AfterResponseContext {
22
+ return {
23
+ procedureName: 'TestProcedure',
24
+ scope: 'test',
25
+ request: { url: 'https://example.com', method: 'GET' },
26
+ response: { status: 200, headers: {}, body: null },
27
+ ...overrides,
28
+ }
29
+ }
30
+
31
+ function makeErrorCtx(overrides?: Partial<ErrorContext>): ErrorContext {
32
+ return {
33
+ procedureName: 'TestProcedure',
34
+ scope: 'test',
35
+ request: { url: 'https://example.com', method: 'GET' },
36
+ error: new Error('something went wrong'),
37
+ ...overrides,
38
+ }
39
+ }
40
+
41
+ // ── runBeforeRequest ──────────────────────────────────────
42
+
43
+ describe('runBeforeRequest', () => {
44
+ it('runs global hook then local hook in order', async () => {
45
+ const order: string[] = []
46
+ const globalHooks: ClientHooks = {
47
+ onBeforeRequest: (ctx) => { order.push('global'); return ctx },
48
+ }
49
+ const localHooks: ClientHooks = {
50
+ onBeforeRequest: (ctx) => { order.push('local'); return ctx },
51
+ }
52
+ const ctx = makeBeforeCtx()
53
+ await runBeforeRequest(ctx, globalHooks, localHooks)
54
+ expect(order).toEqual(['global', 'local'])
55
+ })
56
+
57
+ it('passes the (possibly mutated) context from global to local', async () => {
58
+ const globalHooks: ClientHooks = {
59
+ onBeforeRequest: (ctx) => ({
60
+ ...ctx,
61
+ request: { ...ctx.request, url: 'https://mutated.example.com' },
62
+ }),
63
+ }
64
+ let receivedUrl = ''
65
+ const localHooks: ClientHooks = {
66
+ onBeforeRequest: (ctx) => { receivedUrl = ctx.request.url; return ctx },
67
+ }
68
+ const ctx = makeBeforeCtx()
69
+ await runBeforeRequest(ctx, globalHooks, localHooks)
70
+ expect(receivedUrl).toBe('https://mutated.example.com')
71
+ })
72
+
73
+ it('returns the final mutated context', async () => {
74
+ const globalHooks: ClientHooks = {
75
+ onBeforeRequest: (ctx) => ({ ...ctx, scope: 'mutated-by-global' }),
76
+ }
77
+ const localHooks: ClientHooks = {
78
+ onBeforeRequest: (ctx) => ({ ...ctx, scope: ctx.scope + '-then-local' }),
79
+ }
80
+ const ctx = makeBeforeCtx()
81
+ const result = await runBeforeRequest(ctx, globalHooks, localHooks)
82
+ expect(result.scope).toBe('mutated-by-global-then-local')
83
+ })
84
+
85
+ it('handles async hooks', async () => {
86
+ const order: string[] = []
87
+ const globalHooks: ClientHooks = {
88
+ onBeforeRequest: async (ctx) => { order.push('global'); return ctx },
89
+ }
90
+ const localHooks: ClientHooks = {
91
+ onBeforeRequest: async (ctx) => { order.push('local'); return ctx },
92
+ }
93
+ await runBeforeRequest(makeBeforeCtx(), globalHooks, localHooks)
94
+ expect(order).toEqual(['global', 'local'])
95
+ })
96
+
97
+ it('skips undefined global hook gracefully', async () => {
98
+ const localHooks: ClientHooks = {
99
+ onBeforeRequest: (ctx) => ({ ...ctx, scope: 'local-only' }),
100
+ }
101
+ const result = await runBeforeRequest(makeBeforeCtx(), {}, localHooks)
102
+ expect(result.scope).toBe('local-only')
103
+ })
104
+
105
+ it('skips undefined local hook gracefully', async () => {
106
+ const globalHooks: ClientHooks = {
107
+ onBeforeRequest: (ctx) => ({ ...ctx, scope: 'global-only' }),
108
+ }
109
+ const result = await runBeforeRequest(makeBeforeCtx(), globalHooks, undefined)
110
+ expect(result.scope).toBe('global-only')
111
+ })
112
+
113
+ it('skips both hooks gracefully when neither defined', async () => {
114
+ const ctx = makeBeforeCtx()
115
+ const result = await runBeforeRequest(ctx, {}, undefined)
116
+ expect(result).toEqual(ctx)
117
+ })
118
+ })
119
+
120
+ // ── runAfterResponse ──────────────────────────────────────
121
+
122
+ describe('runAfterResponse', () => {
123
+ it('runs global then local hook', async () => {
124
+ const order: string[] = []
125
+ const globalHooks: ClientHooks = { onAfterResponse: () => { order.push('global') } }
126
+ const localHooks: ClientHooks = { onAfterResponse: () => { order.push('local') } }
127
+ await runAfterResponse(makeAfterCtx(), globalHooks, localHooks)
128
+ expect(order).toEqual(['global', 'local'])
129
+ })
130
+
131
+ it('handles async hooks', async () => {
132
+ const order: string[] = []
133
+ const globalHooks: ClientHooks = {
134
+ onAfterResponse: async () => { order.push('global') },
135
+ }
136
+ const localHooks: ClientHooks = {
137
+ onAfterResponse: async () => { order.push('local') },
138
+ }
139
+ await runAfterResponse(makeAfterCtx(), globalHooks, localHooks)
140
+ expect(order).toEqual(['global', 'local'])
141
+ })
142
+
143
+ it('skips undefined hooks gracefully', async () => {
144
+ // Should not throw
145
+ await runAfterResponse(makeAfterCtx(), {}, undefined)
146
+ })
147
+
148
+ it('returns void', async () => {
149
+ const result = await runAfterResponse(makeAfterCtx(), {}, undefined)
150
+ expect(result).toBeUndefined()
151
+ })
152
+ })
153
+
154
+ // ── runOnError ────────────────────────────────────────────
155
+
156
+ describe('runOnError', () => {
157
+ it('runs global then local hook', async () => {
158
+ const order: string[] = []
159
+ const globalHooks: ClientHooks = { onError: () => { order.push('global') } }
160
+ const localHooks: ClientHooks = { onError: () => { order.push('local') } }
161
+ await runOnError(makeErrorCtx(), globalHooks, localHooks)
162
+ expect(order).toEqual(['global', 'local'])
163
+ })
164
+
165
+ it('handles async hooks', async () => {
166
+ const order: string[] = []
167
+ const globalHooks: ClientHooks = { onError: async () => { order.push('global') } }
168
+ const localHooks: ClientHooks = { onError: async () => { order.push('local') } }
169
+ await runOnError(makeErrorCtx(), globalHooks, localHooks)
170
+ expect(order).toEqual(['global', 'local'])
171
+ })
172
+
173
+ it('skips undefined hooks gracefully', async () => {
174
+ await runOnError(makeErrorCtx(), {}, undefined)
175
+ })
176
+
177
+ it('returns void', async () => {
178
+ const result = await runOnError(makeErrorCtx(), {}, undefined)
179
+ expect(result).toBeUndefined()
180
+ })
181
+
182
+ it('receives the error in context', async () => {
183
+ const receivedErrors: unknown[] = []
184
+ const globalHooks: ClientHooks = {
185
+ onError: (ctx) => { receivedErrors.push(ctx.error) },
186
+ }
187
+ const err = new Error('boom')
188
+ await runOnError(makeErrorCtx({ error: err }), globalHooks, undefined)
189
+ expect(receivedErrors[0]).toBe(err)
190
+ })
191
+ })