@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.
Files changed (94) hide show
  1. package/README.md +3 -9
  2. package/dist/HttpKernel.js +2 -2
  3. package/dist/HttpKernel.js.map +1 -1
  4. package/dist/Ignitor.d.ts +3 -3
  5. package/dist/Ignitor.d.ts.map +1 -1
  6. package/dist/Ignitor.js +50 -0
  7. package/dist/Ignitor.js.map +1 -1
  8. package/dist/bootstrap/hyper-server.js.map +1 -1
  9. package/dist/helpers/compose.d.ts +30 -0
  10. package/dist/helpers/compose.d.ts.map +1 -0
  11. package/dist/helpers/compose.js +27 -0
  12. package/dist/helpers/compose.js.map +1 -0
  13. package/dist/helpers/index.d.ts +1 -0
  14. package/dist/helpers/index.d.ts.map +1 -1
  15. package/dist/helpers/index.js +1 -0
  16. package/dist/helpers/index.js.map +1 -1
  17. package/dist/helpers/is.d.ts +69 -16
  18. package/dist/helpers/is.d.ts.map +1 -1
  19. package/dist/helpers/is.js +80 -20
  20. package/dist/helpers/is.js.map +1 -1
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/middleware/Pipeline.d.ts +9 -3
  26. package/dist/middleware/Pipeline.d.ts.map +1 -1
  27. package/dist/middleware/Pipeline.js +10 -4
  28. package/dist/middleware/Pipeline.js.map +1 -1
  29. package/dist/rpc/RpcRouter.js +2 -2
  30. package/dist/rpc/RpcRouter.js.map +1 -1
  31. package/dist/scheduler/Scheduler.d.ts.map +1 -1
  32. package/dist/scheduler/Scheduler.js +12 -4
  33. package/dist/scheduler/Scheduler.js.map +1 -1
  34. package/dist/services/app.d.ts +6 -0
  35. package/dist/services/app.d.ts.map +1 -1
  36. package/dist/services/app.js +8 -0
  37. package/dist/services/app.js.map +1 -1
  38. package/dist/services/config.d.ts +15 -0
  39. package/dist/services/config.d.ts.map +1 -0
  40. package/dist/services/config.js +18 -0
  41. package/dist/services/config.js.map +1 -0
  42. package/dist/services/encryption.d.ts +23 -0
  43. package/dist/services/encryption.d.ts.map +1 -0
  44. package/dist/services/encryption.js +33 -0
  45. package/dist/services/encryption.js.map +1 -0
  46. package/dist/services/logger.d.ts +29 -0
  47. package/dist/services/logger.d.ts.map +1 -0
  48. package/dist/services/logger.js +40 -0
  49. package/dist/services/logger.js.map +1 -0
  50. package/dist/services/urlBuilder.d.ts +21 -0
  51. package/dist/services/urlBuilder.d.ts.map +1 -0
  52. package/dist/services/urlBuilder.js +34 -0
  53. package/dist/services/urlBuilder.js.map +1 -0
  54. package/dist/session/Session.js.map +1 -1
  55. package/dist/testing/ApiResponse.d.ts +44 -44
  56. package/dist/testing/ApiResponse.d.ts.map +1 -1
  57. package/dist/testing/ApiResponse.js +48 -48
  58. package/dist/testing/ApiResponse.js.map +1 -1
  59. package/dist/testing/RequestBuilder.d.ts +39 -39
  60. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  61. package/dist/testing/RequestBuilder.js +49 -49
  62. package/dist/testing/RequestBuilder.js.map +1 -1
  63. package/dist/testing/TestClient.d.ts +23 -8
  64. package/dist/testing/TestClient.d.ts.map +1 -1
  65. package/dist/testing/TestClient.js +52 -11
  66. package/dist/testing/TestClient.js.map +1 -1
  67. package/dist/testing/testUtils.d.ts +62 -0
  68. package/dist/testing/testUtils.d.ts.map +1 -0
  69. package/dist/testing/testUtils.js +77 -0
  70. package/dist/testing/testUtils.js.map +1 -0
  71. package/events.win32-x64-msvc.node +0 -0
  72. package/index.win32-x64-msvc.node +0 -0
  73. package/package.json +21 -1
  74. package/scheduler.win32-x64-msvc.node +0 -0
  75. package/src/HttpKernel.ts +2 -2
  76. package/src/Ignitor.ts +59 -4
  77. package/src/bootstrap/hyper-server.ts +1 -1
  78. package/src/helpers/compose.ts +84 -0
  79. package/src/helpers/index.ts +1 -0
  80. package/src/helpers/is.ts +88 -22
  81. package/src/index.ts +2 -2
  82. package/src/middleware/Pipeline.ts +10 -4
  83. package/src/rpc/RpcRouter.ts +2 -2
  84. package/src/scheduler/Scheduler.ts +10 -4
  85. package/src/services/app.ts +9 -0
  86. package/src/services/config.ts +24 -0
  87. package/src/services/encryption.ts +42 -0
  88. package/src/services/logger.ts +49 -0
  89. package/src/services/urlBuilder.ts +41 -0
  90. package/src/session/Session.ts +2 -2
  91. package/src/testing/ApiResponse.ts +52 -52
  92. package/src/testing/RequestBuilder.ts +55 -55
  93. package/src/testing/TestClient.ts +55 -13
  94. package/src/testing/testUtils.ts +90 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Fluent HTTP request builder with assertion methods (`@japa/api-client` model).
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`) — Japa parity — via the wider
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 japa/api-client's `.type()`/`.accept()`. */
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 japa/api-client). */
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()` — Japa parity (arrays repeat the key). */
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()` — Japa parity (incl. streams/blobs). */
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) — Japa `.timeout()`. Sender may honour it. */
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/japa's `.file()` options. */
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 (Japa `.redirects()`). */
163
+ /** HTTP statuses that trigger redirect-following (helix `.redirects()`). */
164
164
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
165
- /** Japa follows 5 redirects by default. */
165
+ /** helix follows 5 redirects by default. */
166
166
  const DEFAULT_MAX_REDIRECTS = 5
167
167
 
168
- /** Consumer-registered macros/getters (Japa `ApiRequest.macro`/`.getter`). */
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 (Japa `ApiRequest.addSerializer`), by name. */
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 (japa model): each `assert*`/`expect*` registers a check and
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
- // Japa accepts string | string[]; a list is joined into one header value.
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
- /** Japa `.unsafeJson()` — set a JSON body without any transform (alias of {@link json}). */
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`) — Japa parity.
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
- /** Japa `.unsafeForm()` — set a urlencoded body without transform (alias of {@link form}). */
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/japa's `.field()`. Accepts
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 — Japa `.fields()`. */
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/japa's
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, Japa parity). The filename defaults to the path basename / field
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 japa/api-client's `.qs()`. Values are
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 — Japa
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 — japa `.bearerToken()`. */
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 — japa `.basicAuth()`. */
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 japa/api-client's `.type()`.
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 japa/api-client's `.accept()`.
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 — Japa `.timeout()`. Honoured by the sender. */
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 — Japa `.redirects()`.
388
- * Defaults to 5 (Japa default); pass `0` to disable following.
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 — Japa parity. The test server is plain HTTP over loopback, so
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
- // Japa-shaped code type-checks and runs unchanged. NAMED as a ream deviation.
398
- /** No-op: the loopback test server has no TLS — Japa `.trustLocalhost()`. */
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 — Japa `.ca()`. */
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 — Japa `.cert()`. */
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 — Japa `.privateKey()`. */
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 — Japa `.pfx()`. */
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 — Japa `.disableTLSCerts()`. */
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
- /** japa/api-client's spelling of {@link withCsrf}. */
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} (Japa/api-client parity). Assertion methods return `this`;
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 Japa/api-client ergonomic; removing `.then` breaks every await-the-builder call site.
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 Japa-named equivalent).
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
- // ── Japa assertions (lazy — delegate to the response) ─────────────────────
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 (Japa `request.dump*` — dumps the REQUEST) ──────────
565
+ // ── Request debugging (helix `request.dump*` — dumps the REQUEST) ──────────
566
566
 
567
- /** Print the pending request (method/path/headers/body) — Japa `request.dump()`. */
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 — Japa `request.dumpBody()`. */
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 — Japa `request.dumpHeaders()`. */
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 — Japa `request.dumpCookies()`. */
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 (Japa parity).
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 — Japa `.redirects()` (5 by default).
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} (Japa `ApiRequest.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 (Japa `ApiRequest.macro`) or lazy getter
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 Japa's
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 — Japa `ApiRequest.addSerializer`. */
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 — Japa `ApiRequest.addParser` (delegates to ApiResponse). */
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 (Japa route-registry parity, e.g.
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
- /** Boot the application on a random port. */
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
- /** Close the server. */
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}/...`. Returns 0 before `boot()`.
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 japa/api-client surface
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 — japa/api-client's `.head()`. Returns the rich builder so you
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 — japa/api-client's `.options()`. */
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
- sendRequest(
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 — japa/api-client's `.visit()`. Resolves `name` against
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 (Japa parity),
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 — japa/api-client's `client.request(url, method)` (note the
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)` (Japa) overrides the default.
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
+ }