Skip to content

Commit

Permalink
esm: refactor mocking test
Browse files Browse the repository at this point in the history
PR-URL: nodejs#49465
Reviewed-By: Jacob Smith <jacob@frende.me>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
  • Loading branch information
GeoffreyBooth authored and alexfernandez committed Nov 1, 2023
1 parent f3921ea commit 48909a1
Show file tree
Hide file tree
Showing 3 changed files with 108 additions and 157 deletions.
7 changes: 2 additions & 5 deletions test/es-module/test-esm-loader-mock.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
// Flags: --loader ./test/fixtures/es-module-loaders/mock-loader.mjs
import '../common/index.mjs';
import assert from 'assert/strict';

// This is provided by test/fixtures/es-module-loaders/mock-loader.mjs
import mock from 'node:mock';
import assert from 'node:assert/strict';
import { mock } from '../fixtures/es-module-loaders/mock.mjs';

mock('node:events', {
EventEmitter: 'This is mocked!'
Expand Down
188 changes: 36 additions & 152 deletions test/fixtures/es-module-loaders/mock-loader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { receiveMessageOnPort } from 'node:worker_threads';
const mockedModuleExports = new Map();
let currentMockVersion = 0;

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

/**
* FIXME: this is a hack to workaround loaders being
* single threaded for now, just ensures that the MessagePort drains
*/
function doDrainPort() {
let msg;
while (msg = receiveMessageOnPort(preloadPort)) {
onPreloadPortMessage(msg.message);
}
}

/**
* @param param0 message from the application context
*/
Expand All @@ -54,127 +43,31 @@ function onPreloadPortMessage({
currentMockVersion = mockVersion;
mockedModuleExports.set(resolved, exports);
}
let preloadPort;
export function globalPreload({port}) {
// Save the communication port to the application context to send messages
// to it later
preloadPort = port;
// Every time the application context sends a message over the port
port.on('message', onPreloadPortMessage);
// This prevents the port that the Loader/application talk over
// from keeping the process alive, without this, an application would be kept
// alive just because a loader is waiting for messages
port.unref();

const insideAppContext = (getBuiltin, port, setImportMetaCallback) => {
/**
* This is the Map that saves *all* the mocked URL -> replacement Module
* mappings
* @type {Map<string, {namespace, listeners}>}
*/
let mockedModules = new Map();
let mockVersion = 0;
/**
* This is the value that is placed into the `node:mock` default export
*
* @example
* ```mjs
* import mock from 'node:mock';
* const mutator = mock('file:///app.js', {x:1});
* const namespace = await import('file:///app.js');
* namespace.x; // 1;
* mutator.x = 2;
* namespace.x; // 2;
* ```
*
* @param {string} resolved an absolute URL HREF string
* @param {object} replacementProperties an object to pick properties from
* to act as a module namespace
* @returns {object} a mutator object that can update the module namespace
* since we can't do something like old Object.observe
*/
const doMock = (resolved, replacementProperties) => {
let exportNames = Object.keys(replacementProperties);
let namespace = Object.create(null);
/**
* @type {Array<(name: string)=>void>} functions to call whenever an
* export name is updated
*/
let listeners = [];
for (const name of exportNames) {
let currentValueForPropertyName = replacementProperties[name];
Object.defineProperty(namespace, name, {
enumerable: true,
get() {
return currentValueForPropertyName;
},
set(v) {
currentValueForPropertyName = v;
for (let fn of listeners) {
try {
fn(name);
} catch {
}
}
}
});
}
mockedModules.set(resolved, {
namespace,
listeners
});
mockVersion++;
// Inform the loader that the `resolved` URL should now use the specific
// `mockVersion` and has export names of `exportNames`
//
// This allows the loader to generate a fake module for that version
// and names the next time it resolves a specifier to equal `resolved`
port.postMessage({ mockVersion, resolved, exports: exportNames });
return namespace;
}
// Sets the import.meta properties up
// has the normal chaining workflow with `defaultImportMetaInitializer`
setImportMetaCallback((meta, context, defaultImportMetaInitializer) => {
/**
* 'node:mock' creates its default export by plucking off of import.meta
* and must do so in order to get the communications channel from inside
* preloadCode
*/
if (context.url === 'node:mock') {
meta.doMock = doMock;
return;
}
/**
* Fake modules created by `node:mock` get their meta.mock utility set
* to the corresponding value keyed off `mockedModules` and use this
* to setup their exports/listeners properly
*/
if (context.url.startsWith('mock-facade:')) {
let [proto, version, encodedTargetURL] = context.url.split(':');
let decodedTargetURL = decodeURIComponent(encodedTargetURL);
if (mockedModules.has(decodedTargetURL)) {
meta.mock = mockedModules.get(decodedTargetURL);
return;
}
}
/**
* Ensure we still get things like `import.meta.url`
*/
defaultImportMetaInitializer(meta, context);
});
};
return `(${insideAppContext})(getBuiltin, port, setImportMetaCallback)`
/** @type {URL['href']} */
let mainImportURL;
/** @type {MessagePort} */
let preloadPort;
export async function initialize(data) {
({ mainImportURL, port: preloadPort } = data);

data.port.on('message', onPreloadPortMessage);
}

/**
* Because Node.js internals use a separate MessagePort for cross-thread
* communication, there could be some messages pending that we should handle
* before continuing.
*/
function doDrainPort() {
let msg;
while (msg = receiveMessageOnPort(preloadPort)) {
onPreloadPortMessage(msg.message);
}
}

// Rewrites node: loading to mock-facade: so that it can be intercepted
export async function resolve(specifier, context, defaultResolve) {
if (specifier === 'node:mock') {
return {
shortCircuit: true,
url: specifier
};
}
doDrainPort();
const def = await defaultResolve(specifier, context);
if (context.parentURL?.startsWith('mock-facade:')) {
Expand All @@ -193,55 +86,46 @@ export async function resolve(specifier, context, defaultResolve) {

export async function load(url, context, defaultLoad) {
doDrainPort();
if (url === 'node:mock') {
/**
* Simply grab the import.meta.doMock to establish the communication
* channel with preloadCode
*/
return {
shortCircuit: true,
source: 'export default import.meta.doMock',
format: 'module'
};
}
/**
* Mocked fake module, not going to be handled in default way so it
* generates the source text, then short circuits
*/
if (url.startsWith('mock-facade:')) {
let [proto, version, encodedTargetURL] = url.split(':');
let ret = generateModule(mockedModuleExports.get(
decodeURIComponent(encodedTargetURL)
));
const encodedTargetURL = url.slice(url.lastIndexOf(':') + 1);
return {
shortCircuit: true,
source: ret,
format: 'module'
source: generateModule(encodedTargetURL),
format: 'module',
};
}
return defaultLoad(url, context);
}

/**
*
* @param {Array<string>} exports name of the exports of the module
* Generate the source code for a mocked module.
* @param {string} encodedTargetURL the module being mocked
* @returns {string}
*/
function generateModule(exports) {
function generateModule(encodedTargetURL) {
const exports = mockedModuleExports.get(
decodeURIComponent(encodedTargetURL)
);
let body = [
`import { mockedModules } from ${JSON.stringify(mainImportURL)};`,
'export {};',
'let mapping = {__proto__: null};'
'let mapping = {__proto__: null};',
`const mock = mockedModules.get(${JSON.stringify(encodedTargetURL)});`,
];
for (const [i, name] of Object.entries(exports)) {
let key = JSON.stringify(name);
body.push(`var _${i} = import.meta.mock.namespace[${key}];`);
body.push(`var _${i} = mock.namespace[${key}];`);
body.push(`Object.defineProperty(mapping, ${key}, { enumerable: true, set(v) {_${i} = v;}, get() {return _${i};} });`);
body.push(`export {_${i} as ${name}};`);
}
body.push(`import.meta.mock.listeners.push(${
body.push(`mock.listeners.push(${
() => {
for (var k in mapping) {
mapping[k] = import.meta.mock.namespace[k];
mapping[k] = mock.namespace[k];
}
}
});`);
Expand Down
70 changes: 70 additions & 0 deletions test/fixtures/es-module-loaders/mock.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { register } from 'node:module';
import { MessageChannel } from 'node:worker_threads';


const { port1, port2 } = new MessageChannel();

register('./mock-loader.mjs', import.meta.url, {
data: {
port: port2,
mainImportURL: import.meta.url,
},
transferList: [port2],
});

/**
* This is the Map that saves *all* the mocked URL -> replacement Module
* mappings
* @type {Map<string, {namespace, listeners}>}
*/
export const mockedModules = new Map();
let mockVersion = 0;

/**
* @param {string} resolved an absolute URL HREF string
* @param {object} replacementProperties an object to pick properties from
* to act as a module namespace
* @returns {object} a mutator object that can update the module namespace
* since we can't do something like old Object.observe
*/
export function mock(resolved, replacementProperties) {
const exportNames = Object.keys(replacementProperties);
const namespace = { __proto__: null };
/**
* @type {Array<(name: string)=>void>} functions to call whenever an
* export name is updated
*/
const listeners = [];
for (const name of exportNames) {
let currentValueForPropertyName = replacementProperties[name];
Object.defineProperty(namespace, name, {
__proto__: null,
enumerable: true,
get() {
return currentValueForPropertyName;
},
set(v) {
currentValueForPropertyName = v;
for (const fn of listeners) {
try {
fn(name);
} catch {
/* noop */
}
}
},
});
}
mockedModules.set(encodeURIComponent(resolved), {
namespace,
listeners,
});
mockVersion++;
// Inform the loader that the `resolved` URL should now use the specific
// `mockVersion` and has export names of `exportNames`
//
// This allows the loader to generate a fake module for that version
// and names the next time it resolves a specifier to equal `resolved`
port1.postMessage({ mockVersion, resolved, exports: exportNames });
return namespace;
}

0 comments on commit 48909a1

Please sign in to comment.