@c9up/ream 0.2.5 → 0.2.7
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 +3 -9
- package/dist/HttpKernel.js +2 -2
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +3 -3
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +50 -0
- package/dist/Ignitor.js.map +1 -1
- package/dist/bootstrap/hyper-server.js.map +1 -1
- package/dist/helpers/compose.d.ts +30 -0
- package/dist/helpers/compose.d.ts.map +1 -0
- package/dist/helpers/compose.js +27 -0
- package/dist/helpers/compose.js.map +1 -0
- package/dist/helpers/index.d.ts +1 -0
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/index.js +1 -0
- package/dist/helpers/index.js.map +1 -1
- package/dist/helpers/is.d.ts +69 -16
- package/dist/helpers/is.d.ts.map +1 -1
- package/dist/helpers/is.js +80 -20
- package/dist/helpers/is.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts +9 -3
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +10 -4
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/rpc/RpcRouter.js +2 -2
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/Scheduler.d.ts.map +1 -1
- package/dist/scheduler/Scheduler.js +12 -4
- package/dist/scheduler/Scheduler.js.map +1 -1
- package/dist/services/app.d.ts +6 -0
- package/dist/services/app.d.ts.map +1 -1
- package/dist/services/app.js +8 -0
- package/dist/services/app.js.map +1 -1
- package/dist/services/config.d.ts +15 -0
- package/dist/services/config.d.ts.map +1 -0
- package/dist/services/config.js +18 -0
- package/dist/services/config.js.map +1 -0
- package/dist/services/encryption.d.ts +23 -0
- package/dist/services/encryption.d.ts.map +1 -0
- package/dist/services/encryption.js +33 -0
- package/dist/services/encryption.js.map +1 -0
- package/dist/services/logger.d.ts +29 -0
- package/dist/services/logger.d.ts.map +1 -0
- package/dist/services/logger.js +40 -0
- package/dist/services/logger.js.map +1 -0
- package/dist/services/urlBuilder.d.ts +21 -0
- package/dist/services/urlBuilder.d.ts.map +1 -0
- package/dist/services/urlBuilder.js +34 -0
- package/dist/services/urlBuilder.js.map +1 -0
- package/dist/session/Session.js.map +1 -1
- package/dist/testing/ApiResponse.d.ts +44 -44
- package/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +48 -48
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/RequestBuilder.d.ts +39 -39
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +49 -49
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/testing/TestClient.d.ts +23 -8
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +52 -11
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/testing/testUtils.d.ts +62 -0
- package/dist/testing/testUtils.d.ts.map +1 -0
- package/dist/testing/testUtils.js +77 -0
- package/dist/testing/testUtils.js.map +1 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +21 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/HttpKernel.ts +2 -2
- package/src/Ignitor.ts +59 -4
- package/src/bootstrap/hyper-server.ts +1 -1
- package/src/helpers/compose.ts +84 -0
- package/src/helpers/index.ts +1 -0
- package/src/helpers/is.ts +88 -22
- package/src/index.ts +2 -2
- package/src/middleware/Pipeline.ts +10 -4
- package/src/rpc/RpcRouter.ts +2 -2
- package/src/scheduler/Scheduler.ts +10 -4
- package/src/services/app.ts +9 -0
- package/src/services/config.ts +24 -0
- package/src/services/encryption.ts +42 -0
- package/src/services/logger.ts +49 -0
- package/src/services/urlBuilder.ts +41 -0
- package/src/session/Session.ts +2 -2
- package/src/testing/ApiResponse.ts +52 -52
- package/src/testing/RequestBuilder.ts +55 -55
- package/src/testing/TestClient.ts +55 -13
- package/src/testing/testUtils.ts +90 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fluent HTTP request builder with assertion methods (
|
|
2
|
+
* Fluent HTTP request builder with assertion methods (`helix` model).
|
|
3
3
|
*
|
|
4
4
|
* await client
|
|
5
5
|
* .get('/api/users/42')
|
|
@@ -84,7 +84,7 @@ export interface AuthStrategy {
|
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
86
|
* HTTP methods. The common verbs are named; `request(url, method)` also accepts
|
|
87
|
-
* any other method (e.g. `TRACE`, `CONNECT`) —
|
|
87
|
+
* any other method (e.g. `TRACE`, `CONNECT`) — helix parity — via the wider
|
|
88
88
|
* string fallback.
|
|
89
89
|
*/
|
|
90
90
|
export type HttpMethod =
|
|
@@ -104,7 +104,7 @@ const CSRF_COOKIE_NAME = 'XSRF-TOKEN'
|
|
|
104
104
|
/** Header the client echoes the token back in (Axios/Angular default). */
|
|
105
105
|
const CSRF_HEADER_NAME = 'x-xsrf-token'
|
|
106
106
|
|
|
107
|
-
/** Content-type / accept shorthands — mirrors
|
|
107
|
+
/** Content-type / accept shorthands — mirrors helix's `.type()`/`.accept()`. */
|
|
108
108
|
const TYPE_SHORTHANDS: Record<string, string> = {
|
|
109
109
|
json: 'application/json',
|
|
110
110
|
form: 'application/x-www-form-urlencoded',
|
|
@@ -119,14 +119,14 @@ function resolveMime(type: string): string {
|
|
|
119
119
|
return TYPE_SHORTHANDS[type] ?? type
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/** Primitive accepted as a query-string value (mirrors `.qs()` in
|
|
122
|
+
/** Primitive accepted as a query-string value (mirrors `.qs()` in helix). */
|
|
123
123
|
type QueryValue = string | number | boolean
|
|
124
124
|
/** Query-string map — a value or an array of values (repeated key). */
|
|
125
125
|
export type QueryParams = Record<string, QueryValue | ReadonlyArray<QueryValue>>
|
|
126
126
|
|
|
127
|
-
/** A scalar accepted by `form()`/`fields()` —
|
|
127
|
+
/** A scalar accepted by `form()`/`fields()` — helix parity (arrays repeat the key). */
|
|
128
128
|
export type FieldValue = string | number | boolean | Buffer
|
|
129
|
-
/** A value accepted by a multipart `field()`/`file()` —
|
|
129
|
+
/** A value accepted by a multipart `field()`/`file()` — helix parity (incl. streams/blobs). */
|
|
130
130
|
export type MultipartValue = string | number | boolean | Buffer | Blob | Readable
|
|
131
131
|
|
|
132
132
|
/**
|
|
@@ -139,12 +139,12 @@ export type HttpSender = (
|
|
|
139
139
|
init: {
|
|
140
140
|
headers: Record<string, string>
|
|
141
141
|
body: Buffer
|
|
142
|
-
/** Per-request socket timeout (ms) —
|
|
142
|
+
/** Per-request socket timeout (ms) — helix `.timeout()`. Sender may honour it. */
|
|
143
143
|
timeoutMs?: number
|
|
144
144
|
},
|
|
145
145
|
) => Promise<TestResponse>
|
|
146
146
|
|
|
147
|
-
/** Options for a multipart file part — mirrors Adonis/
|
|
147
|
+
/** Options for a multipart file part — mirrors Adonis/helix's `.file()` options. */
|
|
148
148
|
export interface FilePart {
|
|
149
149
|
/** Filename advertised in the part's `Content-Disposition`. Defaults to the field name / basename. */
|
|
150
150
|
filename?: string
|
|
@@ -160,15 +160,15 @@ type MultipartPart =
|
|
|
160
160
|
| { kind: 'field'; name: string; value: MultipartValue }
|
|
161
161
|
| { kind: 'file'; name: string; filename: string; contentType: string; content: MultipartValue }
|
|
162
162
|
|
|
163
|
-
/** HTTP statuses that trigger redirect-following (
|
|
163
|
+
/** HTTP statuses that trigger redirect-following (helix `.redirects()`). */
|
|
164
164
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
|
|
165
|
-
/**
|
|
165
|
+
/** helix follows 5 redirects by default. */
|
|
166
166
|
const DEFAULT_MAX_REDIRECTS = 5
|
|
167
167
|
|
|
168
|
-
/** Consumer-registered macros/getters (
|
|
168
|
+
/** Consumer-registered macros/getters (helix `ApiRequest.macro`/`.getter`). */
|
|
169
169
|
const requestMacros = new Map<string, unknown>()
|
|
170
170
|
const requestGetters = new Map<string, (this: RequestBuilder, req: RequestBuilder) => unknown>()
|
|
171
|
-
/** Consumer-registered request body serializers (
|
|
171
|
+
/** Consumer-registered request body serializers (helix `ApiRequest.addSerializer`), by name. */
|
|
172
172
|
const requestSerializers = new Map<
|
|
173
173
|
string,
|
|
174
174
|
(data: unknown) => { body: Buffer; contentType: string }
|
|
@@ -194,7 +194,7 @@ export class RequestBuilder {
|
|
|
194
194
|
#timeoutMs: number | undefined
|
|
195
195
|
#maxRedirects = DEFAULT_MAX_REDIRECTS
|
|
196
196
|
#sent: Promise<ApiResponse> | null = null
|
|
197
|
-
// Lazy assertions (
|
|
197
|
+
// Lazy assertions (helix model): each `assert*`/`expect*` registers a check and
|
|
198
198
|
// returns `this` synchronously; the checks run in order when the builder is
|
|
199
199
|
// awaited (`then`) — after the single send.
|
|
200
200
|
#checks: Array<(res: ApiResponse) => void> = []
|
|
@@ -222,7 +222,7 @@ export class RequestBuilder {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
header(name: string, value: string | string[]): this {
|
|
225
|
-
//
|
|
225
|
+
// helix accepts string | string[]; a list is joined into one header value.
|
|
226
226
|
this.#headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value
|
|
227
227
|
return this
|
|
228
228
|
}
|
|
@@ -233,7 +233,7 @@ export class RequestBuilder {
|
|
|
233
233
|
return this
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
/**
|
|
236
|
+
/** helix `.unsafeJson()` — set a JSON body without any transform (alias of {@link json}). */
|
|
237
237
|
unsafeJson(data: unknown): this {
|
|
238
238
|
return this.json(data)
|
|
239
239
|
}
|
|
@@ -245,7 +245,7 @@ export class RequestBuilder {
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
form(data: Record<string, FieldValue | FieldValue[]>): this {
|
|
248
|
-
// Array values repeat the key (`a=1&a=2`) —
|
|
248
|
+
// Array values repeat the key (`a=1&a=2`) — helix parity.
|
|
249
249
|
const pairs: string[] = []
|
|
250
250
|
for (const [k, v] of Object.entries(data)) {
|
|
251
251
|
const values = Array.isArray(v) ? v : [v]
|
|
@@ -258,13 +258,13 @@ export class RequestBuilder {
|
|
|
258
258
|
return this
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
/**
|
|
261
|
+
/** helix `.unsafeForm()` — set a urlencoded body without transform (alias of {@link form}). */
|
|
262
262
|
unsafeForm(data: Record<string, FieldValue | FieldValue[]>): this {
|
|
263
263
|
return this.form(data)
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
/**
|
|
267
|
-
* Add a `multipart/form-data` field — mirrors Adonis/
|
|
267
|
+
* Add a `multipart/form-data` field — mirrors Adonis/helix's `.field()`. Accepts
|
|
268
268
|
* a string/number/boolean, a Buffer, a Blob, a Readable stream, or an array of
|
|
269
269
|
* these (repeated part). Calling `.field()`/`.fields()`/`.file()` switches the
|
|
270
270
|
* body to multipart; the boundary + `Content-Type` header are produced at send
|
|
@@ -282,7 +282,7 @@ export class RequestBuilder {
|
|
|
282
282
|
return this
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
/** Add several multipart fields at once —
|
|
285
|
+
/** Add several multipart fields at once — helix `.fields()`. */
|
|
286
286
|
fields(map: Record<string, MultipartValue | MultipartValue[]>): this {
|
|
287
287
|
for (const [name, value] of Object.entries(map)) {
|
|
288
288
|
this.field(name, value)
|
|
@@ -291,10 +291,10 @@ export class RequestBuilder {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
/**
|
|
294
|
-
* Attach a file as a `multipart/form-data` part — mirrors Adonis/
|
|
294
|
+
* Attach a file as a `multipart/form-data` part — mirrors Adonis/helix's
|
|
295
295
|
* `.file(field, value, { filename, contentType })`. `value` is a Buffer, a
|
|
296
296
|
* Blob, a Readable stream, OR a string absolute PATH to a file on disk (read at
|
|
297
|
-
* call time,
|
|
297
|
+
* call time, helix parity). The filename defaults to the path basename / field
|
|
298
298
|
* name; streams/blobs are drained to Buffers at send time.
|
|
299
299
|
*/
|
|
300
300
|
file(field: string, value: Buffer | string | Blob | Readable, options: FilePart = {}): this {
|
|
@@ -322,7 +322,7 @@ export class RequestBuilder {
|
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
/**
|
|
325
|
-
* Append query-string params — mirrors
|
|
325
|
+
* Append query-string params — mirrors helix's `.qs()`. Values are
|
|
326
326
|
* URL-encoded; arrays repeat the key (`?tag=a&tag=b`). Merges with any query
|
|
327
327
|
* string already on the path and with earlier `.qs()` calls.
|
|
328
328
|
*/
|
|
@@ -338,7 +338,7 @@ export class RequestBuilder {
|
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
/**
|
|
341
|
-
* Append query params WITHOUT any validation/normalisation —
|
|
341
|
+
* Append query params WITHOUT any validation/normalisation — helix
|
|
342
342
|
* `.unsafeQs({ … })`. Values are still URL-encoded so the wire stays valid, but
|
|
343
343
|
* (unlike `.qs()`) no key filtering is applied; arrays repeat the key.
|
|
344
344
|
*/
|
|
@@ -346,13 +346,13 @@ export class RequestBuilder {
|
|
|
346
346
|
return this.qs(params)
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
-
/** Pass a bearer token as the `Authorization` header —
|
|
349
|
+
/** Pass a bearer token as the `Authorization` header — helix `.bearerToken()`. */
|
|
350
350
|
bearerToken(token: string): this {
|
|
351
351
|
this.#headers.authorization = `Bearer ${token}`
|
|
352
352
|
return this
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
-
/** Pass HTTP Basic credentials as the `Authorization` header —
|
|
355
|
+
/** Pass HTTP Basic credentials as the `Authorization` header — helix `.basicAuth()`. */
|
|
356
356
|
basicAuth(user: string, password: string): this {
|
|
357
357
|
const encoded = Buffer.from(`${user}:${password}`, 'utf8').toString('base64')
|
|
358
358
|
this.#headers.authorization = `Basic ${encoded}`
|
|
@@ -361,7 +361,7 @@ export class RequestBuilder {
|
|
|
361
361
|
|
|
362
362
|
/**
|
|
363
363
|
* Set the request `Content-Type` from a shorthand (`'json'`, `'form'`, …) or a
|
|
364
|
-
* full MIME type — mirrors
|
|
364
|
+
* full MIME type — mirrors helix's `.type()`.
|
|
365
365
|
*/
|
|
366
366
|
type(mime: string): this {
|
|
367
367
|
this.#headers['content-type'] = resolveMime(mime)
|
|
@@ -370,52 +370,52 @@ export class RequestBuilder {
|
|
|
370
370
|
|
|
371
371
|
/**
|
|
372
372
|
* Set the `Accept` header from a shorthand (`'json'`, `'html'`, …) or a full
|
|
373
|
-
* MIME type — mirrors
|
|
373
|
+
* MIME type — mirrors helix's `.accept()`.
|
|
374
374
|
*/
|
|
375
375
|
accept(mime: string): this {
|
|
376
376
|
this.#headers.accept = resolveMime(mime)
|
|
377
377
|
return this
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
/** Per-request timeout in ms —
|
|
380
|
+
/** Per-request timeout in ms — helix `.timeout()`. Honoured by the sender. */
|
|
381
381
|
timeout(ms: number): this {
|
|
382
382
|
this.#timeoutMs = ms
|
|
383
383
|
return this
|
|
384
384
|
}
|
|
385
385
|
|
|
386
386
|
/**
|
|
387
|
-
* Follow up to `count` redirects before resolving —
|
|
388
|
-
* Defaults to 5 (
|
|
387
|
+
* Follow up to `count` redirects before resolving — helix `.redirects()`.
|
|
388
|
+
* Defaults to 5 (helix default); pass `0` to disable following.
|
|
389
389
|
*/
|
|
390
390
|
redirects(count: number): this {
|
|
391
391
|
this.#maxRedirects = Math.max(0, count)
|
|
392
392
|
return this
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
-
// TLS knobs —
|
|
395
|
+
// TLS knobs — helix parity. The test server is plain HTTP over loopback, so
|
|
396
396
|
// these are inert (there is no TLS handshake to configure); they exist so
|
|
397
|
-
//
|
|
398
|
-
/** No-op: the loopback test server has no TLS —
|
|
397
|
+
// helix-shaped code type-checks and runs unchanged. NAMED as a ream deviation.
|
|
398
|
+
/** No-op: the loopback test server has no TLS — helix `.trustLocalhost()`. */
|
|
399
399
|
trustLocalhost(_trust = true): this {
|
|
400
400
|
return this
|
|
401
401
|
}
|
|
402
|
-
/** No-op: no TLS on the loopback test server —
|
|
402
|
+
/** No-op: no TLS on the loopback test server — helix `.ca()`. */
|
|
403
403
|
ca(_cert: string): this {
|
|
404
404
|
return this
|
|
405
405
|
}
|
|
406
|
-
/** No-op: no TLS on the loopback test server —
|
|
406
|
+
/** No-op: no TLS on the loopback test server — helix `.cert()`. */
|
|
407
407
|
cert(_chain: string): this {
|
|
408
408
|
return this
|
|
409
409
|
}
|
|
410
|
-
/** No-op: no TLS on the loopback test server —
|
|
410
|
+
/** No-op: no TLS on the loopback test server — helix `.privateKey()`. */
|
|
411
411
|
privateKey(_key: string): this {
|
|
412
412
|
return this
|
|
413
413
|
}
|
|
414
|
-
/** No-op: no TLS on the loopback test server —
|
|
414
|
+
/** No-op: no TLS on the loopback test server — helix `.pfx()`. */
|
|
415
415
|
pfx(_encoded: string | Buffer): this {
|
|
416
416
|
return this
|
|
417
417
|
}
|
|
418
|
-
/** No-op: no TLS on the loopback test server —
|
|
418
|
+
/** No-op: no TLS on the loopback test server — helix `.disableTLSCerts()`. */
|
|
419
419
|
disableTLSCerts(): this {
|
|
420
420
|
return this
|
|
421
421
|
}
|
|
@@ -437,7 +437,7 @@ export class RequestBuilder {
|
|
|
437
437
|
return this
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
-
/**
|
|
440
|
+
/** helix's spelling of {@link withCsrf}. */
|
|
441
441
|
withCsrfToken(token?: string): this {
|
|
442
442
|
return this.withCsrf(token)
|
|
443
443
|
}
|
|
@@ -482,10 +482,10 @@ export class RequestBuilder {
|
|
|
482
482
|
|
|
483
483
|
/**
|
|
484
484
|
* Thenable — `await client.get('/x')` sends the request and resolves to the
|
|
485
|
-
* {@link ApiResponse} (
|
|
485
|
+
* {@link ApiResponse} (helix parity). Assertion methods return `this`;
|
|
486
486
|
* awaiting one resolves via this `then` to the response after running the checks.
|
|
487
487
|
*/
|
|
488
|
-
// biome-ignore lint/suspicious/noThenProperty: the thenable IS the public API — `await client.get('/x')` is the documented
|
|
488
|
+
// biome-ignore lint/suspicious/noThenProperty: the thenable IS the public API — `await client.get('/x')` is the documented helix ergonomic; removing `.then` breaks every await-the-builder call site.
|
|
489
489
|
then<R1 = ApiResponse, R2 = never>(
|
|
490
490
|
onFulfilled?: ((value: ApiResponse) => R1 | PromiseLike<R1>) | null,
|
|
491
491
|
onRejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
|
|
@@ -519,7 +519,7 @@ export class RequestBuilder {
|
|
|
519
519
|
}
|
|
520
520
|
expectJson(expected: unknown): this {
|
|
521
521
|
// ream alias: partial JSON match with its own message (kept for back-compat;
|
|
522
|
-
// `assertBodyContains` is the
|
|
522
|
+
// `assertBodyContains` is the helix-named equivalent).
|
|
523
523
|
return this.#assert((res) => {
|
|
524
524
|
if (!partialMatch(res.json(), expected)) {
|
|
525
525
|
throw new ExpectationError(
|
|
@@ -529,7 +529,7 @@ export class RequestBuilder {
|
|
|
529
529
|
})
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
-
// ──
|
|
532
|
+
// ── helix assertions (lazy — delegate to the response) ─────────────────────
|
|
533
533
|
|
|
534
534
|
assertStatus(code: number): this {
|
|
535
535
|
return this.#assert((res) => res.assertStatus(code))
|
|
@@ -562,9 +562,9 @@ export class RequestBuilder {
|
|
|
562
562
|
return this.#assert((res) => res.assertRedirectsTo(path))
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
-
// ── Request debugging (
|
|
565
|
+
// ── Request debugging (helix `request.dump*` — dumps the REQUEST) ──────────
|
|
566
566
|
|
|
567
|
-
/** Print the pending request (method/path/headers/body) —
|
|
567
|
+
/** Print the pending request (method/path/headers/body) — helix `request.dump()`. */
|
|
568
568
|
dump(): this {
|
|
569
569
|
process.stderr.write(
|
|
570
570
|
`[helix:request.dump] ${this.#method} ${this.#path}\n` +
|
|
@@ -574,17 +574,17 @@ export class RequestBuilder {
|
|
|
574
574
|
)
|
|
575
575
|
return this
|
|
576
576
|
}
|
|
577
|
-
/** Print only the pending request body —
|
|
577
|
+
/** Print only the pending request body — helix `request.dumpBody()`. */
|
|
578
578
|
dumpBody(): this {
|
|
579
579
|
process.stderr.write(`[helix:request.dumpBody] ${capBody(this.#body.toString('utf8'), 2048)}\n`)
|
|
580
580
|
return this
|
|
581
581
|
}
|
|
582
|
-
/** Print only the pending request headers —
|
|
582
|
+
/** Print only the pending request headers — helix `request.dumpHeaders()`. */
|
|
583
583
|
dumpHeaders(): this {
|
|
584
584
|
process.stderr.write(`[helix:request.dumpHeaders] ${JSON.stringify(this.#headers, null, 2)}\n`)
|
|
585
585
|
return this
|
|
586
586
|
}
|
|
587
|
-
/** Print only the pending request cookies —
|
|
587
|
+
/** Print only the pending request cookies — helix `request.dumpCookies()`. */
|
|
588
588
|
dumpCookies(): this {
|
|
589
589
|
process.stderr.write(`[helix:request.dumpCookies] ${JSON.stringify(this.#cookies, null, 2)}\n`)
|
|
590
590
|
return this
|
|
@@ -640,7 +640,7 @@ export class RequestBuilder {
|
|
|
640
640
|
// json()/form()/body(); encode them with a fresh boundary at send time.
|
|
641
641
|
if (this.#multipart.length > 0) {
|
|
642
642
|
const boundary = `----ReamRequestBuilder${crypto.randomUUID().replace(/-/g, '')}`
|
|
643
|
-
// Drain any stream/blob part to a Buffer before encoding (
|
|
643
|
+
// Drain any stream/blob part to a Buffer before encoding (helix parity).
|
|
644
644
|
const resolved: ResolvedPart[] = await Promise.all(
|
|
645
645
|
this.#multipart.map(async (p) =>
|
|
646
646
|
p.kind === 'field'
|
|
@@ -679,7 +679,7 @@ export class RequestBuilder {
|
|
|
679
679
|
timeoutMs: this.#timeoutMs,
|
|
680
680
|
})
|
|
681
681
|
|
|
682
|
-
// Follow redirects up to the configured budget —
|
|
682
|
+
// Follow redirects up to the configured budget — helix `.redirects()` (5 by default).
|
|
683
683
|
const chain: string[] = []
|
|
684
684
|
let followed = 0
|
|
685
685
|
while (
|
|
@@ -715,7 +715,7 @@ export class RequestBuilder {
|
|
|
715
715
|
/**
|
|
716
716
|
* Serialize `data` with a registered serializer and set it as the request body
|
|
717
717
|
* — the send-side twin of {@link ApiResponse.addParser}. Register serializers
|
|
718
|
-
* via {@link RequestBuilder.addSerializer} (
|
|
718
|
+
* via {@link RequestBuilder.addSerializer} (helix `ApiRequest.addSerializer`).
|
|
719
719
|
*/
|
|
720
720
|
serialize(name: string, data: unknown): this {
|
|
721
721
|
const serializer = requestSerializers.get(name)
|
|
@@ -731,9 +731,9 @@ export class RequestBuilder {
|
|
|
731
731
|
}
|
|
732
732
|
|
|
733
733
|
/**
|
|
734
|
-
* Register a shared property (
|
|
734
|
+
* Register a shared property (helix `ApiRequest.macro`) or lazy getter
|
|
735
735
|
* (`ApiRequest.getter`). The callback is invoked with `this` bound to the
|
|
736
|
-
* builder AND the builder as its first arg, so both
|
|
736
|
+
* builder AND the builder as its first arg, so both helix's
|
|
737
737
|
* `function () { return this.header('x') }` and `(req) => …` work.
|
|
738
738
|
*/
|
|
739
739
|
static macro(name: string, value: unknown): void {
|
|
@@ -743,7 +743,7 @@ export class RequestBuilder {
|
|
|
743
743
|
requestGetters.set(name, fn)
|
|
744
744
|
}
|
|
745
745
|
|
|
746
|
-
/** Register a request body serializer —
|
|
746
|
+
/** Register a request body serializer — helix `ApiRequest.addSerializer`. */
|
|
747
747
|
static addSerializer(
|
|
748
748
|
name: string,
|
|
749
749
|
fn: (data: unknown) => { body: Buffer; contentType: string },
|
|
@@ -751,7 +751,7 @@ export class RequestBuilder {
|
|
|
751
751
|
requestSerializers.set(name, fn)
|
|
752
752
|
}
|
|
753
753
|
|
|
754
|
-
/** Register a response body parser —
|
|
754
|
+
/** Register a response body parser — helix `ApiRequest.addParser` (delegates to ApiResponse). */
|
|
755
755
|
static addParser(
|
|
756
756
|
contentType: string,
|
|
757
757
|
fn: (body: string, headers: Record<string, string>) => unknown,
|
|
@@ -37,7 +37,7 @@ export { ApiResponse, type TestResponse } from './RequestBuilder.js'
|
|
|
37
37
|
* named routes without the full Router.
|
|
38
38
|
*
|
|
39
39
|
* A value may be a bare path string OR `{ path, method }` — when the method is
|
|
40
|
-
* present, `visit(name)` uses it (
|
|
40
|
+
* present, `visit(name)` uses it (helix route-registry parity, e.g.
|
|
41
41
|
* `visit('users.store').json(...).assertCreated()` issues a POST).
|
|
42
42
|
*/
|
|
43
43
|
export type RouteEntry = string | { path: string; method?: HttpMethod }
|
|
@@ -68,17 +68,51 @@ export class TestClient {
|
|
|
68
68
|
|
|
69
69
|
#server: { port: number; close: () => Promise<void> | void } | null = null
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* In flight or settled, so concurrent callers share one boot.
|
|
73
|
+
*
|
|
74
|
+
* Also what makes `boot()` optional: the first request awaits this, starting
|
|
75
|
+
* it if nobody has. A test file that never issues a request never starts a
|
|
76
|
+
* server — which is the difference between a unit file paying for one and
|
|
77
|
+
* not, and at ~400ms a file that is most of a campaign's time.
|
|
78
|
+
*/
|
|
79
|
+
#booting: Promise<void> | null = null
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Boot the application on a random port. Idempotent: a second call awaits the
|
|
83
|
+
* first rather than starting a second server.
|
|
84
|
+
*
|
|
85
|
+
* Optional — a request boots on its own. Call it when you need `port` before
|
|
86
|
+
* issuing one (an SSE or WebSocket connection opened with `fetch`).
|
|
87
|
+
*/
|
|
72
88
|
async boot(): Promise<void> {
|
|
89
|
+
this.#booting ??= this.#startServer()
|
|
90
|
+
return this.#booting
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async #startServer(): Promise<void> {
|
|
73
94
|
this.#server = await this.#bootFn(0)
|
|
74
95
|
this.#port = this.#server.port
|
|
75
96
|
}
|
|
76
97
|
|
|
77
|
-
/**
|
|
98
|
+
/** Whether a server is currently running for this client. */
|
|
99
|
+
get booted(): boolean {
|
|
100
|
+
return this.#server !== null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Close the server. A no-op when nothing was ever started, so a teardown can
|
|
105
|
+
* call it unconditionally against a client whose file issued no request.
|
|
106
|
+
*/
|
|
78
107
|
async close(): Promise<void> {
|
|
108
|
+
// Awaited first: closing while a boot is in flight would otherwise leave
|
|
109
|
+
// the server that boot is about to assign running with nobody holding it.
|
|
110
|
+
if (this.#booting) await this.#booting.catch(() => {})
|
|
111
|
+
this.#booting = null
|
|
79
112
|
if (this.#server) {
|
|
80
113
|
await this.#server.close()
|
|
81
114
|
this.#server = null
|
|
115
|
+
this.#port = 0
|
|
82
116
|
}
|
|
83
117
|
}
|
|
84
118
|
|
|
@@ -86,7 +120,11 @@ export class TestClient {
|
|
|
86
120
|
* Ephemeral port the booted server bound to. Useful for long-lived
|
|
87
121
|
* connections (SSE / WebSocket) that the fluent buffered request
|
|
88
122
|
* surface can't model — open them with `fetch` against
|
|
89
|
-
* `http://127.0.0.1:${client.port}/...`.
|
|
123
|
+
* `http://127.0.0.1:${client.port}/...`.
|
|
124
|
+
*
|
|
125
|
+
* Returns 0 while no server is running. Requests boot on their own, so a
|
|
126
|
+
* client that has only been constructed has not bound a port yet — `await
|
|
127
|
+
* client.boot()` first when you need this before issuing one.
|
|
90
128
|
*/
|
|
91
129
|
get port(): number {
|
|
92
130
|
return this.#port
|
|
@@ -98,7 +136,7 @@ export class TestClient {
|
|
|
98
136
|
return this
|
|
99
137
|
}
|
|
100
138
|
|
|
101
|
-
// Verb shortcuts return the RICH builder — the full
|
|
139
|
+
// Verb shortcuts return the RICH builder — the full helix surface
|
|
102
140
|
// (assertOk/assertStatus/assertBody/… + auth/csrf) AND awaitable (`await
|
|
103
141
|
// client.get('/x')` sends and resolves to the response). One unified builder,
|
|
104
142
|
// no split between `get()` and `fluent()`.
|
|
@@ -129,14 +167,14 @@ export class TestClient {
|
|
|
129
167
|
}
|
|
130
168
|
|
|
131
169
|
/**
|
|
132
|
-
* HEAD request —
|
|
170
|
+
* HEAD request — helix's `.head()`. Returns the rich builder so you
|
|
133
171
|
* can assert status/headers (a HEAD response carries headers, no body).
|
|
134
172
|
*/
|
|
135
173
|
head(path: string): RequestBuilder {
|
|
136
174
|
return this.fluent('HEAD', path)
|
|
137
175
|
}
|
|
138
176
|
|
|
139
|
-
/** OPTIONS request —
|
|
177
|
+
/** OPTIONS request — helix's `.options()`. */
|
|
140
178
|
options(path: string): RequestBuilder {
|
|
141
179
|
return this.fluent('OPTIONS', path)
|
|
142
180
|
}
|
|
@@ -148,8 +186,11 @@ export class TestClient {
|
|
|
148
186
|
* `form()`. The lower-level `request()` (below) stays for raw control.
|
|
149
187
|
*/
|
|
150
188
|
fluent(method: HttpMethod, path: string): RequestBuilder {
|
|
151
|
-
const sender: HttpSender = (m, p, init) =>
|
|
152
|
-
|
|
189
|
+
const sender: HttpSender = async (m, p, init) => {
|
|
190
|
+
// Boots on the first request and no earlier. `#port` is read here rather
|
|
191
|
+
// than captured, so it is the port this boot bound to.
|
|
192
|
+
await this.boot()
|
|
193
|
+
return sendRequest(
|
|
153
194
|
this.#port,
|
|
154
195
|
m,
|
|
155
196
|
p,
|
|
@@ -157,11 +198,12 @@ export class TestClient {
|
|
|
157
198
|
init.body.toString('utf8'),
|
|
158
199
|
init.timeoutMs,
|
|
159
200
|
)
|
|
201
|
+
}
|
|
160
202
|
return new RequestBuilder(sender, method, path, this.#auth, this.#sessionSeeder)
|
|
161
203
|
}
|
|
162
204
|
|
|
163
205
|
/**
|
|
164
|
-
* GET a named route —
|
|
206
|
+
* GET a named route — helix's `.visit()`. Resolves `name` against
|
|
165
207
|
* the `routes` manifest (`router.namedManifest()`), fills `:param`
|
|
166
208
|
* placeholders, and returns the rich chainable builder. Throws a clear error
|
|
167
209
|
* when no manifest was configured or the name is unknown.
|
|
@@ -173,14 +215,14 @@ export class TestClient {
|
|
|
173
215
|
)
|
|
174
216
|
}
|
|
175
217
|
const entry = this.#routes[name]
|
|
176
|
-
// Method comes from the registry when the entry carries one (
|
|
218
|
+
// Method comes from the registry when the entry carries one (helix parity),
|
|
177
219
|
// otherwise GET.
|
|
178
220
|
const method: HttpMethod = typeof entry === 'object' && entry.method ? entry.method : 'GET'
|
|
179
221
|
return this.fluent(method, resolveNamedRoute(this.#routes, name, params))
|
|
180
222
|
}
|
|
181
223
|
|
|
182
224
|
/**
|
|
183
|
-
* Generic request —
|
|
225
|
+
* Generic request — helix's `client.request(url, method)` (note the
|
|
184
226
|
* arg order: URL first, method second, defaulting to GET). Returns the same
|
|
185
227
|
* rich, awaitable builder as the verb shortcuts.
|
|
186
228
|
*/
|
|
@@ -294,7 +336,7 @@ async function sendRequest(
|
|
|
294
336
|
// and surfaced as flaky `bob accepts` / `non-member 403` failures
|
|
295
337
|
// in kitchen-sink's workspace.test.ts. 30s matches the helix
|
|
296
338
|
// `--timeout=60000` per-test budget without masking real hangs. A
|
|
297
|
-
// per-request `.timeout(ms)` (
|
|
339
|
+
// per-request `.timeout(ms)` (helix) overrides the default.
|
|
298
340
|
socket.setTimeout(timeoutMs && timeoutMs > 0 ? timeoutMs : 30_000, () => {
|
|
299
341
|
socket.destroy()
|
|
300
342
|
reject(new Error('TestClient request timed out'))
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test utilities, in the shape AdonisJS gives them: `testUtils.httpServer()`
|
|
3
|
+
* starts the application's HTTP server for a test run and hands back the
|
|
4
|
+
* function that closes it.
|
|
5
|
+
*
|
|
6
|
+
* // tests/bootstrap.ts
|
|
7
|
+
* configureSuite(suite) {
|
|
8
|
+
* if (['functional', 'e2e'].includes(suite.name)) {
|
|
9
|
+
* return suite.setup(() => testUtils.httpServer().start())
|
|
10
|
+
* }
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* The point of the shape is WHERE the server starts. A suite that does not
|
|
14
|
+
* declare the hook never starts one, so a unit suite pays nothing — which is
|
|
15
|
+
* the same saving a lazily-booted client buys, obtained by scoping instead of
|
|
16
|
+
* by deferring, and obtained per SUITE rather than per file.
|
|
17
|
+
*
|
|
18
|
+
* `start()` returning its own teardown is what makes it a one-liner in a hook:
|
|
19
|
+
* a setup hook may return its undo, so there is no matching `teardown` to
|
|
20
|
+
* write and no way to forget it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { TestClient } from './TestClient.js'
|
|
24
|
+
|
|
25
|
+
/** Boots the app on `port` and reports the port it bound plus how to stop it. */
|
|
26
|
+
export type BootServer = (
|
|
27
|
+
port: number,
|
|
28
|
+
) => Promise<{ port: number; close: () => Promise<void> | void }>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What `testUtils.httpServer()` returns — AdonisJS `HttpServerUtils`.
|
|
32
|
+
*/
|
|
33
|
+
export class HttpServerUtils {
|
|
34
|
+
readonly #boot: BootServer
|
|
35
|
+
readonly #onStarted: (client: TestClient) => void
|
|
36
|
+
|
|
37
|
+
constructor(boot: BootServer, onStarted: (client: TestClient) => void) {
|
|
38
|
+
this.#boot = boot
|
|
39
|
+
this.#onStarted = onStarted
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Start the server. Returns the function that closes it, so a setup hook can
|
|
44
|
+
* return this call directly.
|
|
45
|
+
*/
|
|
46
|
+
async start(): Promise<() => Promise<void>> {
|
|
47
|
+
const client = new TestClient(this.#boot)
|
|
48
|
+
await client.boot()
|
|
49
|
+
this.#onStarted(client)
|
|
50
|
+
return () => client.close()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The utilities themselves — AdonisJS `TestUtils`.
|
|
56
|
+
*
|
|
57
|
+
* Constructed with how to boot the app, because ream has no ambient
|
|
58
|
+
* application to reach for: the app is whatever the test bootstrap hands over.
|
|
59
|
+
* That is the one place this differs from AdonisJS, where `testUtils` is a
|
|
60
|
+
* service resolved from the container and already knows its app.
|
|
61
|
+
*/
|
|
62
|
+
export class TestUtils {
|
|
63
|
+
readonly #boot: BootServer
|
|
64
|
+
#client: TestClient | undefined
|
|
65
|
+
|
|
66
|
+
constructor(boot: BootServer) {
|
|
67
|
+
this.#boot = boot
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Start/stop the HTTP server for a suite. */
|
|
71
|
+
httpServer(): HttpServerUtils {
|
|
72
|
+
return new HttpServerUtils(this.#boot, (client) => {
|
|
73
|
+
this.#client = client
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The client for the server this run started, or `undefined` before
|
|
79
|
+
* `httpServer().start()`. A plugin reads it to put a client on the test
|
|
80
|
+
* context without booting a second server.
|
|
81
|
+
*/
|
|
82
|
+
client(): TestClient | undefined {
|
|
83
|
+
return this.#client
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build the utilities for an app booted by `boot`. */
|
|
88
|
+
export function createTestUtils(boot: BootServer): TestUtils {
|
|
89
|
+
return new TestUtils(boot)
|
|
90
|
+
}
|