decorator-dependency-injection 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -26
- package/eslint.config.js +73 -0
- package/index.d.ts +146 -0
- package/index.js +103 -107
- package/package.json +46 -7
- package/src/Container.js +184 -0
- package/src/proxy.js +42 -0
- package/.github/workflows/release.yml +0 -129
- package/babel.config.json +0 -6
- package/test/injection.test.js +0 -152
- package/test/mock.test.js +0 -77
- package/test/proxy.test.js +0 -73
package/index.js
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* @
|
|
7
|
-
* @property {Object} [originalInstance] The original instance if it is a mock
|
|
8
|
-
* @property {boolean} [proxy=false] If true, the mock if the injection instance will be a proxy to the original class
|
|
2
|
+
* Decorator Dependency Injection
|
|
3
|
+
*
|
|
4
|
+
* A simple library for dependency injection using TC39 Stage 3 decorators.
|
|
5
|
+
*
|
|
6
|
+
* @module decorator-dependency-injection
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
import {Container} from './src/Container.js'
|
|
10
|
+
|
|
11
|
+
/** @type {Container} The default global container */
|
|
12
|
+
const defaultContainer = new Container()
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Register a class as a singleton. If a name is provided, it will be used as the key in the singleton map.
|
|
16
16
|
* Singleton instances only ever have one instance created via the @Inject decorator.
|
|
17
17
|
*
|
|
18
18
|
* @param {string} [name] The name of the singleton. If not provided, the class will be used as the key.
|
|
19
|
-
* @
|
|
19
|
+
* @returns {(function(Function, {kind: string}): void)}
|
|
20
20
|
* @example @Singleton() class MySingleton {}
|
|
21
21
|
* @example @Singleton('customName') class MySingleton {}
|
|
22
22
|
* @throws {Error} If the injection target is not a class
|
|
23
|
-
* @throws {Error} If a singleton with the same name is already defined
|
|
24
|
-
* @throws {Error} If
|
|
23
|
+
* @throws {Error} If a singleton or factory with the same name is already defined
|
|
24
|
+
* @throws {Error} If the target is not a class constructor
|
|
25
25
|
*/
|
|
26
26
|
export function Singleton(name) {
|
|
27
27
|
return function (clazz, context) {
|
|
28
|
-
if (context.kind !==
|
|
28
|
+
if (context.kind !== 'class') {
|
|
29
29
|
throw new Error('Invalid injection target')
|
|
30
30
|
}
|
|
31
|
-
if (
|
|
32
|
-
throw new Error('
|
|
31
|
+
if (typeof clazz !== 'function' || !clazz.prototype) {
|
|
32
|
+
throw new Error('Target must be a class constructor')
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
defaultContainer.registerSingleton(clazz, name)
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -40,22 +40,22 @@ export function Singleton(name) {
|
|
|
40
40
|
* Factory instances are created via the @Inject decorator. Each call to the factory will create a new instance.
|
|
41
41
|
*
|
|
42
42
|
* @param {string} [name] The name of the factory. If not provided, the class will be used as the key.
|
|
43
|
-
* @
|
|
43
|
+
* @returns {(function(Function, {kind: string}): void)}
|
|
44
44
|
* @example @Factory() class MyFactory {}
|
|
45
45
|
* @example @Factory('customName') class MyFactory {}
|
|
46
46
|
* @throws {Error} If the injection target is not a class
|
|
47
|
-
* @throws {Error} If a factory with the same name is already defined
|
|
48
|
-
* @throws {Error} If
|
|
47
|
+
* @throws {Error} If a factory or singleton with the same name is already defined
|
|
48
|
+
* @throws {Error} If the target is not a class constructor
|
|
49
49
|
*/
|
|
50
50
|
export function Factory(name) {
|
|
51
51
|
return function (clazz, context) {
|
|
52
|
-
if (context.kind !==
|
|
52
|
+
if (context.kind !== 'class') {
|
|
53
53
|
throw new Error('Invalid injection target')
|
|
54
54
|
}
|
|
55
|
-
if (
|
|
56
|
-
throw new Error('
|
|
55
|
+
if (typeof clazz !== 'function' || !clazz.prototype) {
|
|
56
|
+
throw new Error('Target must be a class constructor')
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
defaultContainer.registerFactory(clazz, name)
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -63,95 +63,85 @@ export function Factory(name) {
|
|
|
63
63
|
* Inject a singleton or factory instance into a class field. You can also provide parameters to the constructor.
|
|
64
64
|
* If the instance is a singleton, it will only be created once with the first set of parameters it encounters.
|
|
65
65
|
*
|
|
66
|
-
* @param {string|
|
|
67
|
-
* @param {
|
|
68
|
-
* @
|
|
66
|
+
* @param {string|Function} clazzOrName The singleton or factory class or name
|
|
67
|
+
* @param {...*} params Parameters to pass to the constructor. Recommended to use only with factories.
|
|
68
|
+
* @returns {(function(*, {kind: string, name: string}): function(): Object)}
|
|
69
69
|
* @example @Inject(MySingleton) mySingleton
|
|
70
70
|
* @example @Inject("myCustomName") myFactory
|
|
71
71
|
* @throws {Error} If the injection target is not a field
|
|
72
72
|
* @throws {Error} If the injected field is assigned a value
|
|
73
73
|
*/
|
|
74
74
|
export function Inject(clazzOrName, ...params) {
|
|
75
|
-
return function(
|
|
76
|
-
if (context.kind
|
|
77
|
-
return function(initialValue) {
|
|
78
|
-
if (initialValue) {
|
|
79
|
-
throw new Error('Cannot assign value to injected field')
|
|
80
|
-
}
|
|
81
|
-
const instanceContext = getContext(clazzOrName)
|
|
82
|
-
|
|
83
|
-
if (instanceContext.instance) {
|
|
84
|
-
return instanceContext.instance
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const instance = new instanceContext.clazz(...params)
|
|
88
|
-
|
|
89
|
-
if (instanceContext.type === 'singleton') {
|
|
90
|
-
if (instanceContext.originalClazz && instanceContext.proxy) {
|
|
91
|
-
instanceContext.instance = getProxy(instance, new instanceContext.originalClazz(...params))
|
|
92
|
-
} else {
|
|
93
|
-
instanceContext.instance = instance
|
|
94
|
-
}
|
|
95
|
-
return instanceContext.instance
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (instanceContext.type === 'factory') {
|
|
99
|
-
if (instanceContext.originalClazz && instanceContext.proxy) {
|
|
100
|
-
return getProxy(instance, new instanceContext.originalClazz(...params))
|
|
101
|
-
} else {
|
|
102
|
-
return instance
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
throw new Error('Unexpected injection type')
|
|
107
|
-
}
|
|
108
|
-
} else {
|
|
75
|
+
return function (_, context) {
|
|
76
|
+
if (context.kind !== 'field') {
|
|
109
77
|
throw new Error('Invalid injection target')
|
|
110
78
|
}
|
|
79
|
+
return function (initialValue) {
|
|
80
|
+
if (initialValue) {
|
|
81
|
+
throw new Error('Cannot assign value to injected field')
|
|
82
|
+
}
|
|
83
|
+
const instanceContext = defaultContainer.getContext(clazzOrName)
|
|
84
|
+
return defaultContainer.getInstance(instanceContext, params)
|
|
85
|
+
}
|
|
111
86
|
}
|
|
112
87
|
}
|
|
113
88
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Inject a singleton or factory instance lazily into a class field. You can also provide parameters to the constructor.
|
|
91
|
+
* If the instance is a singleton, it will only be created once with the first set of parameters it encounters.
|
|
92
|
+
*
|
|
93
|
+
* @param {string|Function} clazzOrName The singleton or factory class or name
|
|
94
|
+
* @param {...*} params Parameters to pass to the constructor. Recommended to use only with factories.
|
|
95
|
+
* @returns {(function(*, {kind: string, name: string, addInitializer: Function}): void)}
|
|
96
|
+
* @example @InjectLazy(MySingleton) mySingleton
|
|
97
|
+
* @example @InjectLazy("myCustomName") myFactory
|
|
98
|
+
* @throws {Error} If the injection target is not a field
|
|
99
|
+
* @throws {Error} If the injected field is assigned a value
|
|
100
|
+
*/
|
|
101
|
+
export function InjectLazy(clazzOrName, ...params) {
|
|
102
|
+
const cache = new WeakMap()
|
|
103
|
+
return (_, context) => {
|
|
104
|
+
if (context.kind !== 'field') {
|
|
105
|
+
throw new Error('Invalid injection target')
|
|
121
106
|
}
|
|
122
|
-
|
|
107
|
+
context.addInitializer(function () {
|
|
108
|
+
Object.defineProperty(this, context.name, {
|
|
109
|
+
get() {
|
|
110
|
+
if (!cache.has(this)) {
|
|
111
|
+
const instanceContext = defaultContainer.getContext(clazzOrName)
|
|
112
|
+
const value = defaultContainer.getInstance(instanceContext, params)
|
|
113
|
+
cache.set(this, value)
|
|
114
|
+
}
|
|
115
|
+
return cache.get(this)
|
|
116
|
+
},
|
|
117
|
+
set() {
|
|
118
|
+
throw new Error(`Cannot assign value to lazy-injected field "${context.name}"`)
|
|
119
|
+
},
|
|
120
|
+
configurable: true,
|
|
121
|
+
enumerable: true
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
}
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
/**
|
|
126
128
|
* Mark a class as a mock. This will replace the class with a mock instance when injected.
|
|
127
|
-
*
|
|
128
|
-
* @param {
|
|
129
|
-
* @
|
|
129
|
+
*
|
|
130
|
+
* @param {string|Function} mockedClazzOrName The singleton or factory class or name to be mocked
|
|
131
|
+
* @param {boolean} [proxy=false] If true, the mock will proxy to the original class.
|
|
132
|
+
* Any methods not defined in the mock will be called on the original class.
|
|
133
|
+
* @returns {(function(Function, {kind: string}): void)}
|
|
130
134
|
* @example @Mock(MySingleton) class MyMock {}
|
|
131
|
-
* @example @Mock("myCustomName") class MyMock {}
|
|
135
|
+
* @example @Mock("myCustomName", true) class MyMock {}
|
|
132
136
|
* @throws {Error} If the injection target is not a class
|
|
133
137
|
* @throws {Error} If the injection source is not found
|
|
134
138
|
*/
|
|
135
139
|
export function Mock(mockedClazzOrName, proxy = false) {
|
|
136
|
-
return function(clazz, context) {
|
|
137
|
-
if (context.kind !==
|
|
140
|
+
return function (clazz, context) {
|
|
141
|
+
if (context.kind !== 'class') {
|
|
138
142
|
throw new Error('Invalid injection target')
|
|
139
143
|
}
|
|
140
|
-
|
|
141
|
-
if (instanceContext.originalClazz) {
|
|
142
|
-
throw new Error('Mock already defined, reset before mocking again')
|
|
143
|
-
}
|
|
144
|
-
instanceContext.originalClazz = instanceContext.clazz
|
|
145
|
-
instanceContext.proxy = proxy
|
|
146
|
-
instanceContext.clazz = clazz
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function getContext(mockedClazzOrName) {
|
|
151
|
-
if (instances.has(mockedClazzOrName)) {
|
|
152
|
-
return instances.get(mockedClazzOrName)
|
|
153
|
-
} else {
|
|
154
|
-
throw new Error('Cannot find injection source with the provided name')
|
|
144
|
+
defaultContainer.registerMock(mockedClazzOrName, clazz, proxy)
|
|
155
145
|
}
|
|
156
146
|
}
|
|
157
147
|
|
|
@@ -159,32 +149,38 @@ function getContext(mockedClazzOrName) {
|
|
|
159
149
|
* Reset all mocks to their original classes.
|
|
160
150
|
*/
|
|
161
151
|
export function resetMocks() {
|
|
162
|
-
|
|
163
|
-
reset(instanceContext)
|
|
164
|
-
}
|
|
152
|
+
defaultContainer.resetAllMocks()
|
|
165
153
|
}
|
|
166
154
|
|
|
167
155
|
/**
|
|
168
156
|
* Reset a specific mock to its original class.
|
|
169
|
-
*
|
|
157
|
+
*
|
|
158
|
+
* @param {string|Function} clazzOrName The singleton or factory class or name to reset
|
|
170
159
|
*/
|
|
171
160
|
export function resetMock(clazzOrName) {
|
|
172
|
-
|
|
161
|
+
defaultContainer.resetMock(clazzOrName)
|
|
173
162
|
}
|
|
174
163
|
|
|
175
164
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* @private
|
|
165
|
+
* Clear all registered instances and mocks from the container.
|
|
166
|
+
* Useful for complete test isolation between test suites.
|
|
179
167
|
*/
|
|
180
|
-
function
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
168
|
+
export function clearContainer() {
|
|
169
|
+
defaultContainer.clear()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the default container instance.
|
|
174
|
+
* Useful for advanced use cases or testing the container itself.
|
|
175
|
+
*
|
|
176
|
+
* @returns {Container} The default container
|
|
177
|
+
*/
|
|
178
|
+
export function getContainer() {
|
|
179
|
+
return defaultContainer
|
|
190
180
|
}
|
|
181
|
+
|
|
182
|
+
// Export Container class for advanced use cases (e.g., isolated containers)
|
|
183
|
+
export {Container}
|
|
184
|
+
|
|
185
|
+
// Export createProxy for advanced proxy use cases
|
|
186
|
+
export {createProxy} from './src/proxy.js'
|
package/package.json
CHANGED
|
@@ -1,34 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decorator-dependency-injection",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "A simple library for dependency injection using decorators",
|
|
5
5
|
"author": "Ravi Gairola <mallox@pyxzl.net>",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mallocator/decorator-dependency-injection.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/mallocator/decorator-dependency-injection/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/mallocator/decorator-dependency-injection#readme",
|
|
7
15
|
"keywords": [
|
|
8
16
|
"dependency-injection",
|
|
9
17
|
"di",
|
|
10
18
|
"decorators",
|
|
11
|
-
"mocking"
|
|
19
|
+
"mocking",
|
|
20
|
+
"singleton",
|
|
21
|
+
"factory"
|
|
12
22
|
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18.0.0"
|
|
25
|
+
},
|
|
13
26
|
"main": "index.js",
|
|
27
|
+
"types": "index.d.ts",
|
|
14
28
|
"type": "module",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./index.d.ts",
|
|
32
|
+
"import": "./index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
15
35
|
"scripts": {
|
|
16
36
|
"start": "babel-node index.js",
|
|
17
|
-
"test": "jest"
|
|
37
|
+
"test": "jest --no-watchman",
|
|
38
|
+
"test:coverage": "jest --no-watchman --coverage",
|
|
39
|
+
"typecheck": "tsc test/types.check.ts --noEmit --esModuleInterop --skipLibCheck",
|
|
40
|
+
"lint": "eslint .",
|
|
41
|
+
"lint:fix": "eslint . --fix"
|
|
18
42
|
},
|
|
19
43
|
"jest": {
|
|
20
44
|
"transform": {
|
|
21
45
|
"^.+\\.js$": "babel-jest"
|
|
22
|
-
}
|
|
46
|
+
},
|
|
47
|
+
"coverageThreshold": {
|
|
48
|
+
"global": {
|
|
49
|
+
"branches": 80,
|
|
50
|
+
"functions": 80,
|
|
51
|
+
"lines": 80,
|
|
52
|
+
"statements": 80
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"collectCoverageFrom": [
|
|
56
|
+
"index.js",
|
|
57
|
+
"src/**/*.js"
|
|
58
|
+
]
|
|
23
59
|
},
|
|
24
60
|
"devDependencies": {
|
|
25
61
|
"@babel/cli": "^7.26.4",
|
|
26
62
|
"@babel/core": "^7.26.9",
|
|
63
|
+
"@babel/eslint-parser": "^7.28.6",
|
|
27
64
|
"@babel/plugin-proposal-decorators": "^7.25.9",
|
|
28
|
-
"@babel/polyfill": "^7.12.1",
|
|
29
65
|
"@babel/preset-env": "^7.26.9",
|
|
30
66
|
"@babel/register": "^7.25.9",
|
|
67
|
+
"@eslint/js": "^9.39.2",
|
|
31
68
|
"@types/jest": "^29.5.14",
|
|
32
|
-
"
|
|
69
|
+
"eslint": "^9.39.2",
|
|
70
|
+
"jest": "^29.7.0",
|
|
71
|
+
"typescript": "^5.9.3"
|
|
33
72
|
}
|
|
34
|
-
}
|
|
73
|
+
}
|
package/src/Container.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} InstanceContext
|
|
3
|
+
* @property {'singleton'|'factory'} type - The type of the instance.
|
|
4
|
+
* @property {Function} clazz - The class constructor for the instance.
|
|
5
|
+
* @property {Function} [originalClazz] - The original class if this is a mock.
|
|
6
|
+
* @property {Object} [instance] - The singleton instance, if created.
|
|
7
|
+
* @property {Object} [originalInstance] - The original instance if this is a mock.
|
|
8
|
+
* @property {boolean} [proxy=false] - If true, the mock will proxy to the original class for undefined methods/properties.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {createProxy} from './proxy.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A dependency injection container that manages singleton and factory instances.
|
|
15
|
+
* Supports mocking for testing purposes.
|
|
16
|
+
*/
|
|
17
|
+
export class Container {
|
|
18
|
+
/** @type {Map<string|Function, InstanceContext>} */
|
|
19
|
+
#instances = new Map()
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Register a class as a singleton.
|
|
23
|
+
* @param {Function} clazz The class constructor
|
|
24
|
+
* @param {string} [name] Optional name key
|
|
25
|
+
*/
|
|
26
|
+
registerSingleton(clazz, name) {
|
|
27
|
+
this.#register(clazz, 'singleton', name)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register a class as a factory.
|
|
32
|
+
* @param {Function} clazz The class constructor
|
|
33
|
+
* @param {string} [name] Optional name key
|
|
34
|
+
*/
|
|
35
|
+
registerFactory(clazz, name) {
|
|
36
|
+
this.#register(clazz, 'factory', name)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Internal registration logic.
|
|
41
|
+
* @param {Function} clazz The class constructor
|
|
42
|
+
* @param {'singleton'|'factory'} type The registration type
|
|
43
|
+
* @param {string} [name] Optional name key
|
|
44
|
+
* @private
|
|
45
|
+
*/
|
|
46
|
+
#register(clazz, type, name) {
|
|
47
|
+
const key = name ?? clazz
|
|
48
|
+
if (this.#instances.has(key)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'A different class is already registered under this name. ' +
|
|
51
|
+
'This may be a circular dependency. Try using @InjectLazy'
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
this.#instances.set(key, {clazz, type})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the context for a given class or name.
|
|
59
|
+
* @param {string|Function} clazzOrName The class or name to look up
|
|
60
|
+
* @returns {InstanceContext}
|
|
61
|
+
* @throws {Error} If the context is not found
|
|
62
|
+
*/
|
|
63
|
+
getContext(clazzOrName) {
|
|
64
|
+
if (this.#instances.has(clazzOrName)) {
|
|
65
|
+
return this.#instances.get(clazzOrName)
|
|
66
|
+
}
|
|
67
|
+
const available = Array.from(this.#instances.keys())
|
|
68
|
+
.map(k => typeof k === 'string' ? k : k.name)
|
|
69
|
+
.join(', ')
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Cannot find injection source for "${clazzOrName?.name || clazzOrName}". ` +
|
|
72
|
+
`Available: [${available}]`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if a class or name is registered.
|
|
78
|
+
* @param {string|Function} clazzOrName The class or name to check
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
has(clazzOrName) {
|
|
82
|
+
return this.#instances.has(clazzOrName)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get or create an instance based on the context.
|
|
87
|
+
* @param {InstanceContext} instanceContext The instance context
|
|
88
|
+
* @param {Array} params Constructor parameters
|
|
89
|
+
* @returns {Object} The instance
|
|
90
|
+
*/
|
|
91
|
+
getInstance(instanceContext, params) {
|
|
92
|
+
if (instanceContext.type === 'singleton' && !instanceContext.originalClazz && instanceContext.instance) {
|
|
93
|
+
return instanceContext.instance
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let instance
|
|
97
|
+
try {
|
|
98
|
+
instance = new instanceContext.clazz(...params)
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (err instanceof RangeError) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Circular dependency detected for ${instanceContext.clazz.name || instanceContext.clazz}. ` +
|
|
103
|
+
`Use @InjectLazy to break the cycle.`
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
throw err
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (instanceContext.proxy && instanceContext.originalClazz) {
|
|
110
|
+
const originalInstance = new instanceContext.originalClazz(...params)
|
|
111
|
+
instance = createProxy(instance, originalInstance)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (instanceContext.type === 'singleton') {
|
|
115
|
+
instanceContext.instance = instance
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return instance
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Register a mock for an existing class.
|
|
123
|
+
* @param {string|Function} targetClazzOrName The class or name to mock
|
|
124
|
+
* @param {Function} mockClazz The mock class
|
|
125
|
+
* @param {boolean} [useProxy=false] Whether to proxy unmocked methods to original
|
|
126
|
+
*/
|
|
127
|
+
registerMock(targetClazzOrName, mockClazz, useProxy = false) {
|
|
128
|
+
const instanceContext = this.getContext(targetClazzOrName)
|
|
129
|
+
if (instanceContext.originalClazz) {
|
|
130
|
+
throw new Error('Mock already defined, reset before mocking again')
|
|
131
|
+
}
|
|
132
|
+
instanceContext.originalClazz = instanceContext.clazz
|
|
133
|
+
instanceContext.proxy = useProxy
|
|
134
|
+
instanceContext.clazz = mockClazz
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reset a specific mock to its original class.
|
|
139
|
+
* @param {string|Function} clazzOrName The class or name to reset
|
|
140
|
+
* @throws {Error} If the class or name is not registered
|
|
141
|
+
*/
|
|
142
|
+
resetMock(clazzOrName) {
|
|
143
|
+
const key = typeof clazzOrName === 'string' ? clazzOrName : clazzOrName
|
|
144
|
+
this.#restoreOriginal(this.#instances.get(key), clazzOrName)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reset all mocks to their original classes.
|
|
149
|
+
*/
|
|
150
|
+
resetAllMocks() {
|
|
151
|
+
for (const instanceContext of this.#instances.values()) {
|
|
152
|
+
this.#restoreOriginal(instanceContext)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Clear all registered instances and mocks.
|
|
158
|
+
* Useful for test isolation.
|
|
159
|
+
*/
|
|
160
|
+
clear() {
|
|
161
|
+
this.#instances.clear()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Internal function to restore an instance context to its original.
|
|
166
|
+
* @param {InstanceContext} instanceContext The instance context to reset
|
|
167
|
+
* @param {string|Function} [clazzOrName] Optional identifier for error messages
|
|
168
|
+
* @throws {Error} If instanceContext is null or undefined
|
|
169
|
+
* @private
|
|
170
|
+
*/
|
|
171
|
+
#restoreOriginal(instanceContext, clazzOrName) {
|
|
172
|
+
if (!instanceContext) {
|
|
173
|
+
const name = clazzOrName?.name || clazzOrName || 'unknown'
|
|
174
|
+
throw new Error(`Cannot reset mock for "${name}": not registered`)
|
|
175
|
+
}
|
|
176
|
+
if (instanceContext.originalClazz) {
|
|
177
|
+
instanceContext.clazz = instanceContext.originalClazz
|
|
178
|
+
delete instanceContext.instance
|
|
179
|
+
delete instanceContext.originalClazz
|
|
180
|
+
delete instanceContext.originalInstance
|
|
181
|
+
delete instanceContext.proxy
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a proxy that delegates to the mock first, then falls back to the original.
|
|
3
|
+
* This allows partial mocking where only specific methods are overridden.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} mock The mock instance
|
|
6
|
+
* @param {Object} original The original instance to fall back to
|
|
7
|
+
* @returns {Proxy} A proxy that delegates appropriately
|
|
8
|
+
*/
|
|
9
|
+
export function createProxy(mock, original) {
|
|
10
|
+
return new Proxy(mock, {
|
|
11
|
+
get(target, prop, receiver) {
|
|
12
|
+
if (prop in target) {
|
|
13
|
+
return Reflect.get(target, prop, receiver)
|
|
14
|
+
}
|
|
15
|
+
return Reflect.get(original, prop, original)
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
set(target, prop, value, receiver) {
|
|
19
|
+
if (prop in target) {
|
|
20
|
+
return Reflect.set(target, prop, value, receiver)
|
|
21
|
+
}
|
|
22
|
+
return Reflect.set(original, prop, value, original)
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
has(target, prop) {
|
|
26
|
+
return prop in target || prop in original
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
ownKeys(target) {
|
|
30
|
+
const mockKeys = Reflect.ownKeys(target)
|
|
31
|
+
const originalKeys = Reflect.ownKeys(original)
|
|
32
|
+
return [...new Set([...mockKeys, ...originalKeys])]
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
36
|
+
if (prop in target) {
|
|
37
|
+
return Reflect.getOwnPropertyDescriptor(target, prop)
|
|
38
|
+
}
|
|
39
|
+
return Reflect.getOwnPropertyDescriptor(original, prop)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|