@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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClassKey,
|
|
3
|
+
DependencyRef,
|
|
4
|
+
ExternalDefinition,
|
|
5
|
+
MethodInterception,
|
|
6
|
+
ProviderClass,
|
|
7
|
+
ProviderDefinition,
|
|
8
|
+
ProviderFactory,
|
|
9
|
+
Scope,
|
|
10
|
+
SourceLocation,
|
|
11
|
+
Token,
|
|
12
|
+
ZnightExtensionsMetadata,
|
|
13
|
+
ZnightMetadata,
|
|
14
|
+
} from '@znight/core'
|
|
15
|
+
|
|
16
|
+
import { describeToken, ErrorCode, isProviderDefinition, METADATA_VERSION, ZnightError } from '@znight/core'
|
|
17
|
+
|
|
18
|
+
import { entriesOf } from './utils.ts'
|
|
19
|
+
|
|
20
|
+
/** Options of {@link createMetadata}. */
|
|
21
|
+
export interface CreateMetadataOptions {
|
|
22
|
+
readonly aliases?: Readonly<Record<Token, Token>>
|
|
23
|
+
/** Externals, as tokens or full definitions (`{ token, scope, providedBy }`). */
|
|
24
|
+
readonly externals?: readonly (Token | ExternalDefinition)[]
|
|
25
|
+
readonly extensions?: ZnightExtensionsMetadata
|
|
26
|
+
/** Default: a deterministic topological order (dependencies first, declaration order otherwise). */
|
|
27
|
+
readonly resolutionOrder?: readonly Token[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hand-written metadata with the exact shape the compiler generates (version, resolution order...),
|
|
32
|
+
* for tests that do not run the compiler (plugins, libraries, edge cases).
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* const metadata = createMetadata({
|
|
36
|
+
* 'src/clock.ts#Clock': { scope: 'singleton', class: Clock, deps: [], factory: () => new Clock() },
|
|
37
|
+
* })
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @throws ZnightError ZNIGHT005 for a malformed provider definition.
|
|
41
|
+
*/
|
|
42
|
+
export function createMetadata(
|
|
43
|
+
providers: Readonly<Record<Token, ProviderDefinition>>,
|
|
44
|
+
options: CreateMetadataOptions = {},
|
|
45
|
+
): ZnightMetadata {
|
|
46
|
+
if (providers === null || typeof providers !== 'object') {
|
|
47
|
+
throw new TypeError('[znight] createMetadata() expects a record of provider definitions')
|
|
48
|
+
}
|
|
49
|
+
for (const [token, definition] of Object.entries(providers)) {
|
|
50
|
+
if (!isProviderDefinition(definition)) {
|
|
51
|
+
throw new ZnightError(
|
|
52
|
+
ErrorCode.InvalidDecorator,
|
|
53
|
+
`Invalid provider definition for ${describeToken(token)}: expected { scope, deps, factory }`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const externals = options.externals?.map(external =>
|
|
58
|
+
typeof external === 'string' ? ({ token: external } satisfies ExternalDefinition) : external,
|
|
59
|
+
)
|
|
60
|
+
return {
|
|
61
|
+
version: METADATA_VERSION,
|
|
62
|
+
providers,
|
|
63
|
+
resolutionOrder: options.resolutionOrder ?? topologicalOrder(providers),
|
|
64
|
+
...(options.aliases === undefined ? {} : { aliases: options.aliases }),
|
|
65
|
+
...(externals === undefined ? {} : { externals }),
|
|
66
|
+
...(options.extensions === undefined ? {} : { extensions: options.extensions }),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A provider class or a token, in {@link defineTestMetadata}. */
|
|
71
|
+
export type TestKey = Token | ProviderClass | ClassKey
|
|
72
|
+
|
|
73
|
+
/** A dependency in {@link defineTestMetadata}: like `DependencyRef`, with classes allowed in place of tokens. */
|
|
74
|
+
export type TestDependency =
|
|
75
|
+
| TestKey
|
|
76
|
+
| { readonly token: TestKey; readonly optional: true }
|
|
77
|
+
| { readonly multi: readonly TestKey[] }
|
|
78
|
+
| { readonly lazy: TestKey }
|
|
79
|
+
|
|
80
|
+
/** A provider in {@link defineTestMetadata}. */
|
|
81
|
+
export interface TestProvider<T = unknown> {
|
|
82
|
+
/** Default: `test#<ClassName>` (see `tokenPrefix`). Required without `class`. */
|
|
83
|
+
readonly token?: Token
|
|
84
|
+
readonly class?: ProviderClass<T>
|
|
85
|
+
/** Default: `'singleton'`. */
|
|
86
|
+
readonly scope?: Scope
|
|
87
|
+
/** Constructor (or factory) dependencies, in parameter order. */
|
|
88
|
+
readonly deps?: readonly TestDependency[]
|
|
89
|
+
/** Default: `deps => new Class(...deps)`. Required without `class`. */
|
|
90
|
+
readonly factory?: ProviderFactory<T>
|
|
91
|
+
readonly async?: boolean
|
|
92
|
+
readonly interceptors?: Readonly<Record<string, MethodInterception>>
|
|
93
|
+
readonly dispose?: string
|
|
94
|
+
/** `@Named` qualifier. */
|
|
95
|
+
readonly name?: string
|
|
96
|
+
readonly primary?: boolean
|
|
97
|
+
readonly source?: SourceLocation
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Input of {@link defineTestMetadata}. */
|
|
101
|
+
export interface TestMetadataSpec {
|
|
102
|
+
/** Providers: classes (no dependency) or {@link TestProvider}s. */
|
|
103
|
+
readonly providers?: readonly (TestProvider | ProviderClass)[]
|
|
104
|
+
/** Alias → provider pairs (`[UserRepository, PgUserRepository]`), or a record of tokens. */
|
|
105
|
+
readonly aliases?: Iterable<readonly [TestKey, TestKey]> | Readonly<Record<Token, TestKey>>
|
|
106
|
+
readonly externals?: readonly (Token | ExternalDefinition)[]
|
|
107
|
+
/** Feature metadata (`http`, `messaging`, `config`), tokens written with {@link testToken}. */
|
|
108
|
+
readonly extensions?: ZnightExtensionsMetadata
|
|
109
|
+
readonly resolutionOrder?: readonly Token[]
|
|
110
|
+
/** Prefix of the tokens derived from class names. Default: `'test'` (`test#UserService`). */
|
|
111
|
+
readonly tokenPrefix?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const DEFAULT_PREFIX = 'test'
|
|
115
|
+
|
|
116
|
+
/** The token {@link defineTestMetadata} derives from a class: `<prefix>#<ClassName>`. */
|
|
117
|
+
export function testToken(cls: ProviderClass | ClassKey, prefix: string = DEFAULT_PREFIX): Token {
|
|
118
|
+
const name = typeof cls === 'function' ? (cls as { name?: unknown }).name : undefined
|
|
119
|
+
if (typeof name !== 'string' || name === '') {
|
|
120
|
+
throw new TypeError('[znight] testToken(): expected a named class')
|
|
121
|
+
}
|
|
122
|
+
return `${prefix}#${name}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Class-first hand-written metadata: tokens are derived from class names (`test#UserService`),
|
|
127
|
+
* dependencies may be classes, factories default to `new Class(...deps)` and the resolution order
|
|
128
|
+
* is computed.
|
|
129
|
+
*
|
|
130
|
+
* ```ts
|
|
131
|
+
* const metadata = defineTestMetadata({
|
|
132
|
+
* providers: [
|
|
133
|
+
* PgUserRepository,
|
|
134
|
+
* { class: UserService, deps: [PgUserRepository, '@znight/observability#Logger'] },
|
|
135
|
+
* ],
|
|
136
|
+
* aliases: [[UserRepository, PgUserRepository]],
|
|
137
|
+
* externals: ['@znight/observability#Logger'],
|
|
138
|
+
* })
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* @throws ZnightError ZNIGHT004 for duplicate tokens; TypeError for a provider without class nor
|
|
142
|
+
* token/factory.
|
|
143
|
+
*/
|
|
144
|
+
export function defineTestMetadata(spec: TestMetadataSpec = {}): ZnightMetadata {
|
|
145
|
+
const prefix = spec.tokenPrefix ?? DEFAULT_PREFIX
|
|
146
|
+
const providers = (spec.providers ?? []).map(entry =>
|
|
147
|
+
typeof entry === 'function' ? ({ class: entry } satisfies TestProvider) : entry,
|
|
148
|
+
)
|
|
149
|
+
const classTokens = new Map<unknown, Token>()
|
|
150
|
+
const tokens = providers.map(provider => {
|
|
151
|
+
if (provider === null || typeof provider !== 'object') {
|
|
152
|
+
throw new TypeError(
|
|
153
|
+
'[znight] defineTestMetadata(): a provider must be a class or { class?, token?, deps?, factory? }',
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
const token = provider.token ?? (provider.class === undefined ? undefined : testToken(provider.class, prefix))
|
|
157
|
+
if (token === undefined) throw new TypeError('[znight] defineTestMetadata(): a provider needs a class or a token')
|
|
158
|
+
if (provider.class !== undefined && !classTokens.has(provider.class)) classTokens.set(provider.class, token)
|
|
159
|
+
return token
|
|
160
|
+
})
|
|
161
|
+
const toToken = (key: TestKey): Token => {
|
|
162
|
+
if (typeof key === 'string') return key
|
|
163
|
+
return classTokens.get(key) ?? testToken(key, prefix)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const definitions = new Map<Token, ProviderDefinition>()
|
|
167
|
+
providers.forEach((provider, position) => {
|
|
168
|
+
const token = tokens[position] as Token
|
|
169
|
+
if (definitions.has(token)) {
|
|
170
|
+
throw new ZnightError(
|
|
171
|
+
ErrorCode.DuplicateProvider,
|
|
172
|
+
`defineTestMetadata(): duplicate provider ${describeToken(token)}`,
|
|
173
|
+
{
|
|
174
|
+
hints: ['Give one of them an explicit token'],
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
definitions.set(token, toDefinition(token, provider, toToken))
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
let aliases: Record<Token, Token> | undefined
|
|
182
|
+
if (spec.aliases !== undefined) {
|
|
183
|
+
const pairs = entriesOf<TestKey, TestKey>(spec.aliases).map(([alias, target]): [Token, Token] => [
|
|
184
|
+
toToken(alias),
|
|
185
|
+
toToken(target),
|
|
186
|
+
])
|
|
187
|
+
aliases = Object.fromEntries(pairs)
|
|
188
|
+
}
|
|
189
|
+
return createMetadata(Object.fromEntries(definitions), {
|
|
190
|
+
...(aliases === undefined ? {} : { aliases }),
|
|
191
|
+
...(spec.externals === undefined ? {} : { externals: spec.externals }),
|
|
192
|
+
...(spec.extensions === undefined ? {} : { extensions: spec.extensions }),
|
|
193
|
+
...(spec.resolutionOrder === undefined ? {} : { resolutionOrder: spec.resolutionOrder }),
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function toDefinition(token: Token, provider: TestProvider, toToken: (key: TestKey) => Token): ProviderDefinition {
|
|
198
|
+
const cls = provider.class
|
|
199
|
+
let factory = provider.factory
|
|
200
|
+
if (factory === undefined) {
|
|
201
|
+
if (cls === undefined) throw new TypeError(`[znight] defineTestMetadata(): ${token} needs a class or a factory`)
|
|
202
|
+
const constructible = cls as unknown as new (...args: unknown[]) => unknown
|
|
203
|
+
factory = (deps: unknown[]) => new constructible(...deps)
|
|
204
|
+
}
|
|
205
|
+
const deps = (provider.deps ?? []).map(dep => toDependency(dep, toToken))
|
|
206
|
+
return {
|
|
207
|
+
scope: provider.scope ?? 'singleton',
|
|
208
|
+
deps,
|
|
209
|
+
factory,
|
|
210
|
+
...(cls === undefined ? {} : { class: cls }),
|
|
211
|
+
...(provider.async === undefined ? {} : { async: provider.async }),
|
|
212
|
+
...(provider.interceptors === undefined ? {} : { interceptors: provider.interceptors }),
|
|
213
|
+
...(provider.dispose === undefined ? {} : { dispose: provider.dispose }),
|
|
214
|
+
...(provider.name === undefined ? {} : { name: provider.name }),
|
|
215
|
+
...(provider.primary === undefined ? {} : { primary: provider.primary }),
|
|
216
|
+
...(provider.source === undefined ? {} : { source: provider.source }),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function toDependency(dep: TestDependency, toToken: (key: TestKey) => Token): DependencyRef {
|
|
221
|
+
if (typeof dep === 'string' || typeof dep === 'function') return toToken(dep)
|
|
222
|
+
if (dep !== null && typeof dep === 'object') {
|
|
223
|
+
if ('multi' in dep && Array.isArray(dep.multi)) return { multi: dep.multi.map(toToken) }
|
|
224
|
+
if ('lazy' in dep && dep.lazy !== undefined) return { lazy: toToken(dep.lazy) }
|
|
225
|
+
if ('token' in dep && dep.optional === true) return { token: toToken(dep.token), optional: true }
|
|
226
|
+
if ('prototype' in dep) return toToken(dep as ClassKey)
|
|
227
|
+
}
|
|
228
|
+
throw new TypeError(`[znight] defineTestMetadata(): invalid dependency ${String(dep)}`)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Hard dependencies of a definition (lazy ones excluded: they never constrain the order). */
|
|
232
|
+
function hardDependencies(definition: ProviderDefinition): Token[] {
|
|
233
|
+
const tokens: Token[] = []
|
|
234
|
+
for (const ref of definition.deps) {
|
|
235
|
+
if (typeof ref === 'string') tokens.push(ref)
|
|
236
|
+
else if ('multi' in ref) tokens.push(...ref.multi)
|
|
237
|
+
else if ('token' in ref) tokens.push(ref.token)
|
|
238
|
+
}
|
|
239
|
+
return tokens
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Deterministic topological order (dependencies first). Cycles are left for the container to report. */
|
|
243
|
+
function topologicalOrder(providers: Readonly<Record<Token, ProviderDefinition>>): Token[] {
|
|
244
|
+
const order: Token[] = []
|
|
245
|
+
const state = new Map<Token, 'visiting' | 'done'>()
|
|
246
|
+
const visit = (token: Token): void => {
|
|
247
|
+
if (state.has(token) || !Object.hasOwn(providers, token)) return
|
|
248
|
+
state.set(token, 'visiting')
|
|
249
|
+
for (const dep of hardDependencies(providers[token] as ProviderDefinition)) visit(dep)
|
|
250
|
+
state.set(token, 'done')
|
|
251
|
+
order.push(token)
|
|
252
|
+
}
|
|
253
|
+
for (const token of Object.keys(providers)) visit(token)
|
|
254
|
+
return order
|
|
255
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural readers of the feature metadata (`metadata.extensions`). `@znight/testing` depends on
|
|
3
|
+
* no feature package: it reads the documented shapes (architecture §9) defensively and ignores
|
|
4
|
+
* malformed entries.
|
|
5
|
+
*/
|
|
6
|
+
import type { Token, ZnightMetadata } from '@znight/core'
|
|
7
|
+
|
|
8
|
+
/** A configuration class (`metadata.extensions.config.configs[]`), as far as tests need it. */
|
|
9
|
+
export interface ConfigClassInfo {
|
|
10
|
+
readonly token: Token
|
|
11
|
+
readonly prefix: string | undefined
|
|
12
|
+
readonly fields: readonly ConfigFieldInfo[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A field of a configuration class. */
|
|
16
|
+
export interface ConfigFieldInfo {
|
|
17
|
+
readonly property: string
|
|
18
|
+
readonly env: string | undefined
|
|
19
|
+
/** Config type (`'string'`, `'number'`, `'url'`...), `undefined` when absent from the metadata. */
|
|
20
|
+
readonly type: string | undefined
|
|
21
|
+
/** Whether the field declares a default value (`@Default`; initializers are not in the metadata). */
|
|
22
|
+
readonly hasDefault: boolean
|
|
23
|
+
readonly default: unknown
|
|
24
|
+
readonly required: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Loose = Readonly<Record<string, unknown>>
|
|
28
|
+
|
|
29
|
+
function isRecord(value: unknown): value is Loose {
|
|
30
|
+
return value !== null && typeof value === 'object'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function arrayOf(value: unknown): readonly unknown[] {
|
|
34
|
+
return Array.isArray(value) ? value : []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extension(metadata: ZnightMetadata, name: string): Loose | undefined {
|
|
38
|
+
if (!isRecord(metadata)) return undefined
|
|
39
|
+
const extensions: unknown = metadata.extensions
|
|
40
|
+
if (!isRecord(extensions)) return undefined
|
|
41
|
+
const value = extensions[name]
|
|
42
|
+
return isRecord(value) ? value : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tokensOf(value: unknown): Token[] {
|
|
46
|
+
return arrayOf(value).filter((token): token is Token => typeof token === 'string')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Configuration classes declared by `@znight/config` (`extensions.config.configs`), by token. */
|
|
50
|
+
export function readConfigClasses(metadata: ZnightMetadata): Map<Token, ConfigClassInfo> {
|
|
51
|
+
const result = new Map<Token, ConfigClassInfo>()
|
|
52
|
+
for (const entry of arrayOf(extension(metadata, 'config')?.['configs'])) {
|
|
53
|
+
if (!isRecord(entry) || typeof entry['token'] !== 'string') continue
|
|
54
|
+
const fields: ConfigFieldInfo[] = []
|
|
55
|
+
for (const field of arrayOf(entry['fields'])) {
|
|
56
|
+
if (!isRecord(field) || typeof field['property'] !== 'string') continue
|
|
57
|
+
fields.push({
|
|
58
|
+
property: field['property'],
|
|
59
|
+
env: typeof field['env'] === 'string' ? field['env'] : undefined,
|
|
60
|
+
type: typeof field['type'] === 'string' ? field['type'] : undefined,
|
|
61
|
+
hasDefault: Object.hasOwn(field, 'default') && field['default'] !== undefined,
|
|
62
|
+
default: field['default'],
|
|
63
|
+
required: field['required'] === true,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
const prefix = entry['prefix']
|
|
67
|
+
result.set(entry['token'], {
|
|
68
|
+
token: entry['token'],
|
|
69
|
+
prefix: typeof prefix === 'string' && prefix !== '' ? prefix : undefined,
|
|
70
|
+
fields,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
return result
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Tokens an HTTP application needs at boot: controllers (`extensions.http.controllers[].token`) and
|
|
78
|
+
* the guards and interceptors they and their routes reference.
|
|
79
|
+
*/
|
|
80
|
+
export function readHttpRoots(metadata: ZnightMetadata): Token[] {
|
|
81
|
+
const roots: Token[] = []
|
|
82
|
+
for (const controller of arrayOf(extension(metadata, 'http')?.['controllers'])) {
|
|
83
|
+
if (!isRecord(controller)) continue
|
|
84
|
+
if (typeof controller['token'] === 'string') roots.push(controller['token'])
|
|
85
|
+
roots.push(...tokensOf(controller['guards']), ...tokensOf(controller['interceptors']))
|
|
86
|
+
for (const route of arrayOf(controller['routes'])) {
|
|
87
|
+
if (isRecord(route)) roots.push(...tokensOf(route['guards']), ...tokensOf(route['interceptors']))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return roots
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Message consumers (`extensions.messaging.consumers[].token`). */
|
|
94
|
+
export function readMessagingRoots(metadata: ZnightMetadata): Token[] {
|
|
95
|
+
const roots: Token[] = []
|
|
96
|
+
for (const consumer of arrayOf(extension(metadata, 'messaging')?.['consumers'])) {
|
|
97
|
+
if (isRecord(consumer) && typeof consumer['token'] === 'string') roots.push(consumer['token'])
|
|
98
|
+
}
|
|
99
|
+
return roots
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Hosts of scheduled jobs (`extensions.schedule.jobs[].token`), disabled jobs included (they can be triggered). */
|
|
103
|
+
export function readScheduleRoots(metadata: ZnightMetadata): Token[] {
|
|
104
|
+
const roots: Token[] = []
|
|
105
|
+
for (const job of arrayOf(extension(metadata, 'schedule')?.['jobs'])) {
|
|
106
|
+
if (isRecord(job) && typeof job['token'] === 'string') roots.push(job['token'])
|
|
107
|
+
}
|
|
108
|
+
return roots
|
|
109
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@znight/testing`: unit tests with auto-mocked dependencies (`TestBed`), end-to-end tests of a
|
|
3
|
+
* started application (`TestApp`), deep auto-mocks and a Jest-compatible spy that cooperate with
|
|
4
|
+
* Vitest, Jest and `bun:test`.
|
|
5
|
+
*/
|
|
6
|
+
export type { ConfigProperties, ConfigValues } from './config.ts'
|
|
7
|
+
export type { MockRegistry } from './mock-registry.ts'
|
|
8
|
+
export type {
|
|
9
|
+
AnyFunction,
|
|
10
|
+
DeepMock,
|
|
11
|
+
DeepMockFunction,
|
|
12
|
+
Mocked,
|
|
13
|
+
MockedMember,
|
|
14
|
+
MockFnFactory,
|
|
15
|
+
MockFnOption,
|
|
16
|
+
MockFunction,
|
|
17
|
+
MockResult,
|
|
18
|
+
MockState,
|
|
19
|
+
UntypedFunction,
|
|
20
|
+
} from './types.ts'
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
type CreateMetadataOptions,
|
|
24
|
+
createMetadata,
|
|
25
|
+
defineTestMetadata,
|
|
26
|
+
type TestDependency,
|
|
27
|
+
type TestKey,
|
|
28
|
+
type TestMetadataSpec,
|
|
29
|
+
type TestProvider,
|
|
30
|
+
testToken,
|
|
31
|
+
} from './define-metadata.ts'
|
|
32
|
+
export { type CreateMockOptions, clearMock, createMock, isMock, resetMock } from './mock.ts'
|
|
33
|
+
export { detectMockFn, type MockFnSource, type ResolvedMockFn, resolveMockFn, setDefaultMockFn } from './mock-fn.ts'
|
|
34
|
+
export {
|
|
35
|
+
type OverrideDefinitionEntry,
|
|
36
|
+
type OverrideEntry,
|
|
37
|
+
type OverrideFactoryOptions,
|
|
38
|
+
override,
|
|
39
|
+
overrideFactory,
|
|
40
|
+
} from './overrides.ts'
|
|
41
|
+
export { createSpy, isBuiltinSpy } from './spy.ts'
|
|
42
|
+
export {
|
|
43
|
+
HTTP_PLUGIN_NAME,
|
|
44
|
+
type HttpInjector,
|
|
45
|
+
type HttpInjectResult,
|
|
46
|
+
type HttpStreamInjector,
|
|
47
|
+
type InjectRequest,
|
|
48
|
+
type InjectResponse,
|
|
49
|
+
type InjectSseResponse,
|
|
50
|
+
type InjectStreamRequest,
|
|
51
|
+
type InjectStreamResponse,
|
|
52
|
+
type PluginInitRoots,
|
|
53
|
+
type SetCookieInfo,
|
|
54
|
+
type SseMessage,
|
|
55
|
+
TestApp,
|
|
56
|
+
type TestAppOptions,
|
|
57
|
+
} from './test-app.ts'
|
|
58
|
+
export { type AopMode, TestBed, type TestBedOptions, type TestBedResult } from './testbed.ts'
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { ExternalDefinition, ProviderClass, ProviderDefinition, Token, ZnightMetadata } from '@znight/core'
|
|
2
|
+
|
|
3
|
+
import { describeToken, displayName, ErrorCode, METADATA_VERSION, ZnightError } from '@znight/core'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read-only index over compiled metadata: provider definitions, aliases, externals and the key
|
|
7
|
+
* lookups shared by `TestBed`, `mocks.get()` and the testing helpers (class, token, alias or display
|
|
8
|
+
* name → canonical token). Internal.
|
|
9
|
+
*/
|
|
10
|
+
export class MetadataIndex {
|
|
11
|
+
readonly metadata: ZnightMetadata
|
|
12
|
+
readonly #providers = new Map<Token, ProviderDefinition>()
|
|
13
|
+
readonly #aliases = new Map<Token, Token>()
|
|
14
|
+
readonly #externals = new Map<Token, ExternalDefinition>()
|
|
15
|
+
|
|
16
|
+
constructor(metadata: ZnightMetadata) {
|
|
17
|
+
assertMetadata(metadata)
|
|
18
|
+
this.metadata = metadata
|
|
19
|
+
for (const [token, definition] of Object.entries(metadata.providers)) this.#providers.set(token, definition)
|
|
20
|
+
for (const [alias, target] of Object.entries(metadata.aliases ?? {})) this.#aliases.set(alias, target)
|
|
21
|
+
for (const external of metadata.externals ?? []) {
|
|
22
|
+
if (typeof external?.token === 'string') this.#externals.set(external.token, external)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Every provider token, in declaration order. */
|
|
27
|
+
providerTokens(): IterableIterator<Token> {
|
|
28
|
+
return this.#providers.keys()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
definition(token: Token): ProviderDefinition | undefined {
|
|
32
|
+
return this.#providers.get(token)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isProvider(token: Token): boolean {
|
|
36
|
+
return this.#providers.has(token)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
external(token: Token): ExternalDefinition | undefined {
|
|
40
|
+
return this.#externals.get(token)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether `token` is a provider, an alias or an external of the metadata. */
|
|
44
|
+
isKnown(token: Token): boolean {
|
|
45
|
+
return this.#providers.has(token) || this.#aliases.has(token) || this.#externals.has(token)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Provider, alias and external tokens. */
|
|
49
|
+
knownTokens(): Set<Token> {
|
|
50
|
+
return new Set([...this.#providers.keys(), ...this.#aliases.keys(), ...this.#externals.keys()])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The class of a provider, when it is a class provider. */
|
|
54
|
+
classOf(token: Token): ProviderClass | undefined {
|
|
55
|
+
return this.#providers.get(token)?.class
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Follows aliases (cycle-guarded) until a provider token; returns `token` itself otherwise. */
|
|
59
|
+
canonical(token: Token): Token {
|
|
60
|
+
let current = token
|
|
61
|
+
const seen = new Set<Token>()
|
|
62
|
+
while (!this.#providers.has(current) && this.#aliases.has(current) && !seen.has(current)) {
|
|
63
|
+
seen.add(current)
|
|
64
|
+
current = this.#aliases.get(current) as Token
|
|
65
|
+
}
|
|
66
|
+
return current
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Alias entries (alias → target), for filtering and display. */
|
|
70
|
+
aliases(): IterableIterator<[Token, Token]> {
|
|
71
|
+
return this.#aliases.entries()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Finds the canonical token designated by `key` among `universe`:
|
|
76
|
+
* - a string: a token of `universe`, an alias resolving into it, or a unique display name
|
|
77
|
+
* (`'UserService'`) of a token or alias;
|
|
78
|
+
* - a class: the token whose provider class is `key`, else the unique token whose class extends
|
|
79
|
+
* it, else a unique display name equal to the class name (externals, interfaces).
|
|
80
|
+
*
|
|
81
|
+
* @throws ZnightError ZNIGHT008 when a display name is ambiguous.
|
|
82
|
+
*/
|
|
83
|
+
find(key: unknown, universe: ReadonlySet<Token>): Token | undefined {
|
|
84
|
+
if (typeof key === 'string') {
|
|
85
|
+
if (universe.has(key)) return key
|
|
86
|
+
const canonical = this.canonical(key)
|
|
87
|
+
if (universe.has(canonical)) return canonical
|
|
88
|
+
return this.#findByName(key, universe, undefined)
|
|
89
|
+
}
|
|
90
|
+
if (typeof key !== 'function') return undefined
|
|
91
|
+
const cls = key as ProviderClass
|
|
92
|
+
const exact = [...universe].filter(token => this.classOf(token) === cls)
|
|
93
|
+
if (exact.length > 0) return exact[0]
|
|
94
|
+
const derived = [...universe].filter(token => extendsClass(this.classOf(token), cls))
|
|
95
|
+
if (derived.length === 1) return derived[0]
|
|
96
|
+
if (derived.length > 1) throw ambiguousError(cls.name, derived)
|
|
97
|
+
const name = cls.name
|
|
98
|
+
return typeof name === 'string' && name !== '' ? this.#findByName(name, universe, cls) : undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#findByName(name: string, universe: ReadonlySet<Token>, cls: ProviderClass | undefined): Token | undefined {
|
|
102
|
+
// an alias and its provider designate the same thing: matches are compared by canonical token
|
|
103
|
+
const matches = new Set<Token>()
|
|
104
|
+
const add = (token: Token): void => {
|
|
105
|
+
const canonical = this.canonical(token)
|
|
106
|
+
if (universe.has(canonical)) matches.add(canonical)
|
|
107
|
+
else if (universe.has(token)) matches.add(token)
|
|
108
|
+
}
|
|
109
|
+
for (const token of universe) if (displayName(token) === name) add(token)
|
|
110
|
+
for (const [alias] of this.#aliases) if (displayName(alias) === name) add(alias)
|
|
111
|
+
const candidates = [...matches].filter(token => {
|
|
112
|
+
if (cls === undefined) return true
|
|
113
|
+
const own = this.classOf(token)
|
|
114
|
+
return own === undefined || own === cls || extendsClass(own, cls)
|
|
115
|
+
})
|
|
116
|
+
if (candidates.length === 1) return candidates[0]
|
|
117
|
+
if (candidates.length > 1) throw ambiguousError(name, candidates)
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function extendsClass(candidate: ProviderClass | undefined, base: ProviderClass): boolean {
|
|
123
|
+
if (candidate === undefined || candidate === base) return false
|
|
124
|
+
const prototype: unknown = base.prototype
|
|
125
|
+
return typeof prototype === 'object' && prototype !== null && candidate.prototype instanceof base
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ambiguousError(name: string, tokens: readonly Token[]): ZnightError {
|
|
129
|
+
return new ZnightError(
|
|
130
|
+
ErrorCode.AmbiguousInjection,
|
|
131
|
+
`"${name}" is ambiguous: it matches ${tokens.map(describeToken).join(', ')}`,
|
|
132
|
+
{ hints: ['Use the full token (or the class) instead of the display name'] },
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function assertMetadata(metadata: ZnightMetadata): void {
|
|
137
|
+
if (
|
|
138
|
+
metadata === null ||
|
|
139
|
+
typeof metadata !== 'object' ||
|
|
140
|
+
metadata.providers === null ||
|
|
141
|
+
typeof metadata.providers !== 'object'
|
|
142
|
+
) {
|
|
143
|
+
throw new ZnightError(
|
|
144
|
+
ErrorCode.MissingProvider,
|
|
145
|
+
'No compiled metadata: expected the generated __ZNIGHT_METADATA__',
|
|
146
|
+
{
|
|
147
|
+
hints: [
|
|
148
|
+
'Pass { metadata: __ZNIGHT_METADATA__ } imported from src/.znight/container.generated.ts',
|
|
149
|
+
'Generate it before running tests: `znight generate`, the Bun preload `@znight/compiler/bun` or the Vite plugin under Vitest',
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
if (metadata.version !== METADATA_VERSION) {
|
|
155
|
+
throw new ZnightError(
|
|
156
|
+
ErrorCode.VersionMismatch,
|
|
157
|
+
`Unsupported metadata version ${String(metadata.version)} (expected ${METADATA_VERSION})`,
|
|
158
|
+
{ hints: ['Regenerate the metadata with the @znight/compiler version matching @znight/testing'] },
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/mock-fn.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { MockFnFactory, MockFnOption } from './types.ts'
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
|
|
5
|
+
import { createSpy } from './spy.ts'
|
|
6
|
+
|
|
7
|
+
/** Where the spy factory used by mocks comes from. */
|
|
8
|
+
export type MockFnSource = 'custom' | 'vitest' | 'jest' | 'bun' | 'builtin'
|
|
9
|
+
|
|
10
|
+
/** A spy factory and its origin (see {@link resolveMockFn}). */
|
|
11
|
+
export interface ResolvedMockFn {
|
|
12
|
+
readonly source: MockFnSource
|
|
13
|
+
readonly create: MockFnFactory
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BUILTIN: ResolvedMockFn = Object.freeze({ source: 'builtin', create: createSpy as MockFnFactory })
|
|
17
|
+
|
|
18
|
+
let defaultOption: MockFnOption = 'auto'
|
|
19
|
+
/** `mock` of `bun:test`: `undefined` until probed, `null` when unavailable. */
|
|
20
|
+
let bunMock: MockFnFactory | null | undefined
|
|
21
|
+
|
|
22
|
+
function assertOption(option: unknown, where: string): asserts option is MockFnOption {
|
|
23
|
+
if (typeof option === 'function' || option === 'auto' || option === 'builtin') return
|
|
24
|
+
throw new TypeError(`[znight] ${where}: mockFn must be 'auto', 'builtin' or a function such as vi.fn`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sets the spy factory used when `mockFn` is not passed explicitly (e.g. in a Vitest setup file:
|
|
29
|
+
* `setDefaultMockFn(vi.fn)`). Without argument, restores auto-detection.
|
|
30
|
+
*/
|
|
31
|
+
export function setDefaultMockFn(option: MockFnOption = 'auto'): void {
|
|
32
|
+
assertOption(option, 'setDefaultMockFn()')
|
|
33
|
+
defaultOption = option
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolves a {@link MockFnOption} (default: the option set with {@link setDefaultMockFn}, `'auto'`
|
|
38
|
+
* initially) to a spy factory.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveMockFn(option?: MockFnOption): ResolvedMockFn {
|
|
41
|
+
const effective = option ?? defaultOption
|
|
42
|
+
assertOption(effective, 'resolveMockFn()')
|
|
43
|
+
if (typeof effective === 'function') return { source: 'custom', create: effective }
|
|
44
|
+
if (effective === 'builtin') return BUILTIN
|
|
45
|
+
return detectMockFn()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Auto-detects the spy factory of the running test framework: Vitest (`globalThis.vi.fn`, with
|
|
50
|
+
* `globals: true`), Jest (`globalThis.jest.fn`), `bun:test` (`mock`, loaded only when running on
|
|
51
|
+
* Bun), otherwise the built-in spy ({@link createSpy}).
|
|
52
|
+
*/
|
|
53
|
+
export function detectMockFn(): ResolvedMockFn {
|
|
54
|
+
const scope = globalThis as { vi?: { fn?: unknown }; jest?: { fn?: unknown } }
|
|
55
|
+
const vitest = scope.vi?.fn
|
|
56
|
+
if (typeof vitest === 'function') return { source: 'vitest', create: vitest as MockFnFactory }
|
|
57
|
+
const jest = scope.jest?.fn
|
|
58
|
+
if (typeof jest === 'function') return { source: 'jest', create: jest as MockFnFactory }
|
|
59
|
+
const bun = loadBunMock()
|
|
60
|
+
if (bun !== undefined) return { source: 'bun', create: bun }
|
|
61
|
+
return BUILTIN
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether the code runs on Bun. */
|
|
65
|
+
export function isBunRuntime(): boolean {
|
|
66
|
+
return typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadBunMock(): MockFnFactory | undefined {
|
|
70
|
+
bunMock ??= isBunRuntime() ? requireBunMock() : null
|
|
71
|
+
return bunMock ?? undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function requireBunMock(): MockFnFactory | null {
|
|
75
|
+
try {
|
|
76
|
+
// non-literal specifier: bundlers and Vite never try to resolve `bun:test`
|
|
77
|
+
const specifier = ['bun', 'test'].join(':')
|
|
78
|
+
const bunTest = createRequire(import.meta.url)(specifier) as { mock?: unknown }
|
|
79
|
+
return typeof bunTest.mock === 'function' ? (bunTest.mock as MockFnFactory) : null
|
|
80
|
+
} catch {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
}
|