@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.
- package/README.md +65 -0
- package/dist/Ignitor.d.ts +115 -7
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +233 -37
- package/dist/Ignitor.js.map +1 -1
- package/dist/console/BaseCommand.d.ts +217 -0
- package/dist/console/BaseCommand.d.ts.map +1 -0
- package/dist/console/BaseCommand.js +340 -0
- package/dist/console/BaseCommand.js.map +1 -0
- package/dist/console/Console.d.ts +86 -0
- package/dist/console/Console.d.ts.map +1 -0
- package/dist/console/Console.js +134 -0
- package/dist/console/Console.js.map +1 -0
- package/dist/console/ExceptionHandler.d.ts +28 -0
- package/dist/console/ExceptionHandler.d.ts.map +1 -0
- package/dist/console/ExceptionHandler.js +76 -0
- package/dist/console/ExceptionHandler.js.map +1 -0
- package/dist/console/HelpCommand.d.ts +16 -0
- package/dist/console/HelpCommand.d.ts.map +1 -0
- package/dist/console/HelpCommand.js +40 -0
- package/dist/console/HelpCommand.js.map +1 -0
- package/dist/console/IndexGenerator.d.ts +24 -0
- package/dist/console/IndexGenerator.d.ts.map +1 -0
- package/dist/console/IndexGenerator.js +65 -0
- package/dist/console/IndexGenerator.js.map +1 -0
- package/dist/console/Kernel.d.ts +298 -0
- package/dist/console/Kernel.d.ts.map +1 -0
- package/dist/console/Kernel.js +1120 -0
- package/dist/console/Kernel.js.map +1 -0
- package/dist/console/ListCommand.d.ts +18 -0
- package/dist/console/ListCommand.d.ts.map +1 -0
- package/dist/console/ListCommand.js +60 -0
- package/dist/console/ListCommand.js.map +1 -0
- package/dist/console/assertions.d.ts +20 -0
- package/dist/console/assertions.d.ts.map +1 -0
- package/dist/console/assertions.js +84 -0
- package/dist/console/assertions.js.map +1 -0
- package/dist/console/cliui.d.ts +248 -0
- package/dist/console/cliui.d.ts.map +1 -0
- package/dist/console/cliui.js +587 -0
- package/dist/console/cliui.js.map +1 -0
- package/dist/console/decorators.d.ts +90 -0
- package/dist/console/decorators.d.ts.map +1 -0
- package/dist/console/decorators.js +142 -0
- package/dist/console/decorators.js.map +1 -0
- package/dist/console/index.d.ts +32 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +24 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/loaders.d.ts +52 -0
- package/dist/console/loaders.d.ts.map +1 -0
- package/dist/console/loaders.js +131 -0
- package/dist/console/loaders.js.map +1 -0
- package/dist/console/parser.d.ts +86 -0
- package/dist/console/parser.d.ts.map +1 -0
- package/dist/console/parser.js +440 -0
- package/dist/console/parser.js.map +1 -0
- package/dist/console/prompts.d.ts +124 -0
- package/dist/console/prompts.d.ts.map +1 -0
- package/dist/console/prompts.js +356 -0
- package/dist/console/prompts.js.map +1 -0
- package/dist/console/tracing.d.ts +17 -0
- package/dist/console/tracing.d.ts.map +1 -0
- package/dist/console/tracing.js +11 -0
- package/dist/console/tracing.js.map +1 -0
- package/dist/console/types.d.ts +255 -0
- package/dist/console/types.d.ts.map +1 -0
- package/dist/console/types.js +25 -0
- package/dist/console/types.js.map +1 -0
- package/dist/console/ui.d.ts +20 -0
- package/dist/console/ui.d.ts.map +1 -0
- package/dist/console/ui.js +31 -0
- package/dist/console/ui.js.map +1 -0
- package/dist/console/utils.d.ts +27 -0
- package/dist/console/utils.d.ts.map +1 -0
- package/dist/console/utils.js +56 -0
- package/dist/console/utils.js.map +1 -0
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +40 -12
- package/dist/container/Container.js.map +1 -1
- package/dist/container/types.d.ts +14 -2
- package/dist/container/types.d.ts.map +1 -1
- package/dist/decorators/Service.d.ts +7 -0
- package/dist/decorators/Service.d.ts.map +1 -1
- package/dist/decorators/Service.js +23 -3
- package/dist/decorators/Service.js.map +1 -1
- package/dist/errors/ReamError.d.ts +25 -0
- package/dist/errors/ReamError.d.ts.map +1 -1
- package/dist/errors/ReamError.js +5 -0
- package/dist/errors/ReamError.js.map +1 -1
- package/dist/errors/prettyPrintError.d.ts +9 -0
- package/dist/errors/prettyPrintError.d.ts.map +1 -0
- package/dist/errors/prettyPrintError.js +22 -0
- package/dist/errors/prettyPrintError.js.map +1 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts +29 -8
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +39 -1
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +13 -2
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/services/app.d.ts +1 -1
- package/dist/services/app.js +1 -1
- package/dist/services/console.d.ts +21 -0
- package/dist/services/console.d.ts.map +1 -0
- package/dist/services/console.js +35 -0
- package/dist/services/console.js.map +1 -0
- package/dist/testing/ApiResponse.d.ts +218 -0
- package/dist/testing/ApiResponse.d.ts.map +1 -0
- package/dist/testing/ApiResponse.js +576 -0
- package/dist/testing/ApiResponse.js.map +1 -0
- package/dist/testing/RequestBuilder.d.ts +132 -72
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +389 -222
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/testing/TestClient.d.ts +34 -39
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +48 -56
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/utils/Macroable.js +3 -3
- package/dist/utils/Macroable.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +15 -2
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Ignitor.ts +340 -42
- package/src/console/BaseCommand.ts +467 -0
- package/src/console/Console.ts +174 -0
- package/src/console/ExceptionHandler.ts +91 -0
- package/src/console/HelpCommand.ts +37 -0
- package/src/console/IndexGenerator.ts +79 -0
- package/src/console/Kernel.ts +1382 -0
- package/src/console/ListCommand.ts +55 -0
- package/src/console/assertions.ts +114 -0
- package/src/console/cliui.ts +756 -0
- package/src/console/decorators.ts +199 -0
- package/src/console/index.ts +67 -0
- package/src/console/loaders.ts +145 -0
- package/src/console/parser.ts +598 -0
- package/src/console/prompts.ts +548 -0
- package/src/console/tracing.ts +22 -0
- package/src/console/types.ts +280 -0
- package/src/console/ui.ts +37 -0
- package/src/console/utils.ts +69 -0
- package/src/container/Container.ts +55 -13
- package/src/container/types.ts +15 -2
- package/src/decorators/Service.ts +28 -3
- package/src/errors/ReamError.ts +31 -0
- package/src/errors/prettyPrintError.ts +20 -0
- package/src/index.ts +30 -4
- package/src/middleware/Pipeline.ts +76 -5
- package/src/rpc/RpcRouter.ts +16 -2
- package/src/services/app.ts +1 -1
- package/src/services/console.ts +42 -0
- package/src/testing/ApiResponse.ts +691 -0
- package/src/testing/RequestBuilder.ts +498 -275
- package/src/testing/TestClient.ts +63 -86
- package/src/utils/Macroable.ts +3 -3
- package/dist/console/CommandRunner.d.ts +0 -18
- package/dist/console/CommandRunner.d.ts.map +0 -1
- package/dist/console/CommandRunner.js +0 -65
- package/dist/console/CommandRunner.js.map +0 -1
- 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
|
+
}
|