ts-procedures 5.9.0 → 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.
- package/README.md +1 -1
- package/agent_config/claude-code/agents/ts-procedures-architect.md +46 -101
- package/agent_config/claude-code/skills/guide/SKILL.md +49 -34
- package/agent_config/claude-code/skills/guide/anti-patterns.md +6 -5
- package/agent_config/claude-code/skills/guide/api-reference.md +60 -49
- package/agent_config/claude-code/skills/review/SKILL.md +12 -17
- package/agent_config/claude-code/skills/scaffold/SKILL.md +18 -23
- package/agent_config/claude-code/skills/scaffold/templates/client.md +115 -0
- package/agent_config/lib/install-claude.mjs +22 -22
- package/docs/core.md +5 -9
- package/docs/streaming.md +9 -9
- package/package.json +3 -14
- package/src/client/call.test.ts +162 -0
- package/src/client/errors.test.ts +43 -0
- package/src/client/fetch-adapter.test.ts +340 -0
- package/src/client/hooks.test.ts +191 -0
- package/src/client/index.test.ts +290 -0
- package/src/client/request-builder.test.ts +184 -0
- package/src/client/stream.test.ts +331 -0
- package/src/codegen/bin/cli.test.ts +260 -0
- package/src/codegen/bin/cli.ts +282 -0
- package/src/codegen/constants.ts +1 -0
- package/src/codegen/e2e.test.ts +565 -0
- package/src/codegen/emit-client-runtime.test.ts +93 -0
- package/src/codegen/emit-client-runtime.ts +114 -0
- package/src/codegen/emit-client-types.test.ts +39 -0
- package/src/codegen/emit-client-types.ts +27 -0
- package/src/codegen/emit-errors.test.ts +202 -0
- package/src/codegen/emit-errors.ts +80 -0
- package/src/codegen/emit-index.test.ts +127 -0
- package/src/codegen/emit-index.ts +58 -0
- package/src/codegen/emit-scope.test.ts +624 -0
- package/src/codegen/emit-scope.ts +389 -0
- package/src/codegen/emit-types.test.ts +205 -0
- package/src/codegen/emit-types.ts +158 -0
- package/src/codegen/group-routes.test.ts +159 -0
- package/src/codegen/group-routes.ts +61 -0
- package/src/codegen/index.ts +30 -0
- package/src/codegen/naming.test.ts +50 -0
- package/src/codegen/naming.ts +25 -0
- package/src/codegen/pipeline.test.ts +316 -0
- package/src/codegen/pipeline.ts +108 -0
- package/src/codegen/resolve-envelope.test.ts +76 -0
- package/src/codegen/resolve-envelope.ts +61 -0
- package/src/errors.test.ts +163 -0
- package/src/errors.ts +107 -0
- package/src/exports.ts +7 -0
- package/src/implementations/http/doc-registry.test.ts +415 -0
- package/src/implementations/http/doc-registry.ts +143 -0
- package/src/implementations/http/express-rpc/README.md +6 -6
- package/src/implementations/http/express-rpc/index.test.ts +957 -0
- package/src/implementations/http/express-rpc/index.ts +266 -0
- package/src/implementations/http/express-rpc/types.ts +16 -0
- package/src/implementations/http/hono-api/index.test.ts +1341 -0
- package/src/implementations/http/hono-api/index.ts +463 -0
- package/src/implementations/http/hono-api/types.ts +16 -0
- package/src/implementations/http/hono-rpc/README.md +6 -6
- package/src/implementations/http/hono-rpc/index.test.ts +1075 -0
- package/src/implementations/http/hono-rpc/index.ts +238 -0
- package/src/implementations/http/hono-rpc/types.ts +16 -0
- package/src/implementations/http/hono-stream/README.md +12 -12
- package/src/implementations/http/hono-stream/index.test.ts +1768 -0
- package/src/implementations/http/hono-stream/index.ts +456 -0
- package/src/implementations/http/hono-stream/types.ts +20 -0
- package/src/implementations/types.ts +174 -0
- package/src/index.test.ts +1185 -0
- package/src/index.ts +522 -0
- package/src/schema/compute-schema.test.ts +128 -0
- package/src/schema/compute-schema.ts +88 -0
- package/src/schema/extract-json-schema.test.ts +25 -0
- package/src/schema/extract-json-schema.ts +15 -0
- package/src/schema/parser.test.ts +182 -0
- package/src/schema/parser.ts +215 -0
- package/src/schema/resolve-schema-lib.test.ts +19 -0
- package/src/schema/resolve-schema-lib.ts +29 -0
- package/src/schema/types.ts +20 -0
- package/src/stack-utils.test.ts +94 -0
- package/src/stack-utils.ts +129 -0
- package/docs/superpowers/plans/2026-03-30-client-codegen.md +0 -2833
- package/docs/superpowers/specs/2026-03-30-client-codegen-design.md +0 -632
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { createTypedStream, executeStream } from './stream.js'
|
|
3
|
+
import { ClientRequestError } from './errors.js'
|
|
4
|
+
import type {
|
|
5
|
+
ClientAdapter,
|
|
6
|
+
AdapterRequest,
|
|
7
|
+
AdapterStreamResponse,
|
|
8
|
+
ClientHooks,
|
|
9
|
+
StreamDescriptor,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
|
|
12
|
+
// ── helpers ───────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function makeDescriptor(overrides?: Partial<StreamDescriptor>): StreamDescriptor {
|
|
15
|
+
return {
|
|
16
|
+
name: 'WatchUpdates',
|
|
17
|
+
scope: 'updates',
|
|
18
|
+
path: '/stream/updates',
|
|
19
|
+
method: 'POST',
|
|
20
|
+
kind: 'stream',
|
|
21
|
+
streamMode: 'sse',
|
|
22
|
+
params: {},
|
|
23
|
+
...overrides,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function* makeAsyncIterable<T>(items: T[]): AsyncIterable<T> {
|
|
28
|
+
for (const item of items) {
|
|
29
|
+
yield item
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeStreamAdapter(
|
|
34
|
+
response?: Partial<AdapterStreamResponse>,
|
|
35
|
+
items?: unknown[]
|
|
36
|
+
): ClientAdapter {
|
|
37
|
+
return {
|
|
38
|
+
request: vi.fn(async (): Promise<never> => {
|
|
39
|
+
throw new Error('request not expected in stream tests')
|
|
40
|
+
}),
|
|
41
|
+
stream: vi.fn(async (_req: AdapterRequest): Promise<AdapterStreamResponse> => ({
|
|
42
|
+
status: 200,
|
|
43
|
+
headers: {},
|
|
44
|
+
body: makeAsyncIterable(items ?? []),
|
|
45
|
+
...response,
|
|
46
|
+
})),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── createTypedStream — SSE mode ──────────────────────────
|
|
51
|
+
|
|
52
|
+
describe('createTypedStream — SSE mode', () => {
|
|
53
|
+
it('yields data payloads for normal events', async () => {
|
|
54
|
+
const sseItems = [
|
|
55
|
+
{ data: { count: 1 }, event: 'update' },
|
|
56
|
+
{ data: { count: 2 }, event: 'update' },
|
|
57
|
+
]
|
|
58
|
+
const stream = createTypedStream<{ count: number }, void>(
|
|
59
|
+
makeAsyncIterable(sseItems),
|
|
60
|
+
'sse'
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const received: { count: number }[] = []
|
|
64
|
+
for await (const item of stream) {
|
|
65
|
+
received.push(item)
|
|
66
|
+
}
|
|
67
|
+
expect(received).toEqual([{ count: 1 }, { count: 2 }])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('captures return event data in .result instead of yielding', async () => {
|
|
71
|
+
const sseItems = [
|
|
72
|
+
{ data: { count: 1 }, event: 'update' },
|
|
73
|
+
{ data: { total: 99 }, event: 'return' },
|
|
74
|
+
]
|
|
75
|
+
const stream = createTypedStream<{ count: number }, { total: number }>(
|
|
76
|
+
makeAsyncIterable(sseItems),
|
|
77
|
+
'sse'
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const yielded: { count: number }[] = []
|
|
81
|
+
for await (const item of stream) {
|
|
82
|
+
yielded.push(item)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Only the 'update' event is yielded
|
|
86
|
+
expect(yielded).toEqual([{ count: 1 }])
|
|
87
|
+
|
|
88
|
+
// The 'return' event resolves .result
|
|
89
|
+
const result = await stream.result
|
|
90
|
+
expect(result).toEqual({ total: 99 })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('resolves .result with undefined when no return event', async () => {
|
|
94
|
+
const sseItems = [
|
|
95
|
+
{ data: 'hello', event: 'message' },
|
|
96
|
+
]
|
|
97
|
+
const stream = createTypedStream<string, undefined>(
|
|
98
|
+
makeAsyncIterable(sseItems),
|
|
99
|
+
'sse'
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
for await (const _ of stream) { /* drain */ }
|
|
103
|
+
const result = await stream.result
|
|
104
|
+
expect(result).toBeUndefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('rejects .result and re-throws on error', async () => {
|
|
108
|
+
async function* errorIterable(): AsyncIterable<unknown> {
|
|
109
|
+
yield { data: 'first', event: 'message' }
|
|
110
|
+
throw new Error('stream broke')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const stream = createTypedStream<string, void>(errorIterable(), 'sse')
|
|
114
|
+
|
|
115
|
+
// Consuming the stream should throw
|
|
116
|
+
await expect(async () => {
|
|
117
|
+
for await (const _ of stream) { /* drain */ }
|
|
118
|
+
}).rejects.toThrow('stream broke')
|
|
119
|
+
|
|
120
|
+
// .result should also reject
|
|
121
|
+
await expect(stream.result).rejects.toThrow('stream broke')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('handles SSE items without event field (defaults to yielding data)', async () => {
|
|
125
|
+
const sseItems = [
|
|
126
|
+
{ data: 'a' },
|
|
127
|
+
{ data: 'b' },
|
|
128
|
+
]
|
|
129
|
+
const stream = createTypedStream<string, void>(
|
|
130
|
+
makeAsyncIterable(sseItems),
|
|
131
|
+
'sse'
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const yielded: string[] = []
|
|
135
|
+
for await (const item of stream) {
|
|
136
|
+
yielded.push(item)
|
|
137
|
+
}
|
|
138
|
+
expect(yielded).toEqual(['a', 'b'])
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// ── createTypedStream — text mode ────────────────────────
|
|
143
|
+
|
|
144
|
+
describe('createTypedStream — text mode', () => {
|
|
145
|
+
it('yields each chunk as-is', async () => {
|
|
146
|
+
const chunks = ['chunk1', 'chunk2', 'chunk3']
|
|
147
|
+
const stream = createTypedStream<string, void>(
|
|
148
|
+
makeAsyncIterable(chunks),
|
|
149
|
+
'text'
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const received: string[] = []
|
|
153
|
+
for await (const chunk of stream) {
|
|
154
|
+
received.push(chunk)
|
|
155
|
+
}
|
|
156
|
+
expect(received).toEqual(chunks)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('.result resolves to void on normal completion', async () => {
|
|
160
|
+
const stream = createTypedStream<string, void>(
|
|
161
|
+
makeAsyncIterable(['a', 'b']),
|
|
162
|
+
'text'
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
for await (const _ of stream) { /* drain */ }
|
|
166
|
+
const result = await stream.result
|
|
167
|
+
expect(result).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('rejects .result and re-throws on error', async () => {
|
|
171
|
+
async function* errorIterable(): AsyncIterable<string> {
|
|
172
|
+
yield 'before error'
|
|
173
|
+
throw new Error('text stream error')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const stream = createTypedStream<string, void>(errorIterable(), 'text')
|
|
177
|
+
|
|
178
|
+
await expect(async () => {
|
|
179
|
+
for await (const _ of stream) { /* drain */ }
|
|
180
|
+
}).rejects.toThrow('text stream error')
|
|
181
|
+
|
|
182
|
+
await expect(stream.result).rejects.toThrow('text stream error')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// ── executeStream ─────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
describe('executeStream', () => {
|
|
189
|
+
it('calls adapter.stream and returns a TypedStream', async () => {
|
|
190
|
+
const items = [{ data: 'hello', event: 'msg' }]
|
|
191
|
+
const adapter = makeStreamAdapter({}, items)
|
|
192
|
+
|
|
193
|
+
const stream = await executeStream<string, void>(
|
|
194
|
+
makeDescriptor(),
|
|
195
|
+
'https://api.example.com',
|
|
196
|
+
adapter,
|
|
197
|
+
{},
|
|
198
|
+
undefined
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
expect(adapter.stream).toHaveBeenCalledOnce()
|
|
202
|
+
const received: string[] = []
|
|
203
|
+
for await (const item of stream) {
|
|
204
|
+
received.push(item)
|
|
205
|
+
}
|
|
206
|
+
expect(received).toEqual(['hello'])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('runs onBeforeRequest before calling adapter', async () => {
|
|
210
|
+
const capturedHeaders: Record<string, string>[] = []
|
|
211
|
+
const adapter: ClientAdapter = {
|
|
212
|
+
request: vi.fn(async (): Promise<never> => { throw new Error('not expected') }),
|
|
213
|
+
stream: vi.fn(async (req: AdapterRequest): Promise<AdapterStreamResponse> => {
|
|
214
|
+
capturedHeaders.push(req.headers ?? {})
|
|
215
|
+
return { status: 200, headers: {}, body: makeAsyncIterable([]) }
|
|
216
|
+
}),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const globalHooks: ClientHooks = {
|
|
220
|
+
onBeforeRequest: (ctx) => ({
|
|
221
|
+
...ctx,
|
|
222
|
+
request: { ...ctx.request, headers: { 'x-stream-auth': 'stream-token' } },
|
|
223
|
+
}),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const stream = await executeStream(
|
|
227
|
+
makeDescriptor(),
|
|
228
|
+
'https://api.example.com',
|
|
229
|
+
adapter,
|
|
230
|
+
globalHooks,
|
|
231
|
+
undefined
|
|
232
|
+
)
|
|
233
|
+
// Drain
|
|
234
|
+
for await (const _ of stream) { /* noop */ }
|
|
235
|
+
|
|
236
|
+
expect(capturedHeaders[0]?.['x-stream-auth']).toBe('stream-token')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('runs onAfterResponse immediately (before iteration)', async () => {
|
|
240
|
+
const order: string[] = []
|
|
241
|
+
const adapter: ClientAdapter = {
|
|
242
|
+
request: vi.fn(async (): Promise<never> => { throw new Error('not expected') }),
|
|
243
|
+
stream: vi.fn(async (): Promise<AdapterStreamResponse> => {
|
|
244
|
+
order.push('adapter')
|
|
245
|
+
return { status: 200, headers: {}, body: makeAsyncIterable([]) }
|
|
246
|
+
}),
|
|
247
|
+
}
|
|
248
|
+
const globalHooks: ClientHooks = {
|
|
249
|
+
onAfterResponse: () => { order.push('afterResponse') },
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await executeStream(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
|
|
253
|
+
// After executeStream returns (before iteration), afterResponse should have fired
|
|
254
|
+
expect(order).toEqual(['adapter', 'afterResponse'])
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('throws ClientRequestError on non-2xx status', async () => {
|
|
258
|
+
const adapter: ClientAdapter = {
|
|
259
|
+
request: vi.fn(async (): Promise<never> => { throw new Error('not expected') }),
|
|
260
|
+
stream: vi.fn(async (): Promise<AdapterStreamResponse> => ({
|
|
261
|
+
status: 403,
|
|
262
|
+
headers: {},
|
|
263
|
+
body: makeAsyncIterable([]),
|
|
264
|
+
})),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
executeStream(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
|
|
269
|
+
).rejects.toThrow(ClientRequestError)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('runs onError on adapter failure and re-throws', async () => {
|
|
273
|
+
const adapterError = new Error('stream connection failed')
|
|
274
|
+
const adapter: ClientAdapter = {
|
|
275
|
+
request: vi.fn(async (): Promise<never> => { throw new Error('not expected') }),
|
|
276
|
+
stream: vi.fn(async () => { throw adapterError }),
|
|
277
|
+
}
|
|
278
|
+
const receivedErrors: unknown[] = []
|
|
279
|
+
const globalHooks: ClientHooks = {
|
|
280
|
+
onError: (ctx) => { receivedErrors.push(ctx.error) },
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await expect(
|
|
284
|
+
executeStream(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
|
|
285
|
+
).rejects.toThrow('stream connection failed')
|
|
286
|
+
expect(receivedErrors[0]).toBe(adapterError)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('returns TypedStream with working .result for SSE return event', async () => {
|
|
290
|
+
const sseItems = [
|
|
291
|
+
{ data: { n: 1 }, event: 'tick' },
|
|
292
|
+
{ data: { final: true }, event: 'return' },
|
|
293
|
+
]
|
|
294
|
+
const adapter = makeStreamAdapter({}, sseItems)
|
|
295
|
+
|
|
296
|
+
const stream = await executeStream<{ n: number }, { final: boolean }>(
|
|
297
|
+
makeDescriptor({ streamMode: 'sse' }),
|
|
298
|
+
'https://api.example.com',
|
|
299
|
+
adapter,
|
|
300
|
+
{},
|
|
301
|
+
undefined
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
const yielded: { n: number }[] = []
|
|
305
|
+
for await (const item of stream) {
|
|
306
|
+
yielded.push(item)
|
|
307
|
+
}
|
|
308
|
+
expect(yielded).toEqual([{ n: 1 }])
|
|
309
|
+
await expect(stream.result).resolves.toEqual({ final: true })
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('returns TypedStream for text mode', async () => {
|
|
313
|
+
const chunks = ['line1', 'line2']
|
|
314
|
+
const adapter = makeStreamAdapter({}, chunks)
|
|
315
|
+
|
|
316
|
+
const stream = await executeStream<string, void>(
|
|
317
|
+
makeDescriptor({ streamMode: 'text' }),
|
|
318
|
+
'https://api.example.com',
|
|
319
|
+
adapter,
|
|
320
|
+
{},
|
|
321
|
+
undefined
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
const received: string[] = []
|
|
325
|
+
for await (const chunk of stream) {
|
|
326
|
+
received.push(chunk)
|
|
327
|
+
}
|
|
328
|
+
expect(received).toEqual(chunks)
|
|
329
|
+
await expect(stream.result).resolves.toBeUndefined()
|
|
330
|
+
})
|
|
331
|
+
})
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseArgs, loadConfigFile, extractConfigPath, type CodegenConfig } from './cli.js'
|
|
3
|
+
|
|
4
|
+
describe('parseArgs', () => {
|
|
5
|
+
it('parses --url and --out', () => {
|
|
6
|
+
const result = parseArgs(['--url', 'http://localhost:3000/docs', '--out', './generated'])
|
|
7
|
+
expect(result.url).toBe('http://localhost:3000/docs')
|
|
8
|
+
expect(result.outDir).toBe('./generated')
|
|
9
|
+
expect(result.file).toBeUndefined()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('parses --file and --out', () => {
|
|
13
|
+
const result = parseArgs(['--file', './envelope.json', '--out', './generated'])
|
|
14
|
+
expect(result.file).toBe('./envelope.json')
|
|
15
|
+
expect(result.outDir).toBe('./generated')
|
|
16
|
+
expect(result.url).toBeUndefined()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('parses --enum-style union', () => {
|
|
20
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--enum-style', 'union'])
|
|
21
|
+
expect(result.ajsc?.enumStyle).toBe('union')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('parses --enum-style enum', () => {
|
|
25
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--enum-style', 'enum'])
|
|
26
|
+
expect(result.ajsc?.enumStyle).toBe('enum')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('parses --depluralize flag', () => {
|
|
30
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--depluralize'])
|
|
31
|
+
expect(result.ajsc?.depluralize).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('parses --watch flag', () => {
|
|
35
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--watch'])
|
|
36
|
+
expect(result.watch).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('parses --interval value', () => {
|
|
40
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--watch', '--interval', '5000'])
|
|
41
|
+
expect(result.interval).toBe(5000)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('defaults watch to false when not provided', () => {
|
|
45
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out'])
|
|
46
|
+
expect(result.watch).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('defaults interval to 3000 when not provided', () => {
|
|
50
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out'])
|
|
51
|
+
expect(result.interval).toBe(3000)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('throws when --out is missing', () => {
|
|
55
|
+
expect(() => parseArgs(['--url', 'http://x.com/docs'])).toThrow(/--out/)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('throws when no input source (neither --url nor --file)', () => {
|
|
59
|
+
expect(() => parseArgs(['--out', './generated'])).toThrow(/--url.*--file|--file.*--url|input/i)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('parses all flags together', () => {
|
|
63
|
+
const result = parseArgs([
|
|
64
|
+
'--file', './envelope.json',
|
|
65
|
+
'--out', './generated',
|
|
66
|
+
'--enum-style', 'union',
|
|
67
|
+
'--depluralize',
|
|
68
|
+
'--watch',
|
|
69
|
+
'--interval', '10000',
|
|
70
|
+
])
|
|
71
|
+
expect(result.file).toBe('./envelope.json')
|
|
72
|
+
expect(result.outDir).toBe('./generated')
|
|
73
|
+
expect(result.ajsc?.enumStyle).toBe('union')
|
|
74
|
+
expect(result.ajsc?.depluralize).toBe(true)
|
|
75
|
+
expect(result.watch).toBe(true)
|
|
76
|
+
expect(result.interval).toBe(10000)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('parses --client-import-path flag', () => {
|
|
80
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--client-import-path', '@my-app/client'])
|
|
81
|
+
expect(result.clientImportPath).toBe('@my-app/client')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('defaults clientImportPath to undefined when not provided', () => {
|
|
85
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out'])
|
|
86
|
+
expect(result.clientImportPath).toBeUndefined()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('parses --dry-run flag', () => {
|
|
90
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--dry-run'])
|
|
91
|
+
expect(result.dryRun).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('defaults dryRun to false when not provided', () => {
|
|
95
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out'])
|
|
96
|
+
expect(result.dryRun).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('parses --namespace-types flag', () => {
|
|
100
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--namespace-types'])
|
|
101
|
+
expect(result.namespaceTypes).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('defaults namespaceTypes to true when not provided', () => {
|
|
105
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out'])
|
|
106
|
+
expect(result.namespaceTypes).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('parses --no-namespace-types flag', () => {
|
|
110
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--no-namespace-types'])
|
|
111
|
+
expect(result.namespaceTypes).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('defaults ajsc.jsdoc to true when not provided', () => {
|
|
115
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out'])
|
|
116
|
+
expect(result.ajsc?.jsdoc).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('parses --jsdoc flag', () => {
|
|
120
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--jsdoc'])
|
|
121
|
+
expect(result.ajsc?.jsdoc).toBe(true)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('parses --no-jsdoc flag', () => {
|
|
125
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--no-jsdoc'])
|
|
126
|
+
expect(result.ajsc?.jsdoc).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('parses --array-item-naming with a string value', () => {
|
|
130
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--array-item-naming', 'Item'])
|
|
131
|
+
expect(result.ajsc?.arrayItemNaming).toBe('Item')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('parses --array-item-naming false to disable', () => {
|
|
135
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--array-item-naming', 'false'])
|
|
136
|
+
expect(result.ajsc?.arrayItemNaming).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('parses --uncountable-words as comma-separated list', () => {
|
|
140
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--uncountable-words', 'criteria,alumni,corpus'])
|
|
141
|
+
expect(result.ajsc?.uncountableWords).toEqual(['criteria', 'alumni', 'corpus'])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('trims whitespace in --uncountable-words', () => {
|
|
145
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--uncountable-words', ' data , info '])
|
|
146
|
+
expect(result.ajsc?.uncountableWords).toEqual(['data', 'info'])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('parses --self-contained flag', () => {
|
|
150
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--self-contained'])
|
|
151
|
+
expect(result.selfContained).toBe(true)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('defaults selfContained to true when not provided', () => {
|
|
155
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out'])
|
|
156
|
+
expect(result.selfContained).toBe(true)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('parses --no-self-contained flag', () => {
|
|
160
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--no-self-contained'])
|
|
161
|
+
expect(result.selfContained).toBe(false)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('parses --service-name flag', () => {
|
|
165
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out', '--service-name', 'Auth'])
|
|
166
|
+
expect(result.serviceName).toBe('Auth')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('defaults serviceName to undefined when not provided', () => {
|
|
170
|
+
const result = parseArgs(['--url', 'http://x.com/docs', '--out', './out'])
|
|
171
|
+
expect(result.serviceName).toBeUndefined()
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('config file support', () => {
|
|
176
|
+
it('parseArgs uses config as defaults', () => {
|
|
177
|
+
const config: CodegenConfig = {
|
|
178
|
+
url: 'http://config.com/docs',
|
|
179
|
+
outDir: './config-out',
|
|
180
|
+
namespaceTypes: true,
|
|
181
|
+
ajsc: { enumStyle: 'enum', depluralize: true },
|
|
182
|
+
}
|
|
183
|
+
const result = parseArgs([], config)
|
|
184
|
+
expect(result.url).toBe('http://config.com/docs')
|
|
185
|
+
expect(result.outDir).toBe('./config-out')
|
|
186
|
+
expect(result.namespaceTypes).toBe(true)
|
|
187
|
+
expect(result.ajsc?.enumStyle).toBe('enum')
|
|
188
|
+
expect(result.ajsc?.depluralize).toBe(true)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('CLI flags override config values', () => {
|
|
192
|
+
const config: CodegenConfig = {
|
|
193
|
+
url: 'http://config.com/docs',
|
|
194
|
+
outDir: './config-out',
|
|
195
|
+
namespaceTypes: false,
|
|
196
|
+
}
|
|
197
|
+
const result = parseArgs(['--url', 'http://override.com/docs', '--out', './override-out', '--namespace-types'], config)
|
|
198
|
+
expect(result.url).toBe('http://override.com/docs')
|
|
199
|
+
expect(result.outDir).toBe('./override-out')
|
|
200
|
+
expect(result.namespaceTypes).toBe(true)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('config file selfContained is used as default', () => {
|
|
204
|
+
const config: CodegenConfig = {
|
|
205
|
+
url: 'http://config.com/docs',
|
|
206
|
+
outDir: './config-out',
|
|
207
|
+
selfContained: true,
|
|
208
|
+
}
|
|
209
|
+
const result = parseArgs([], config)
|
|
210
|
+
expect(result.selfContained).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('config file serviceName is used as default', () => {
|
|
214
|
+
const config: CodegenConfig = {
|
|
215
|
+
url: 'http://config.com/docs',
|
|
216
|
+
outDir: './config-out',
|
|
217
|
+
serviceName: 'Auth',
|
|
218
|
+
}
|
|
219
|
+
const result = parseArgs([], config)
|
|
220
|
+
expect(result.serviceName).toBe('Auth')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('CLI --service-name overrides config serviceName', () => {
|
|
224
|
+
const config: CodegenConfig = {
|
|
225
|
+
url: 'http://config.com/docs',
|
|
226
|
+
outDir: './config-out',
|
|
227
|
+
serviceName: 'Auth',
|
|
228
|
+
}
|
|
229
|
+
const result = parseArgs(['--service-name', 'UsersApi'], config)
|
|
230
|
+
expect(result.serviceName).toBe('UsersApi')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('CLI ajsc flags merge with config ajsc', () => {
|
|
234
|
+
const config: CodegenConfig = {
|
|
235
|
+
url: 'http://config.com/docs',
|
|
236
|
+
outDir: './out',
|
|
237
|
+
ajsc: { enumStyle: 'enum' },
|
|
238
|
+
}
|
|
239
|
+
const result = parseArgs(['--depluralize'], config)
|
|
240
|
+
expect(result.ajsc?.enumStyle).toBe('enum')
|
|
241
|
+
expect(result.ajsc?.depluralize).toBe(true)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('extractConfigPath finds --config value', () => {
|
|
245
|
+
expect(extractConfigPath(['--config', 'my-config.json', '--out', './out'])).toBe('my-config.json')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('extractConfigPath returns undefined when not provided', () => {
|
|
249
|
+
expect(extractConfigPath(['--out', './out'])).toBeUndefined()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('loadConfigFile returns undefined for non-existent default path', async () => {
|
|
253
|
+
const result = await loadConfigFile()
|
|
254
|
+
expect(result).toBeUndefined()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('loadConfigFile throws for explicit non-existent path', async () => {
|
|
258
|
+
await expect(loadConfigFile('/nonexistent/config.json')).rejects.toThrow('Failed to load config')
|
|
259
|
+
})
|
|
260
|
+
})
|