Skip to content

Commit f34bd15

Browse files
GeoffreyBoothruyadorno
authored andcommitted
esm: refactor mocking test
PR-URL: #49465 Reviewed-By: Jacob Smith <jacob@frende.me> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
1 parent 2ceab87 commit f34bd15

File tree

3 files changed

+108
-157
lines changed

3 files changed

+108
-157
lines changed

test/es-module/test-esm-loader-mock.mjs

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
// Flags: --loader ./test/fixtures/es-module-loaders/mock-loader.mjs
21
import '../common/index.mjs';
3-
import assert from 'assert/strict';
4-
5-
// This is provided by test/fixtures/es-module-loaders/mock-loader.mjs
6-
import mock from 'node:mock';
2+
import assert from 'node:assert/strict';
3+
import { mock } from '../fixtures/es-module-loaders/mock.mjs';
74

85
mock('node:events', {
96
EventEmitter: 'This is mocked!'

test/fixtures/es-module-loaders/mock-loader.mjs

+36-152
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { receiveMessageOnPort } from 'node:worker_threads';
22
const mockedModuleExports = new Map();
33
let currentMockVersion = 0;
44

5-
// This loader causes a new module `node:mock` to become available as a way to
5+
// These hooks enable code running on the application thread to
66
// swap module resolution results for mocking purposes. It uses this instead
77
// of import.meta so that CommonJS can still use the functionality.
88
//
@@ -22,7 +22,7 @@ let currentMockVersion = 0;
2222
// it cannot be changed. So things like the following DO NOT WORK:
2323
//
2424
// ```mjs
25-
// import mock from 'node:mock';
25+
// import mock from 'test-esm-loader-mock'; // See test-esm-loader-mock.mjs
2626
// mock('file:///app.js', {x:1});
2727
// const namespace1 = await import('file:///app.js');
2828
// namespace1.x; // 1
@@ -34,17 +34,6 @@ let currentMockVersion = 0;
3434
// assert(namespace1 === namespace2);
3535
// ```
3636

37-
/**
38-
* FIXME: this is a hack to workaround loaders being
39-
* single threaded for now, just ensures that the MessagePort drains
40-
*/
41-
function doDrainPort() {
42-
let msg;
43-
while (msg = receiveMessageOnPort(preloadPort)) {
44-
onPreloadPortMessage(msg.message);
45-
}
46-
}
47-
4837
/**
4938
* @param param0 message from the application context
5039
*/
@@ -54,127 +43,31 @@ function onPreloadPortMessage({
5443
currentMockVersion = mockVersion;
5544
mockedModuleExports.set(resolved, exports);
5645
}
57-
let preloadPort;
58-
export function globalPreload({port}) {
59-
// Save the communication port to the application context to send messages
60-
// to it later
61-
preloadPort = port;
62-
// Every time the application context sends a message over the port
63-
port.on('message', onPreloadPortMessage);
64-
// This prevents the port that the Loader/application talk over
65-
// from keeping the process alive, without this, an application would be kept
66-
// alive just because a loader is waiting for messages
67-
port.unref();
6846

69-
const insideAppContext = (getBuiltin, port, setImportMetaCallback) => {
70-
/**
71-
* This is the Map that saves *all* the mocked URL -> replacement Module
72-
* mappings
73-
* @type {Map<string, {namespace, listeners}>}
74-
*/
75-
let mockedModules = new Map();
76-
let mockVersion = 0;
77-
/**
78-
* This is the value that is placed into the `node:mock` default export
79-
*
80-
* @example
81-
* ```mjs
82-
* import mock from 'node:mock';
83-
* const mutator = mock('file:///app.js', {x:1});
84-
* const namespace = await import('file:///app.js');
85-
* namespace.x; // 1;
86-
* mutator.x = 2;
87-
* namespace.x; // 2;
88-
* ```
89-
*
90-
* @param {string} resolved an absolute URL HREF string
91-
* @param {object} replacementProperties an object to pick properties from
92-
* to act as a module namespace
93-
* @returns {object} a mutator object that can update the module namespace
94-
* since we can't do something like old Object.observe
95-
*/
96-
const doMock = (resolved, replacementProperties) => {
97-
let exportNames = Object.keys(replacementProperties);
98-
let namespace = Object.create(null);
99-
/**
100-
* @type {Array<(name: string)=>void>} functions to call whenever an
101-
* export name is updated
102-
*/
103-
let listeners = [];
104-
for (const name of exportNames) {
105-
let currentValueForPropertyName = replacementProperties[name];
106-
Object.defineProperty(namespace, name, {
107-
enumerable: true,
108-
get() {
109-
return currentValueForPropertyName;
110-
},
111-
set(v) {
112-
currentValueForPropertyName = v;
113-
for (let fn of listeners) {
114-
try {
115-
fn(name);
116-
} catch {
117-
}
118-
}
119-
}
120-
});
121-
}
122-
mockedModules.set(resolved, {
123-
namespace,
124-
listeners
125-
});
126-
mockVersion++;
127-
// Inform the loader that the `resolved` URL should now use the specific
128-
// `mockVersion` and has export names of `exportNames`
129-
//
130-
// This allows the loader to generate a fake module for that version
131-
// and names the next time it resolves a specifier to equal `resolved`
132-
port.postMessage({ mockVersion, resolved, exports: exportNames });
133-
return namespace;
134-
}
135-
// Sets the import.meta properties up
136-
// has the normal chaining workflow with `defaultImportMetaInitializer`
137-
setImportMetaCallback((meta, context, defaultImportMetaInitializer) => {
138-
/**
139-
* 'node:mock' creates its default export by plucking off of import.meta
140-
* and must do so in order to get the communications channel from inside
141-
* preloadCode
142-
*/
143-
if (context.url === 'node:mock') {
144-
meta.doMock = doMock;
145-
return;
146-
}
147-
/**
148-
* Fake modules created by `node:mock` get their meta.mock utility set
149-
* to the corresponding value keyed off `mockedModules` and use this
150-
* to setup their exports/listeners properly
151-
*/
152-
if (context.url.startsWith('mock-facade:')) {
153-
let [proto, version, encodedTargetURL] = context.url.split(':');
154-
let decodedTargetURL = decodeURIComponent(encodedTargetURL);
155-
if (mockedModules.has(decodedTargetURL)) {
156-
meta.mock = mockedModules.get(decodedTargetURL);
157-
return;
158-
}
159-
}
160-
/**
161-
* Ensure we still get things like `import.meta.url`
162-
*/
163-
defaultImportMetaInitializer(meta, context);
164-
});
165-
};
166-
return `(${insideAppContext})(getBuiltin, port, setImportMetaCallback)`
47+
/** @type {URL['href']} */
48+
let mainImportURL;
49+
/** @type {MessagePort} */
50+
let preloadPort;
51+
export async function initialize(data) {
52+
({ mainImportURL, port: preloadPort } = data);
53+
54+
data.port.on('message', onPreloadPortMessage);
16755
}
16856

57+
/**
58+
* Because Node.js internals use a separate MessagePort for cross-thread
59+
* communication, there could be some messages pending that we should handle
60+
* before continuing.
61+
*/
62+
function doDrainPort() {
63+
let msg;
64+
while (msg = receiveMessageOnPort(preloadPort)) {
65+
onPreloadPortMessage(msg.message);
66+
}
67+
}
16968

17069
// Rewrites node: loading to mock-facade: so that it can be intercepted
17170
export async function resolve(specifier, context, defaultResolve) {
172-
if (specifier === 'node:mock') {
173-
return {
174-
shortCircuit: true,
175-
url: specifier
176-
};
177-
}
17871
doDrainPort();
17972
const def = await defaultResolve(specifier, context);
18073
if (context.parentURL?.startsWith('mock-facade:')) {
@@ -193,55 +86,46 @@ export async function resolve(specifier, context, defaultResolve) {
19386

19487
export async function load(url, context, defaultLoad) {
19588
doDrainPort();
196-
if (url === 'node:mock') {
197-
/**
198-
* Simply grab the import.meta.doMock to establish the communication
199-
* channel with preloadCode
200-
*/
201-
return {
202-
shortCircuit: true,
203-
source: 'export default import.meta.doMock',
204-
format: 'module'
205-
};
206-
}
20789
/**
20890
* Mocked fake module, not going to be handled in default way so it
20991
* generates the source text, then short circuits
21092
*/
21193
if (url.startsWith('mock-facade:')) {
212-
let [proto, version, encodedTargetURL] = url.split(':');
213-
let ret = generateModule(mockedModuleExports.get(
214-
decodeURIComponent(encodedTargetURL)
215-
));
94+
const encodedTargetURL = url.slice(url.lastIndexOf(':') + 1);
21695
return {
21796
shortCircuit: true,
218-
source: ret,
219-
format: 'module'
97+
source: generateModule(encodedTargetURL),
98+
format: 'module',
22099
};
221100
}
222101
return defaultLoad(url, context);
223102
}
224103

225104
/**
226-
*
227-
* @param {Array<string>} exports name of the exports of the module
105+
* Generate the source code for a mocked module.
106+
* @param {string} encodedTargetURL the module being mocked
228107
* @returns {string}
229108
*/
230-
function generateModule(exports) {
109+
function generateModule(encodedTargetURL) {
110+
const exports = mockedModuleExports.get(
111+
decodeURIComponent(encodedTargetURL)
112+
);
231113
let body = [
114+
`import { mockedModules } from ${JSON.stringify(mainImportURL)};`,
232115
'export {};',
233-
'let mapping = {__proto__: null};'
116+
'let mapping = {__proto__: null};',
117+
`const mock = mockedModules.get(${JSON.stringify(encodedTargetURL)});`,
234118
];
235119
for (const [i, name] of Object.entries(exports)) {
236120
let key = JSON.stringify(name);
237-
body.push(`var _${i} = import.meta.mock.namespace[${key}];`);
121+
body.push(`var _${i} = mock.namespace[${key}];`);
238122
body.push(`Object.defineProperty(mapping, ${key}, { enumerable: true, set(v) {_${i} = v;}, get() {return _${i};} });`);
239123
body.push(`export {_${i} as ${name}};`);
240124
}
241-
body.push(`import.meta.mock.listeners.push(${
125+
body.push(`mock.listeners.push(${
242126
() => {
243127
for (var k in mapping) {
244-
mapping[k] = import.meta.mock.namespace[k];
128+
mapping[k] = mock.namespace[k];
245129
}
246130
}
247131
});`);
+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { register } from 'node:module';
2+
import { MessageChannel } from 'node:worker_threads';
3+
4+
5+
const { port1, port2 } = new MessageChannel();
6+
7+
register('./mock-loader.mjs', import.meta.url, {
8+
data: {
9+
port: port2,
10+
mainImportURL: import.meta.url,
11+
},
12+
transferList: [port2],
13+
});
14+
15+
/**
16+
* This is the Map that saves *all* the mocked URL -> replacement Module
17+
* mappings
18+
* @type {Map<string, {namespace, listeners}>}
19+
*/
20+
export const mockedModules = new Map();
21+
let mockVersion = 0;
22+
23+
/**
24+
* @param {string} resolved an absolute URL HREF string
25+
* @param {object} replacementProperties an object to pick properties from
26+
* to act as a module namespace
27+
* @returns {object} a mutator object that can update the module namespace
28+
* since we can't do something like old Object.observe
29+
*/
30+
export function mock(resolved, replacementProperties) {
31+
const exportNames = Object.keys(replacementProperties);
32+
const namespace = { __proto__: null };
33+
/**
34+
* @type {Array<(name: string)=>void>} functions to call whenever an
35+
* export name is updated
36+
*/
37+
const listeners = [];
38+
for (const name of exportNames) {
39+
let currentValueForPropertyName = replacementProperties[name];
40+
Object.defineProperty(namespace, name, {
41+
__proto__: null,
42+
enumerable: true,
43+
get() {
44+
return currentValueForPropertyName;
45+
},
46+
set(v) {
47+
currentValueForPropertyName = v;
48+
for (const fn of listeners) {
49+
try {
50+
fn(name);
51+
} catch {
52+
/* noop */
53+
}
54+
}
55+
},
56+
});
57+
}
58+
mockedModules.set(encodeURIComponent(resolved), {
59+
namespace,
60+
listeners,
61+
});
62+
mockVersion++;
63+
// Inform the loader that the `resolved` URL should now use the specific
64+
// `mockVersion` and has export names of `exportNames`
65+
//
66+
// This allows the loader to generate a fake module for that version
67+
// and names the next time it resolves a specifier to equal `resolved`
68+
port1.postMessage({ mockVersion, resolved, exports: exportNames });
69+
return namespace;
70+
}

0 commit comments

Comments
 (0)