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