@c9up/ream 0.1.26 → 0.1.27

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 (179) hide show
  1. package/README.md +65 -0
  2. package/dist/Ignitor.d.ts +115 -7
  3. package/dist/Ignitor.d.ts.map +1 -1
  4. package/dist/Ignitor.js +233 -37
  5. package/dist/Ignitor.js.map +1 -1
  6. package/dist/console/BaseCommand.d.ts +217 -0
  7. package/dist/console/BaseCommand.d.ts.map +1 -0
  8. package/dist/console/BaseCommand.js +340 -0
  9. package/dist/console/BaseCommand.js.map +1 -0
  10. package/dist/console/Console.d.ts +86 -0
  11. package/dist/console/Console.d.ts.map +1 -0
  12. package/dist/console/Console.js +134 -0
  13. package/dist/console/Console.js.map +1 -0
  14. package/dist/console/ExceptionHandler.d.ts +28 -0
  15. package/dist/console/ExceptionHandler.d.ts.map +1 -0
  16. package/dist/console/ExceptionHandler.js +76 -0
  17. package/dist/console/ExceptionHandler.js.map +1 -0
  18. package/dist/console/HelpCommand.d.ts +16 -0
  19. package/dist/console/HelpCommand.d.ts.map +1 -0
  20. package/dist/console/HelpCommand.js +40 -0
  21. package/dist/console/HelpCommand.js.map +1 -0
  22. package/dist/console/IndexGenerator.d.ts +24 -0
  23. package/dist/console/IndexGenerator.d.ts.map +1 -0
  24. package/dist/console/IndexGenerator.js +65 -0
  25. package/dist/console/IndexGenerator.js.map +1 -0
  26. package/dist/console/Kernel.d.ts +298 -0
  27. package/dist/console/Kernel.d.ts.map +1 -0
  28. package/dist/console/Kernel.js +1120 -0
  29. package/dist/console/Kernel.js.map +1 -0
  30. package/dist/console/ListCommand.d.ts +18 -0
  31. package/dist/console/ListCommand.d.ts.map +1 -0
  32. package/dist/console/ListCommand.js +60 -0
  33. package/dist/console/ListCommand.js.map +1 -0
  34. package/dist/console/assertions.d.ts +20 -0
  35. package/dist/console/assertions.d.ts.map +1 -0
  36. package/dist/console/assertions.js +84 -0
  37. package/dist/console/assertions.js.map +1 -0
  38. package/dist/console/cliui.d.ts +248 -0
  39. package/dist/console/cliui.d.ts.map +1 -0
  40. package/dist/console/cliui.js +587 -0
  41. package/dist/console/cliui.js.map +1 -0
  42. package/dist/console/decorators.d.ts +90 -0
  43. package/dist/console/decorators.d.ts.map +1 -0
  44. package/dist/console/decorators.js +142 -0
  45. package/dist/console/decorators.js.map +1 -0
  46. package/dist/console/index.d.ts +32 -0
  47. package/dist/console/index.d.ts.map +1 -0
  48. package/dist/console/index.js +24 -0
  49. package/dist/console/index.js.map +1 -0
  50. package/dist/console/loaders.d.ts +52 -0
  51. package/dist/console/loaders.d.ts.map +1 -0
  52. package/dist/console/loaders.js +131 -0
  53. package/dist/console/loaders.js.map +1 -0
  54. package/dist/console/parser.d.ts +86 -0
  55. package/dist/console/parser.d.ts.map +1 -0
  56. package/dist/console/parser.js +440 -0
  57. package/dist/console/parser.js.map +1 -0
  58. package/dist/console/prompts.d.ts +124 -0
  59. package/dist/console/prompts.d.ts.map +1 -0
  60. package/dist/console/prompts.js +356 -0
  61. package/dist/console/prompts.js.map +1 -0
  62. package/dist/console/tracing.d.ts +17 -0
  63. package/dist/console/tracing.d.ts.map +1 -0
  64. package/dist/console/tracing.js +11 -0
  65. package/dist/console/tracing.js.map +1 -0
  66. package/dist/console/types.d.ts +255 -0
  67. package/dist/console/types.d.ts.map +1 -0
  68. package/dist/console/types.js +25 -0
  69. package/dist/console/types.js.map +1 -0
  70. package/dist/console/ui.d.ts +20 -0
  71. package/dist/console/ui.d.ts.map +1 -0
  72. package/dist/console/ui.js +31 -0
  73. package/dist/console/ui.js.map +1 -0
  74. package/dist/console/utils.d.ts +27 -0
  75. package/dist/console/utils.d.ts.map +1 -0
  76. package/dist/console/utils.js +56 -0
  77. package/dist/console/utils.js.map +1 -0
  78. package/dist/container/Container.d.ts.map +1 -1
  79. package/dist/container/Container.js +40 -12
  80. package/dist/container/Container.js.map +1 -1
  81. package/dist/container/types.d.ts +14 -2
  82. package/dist/container/types.d.ts.map +1 -1
  83. package/dist/decorators/Service.d.ts +7 -0
  84. package/dist/decorators/Service.d.ts.map +1 -1
  85. package/dist/decorators/Service.js +23 -3
  86. package/dist/decorators/Service.js.map +1 -1
  87. package/dist/errors/ReamError.d.ts +25 -0
  88. package/dist/errors/ReamError.d.ts.map +1 -1
  89. package/dist/errors/ReamError.js +5 -0
  90. package/dist/errors/ReamError.js.map +1 -1
  91. package/dist/errors/prettyPrintError.d.ts +9 -0
  92. package/dist/errors/prettyPrintError.d.ts.map +1 -0
  93. package/dist/errors/prettyPrintError.js +22 -0
  94. package/dist/errors/prettyPrintError.js.map +1 -0
  95. package/dist/index.d.ts +14 -4
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +16 -2
  98. package/dist/index.js.map +1 -1
  99. package/dist/middleware/Pipeline.d.ts +29 -8
  100. package/dist/middleware/Pipeline.d.ts.map +1 -1
  101. package/dist/middleware/Pipeline.js +39 -1
  102. package/dist/middleware/Pipeline.js.map +1 -1
  103. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  104. package/dist/rpc/RpcRouter.js +13 -2
  105. package/dist/rpc/RpcRouter.js.map +1 -1
  106. package/dist/services/app.d.ts +1 -1
  107. package/dist/services/app.js +1 -1
  108. package/dist/services/console.d.ts +21 -0
  109. package/dist/services/console.d.ts.map +1 -0
  110. package/dist/services/console.js +35 -0
  111. package/dist/services/console.js.map +1 -0
  112. package/dist/testing/ApiResponse.d.ts +218 -0
  113. package/dist/testing/ApiResponse.d.ts.map +1 -0
  114. package/dist/testing/ApiResponse.js +576 -0
  115. package/dist/testing/ApiResponse.js.map +1 -0
  116. package/dist/testing/RequestBuilder.d.ts +132 -72
  117. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  118. package/dist/testing/RequestBuilder.js +389 -222
  119. package/dist/testing/RequestBuilder.js.map +1 -1
  120. package/dist/testing/TestClient.d.ts +34 -39
  121. package/dist/testing/TestClient.d.ts.map +1 -1
  122. package/dist/testing/TestClient.js +48 -56
  123. package/dist/testing/TestClient.js.map +1 -1
  124. package/dist/utils/Macroable.js +3 -3
  125. package/dist/utils/Macroable.js.map +1 -1
  126. package/events.darwin-arm64.node +0 -0
  127. package/events.darwin-x64.node +0 -0
  128. package/events.linux-arm64-gnu.node +0 -0
  129. package/events.linux-x64-gnu.node +0 -0
  130. package/events.win32-x64-msvc.node +0 -0
  131. package/index.darwin-arm64.node +0 -0
  132. package/index.darwin-x64.node +0 -0
  133. package/index.linux-arm64-gnu.node +0 -0
  134. package/index.linux-x64-gnu.node +0 -0
  135. package/index.win32-x64-msvc.node +0 -0
  136. package/package.json +15 -2
  137. package/scheduler.darwin-arm64.node +0 -0
  138. package/scheduler.darwin-x64.node +0 -0
  139. package/scheduler.linux-arm64-gnu.node +0 -0
  140. package/scheduler.linux-x64-gnu.node +0 -0
  141. package/scheduler.win32-x64-msvc.node +0 -0
  142. package/src/Ignitor.ts +340 -42
  143. package/src/console/BaseCommand.ts +467 -0
  144. package/src/console/Console.ts +174 -0
  145. package/src/console/ExceptionHandler.ts +91 -0
  146. package/src/console/HelpCommand.ts +37 -0
  147. package/src/console/IndexGenerator.ts +79 -0
  148. package/src/console/Kernel.ts +1382 -0
  149. package/src/console/ListCommand.ts +55 -0
  150. package/src/console/assertions.ts +114 -0
  151. package/src/console/cliui.ts +756 -0
  152. package/src/console/decorators.ts +199 -0
  153. package/src/console/index.ts +67 -0
  154. package/src/console/loaders.ts +145 -0
  155. package/src/console/parser.ts +598 -0
  156. package/src/console/prompts.ts +548 -0
  157. package/src/console/tracing.ts +22 -0
  158. package/src/console/types.ts +280 -0
  159. package/src/console/ui.ts +37 -0
  160. package/src/console/utils.ts +69 -0
  161. package/src/container/Container.ts +55 -13
  162. package/src/container/types.ts +15 -2
  163. package/src/decorators/Service.ts +28 -3
  164. package/src/errors/ReamError.ts +31 -0
  165. package/src/errors/prettyPrintError.ts +20 -0
  166. package/src/index.ts +30 -4
  167. package/src/middleware/Pipeline.ts +76 -5
  168. package/src/rpc/RpcRouter.ts +16 -2
  169. package/src/services/app.ts +1 -1
  170. package/src/services/console.ts +42 -0
  171. package/src/testing/ApiResponse.ts +691 -0
  172. package/src/testing/RequestBuilder.ts +498 -275
  173. package/src/testing/TestClient.ts +63 -86
  174. package/src/utils/Macroable.ts +3 -3
  175. package/dist/console/CommandRunner.d.ts +0 -18
  176. package/dist/console/CommandRunner.d.ts.map +0 -1
  177. package/dist/console/CommandRunner.js +0 -65
  178. package/dist/console/CommandRunner.js.map +0 -1
  179. package/src/console/CommandRunner.ts +0 -82
@@ -0,0 +1,691 @@
1
+ /**
2
+ * Rich HTTP test response — `@japa/api-client`'s `ApiResponse` parity.
3
+ *
4
+ * Wraps the raw {@link TestResponse} and exposes the full Japa response surface:
5
+ * accessor METHODS (`status()`, `headers()`, `body()`, `text()`, `header()`,
6
+ * `cookies()`, `redirects()`, `error()`, …) matching Japa exactly, the complete
7
+ * `assert*` family, response `dump*()` debuggers, and `macro()`/`getter()` for
8
+ * consumer extension.
9
+ *
10
+ * `json()` is kept as an additive convenience (not in Japa, but harmless for
11
+ * parity — Japa code never calls it) so `res.json()` call sites need no change.
12
+ */
13
+
14
+ import type { Dict } from '../types/helpers.js'
15
+
16
+ /** The raw response shape the low-level sender produces. */
17
+ export interface TestResponse {
18
+ status: number
19
+ headers: Dict
20
+ body: string
21
+ json<T = unknown>(): T
22
+ }
23
+
24
+ /** Assertion failure — plain Error so tests see a clean stack trace. */
25
+ export class ExpectationError extends Error {
26
+ constructor(message: string) {
27
+ super(message)
28
+ this.name = 'ExpectationError'
29
+ }
30
+ }
31
+
32
+ export function capBody(s: string, max = 512): string {
33
+ return s.length <= max ? s : `${s.slice(0, max)}...[truncated]`
34
+ }
35
+
36
+ /** Narrowing guard — a non-null, non-array object usable as a string map. */
37
+ export function isRecord(x: unknown): x is Record<string, unknown> {
38
+ return typeof x === 'object' && x !== null && !Array.isArray(x)
39
+ }
40
+
41
+ /** Exact structural equality (order-insensitive object keys) for `assertBody`. */
42
+ export function deepEqual(a: unknown, b: unknown): boolean {
43
+ if (a === b) return true
44
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
45
+ return false
46
+ }
47
+ if (Array.isArray(a) || Array.isArray(b)) {
48
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
49
+ return a.every((item, i) => deepEqual(item, b[i]))
50
+ }
51
+ if (!isRecord(a) || !isRecord(b)) return false
52
+ const aKeys = Object.keys(a)
53
+ const bKeys = Object.keys(b)
54
+ if (aKeys.length !== bKeys.length) return false
55
+ return aKeys.every((key) => key in b && deepEqual(a[key], b[key]))
56
+ }
57
+
58
+ /**
59
+ * Partial deep-match. For objects, every key in `expected` must match in
60
+ * `actual`. For arrays, every element in `expected` must match SOMEWHERE in
61
+ * `actual` (order-independent). Primitives compared by strict equality.
62
+ */
63
+ export function partialMatch(actual: unknown, expected: unknown): boolean {
64
+ if (expected === null || expected === undefined) {
65
+ return actual === expected
66
+ }
67
+ if (typeof expected !== 'object') return actual === expected
68
+ if (Array.isArray(expected)) {
69
+ if (!Array.isArray(actual)) return false
70
+ return expected.every((want) => actual.some((have) => partialMatch(have, want)))
71
+ }
72
+ if (!isRecord(actual) || !isRecord(expected)) return false
73
+ for (const key of Object.keys(expected)) {
74
+ if (!partialMatch(actual[key], expected[key])) return false
75
+ }
76
+ return true
77
+ }
78
+
79
+ /** Split a `Set-Cookie` header value into its individual cookie strings. */
80
+ function splitSetCookie(setCookie: string): string[] {
81
+ return setCookie.split(/,(?=\s*[\w-]+=)/).map((c) => c.trim())
82
+ }
83
+
84
+ /** A parsed response cookie — `name` + `value` plus any attributes present. */
85
+ export interface ResponseCookie {
86
+ name: string
87
+ value: string
88
+ [attr: string]: string
89
+ }
90
+
91
+ function parseCookies(setCookie: string | undefined): Record<string, ResponseCookie> {
92
+ const out: Record<string, ResponseCookie> = {}
93
+ if (!setCookie) return out
94
+ for (const raw of splitSetCookie(setCookie)) {
95
+ const segments = raw.split(';')
96
+ const first = segments[0] ?? ''
97
+ const eq = first.indexOf('=')
98
+ if (eq === -1) continue
99
+ const name = first.slice(0, eq).trim()
100
+ const value = first.slice(eq + 1).trim()
101
+ const cookie: ResponseCookie = { name, value }
102
+ for (const attr of segments.slice(1)) {
103
+ const aEq = attr.indexOf('=')
104
+ if (aEq === -1) cookie[attr.trim().toLowerCase()] = ''
105
+ else cookie[attr.slice(0, aEq).trim().toLowerCase()] = attr.slice(aEq + 1).trim()
106
+ }
107
+ out[name] = cookie
108
+ }
109
+ return out
110
+ }
111
+
112
+ /** A file parsed from a `multipart/form-data` response — Japa `files()` entry. */
113
+ export interface ResponseFile {
114
+ /** The form field name. */
115
+ fieldName: string
116
+ /** The advertised filename. */
117
+ filename: string
118
+ /** The part's `Content-Type`. */
119
+ type: string
120
+ /** Byte length of the file content. */
121
+ size: number
122
+ /** The raw file bytes. */
123
+ content: Buffer
124
+ }
125
+
126
+ /** Consumer-registered response parsers (Japa `ApiRequest.addParser`), by content-type. */
127
+ const responseParsers = new Map<string, (body: string, headers: Dict) => unknown>()
128
+
129
+ /**
130
+ * Parse a `multipart/form-data` response body (RFC 7578) into its non-file
131
+ * `fields` and its `files`. Best-effort; a malformed part is skipped.
132
+ */
133
+ function parseMultipartResponse(
134
+ body: string,
135
+ contentType: string,
136
+ ): { fields: Record<string, string>; files: Record<string, ResponseFile> } {
137
+ const fields: Record<string, string> = {}
138
+ const files: Record<string, ResponseFile> = {}
139
+ const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i)
140
+ const boundary = boundaryMatch?.[1] ?? boundaryMatch?.[2]?.trim()
141
+ if (!boundary) return { fields, files }
142
+ const buf = Buffer.from(body, 'binary')
143
+ const delim = Buffer.from(`--${boundary}`, 'utf8')
144
+ const parts = splitBuffer(buf, delim)
145
+ for (const part of parts) {
146
+ const headerEnd = part.indexOf('\r\n\r\n')
147
+ if (headerEnd === -1) continue
148
+ const headerText = part.subarray(0, headerEnd).toString('utf8')
149
+ // Trim the trailing CRLF before the next boundary.
150
+ let content = part.subarray(headerEnd + 4)
151
+ if (content.subarray(-2).toString('binary') === '\r\n') content = content.subarray(0, -2)
152
+ const nameMatch = headerText.match(/name="([^"]*)"/i)
153
+ if (!nameMatch) continue
154
+ const name = nameMatch[1]
155
+ const fileMatch = headerText.match(/filename="([^"]*)"/i)
156
+ if (fileMatch) {
157
+ const typeMatch = headerText.match(/content-type:\s*([^\r\n]+)/i)
158
+ files[name] = {
159
+ fieldName: name,
160
+ filename: fileMatch[1],
161
+ type: typeMatch?.[1]?.trim() ?? 'application/octet-stream',
162
+ size: content.length,
163
+ content,
164
+ }
165
+ } else {
166
+ fields[name] = content.toString('utf8')
167
+ }
168
+ }
169
+ return { fields, files }
170
+ }
171
+
172
+ /** Split a buffer on a delimiter (used by the multipart parser). */
173
+ function splitBuffer(buf: Buffer, delim: Buffer): Buffer[] {
174
+ const out: Buffer[] = []
175
+ let start = 0
176
+ let idx = buf.indexOf(delim, start)
177
+ while (idx !== -1) {
178
+ if (idx > start) out.push(buf.subarray(start, idx))
179
+ start = idx + delim.length
180
+ idx = buf.indexOf(delim, start)
181
+ }
182
+ if (start < buf.length) out.push(buf.subarray(start))
183
+ // Drop the leading preamble + trailing `--` epilogue fragments.
184
+ return out.filter((p) => {
185
+ const s = p.subarray(0, 2).toString('binary')
186
+ return s !== '--' && p.length > 4
187
+ })
188
+ }
189
+
190
+ /**
191
+ * Validator for `assertAgainstApiSpec()` — mirrors Japa's OpenAPI plugin, which
192
+ * is a SEPARATE opt-in (`@japa/openapi-assertions`). Register one to enable spec
193
+ * assertions; without it, the assertion throws a clear "not configured" error
194
+ * rather than silently passing.
195
+ */
196
+ let apiSpecValidator: ((res: ApiResponse) => void) | undefined
197
+
198
+ /** Tolerant JSON parse for assertions (independent of the response content-type). */
199
+ function jsonOf(raw: TestResponse): unknown {
200
+ try {
201
+ return raw.json()
202
+ } catch (err) {
203
+ throw new ExpectationError(
204
+ `Expected JSON body, got non-JSON. Body: ${capBody(raw.body)} (parse error: ${err instanceof Error ? err.message : String(err)})`,
205
+ )
206
+ }
207
+ }
208
+
209
+ /** The Japa error object returned by `response.error()`. */
210
+ export interface ResponseError {
211
+ /** The error status code (>= 400). */
212
+ status: number
213
+ /** The response text. */
214
+ text: string
215
+ }
216
+
217
+ /** Extra context the builder threads into the response. */
218
+ export interface ApiResponseMeta {
219
+ /** The (final) request method — Japa `response.method()`. */
220
+ method?: string
221
+ /** URLs followed when redirects were configured — Japa `response.redirects()`. */
222
+ redirects?: string[]
223
+ }
224
+
225
+ /**
226
+ * Every Japa status-shortcut assertion, as `methodName → code`. Consumed to
227
+ * generate the `assert*` methods on both {@link ApiResponse} and the builder,
228
+ * so the two surfaces never drift.
229
+ */
230
+ export const STATUS_ASSERTIONS = {
231
+ assertOk: 200,
232
+ assertCreated: 201,
233
+ assertAccepted: 202,
234
+ assertNoContent: 204,
235
+ assertMovedPermanently: 301,
236
+ assertFound: 302,
237
+ assertBadRequest: 400,
238
+ assertUnauthorized: 401,
239
+ assertPaymentRequired: 402,
240
+ assertForbidden: 403,
241
+ assertNotFound: 404,
242
+ assertMethodNotAllowed: 405,
243
+ assertNotAcceptable: 406,
244
+ assertRequestTimeout: 408,
245
+ assertConflict: 409,
246
+ assertGone: 410,
247
+ assertLengthRequired: 411,
248
+ assertPreconditionFailed: 412,
249
+ assertPayloadTooLarge: 413,
250
+ assertURITooLong: 414,
251
+ assertUnsupportedMediaType: 415,
252
+ assertRangeNotSatisfiable: 416,
253
+ assertImATeapot: 418,
254
+ assertUnprocessableEntity: 422,
255
+ assertLocked: 423,
256
+ assertTooManyRequests: 429,
257
+ /** ream extra (not in Japa's list, kept for convenience). */
258
+ assertInternalServerError: 500,
259
+ } as const
260
+
261
+ /** Consumer-registered macros/getters (Japa `ApiResponse.macro`/`.getter`). */
262
+ const responseMacros = new Map<string, unknown>()
263
+ const responseGetters = new Map<string, (this: ApiResponse, res: ApiResponse) => unknown>()
264
+
265
+ /**
266
+ * The rich response. Constructed by the request builder from a raw
267
+ * {@link TestResponse}; exposes the Japa accessor + assertion surface.
268
+ *
269
+ * PARITY NOTE: the status code / headers / raw text are METHODS — `status()`,
270
+ * `headers()`, `text()` — and `body()` returns the content-type-parsed body,
271
+ * exactly like Japa. `json()` is kept as an additive convenience.
272
+ */
273
+ // biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the merged interface (StatusAssertions) types the status-shortcut asserts attached to the prototype from STATUS_ASSERTIONS — the same generated-method pattern as EonSchema/Macroable (AdonisJS parity); every member is implemented, so the merge is safe.
274
+ export class ApiResponse {
275
+ readonly #raw: TestResponse
276
+ readonly #method: string
277
+ readonly #redirects: string[]
278
+
279
+ constructor(raw: TestResponse, meta: ApiResponseMeta = {}) {
280
+ this.#raw = raw
281
+ this.#method = meta.method ?? 'GET'
282
+ this.#redirects = meta.redirects ?? []
283
+ applyResponseExtensions(this)
284
+ }
285
+
286
+ // ── Japa accessors (method form) ─────────────────────────────────────────
287
+
288
+ /** HTTP status code — Japa `status()`. */
289
+ status(): number {
290
+ return this.#raw.status
291
+ }
292
+
293
+ /** Status class digit (2 for 2xx, 4 for 4xx, …) — Japa `statusType()`. */
294
+ statusType(): number {
295
+ return Math.floor(this.#raw.status / 100)
296
+ }
297
+
298
+ /** All response headers (lower-cased keys) — Japa `headers()`. */
299
+ headers(): Dict {
300
+ return this.#raw.headers
301
+ }
302
+
303
+ /** A single response header value — Japa `header(name)`. */
304
+ header(name: string): string | undefined {
305
+ return this.#raw.headers[name.toLowerCase()]
306
+ }
307
+
308
+ /** Raw response text — Japa `text()`. */
309
+ text(): string {
310
+ return this.#raw.body
311
+ }
312
+
313
+ /**
314
+ * The parsed response body — Japa `body()`. Parses by `Content-Type`:
315
+ * `application/json` → object, `application/x-www-form-urlencoded` → object,
316
+ * `multipart/form-data` → the non-file fields (files come from {@link files});
317
+ * otherwise the raw text. A registered parser (`ApiResponse.addParser`) for the
318
+ * content-type takes precedence.
319
+ */
320
+ body(): unknown {
321
+ const ct = (this.#raw.headers['content-type'] ?? '').toLowerCase()
322
+ const text = this.#raw.body
323
+ const type = ct.split(';')[0]?.trim() ?? ''
324
+ const custom = responseParsers.get(type)
325
+ if (custom) return custom(text, this.#raw.headers)
326
+ if (ct.includes('application/json')) {
327
+ return text.length > 0 ? JSON.parse(text) : undefined
328
+ }
329
+ if (ct.includes('application/x-www-form-urlencoded')) {
330
+ return Object.fromEntries(new URLSearchParams(text))
331
+ }
332
+ if (ct.includes('multipart/form-data')) {
333
+ // Pass the ORIGINAL-case header — multipart boundaries are case-sensitive.
334
+ return parseMultipartResponse(text, this.#raw.headers['content-type'] ?? '').fields
335
+ }
336
+ return text
337
+ }
338
+
339
+ /** Convenience JSON parse (additive; not in Japa). */
340
+ json<T = unknown>(): T {
341
+ return JSON.parse(this.#raw.body) as T
342
+ }
343
+
344
+ /** The request method that produced this response — Japa `method()`. */
345
+ method(): string {
346
+ return this.#method
347
+ }
348
+
349
+ /** Response `Content-Type` without parameters — Japa `type()`. */
350
+ type(): string | undefined {
351
+ const ct = this.#raw.headers['content-type']
352
+ return ct ? (ct.split(';')[0]?.trim() ?? ct) : undefined
353
+ }
354
+
355
+ /** Charset from `Content-Type`, if any — Japa `charset()`. */
356
+ charset(): string | undefined {
357
+ const ct = this.#raw.headers['content-type'] ?? ''
358
+ const m = ct.match(/charset=([^;]+)/i)
359
+ return m ? m[1].trim() : undefined
360
+ }
361
+
362
+ /** All response cookies, parsed from `Set-Cookie` — Japa `cookies()`. */
363
+ cookies(): Record<string, ResponseCookie> {
364
+ return parseCookies(this.#raw.headers['set-cookie'])
365
+ }
366
+
367
+ /** A single parsed response cookie — Japa `cookie(name)`. */
368
+ cookie(name: string): ResponseCookie | undefined {
369
+ return this.cookies()[name]
370
+ }
371
+
372
+ /** URLs followed for a redirected request — Japa `redirects()`. */
373
+ redirects(): string[] {
374
+ return [...this.#redirects]
375
+ }
376
+
377
+ /** Parsed `Link` header (`rel → url`) — Japa `links()`. */
378
+ links(): Record<string, string> {
379
+ const link = this.#raw.headers.link
380
+ if (!link) return {}
381
+ const out: Record<string, string> = {}
382
+ for (const part of link.split(',')) {
383
+ const m = part.match(/<([^>]+)>\s*;\s*rel="?([^";]+)"?/)
384
+ if (m) out[m[2]] = m[1]
385
+ }
386
+ return out
387
+ }
388
+
389
+ /**
390
+ * Files parsed from a `multipart/form-data` response — Japa `files()`. Each
391
+ * entry carries the field name, filename, content-type and the raw bytes.
392
+ * Empty for non-multipart responses.
393
+ */
394
+ files(): Record<string, ResponseFile> {
395
+ const ctRaw = this.#raw.headers['content-type'] ?? ''
396
+ if (!ctRaw.toLowerCase().includes('multipart/form-data')) return {}
397
+ // Pass the ORIGINAL-case header — multipart boundaries are case-sensitive.
398
+ return parseMultipartResponse(this.#raw.body, ctRaw).files
399
+ }
400
+
401
+ /** Whether the response carries a body — Japa `hasBody()`. */
402
+ hasBody(): boolean {
403
+ return this.#raw.body.length > 0
404
+ }
405
+
406
+ /** Whether the status is an error (>= 400) — Japa `hasError()`. */
407
+ hasError(): boolean {
408
+ return this.#raw.status >= 400
409
+ }
410
+
411
+ /** Whether the status is a server error (>= 500) — Japa `hasFatalError()`. */
412
+ hasFatalError(): boolean {
413
+ return this.#raw.status >= 500
414
+ }
415
+
416
+ /**
417
+ * The error object when the response is an error, else `undefined` — Japa
418
+ * `error()`. Exposes `.status` and `.text` (superagent-style).
419
+ */
420
+ error(): ResponseError | undefined {
421
+ if (!this.hasError()) return undefined
422
+ return { status: this.#raw.status, text: this.#raw.body }
423
+ }
424
+
425
+ // ── Debugging (Japa response `dump*`) ────────────────────────────────────
426
+
427
+ /** Print status + headers + body to stderr — Japa `response.dump()`. */
428
+ dump(): this {
429
+ process.stderr.write(
430
+ `[helix:response.dump] ${this.#method} → ${this.#raw.status}\n` +
431
+ `headers: ${JSON.stringify(this.#raw.headers, null, 2)}\n` +
432
+ `body: ${capBody(this.#raw.body, 2048)}\n`,
433
+ )
434
+ return this
435
+ }
436
+ /** Print only the response body — Japa `response.dumpBody()`. */
437
+ dumpBody(): this {
438
+ process.stderr.write(`[helix:response.dumpBody] ${capBody(this.#raw.body, 2048)}\n`)
439
+ return this
440
+ }
441
+ /** Print only the response headers — Japa `response.dumpHeaders()`. */
442
+ dumpHeaders(): this {
443
+ process.stderr.write(
444
+ `[helix:response.dumpHeaders] ${JSON.stringify(this.#raw.headers, null, 2)}\n`,
445
+ )
446
+ return this
447
+ }
448
+ /** Print only the response cookies — Japa `response.dumpCookies()`. */
449
+ dumpCookies(): this {
450
+ process.stderr.write(
451
+ `[helix:response.dumpCookies] ${JSON.stringify(this.cookies(), null, 2)}\n`,
452
+ )
453
+ return this
454
+ }
455
+ /** Print the error object, if any — Japa `response.dumpError()`. */
456
+ dumpError(): this {
457
+ process.stderr.write(`[helix:response.dumpError] ${JSON.stringify(this.error(), null, 2)}\n`)
458
+ return this
459
+ }
460
+
461
+ // ── Assertions (immediate; each returns `this`) ──────────────────────────
462
+
463
+ assertStatus(code: number): this {
464
+ if (this.#raw.status !== code) {
465
+ throw new ExpectationError(
466
+ `Expected status ${code}, got ${this.#raw.status}. Body: ${capBody(this.#raw.body)}`,
467
+ )
468
+ }
469
+ return this
470
+ }
471
+
472
+ assertHeader(name: string, value?: string | RegExp): this {
473
+ const actual = this.#raw.headers[name.toLowerCase()]
474
+ if (actual === undefined) {
475
+ throw new ExpectationError(
476
+ `Expected header ${name}, not present in: ${Object.keys(this.#raw.headers).join(', ')}`,
477
+ )
478
+ }
479
+ if (value === undefined) return this
480
+ if (value instanceof RegExp) {
481
+ if (!value.test(actual)) {
482
+ throw new ExpectationError(`Expected header ${name} to match ${value}, got "${actual}"`)
483
+ }
484
+ } else if (actual !== value) {
485
+ throw new ExpectationError(`Expected header ${name} = "${value}", got "${actual}"`)
486
+ }
487
+ return this
488
+ }
489
+
490
+ assertHeaderMissing(name: string): this {
491
+ if (this.#raw.headers[name.toLowerCase()] !== undefined) {
492
+ throw new ExpectationError(`Expected header ${name} to be absent, but it was present.`)
493
+ }
494
+ return this
495
+ }
496
+
497
+ assertCookie(name: string, value?: string | RegExp): this {
498
+ const cookie = this.cookies()[name]
499
+ if (!cookie) {
500
+ throw new ExpectationError(
501
+ `Expected cookie ${name}, not found in Set-Cookie: ${this.#raw.headers['set-cookie'] ?? '(none)'}`,
502
+ )
503
+ }
504
+ if (value === undefined) return this
505
+ if (value instanceof RegExp) {
506
+ if (!value.test(cookie.value)) {
507
+ throw new ExpectationError(
508
+ `Expected cookie ${name} to match ${value}, got "${cookie.value}"`,
509
+ )
510
+ }
511
+ } else if (cookie.value !== value) {
512
+ throw new ExpectationError(`Expected cookie ${name} = "${value}", got "${cookie.value}"`)
513
+ }
514
+ return this
515
+ }
516
+
517
+ assertCookieMissing(name: string): this {
518
+ if (this.cookies()[name]) {
519
+ throw new ExpectationError(`Expected cookie ${name} to be absent, but it was set.`)
520
+ }
521
+ return this
522
+ }
523
+
524
+ /**
525
+ * The body used by `assertBody*`, parsed per `Content-Type` (Japa parity):
526
+ * urlencoded/multipart → the structured `body()`; JSON or an unknown/absent
527
+ * content-type → a tolerant JSON parse (so bodyless test fakes keep working).
528
+ */
529
+ #assertionBody(): unknown {
530
+ const ct = (this.#raw.headers['content-type'] ?? '').toLowerCase()
531
+ if (ct.includes('application/x-www-form-urlencoded') || ct.includes('multipart/form-data')) {
532
+ return this.body()
533
+ }
534
+ return jsonOf(this.#raw)
535
+ }
536
+
537
+ assertBody(expected: unknown): this {
538
+ const actual = this.#assertionBody()
539
+ if (!deepEqual(actual, expected)) {
540
+ throw new ExpectationError(
541
+ `Expected body to equal.\nExpected: ${JSON.stringify(expected)}\nActual: ${capBody(JSON.stringify(actual))}`,
542
+ )
543
+ }
544
+ return this
545
+ }
546
+
547
+ assertBodyContains(subset: unknown): this {
548
+ const actual = this.#assertionBody()
549
+ if (!partialMatch(actual, subset)) {
550
+ throw new ExpectationError(
551
+ `Expected body to contain subset.\nSubset: ${JSON.stringify(subset)}\nActual: ${capBody(JSON.stringify(actual))}`,
552
+ )
553
+ }
554
+ return this
555
+ }
556
+
557
+ assertBodyNotContains(subset: unknown): this {
558
+ const actual = this.#assertionBody()
559
+ if (partialMatch(actual, subset)) {
560
+ throw new ExpectationError(
561
+ `Expected body NOT to contain subset, but it did.\nSubset: ${JSON.stringify(subset)}\nActual: ${capBody(JSON.stringify(actual))}`,
562
+ )
563
+ }
564
+ return this
565
+ }
566
+
567
+ assertTextIncludes(substring: string): this {
568
+ if (!this.#raw.body.includes(substring)) {
569
+ throw new ExpectationError(
570
+ `Expected response text to include ${JSON.stringify(substring)}.\nActual: ${capBody(this.#raw.body)}`,
571
+ )
572
+ }
573
+ return this
574
+ }
575
+
576
+ /**
577
+ * Assert the request was redirected to `path` — Japa `assertRedirectsTo`.
578
+ * Checks the followed redirect chain (`redirects()`); also accepts a direct
579
+ * 3xx `Location` match when no redirects were followed.
580
+ */
581
+ assertRedirectsTo(path: string): this {
582
+ const pathOf = (loc: string) =>
583
+ loc.startsWith('http') ? new URL(loc).pathname : (loc.split('?')[0] ?? loc)
584
+ if (this.#redirects.some((loc) => pathOf(loc) === path)) return this
585
+ const location = this.#raw.headers.location
586
+ if (
587
+ this.#raw.status >= 300 &&
588
+ this.#raw.status < 400 &&
589
+ location &&
590
+ pathOf(location) === path
591
+ ) {
592
+ return this
593
+ }
594
+ const chain = this.#redirects.length > 0 ? this.#redirects.map(pathOf).join(', ') : '(none)'
595
+ throw new ExpectationError(
596
+ `Expected redirect to "${path}". Followed: ${chain}; final status ${this.#raw.status}` +
597
+ (location ? `, Location "${location}"` : ''),
598
+ )
599
+ }
600
+
601
+ /**
602
+ * Assert the response conforms to the registered OpenAPI spec — Japa
603
+ * `assertAgainstApiSpec()`. Japa ships this via the SEPARATE opt-in
604
+ * `@japa/openapi-assertions` plugin; register a validator with
605
+ * {@link ApiResponse.registerApiSpecValidator} to enable it. Without one, this
606
+ * throws (never silently passes).
607
+ */
608
+ assertAgainstApiSpec(): this {
609
+ if (!apiSpecValidator) {
610
+ throw new ExpectationError(
611
+ 'assertAgainstApiSpec() requires an OpenAPI validator. Register one via ' +
612
+ 'ApiResponse.registerApiSpecValidator(fn) (the opt-in equivalent of @japa/openapi-assertions).',
613
+ )
614
+ }
615
+ apiSpecValidator(this)
616
+ return this
617
+ }
618
+
619
+ /**
620
+ * Register a shared property (Japa `ApiResponse.macro`) or lazy getter
621
+ * (`ApiResponse.getter`). The callback is invoked with `this` bound to the
622
+ * response AND the response as its first argument, so both Japa's
623
+ * `function () { return this.header('x') }` and `(res) => res.header('x')` work.
624
+ */
625
+ static macro(name: string, value: unknown): void {
626
+ responseMacros.set(name, value)
627
+ }
628
+ static getter(name: string, fn: (this: ApiResponse, res: ApiResponse) => unknown): void {
629
+ responseGetters.set(name, fn)
630
+ }
631
+
632
+ /** Register a response body parser for a content-type — Japa `ApiRequest.addParser`. */
633
+ static addParser(contentType: string, fn: (body: string, headers: Dict) => unknown): void {
634
+ responseParsers.set(contentType, fn)
635
+ }
636
+
637
+ /** Register the validator backing {@link assertAgainstApiSpec} (OpenAPI opt-in). */
638
+ static registerApiSpecValidator(fn: (res: ApiResponse) => void): void {
639
+ apiSpecValidator = fn
640
+ }
641
+ }
642
+
643
+ // The status-shortcut assertions are attached to the prototype from the shared
644
+ // map so ApiResponse and the builder can never disagree on the code set.
645
+ for (const [method, code] of Object.entries(STATUS_ASSERTIONS)) {
646
+ Object.defineProperty(ApiResponse.prototype, method, {
647
+ value(this: ApiResponse) {
648
+ return this.assertStatus(code)
649
+ },
650
+ enumerable: false,
651
+ configurable: true,
652
+ writable: true,
653
+ })
654
+ }
655
+
656
+ /** The status-shortcut assertion methods, typed onto ApiResponse + the builder. */
657
+ export type StatusAssertions<T> = {
658
+ [K in keyof typeof STATUS_ASSERTIONS]: () => T
659
+ }
660
+
661
+ // Merge the generated status-shortcut methods into the ApiResponse type.
662
+ // biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the merged interface (StatusAssertions) types the status-shortcut asserts attached to the prototype from STATUS_ASSERTIONS — the same generated-method pattern as EonSchema/Macroable (AdonisJS parity); every member is implemented, so the merge is safe.
663
+ export interface ApiResponse extends StatusAssertions<ApiResponse> {}
664
+
665
+ /** Apply registered macros/getters onto a freshly built response. */
666
+ function applyResponseExtensions(res: ApiResponse): void {
667
+ for (const [name, value] of responseMacros) {
668
+ Object.defineProperty(res, name, {
669
+ value,
670
+ enumerable: true,
671
+ configurable: true,
672
+ writable: true,
673
+ })
674
+ }
675
+ for (const [name, fn] of responseGetters) {
676
+ let computed = false
677
+ let cached: unknown
678
+ Object.defineProperty(res, name, {
679
+ enumerable: true,
680
+ configurable: true,
681
+ get() {
682
+ if (!computed) {
683
+ // `this`-bound AND passed as arg → both `function(){this.x}` and `(res)=>res.x` work.
684
+ cached = fn.call(res, res)
685
+ computed = true
686
+ }
687
+ return cached
688
+ },
689
+ })
690
+ }
691
+ }