@znight/testing 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +441 -2
- package/dist/index.d.mts +697 -0
- package/dist/index.mjs +1561 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +49 -4
- package/src/config.ts +214 -0
- package/src/define-metadata.ts +255 -0
- package/src/extensions.ts +109 -0
- package/src/index.ts +58 -0
- package/src/metadata-index.ts +161 -0
- package/src/mock-fn.ts +83 -0
- package/src/mock-registry.ts +118 -0
- package/src/mock.ts +270 -0
- package/src/overrides.ts +54 -0
- package/src/spy.ts +246 -0
- package/src/test-app.ts +551 -0
- package/src/testbed-builder.ts +366 -0
- package/src/testbed.ts +97 -0
- package/src/types.ts +107 -0
- package/src/utils.ts +37 -0
package/src/test-app.ts
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClassKey,
|
|
3
|
+
Container,
|
|
4
|
+
CoreLogger,
|
|
5
|
+
Plugin,
|
|
6
|
+
PluginRootsContext,
|
|
7
|
+
ProviderClass,
|
|
8
|
+
ProviderKey,
|
|
9
|
+
ProviderValues,
|
|
10
|
+
ShutdownOptions,
|
|
11
|
+
Token,
|
|
12
|
+
TypedToken,
|
|
13
|
+
ZnightMetadata,
|
|
14
|
+
} from '@znight/core'
|
|
15
|
+
|
|
16
|
+
import { Application, createConsoleLogger, ErrorCode, ZnightError } from '@znight/core'
|
|
17
|
+
|
|
18
|
+
import { readHttpRoots, readMessagingRoots, readScheduleRoots } from './extensions.ts'
|
|
19
|
+
import { MetadataIndex } from './metadata-index.ts'
|
|
20
|
+
import { entriesOf } from './utils.ts'
|
|
21
|
+
|
|
22
|
+
/** Name of the HTTP plugin `TestApp.inject()` delegates to (`HttpPlugin.configure()` from `@znight/http`). */
|
|
23
|
+
export const HTTP_PLUGIN_NAME = 'http'
|
|
24
|
+
|
|
25
|
+
/** An in-memory HTTP request for {@link TestApp.inject}. */
|
|
26
|
+
export interface InjectRequest {
|
|
27
|
+
/** HTTP method. Default: `GET`. */
|
|
28
|
+
readonly method?: string
|
|
29
|
+
/** Path, with an optional query string (`/users?page=2`). */
|
|
30
|
+
readonly url: string
|
|
31
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
32
|
+
/**
|
|
33
|
+
* Request body, encoded by the HTTP plugin: a string or bytes are sent as is (`text/plain` /
|
|
34
|
+
* `application/octet-stream` without `content-type` header), a `FormData` as
|
|
35
|
+
* `multipart/form-data` (files included: uploads), a `Blob` / `File` with its type, anything else
|
|
36
|
+
* as JSON (`application/json`).
|
|
37
|
+
*/
|
|
38
|
+
readonly body?: unknown
|
|
39
|
+
/** Query parameters added to those of `url`. */
|
|
40
|
+
readonly query?: Readonly<Record<string, string | readonly string[]>>
|
|
41
|
+
/**
|
|
42
|
+
* Request cookies (name → value), sent by the HTTP plugin in a `cookie` header, after any `cookie`
|
|
43
|
+
* header of `headers`. Values are encoded like the cookies the plugin sets, so a value read from
|
|
44
|
+
* `response.cookies` can be sent back as is (signed values included).
|
|
45
|
+
*/
|
|
46
|
+
readonly cookies?: Readonly<Record<string, string>>
|
|
47
|
+
/**
|
|
48
|
+
* Cookies the HTTP plugin signs with its current secret (`cookies.secrets[0]`) and merges into
|
|
49
|
+
* `cookies` before dispatch: the way to send a valid `@SignedCookie`.
|
|
50
|
+
*/
|
|
51
|
+
readonly signedCookies?: Readonly<Record<string, string>>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A parsed `Set-Cookie` header of an {@link InjectResponse}: the shape of `SetCookieInfo` from
|
|
56
|
+
* `@znight/http`.
|
|
57
|
+
*/
|
|
58
|
+
export interface SetCookieInfo {
|
|
59
|
+
readonly name: string
|
|
60
|
+
/** Decoded value (a signed cookie: `<value>.<signature>`). */
|
|
61
|
+
readonly value: string
|
|
62
|
+
/** The `Set-Cookie` header line. */
|
|
63
|
+
readonly raw: string
|
|
64
|
+
readonly expires?: Date
|
|
65
|
+
readonly maxAge?: number
|
|
66
|
+
readonly domain?: string
|
|
67
|
+
readonly path?: string
|
|
68
|
+
readonly secure: boolean
|
|
69
|
+
readonly httpOnly: boolean
|
|
70
|
+
readonly sameSite?: 'strict' | 'lax' | 'none'
|
|
71
|
+
readonly partitioned: boolean
|
|
72
|
+
readonly priority?: 'low' | 'medium' | 'high'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The response of {@link TestApp.inject}. */
|
|
76
|
+
export interface InjectResponse {
|
|
77
|
+
readonly status: number
|
|
78
|
+
/** Response headers (lower-cased names). */
|
|
79
|
+
readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
|
|
80
|
+
/** The body decoded as UTF-8. */
|
|
81
|
+
readonly body: string
|
|
82
|
+
/** The raw body bytes (downloads, binary responses). */
|
|
83
|
+
readonly bytes: Uint8Array
|
|
84
|
+
/** The `set-cookie` headers parsed, by cookie name (the last one wins); `{}` without any. */
|
|
85
|
+
readonly cookies: Readonly<Record<string, SetCookieInfo>>
|
|
86
|
+
/** The body parsed as JSON. */
|
|
87
|
+
json<T = unknown>(): T
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What the `inject()` of the HTTP plugin resolves with: an {@link InjectResponse} whose `bytes` and
|
|
92
|
+
* `cookies` may be absent (`TestApp.inject()` then encodes `body` as UTF-8 and returns no cookies).
|
|
93
|
+
*/
|
|
94
|
+
export type HttpInjectResult = Omit<InjectResponse, 'bytes' | 'cookies'> & {
|
|
95
|
+
readonly bytes?: Uint8Array
|
|
96
|
+
readonly cookies?: Readonly<Record<string, SetCookieInfo>>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What `TestApp.inject()` expects from the plugin named `'http'`: an in-memory dispatch (no socket).
|
|
101
|
+
* `HttpPlugin` from `@znight/http` implements it.
|
|
102
|
+
*/
|
|
103
|
+
export interface HttpInjector {
|
|
104
|
+
inject(request: InjectRequest & { readonly method: string }): Promise<HttpInjectResult>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A request for {@link TestApp.injectStream} / {@link TestApp.injectSse}: an {@link InjectRequest} that can be aborted. */
|
|
108
|
+
export interface InjectStreamRequest extends InjectRequest {
|
|
109
|
+
/** Aborting it disconnects the client (like cancelling the response body). */
|
|
110
|
+
readonly signal?: AbortSignal
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The response of {@link TestApp.injectStream}: available at the headers, the body read as it is
|
|
115
|
+
* produced. Cancelling `body` disconnects the client. The shape of `InjectStreamResponse` from
|
|
116
|
+
* `@znight/http`.
|
|
117
|
+
*/
|
|
118
|
+
export interface InjectStreamResponse {
|
|
119
|
+
readonly status: number
|
|
120
|
+
readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
|
|
121
|
+
readonly cookies: Readonly<Record<string, SetCookieInfo>>
|
|
122
|
+
readonly body: ReadableStream<Uint8Array>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** An event of an event stream (`SseMessage` of `@znight/http`). */
|
|
126
|
+
export interface SseMessage {
|
|
127
|
+
/** Event type: `message` by default. */
|
|
128
|
+
readonly event: string
|
|
129
|
+
readonly data: string
|
|
130
|
+
/** The last event id at dispatch time. */
|
|
131
|
+
readonly id: string
|
|
132
|
+
json<T = unknown>(): T
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The response of {@link TestApp.injectSse}: an event stream read in memory like `EventSource`
|
|
137
|
+
* would (`InjectSseResponse` of `@znight/http`).
|
|
138
|
+
*/
|
|
139
|
+
export interface InjectSseResponse extends AsyncIterable<SseMessage>, AsyncDisposable {
|
|
140
|
+
readonly status: number
|
|
141
|
+
readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
|
|
142
|
+
readonly cookies: Readonly<Record<string, SetCookieInfo>>
|
|
143
|
+
/** `''` for an event stream; otherwise the whole body (an error, a 204...). */
|
|
144
|
+
readonly body: string
|
|
145
|
+
json<T = unknown>(): T
|
|
146
|
+
/** The next event (comments skipped). Rejects on timeout (default 2 000 ms) or when the stream ended. */
|
|
147
|
+
next(options?: { readonly timeoutMs?: number }): Promise<SseMessage>
|
|
148
|
+
take(count: number, options?: { readonly timeoutMs?: number }): Promise<SseMessage[]>
|
|
149
|
+
/** Comments received so far (heartbeats are `''`). */
|
|
150
|
+
readonly comments: readonly string[]
|
|
151
|
+
/** What a browser would send as `Last-Event-ID`. */
|
|
152
|
+
readonly lastEventId: string
|
|
153
|
+
/** The last `retry` received. */
|
|
154
|
+
readonly retry: number | undefined
|
|
155
|
+
/** Resolves when the server ends the stream. */
|
|
156
|
+
readonly done: Promise<void>
|
|
157
|
+
/** Disconnects (the server sees a client disconnect). Idempotent. */
|
|
158
|
+
close(): Promise<void>
|
|
159
|
+
/** Reconnects like `EventSource`: the same request plus `last-event-id`. */
|
|
160
|
+
reconnect(): Promise<InjectSseResponse>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What `TestApp.injectStream()` / `injectSse()` expect from the plugin named `'http'`
|
|
165
|
+
* (`HttpPlugin` of `@znight/http` ≥ 0.1.0, the release that ships streaming).
|
|
166
|
+
*/
|
|
167
|
+
export interface HttpStreamInjector {
|
|
168
|
+
injectStream(request: InjectStreamRequest & { readonly method: string }): Promise<InjectStreamResponse>
|
|
169
|
+
injectSse(request: InjectStreamRequest & { readonly method: string }): Promise<InjectSseResponse>
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A plugin implementing the optional `initRoots(context)` member of `Plugin` (`@znight/core`), read
|
|
174
|
+
* by `TestApp.create()`: the providers the plugin needs at boot although the metadata does not make
|
|
175
|
+
* them entry points, i.e. classes or tokens given in its options (`HttpPlugin.configure({ guards,
|
|
176
|
+
* interceptors, exceptionMappers })`) or derived from its metadata section (`context.metadata`).
|
|
177
|
+
* `TestApp` adds them to the providers instantiated at boot (see {@link TestAppOptions.only}), so
|
|
178
|
+
* that their async dependencies exist when the plugin resolves them: an async factory cannot run on
|
|
179
|
+
* demand.
|
|
180
|
+
*
|
|
181
|
+
* Called once per `TestApp.create()`, before the application is created. Keys that are not
|
|
182
|
+
* providers of the metadata are ignored (the plugin reports them itself).
|
|
183
|
+
*/
|
|
184
|
+
export type PluginInitRoots = Required<Pick<Plugin, 'initRoots'>>
|
|
185
|
+
|
|
186
|
+
/** Options of {@link TestApp.create}. */
|
|
187
|
+
export interface TestAppOptions {
|
|
188
|
+
/** The generated metadata (`__ZNIGHT_METADATA__`). */
|
|
189
|
+
readonly metadata: ZnightMetadata
|
|
190
|
+
readonly plugins?: readonly Plugin[]
|
|
191
|
+
/** Values supplied by the application (externals, configuration...), as in `Application.create`. */
|
|
192
|
+
readonly providers?: ProviderValues
|
|
193
|
+
/**
|
|
194
|
+
* Replacements applied last, with the highest precedence: values or provider definitions
|
|
195
|
+
* (`{ scope, deps, factory }`), keyed by class or token. Overridden providers are instantiated.
|
|
196
|
+
*/
|
|
197
|
+
readonly overrides?: ProviderValues
|
|
198
|
+
/**
|
|
199
|
+
* Which providers are instantiated at boot (with their dependency closure, plus the overrides and
|
|
200
|
+
* the providers the plugins resolve at bootstrap, see {@link PluginInitRoots}: global guards,
|
|
201
|
+
* interceptors and exception mappers of `HttpPlugin`):
|
|
202
|
+
* - default: the entry points of the metadata, i.e. the HTTP controllers (with their guards and
|
|
203
|
+
* interceptors), the message consumers and the hosts of scheduled jobs, or every provider when
|
|
204
|
+
* there is none;
|
|
205
|
+
* - `'all'`: every provider;
|
|
206
|
+
* - a list of classes or tokens.
|
|
207
|
+
*
|
|
208
|
+
* Other providers are created on first use (synchronous factories only: list a provider here
|
|
209
|
+
* when it, or one of its dependencies, has an async factory).
|
|
210
|
+
*/
|
|
211
|
+
readonly only?: readonly ProviderKey[] | 'all'
|
|
212
|
+
/** Application logger. Default: a console logger writing warnings and errors. */
|
|
213
|
+
readonly logger?: CoreLogger
|
|
214
|
+
/** Run `app.start()` (plugins `onStart`: listen, subscribe...). Default: `true`. */
|
|
215
|
+
readonly start?: boolean
|
|
216
|
+
/** Shutdown timeouts. Signal handlers are always disabled. */
|
|
217
|
+
readonly shutdown?: Omit<ShutdownOptions, 'signals' | 'process'>
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* End-to-end harness: a started {@link Application} without signal handlers, overrides applied last
|
|
222
|
+
* and only what the tested entry points need instantiated.
|
|
223
|
+
*
|
|
224
|
+
* ```ts
|
|
225
|
+
* await using app = await TestApp.create({
|
|
226
|
+
* metadata: __ZNIGHT_METADATA__,
|
|
227
|
+
* plugins: [HttpPlugin.configure({ adapter: fastify() })],
|
|
228
|
+
* overrides: [[UserRepository, fakeRepository]],
|
|
229
|
+
* })
|
|
230
|
+
* const response = await app.inject({ method: 'GET', url: '/users/1' })
|
|
231
|
+
* expect(response.status).toBe(200)
|
|
232
|
+
* expect(response.json()).toEqual({ id: '1' })
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
export class TestApp implements AsyncDisposable {
|
|
236
|
+
/** The application under test. */
|
|
237
|
+
readonly app: Application
|
|
238
|
+
#closed = false
|
|
239
|
+
|
|
240
|
+
private constructor(app: Application) {
|
|
241
|
+
this.app = app
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Creates and (by default) starts the application.
|
|
246
|
+
*
|
|
247
|
+
* Plugins are used as configured: give the servers they start a free port or none in tests
|
|
248
|
+
* (`HttpPlugin.configure({ port: 0 })`, `ObservabilityPlugin.configure({ metrics: { server: false } })`);
|
|
249
|
+
* a start failing on a port already in use (`EADDRINUSE`) gets a hint saying so.
|
|
250
|
+
*
|
|
251
|
+
* @throws the error of `Application.create()` / `app.start()` (everything is rolled back).
|
|
252
|
+
*/
|
|
253
|
+
static async create(options: TestAppOptions): Promise<TestApp> {
|
|
254
|
+
if (options === null || typeof options !== 'object') {
|
|
255
|
+
throw new TypeError('[znight] TestApp.create({ metadata }) requires options with the generated metadata')
|
|
256
|
+
}
|
|
257
|
+
const index = new MetadataIndex(options.metadata)
|
|
258
|
+
const providers = resolveKeys(options.providers, index)
|
|
259
|
+
const overrides = resolveKeys(options.overrides, index)
|
|
260
|
+
const init = initOptions(options, index, overrides)
|
|
261
|
+
try {
|
|
262
|
+
const app = await Application.create({
|
|
263
|
+
metadata: options.metadata,
|
|
264
|
+
plugins: options.plugins ?? [],
|
|
265
|
+
...(providers === undefined ? {} : { providers }),
|
|
266
|
+
...(overrides === undefined ? {} : { overrides }),
|
|
267
|
+
logger: options.logger ?? createConsoleLogger({ level: 'warn' }),
|
|
268
|
+
shutdown: { ...options.shutdown, signals: false },
|
|
269
|
+
init,
|
|
270
|
+
})
|
|
271
|
+
if (options.start !== false) await app.start()
|
|
272
|
+
return new TestApp(app)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
addHints(error, init.only !== undefined)
|
|
275
|
+
throw error
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The application container. */
|
|
280
|
+
get container(): Container {
|
|
281
|
+
return this.app.container
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Shortcut for `app.container.get(key)`. */
|
|
285
|
+
get<T>(key: ProviderClass<T>): T
|
|
286
|
+
get<T>(key: ClassKey<T>): T
|
|
287
|
+
get<T = unknown>(key: TypedToken<T>): T
|
|
288
|
+
get(key: ProviderKey): unknown {
|
|
289
|
+
return this.app.container.get(key as TypedToken<unknown>)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Dispatches a request in memory through the HTTP plugin (`HttpPlugin`): routing, guards,
|
|
294
|
+
* interceptors, validation, serialisation, without opening a socket. Send uploads as a
|
|
295
|
+
* `FormData` body; read downloads from `response.bytes`. Send cookies with `cookies` (and
|
|
296
|
+
* `signedCookies`), read those the response sets from `response.cookies`.
|
|
297
|
+
*
|
|
298
|
+
* @throws ZnightError ZNIGHT001 when the application has no HTTP plugin, ZNIGHT021 once closed.
|
|
299
|
+
*/
|
|
300
|
+
inject(request: InjectRequest): Promise<InjectResponse> {
|
|
301
|
+
try {
|
|
302
|
+
if (this.#closed) {
|
|
303
|
+
throw new ZnightError(ErrorCode.LifecycleFailed, 'TestApp.inject(): the application is closed')
|
|
304
|
+
}
|
|
305
|
+
if (request === null || typeof request !== 'object' || typeof request.url !== 'string') {
|
|
306
|
+
throw new TypeError(
|
|
307
|
+
'[znight] TestApp.inject() expects { method?, url, headers?, body?, query?, cookies?, signedCookies? }',
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
const plugin = this.app.getPlugin<Plugin & Partial<HttpInjector>>(HTTP_PLUGIN_NAME)
|
|
311
|
+
if (plugin === undefined || typeof plugin.inject !== 'function') {
|
|
312
|
+
throw new ZnightError(ErrorCode.MissingProvider, 'TestApp.inject() requires the HTTP plugin', {
|
|
313
|
+
hints: ['Add HttpPlugin.configure({ adapter }) from @znight/http to TestApp.create({ plugins })'],
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
const method = (request.method ?? 'GET').toUpperCase()
|
|
317
|
+
return Promise.resolve(plugin.inject({ ...request, method })).then(complete)
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return Promise.reject(error)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Dispatches a request in memory and resolves at the response headers; the body is read as it is
|
|
325
|
+
* produced (`response.body`). Cancelling the body (or aborting `signal`) disconnects the client.
|
|
326
|
+
*
|
|
327
|
+
* @throws ZnightError ZNIGHT001 when the HTTP plugin does not support streaming, ZNIGHT021 once closed.
|
|
328
|
+
*/
|
|
329
|
+
injectStream(request: InjectStreamRequest): Promise<InjectStreamResponse> {
|
|
330
|
+
return this.#stream('injectStream', request)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Opens an event stream (Server-Sent Events) in memory and reads it like `EventSource`:
|
|
335
|
+
*
|
|
336
|
+
* ```ts
|
|
337
|
+
* await using stream = await app.injectSse({ url: '/orders/o-1/events' })
|
|
338
|
+
* const event = await stream.next() // rejects after 2 000 ms
|
|
339
|
+
* expect(event.json()).toMatchObject({ status: 'paid' })
|
|
340
|
+
* const resumed = await stream.reconnect() // last-event-id, like EventSource
|
|
341
|
+
* ```
|
|
342
|
+
*
|
|
343
|
+
* Leaving the `await using` block (or `close()`) disconnects: the server sees `ctx.signal` abort.
|
|
344
|
+
*
|
|
345
|
+
* @throws ZnightError ZNIGHT001 when the HTTP plugin does not support event streams, ZNIGHT021 once closed.
|
|
346
|
+
*/
|
|
347
|
+
injectSse(request: InjectStreamRequest): Promise<InjectSseResponse> {
|
|
348
|
+
return this.#stream('injectSse', request)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#stream<K extends keyof HttpStreamInjector>(
|
|
352
|
+
method: K,
|
|
353
|
+
request: InjectStreamRequest,
|
|
354
|
+
): ReturnType<HttpStreamInjector[K]> {
|
|
355
|
+
try {
|
|
356
|
+
if (this.#closed) {
|
|
357
|
+
throw new ZnightError(ErrorCode.LifecycleFailed, `TestApp.${method}(): the application is closed`)
|
|
358
|
+
}
|
|
359
|
+
if (request === null || typeof request !== 'object' || typeof request.url !== 'string') {
|
|
360
|
+
throw new TypeError(
|
|
361
|
+
`[znight] TestApp.${method}() expects { method?, url, headers?, body?, query?, cookies?, signedCookies?, signal? }`,
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
const plugin = this.app.getPlugin<Plugin & Partial<HttpStreamInjector>>(HTTP_PLUGIN_NAME)
|
|
365
|
+
const dispatch = plugin?.[method] as
|
|
366
|
+
| ((this: unknown, request: InjectStreamRequest & { readonly method: string }) => unknown)
|
|
367
|
+
| undefined
|
|
368
|
+
if (typeof dispatch !== 'function') {
|
|
369
|
+
throw new ZnightError(
|
|
370
|
+
ErrorCode.MissingProvider,
|
|
371
|
+
`TestApp.${method}() requires the HTTP plugin with streaming support`,
|
|
372
|
+
{
|
|
373
|
+
hints: [
|
|
374
|
+
plugin === undefined
|
|
375
|
+
? 'Add HttpPlugin.configure({ adapter }) from @znight/http to TestApp.create({ plugins })'
|
|
376
|
+
: `The plugin named 'http' does not implement ${method}(): register HttpPlugin.configure({ adapter }) from @znight/http ≥ 0.1.0 (the same release as @znight/testing)`,
|
|
377
|
+
],
|
|
378
|
+
},
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
const sent = { ...request, method: (request.method ?? 'GET').toUpperCase() }
|
|
382
|
+
return Promise.resolve(dispatch.call(plugin, sent)) as ReturnType<HttpStreamInjector[K]>
|
|
383
|
+
} catch (error) {
|
|
384
|
+
return Promise.reject(error) as ReturnType<HttpStreamInjector[K]>
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Stops the application (idempotent): plugins `onStop`, providers `onStop` and disposal, plugins `onDispose`. */
|
|
389
|
+
close(): Promise<void> {
|
|
390
|
+
this.#closed = true
|
|
391
|
+
return this.app.stop()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** {@link close}, for `await using app = await TestApp.create(...)`. */
|
|
395
|
+
[Symbol.asyncDispose](): Promise<void> {
|
|
396
|
+
return this.close()
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const ENCODER = new TextEncoder()
|
|
401
|
+
|
|
402
|
+
const NO_COOKIES: Readonly<Record<string, SetCookieInfo>> = Object.freeze({})
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The plugin response, as is when it is complete; otherwise with `bytes` derived from `body` and
|
|
406
|
+
* no `cookies` when the plugin did not provide them.
|
|
407
|
+
*/
|
|
408
|
+
function complete(response: HttpInjectResult): InjectResponse {
|
|
409
|
+
const hasBytes = response.bytes instanceof Uint8Array
|
|
410
|
+
const hasCookies = response.cookies !== null && typeof response.cookies === 'object'
|
|
411
|
+
if (hasBytes && hasCookies) return response as InjectResponse
|
|
412
|
+
return {
|
|
413
|
+
status: response.status,
|
|
414
|
+
headers: response.headers,
|
|
415
|
+
body: response.body,
|
|
416
|
+
bytes: hasBytes ? (response.bytes as Uint8Array) : ENCODER.encode(response.body),
|
|
417
|
+
cookies: hasCookies ? (response.cookies as Readonly<Record<string, SetCookieInfo>>) : NO_COOKIES,
|
|
418
|
+
json: <T = unknown>(): T => response.json<T>(),
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
type Entries = readonly (readonly [ProviderKey, unknown])[]
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Materialises `providers`/`overrides` and maps string keys that are not tokens of the metadata to
|
|
426
|
+
* the token they designate (alias or unique display name: `'UserRepository'`), like `TestBed` does.
|
|
427
|
+
* Unknown strings are kept (tokens registered at runtime by plugins); classes are resolved by the
|
|
428
|
+
* container.
|
|
429
|
+
*/
|
|
430
|
+
function resolveKeys(values: ProviderValues | undefined, index: MetadataIndex): Entries | undefined {
|
|
431
|
+
if (values === undefined) return undefined
|
|
432
|
+
const known = index.knownTokens()
|
|
433
|
+
return entriesOf<ProviderKey, unknown>(values).map(([key, value]): readonly [ProviderKey, unknown] => {
|
|
434
|
+
if (typeof key !== 'string' || known.has(key)) return [key, value]
|
|
435
|
+
return [index.find(key, known) ?? key, value]
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function initOptions(
|
|
440
|
+
options: TestAppOptions,
|
|
441
|
+
index: MetadataIndex,
|
|
442
|
+
overrides: Entries | undefined,
|
|
443
|
+
): { readonly only?: readonly ProviderKey[] } {
|
|
444
|
+
const only = options.only
|
|
445
|
+
if (only === 'all') return {}
|
|
446
|
+
let roots: readonly ProviderKey[]
|
|
447
|
+
if (only === undefined) {
|
|
448
|
+
const metadata = options.metadata
|
|
449
|
+
roots = [...readHttpRoots(metadata), ...readMessagingRoots(metadata), ...readScheduleRoots(metadata)].filter(
|
|
450
|
+
token => index.isProvider(token),
|
|
451
|
+
)
|
|
452
|
+
// plugin roots complete the entry points; they never restrict the boot by themselves
|
|
453
|
+
if (roots.length === 0) return {}
|
|
454
|
+
} else {
|
|
455
|
+
if (!Array.isArray(only)) throw new TypeError("[znight] TestApp.create(): only must be 'all' or an array of keys")
|
|
456
|
+
roots = only
|
|
457
|
+
}
|
|
458
|
+
const extra = (overrides ?? []).map(([key]) => key)
|
|
459
|
+
const plugins = pluginRoots(options.plugins ?? [], { metadata: options.metadata }, index)
|
|
460
|
+
return { only: unique([...roots, ...plugins, ...extra]) }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Providers the plugins need at boot (`Plugin.initRoots()`, {@link PluginInitRoots}), as provider
|
|
465
|
+
* tokens of the metadata. Other keys (classes without provider, unknown or ambiguous names) are
|
|
466
|
+
* left to the plugin, which reports them with its own message.
|
|
467
|
+
*/
|
|
468
|
+
function pluginRoots(plugins: readonly Plugin[], context: PluginRootsContext, index: MetadataIndex): Token[] {
|
|
469
|
+
const providers = new Set(index.providerTokens())
|
|
470
|
+
const roots: Token[] = []
|
|
471
|
+
const frozen: PluginRootsContext = Object.freeze({ metadata: context.metadata })
|
|
472
|
+
for (const plugin of plugins) {
|
|
473
|
+
const initRoots = (plugin as Partial<PluginInitRoots> | null | undefined)?.initRoots
|
|
474
|
+
if (typeof initRoots !== 'function') continue
|
|
475
|
+
const keys: unknown = initRoots.call(plugin, frozen)
|
|
476
|
+
if (!Array.isArray(keys)) {
|
|
477
|
+
throw new TypeError(
|
|
478
|
+
`[znight] TestApp.create(): initRoots() of the plugin "${String(plugin.name)}" must return an array of classes or tokens`,
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
for (const key of keys) {
|
|
482
|
+
const token = findProvider(key, providers, index)
|
|
483
|
+
if (token !== undefined) roots.push(token)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return roots
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function findProvider(key: unknown, providers: ReadonlySet<Token>, index: MetadataIndex): Token | undefined {
|
|
490
|
+
try {
|
|
491
|
+
return index.find(key, providers)
|
|
492
|
+
} catch {
|
|
493
|
+
return undefined // ambiguous display name
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const ONLY_HINT = "With TestApp: list it in TestApp.create({ only: [...] }) (or pass only: 'all') to create it at boot"
|
|
498
|
+
|
|
499
|
+
/** Hint added to start failures caused by a port already in use (`EADDRINUSE`). */
|
|
500
|
+
const PORT_IN_USE_HINT =
|
|
501
|
+
'With TestApp, servers need no fixed port (inject() sends requests in memory): configure HttpPlugin with { port: 0 } and ObservabilityPlugin with { metrics: { server: false } } (or server: { port: 0 }) in tests'
|
|
502
|
+
|
|
503
|
+
interface ErrorLike {
|
|
504
|
+
readonly code?: unknown
|
|
505
|
+
readonly hints?: unknown
|
|
506
|
+
readonly cause?: unknown
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Adds TestApp hints to a failed `TestApp.create()`, along the `cause` chain:
|
|
511
|
+
* - the TestApp form of the container hint to "cannot create on demand: its factory is async"
|
|
512
|
+
* errors (ZNIGHT015, hint `init({ only })`) of a restricted boot, which `TestApp` exposes as its
|
|
513
|
+
* `only` option;
|
|
514
|
+
* - when a server could not listen because its port is taken (`EADDRINUSE`: a fixed port, such as
|
|
515
|
+
* the default `9464` of the observability metrics server, shared by test files running in
|
|
516
|
+
* parallel or by `znight dev`), the options giving it a free port or none, on the outermost error
|
|
517
|
+
* carrying hints (the `ZnightError` the application wraps plugin failures in).
|
|
518
|
+
*/
|
|
519
|
+
function addHints(error: unknown, restricted: boolean): void {
|
|
520
|
+
const chain: (object & ErrorLike)[] = []
|
|
521
|
+
let current = error
|
|
522
|
+
while (current !== null && typeof current === 'object' && !chain.includes(current)) {
|
|
523
|
+
chain.push(current)
|
|
524
|
+
current = (current as ErrorLike).cause
|
|
525
|
+
}
|
|
526
|
+
if (restricted) {
|
|
527
|
+
for (const item of chain) {
|
|
528
|
+
if (
|
|
529
|
+
item.code === ErrorCode.InstantiationFailed &&
|
|
530
|
+
Array.isArray(item.hints) &&
|
|
531
|
+
item.hints.some(hint => typeof hint === 'string' && hint.includes('init({ only })'))
|
|
532
|
+
) {
|
|
533
|
+
addHint(item, ONLY_HINT)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (chain.some(item => item.code === 'EADDRINUSE')) {
|
|
538
|
+
const outermost = chain.find(item => Array.isArray(item.hints))
|
|
539
|
+
if (outermost !== undefined) addHint(outermost, PORT_IN_USE_HINT)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function addHint(error: object & ErrorLike, hint: string): void {
|
|
544
|
+
const hints = error.hints as readonly unknown[]
|
|
545
|
+
// `hints` is a plain own property of ZnightError; a frozen error is left as is
|
|
546
|
+
if (!hints.includes(hint)) Reflect.set(error, 'hints', [...hints, hint])
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function unique<T>(values: readonly T[]): T[] {
|
|
550
|
+
return [...new Set(values)]
|
|
551
|
+
}
|