node-esm-mock 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/README.md +83 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +94 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# esm-mock
|
|
2
|
+
|
|
3
|
+
Mock ES modules in Node.js using `registerHooks` (Node >=22.7.0).
|
|
4
|
+
|
|
5
|
+
No loader flags, no `--experimental-loader`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install esm-mock
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { mock } from 'esm-mock';
|
|
17
|
+
import assert from 'node:assert';
|
|
18
|
+
import sinon from 'sinon';
|
|
19
|
+
|
|
20
|
+
const handler = sinon.stub().returns('mocked!');
|
|
21
|
+
|
|
22
|
+
const mod = await mock({
|
|
23
|
+
'./module.ts': { greet: handler },
|
|
24
|
+
}).for<typeof import('./module.ts')>('./module.ts');
|
|
25
|
+
|
|
26
|
+
mod.greet();
|
|
27
|
+
assert.equal(handler.callCount, 1);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Without mocks, `for()` imports the module cleanly (cache-busted):
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const mod = await mock().for('./module.ts');
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### API
|
|
37
|
+
|
|
38
|
+
#### `mock(mocks?)`
|
|
39
|
+
|
|
40
|
+
| Param | Type | Default | Description |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| `mocks` | `Record<string, Record<string, unknown>>` | `{}` | A map of module specifiers to their replacement exports |
|
|
43
|
+
|
|
44
|
+
Returns an object with a `for()` method.
|
|
45
|
+
|
|
46
|
+
#### `mock(mocks).for<T>(specifier): Promise<T>`
|
|
47
|
+
|
|
48
|
+
| Param | Type | Description |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `specifier` | `string` | Module specifier to import (cache-busted with a timestamp query) |
|
|
51
|
+
| `T` (generic) | — | Type of the imported module (optional) |
|
|
52
|
+
|
|
53
|
+
Imports the given specifier with the configured mocks active. After the import resolves, mock registrations are automatically cleaned up.
|
|
54
|
+
|
|
55
|
+
### Examples
|
|
56
|
+
|
|
57
|
+
**Mock a built-in module:**
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const { Worker } = await mock({
|
|
61
|
+
'node:worker_threads': { Worker: FakeWorker },
|
|
62
|
+
}).for<typeof import('../worker.js')>('../worker.js');
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**No mocks (plain import with cache busting):**
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const { Worker } = await mock().for('../worker.js');
|
|
69
|
+
assert.ok(Worker instanceof worker_threads.Worker);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How it works
|
|
73
|
+
|
|
74
|
+
`esm-mock` registers a [`registerHooks`](https://nodejs.org/api/module.html#moduleregisterhooksoptions) instance at module scope that intercepts `resolve` and `load` for any module URL registered via `add()`. When a mocked module is requested, resolution short-circuits to a synthetic `mock-facade:` URL and `load` serves auto-generated ES module source that re-exports the replacement values.
|
|
75
|
+
|
|
76
|
+
Each `mock(mocks).for(specifier)` call:
|
|
77
|
+
1. Registers each mock entry via the internal `add()` function.
|
|
78
|
+
2. Dynamically imports the specifier with a cache-busting query parameter.
|
|
79
|
+
3. Cleans up all mock registrations after the import resolves.
|
|
80
|
+
|
|
81
|
+
## Requirements
|
|
82
|
+
|
|
83
|
+
- **Node.js >= 22.7.0** (for `module.registerHooks`)
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { registerHooks } from 'node:module';
|
|
2
|
+
const mockedModuleExports = new Map();
|
|
3
|
+
let mainImportURL = import.meta.url;
|
|
4
|
+
registerHooks({
|
|
5
|
+
resolve(specifier, context, nextResolve) {
|
|
6
|
+
var _a;
|
|
7
|
+
const def = nextResolve(specifier, context);
|
|
8
|
+
if (!((_a = context.parentURL) === null || _a === void 0 ? void 0 : _a.startsWith('mock-facade:'))) {
|
|
9
|
+
if (mockedModuleExports.has(def.url)) {
|
|
10
|
+
return {
|
|
11
|
+
shortCircuit: true, url: `mock-facade:${encodeURIComponent(def.url)}`,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return def;
|
|
16
|
+
}, load(url, context, nextLoad) {
|
|
17
|
+
if (url.startsWith('mock-facade:')) {
|
|
18
|
+
const encodedTargetURL = url.slice(url.lastIndexOf(':') + 1);
|
|
19
|
+
return {
|
|
20
|
+
shortCircuit: true, source: generateModule(encodedTargetURL), format: 'module',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return nextLoad(url, context);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
function generateModule(encodedTargetURL) {
|
|
27
|
+
const exports = mockedModuleExports.get(decodeURIComponent(encodedTargetURL));
|
|
28
|
+
const body = [
|
|
29
|
+
`import { mockedModules } from ${JSON.stringify(mainImportURL)};`,
|
|
30
|
+
'export {};',
|
|
31
|
+
'let mapping = {__proto__: null};',
|
|
32
|
+
`const mock = mockedModules.get(${JSON.stringify(encodedTargetURL)});`,
|
|
33
|
+
];
|
|
34
|
+
for (const [i, name] of Object.entries(exports)) {
|
|
35
|
+
const key = JSON.stringify(name);
|
|
36
|
+
body.push(`var _${i} = mock.namespace[${key}];`);
|
|
37
|
+
body.push(`Object.defineProperty(mapping, ${key}, { enumerable: true, set(v) {_${i} = v;}, get() {return _${i};} });`);
|
|
38
|
+
body.push(`export {_${i} as ${name}};`);
|
|
39
|
+
}
|
|
40
|
+
body.push(`mock.listeners.push(() => {
|
|
41
|
+
for (var k in mapping) {
|
|
42
|
+
mapping[k] = mock.namespace[k];
|
|
43
|
+
}
|
|
44
|
+
});`);
|
|
45
|
+
return body.join('\n');
|
|
46
|
+
}
|
|
47
|
+
export const mockedModules = new Map();
|
|
48
|
+
function add(resolved, replacementProperties) {
|
|
49
|
+
const exportNames = Object.keys(replacementProperties);
|
|
50
|
+
const namespace = { __proto__: null };
|
|
51
|
+
const listeners = [];
|
|
52
|
+
for (const name of exportNames) {
|
|
53
|
+
let currentValueForPropertyName = replacementProperties[name];
|
|
54
|
+
Object.defineProperty(namespace, name, {
|
|
55
|
+
// @ts-ignore
|
|
56
|
+
__proto__: null,
|
|
57
|
+
enumerable: true,
|
|
58
|
+
get() {
|
|
59
|
+
return currentValueForPropertyName;
|
|
60
|
+
}, set(v) {
|
|
61
|
+
currentValueForPropertyName = v;
|
|
62
|
+
for (const fn of listeners) {
|
|
63
|
+
try {
|
|
64
|
+
fn(name);
|
|
65
|
+
}
|
|
66
|
+
catch (_a) {
|
|
67
|
+
/* noop */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
mockedModules.set(encodeURIComponent(resolved), {
|
|
74
|
+
namespace, listeners,
|
|
75
|
+
});
|
|
76
|
+
mockedModuleExports.set(resolved, exportNames);
|
|
77
|
+
return namespace;
|
|
78
|
+
}
|
|
79
|
+
export function mock(mocks = {}) {
|
|
80
|
+
const mockedModules = new Map();
|
|
81
|
+
return {
|
|
82
|
+
for(specifier) {
|
|
83
|
+
try {
|
|
84
|
+
for (const spec in mocks) {
|
|
85
|
+
mockedModules.set(spec, add(spec, mocks[spec]));
|
|
86
|
+
}
|
|
87
|
+
return import(`${specifier}?${+new Date()}`);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
mockedModules.forEach((_, mockedModule) => mockedModuleExports.delete(mockedModule));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-esm-mock",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mock ES modules in Node.js using registerHooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mock",
|
|
19
|
+
"module",
|
|
20
|
+
"esm",
|
|
21
|
+
"test",
|
|
22
|
+
"node",
|
|
23
|
+
"registerHooks"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.7.0"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"typescript": "^5.5.3"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^25.9.3",
|
|
34
|
+
"@types/sinon": "^21.0.1",
|
|
35
|
+
"sinon": "^22.0.0",
|
|
36
|
+
"ts-node": "^10.9.2"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"test": "node --test test/index.ts"
|
|
41
|
+
}
|
|
42
|
+
}
|