@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.
@@ -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
+ }