node/lib/internal/test_runner/mock/mock.js
Joyee Cheung 3e31baeda6
esm: use sync loading/resolving on non-loader-hook thread
ESM resolution and loading is now always synchronous from a
non-loader-hook thread. If no asynchrnous loader hooks are
registered, the resolution/loading is entirely synchronous.
If asynchronous loader hooks are registered, these would be
synchronous on the non-loader-hook thread, and asynchronous
on the loader hook thread.

This avoids several races caused by async/sync loading sharing
the same cache. In particular, asynchronous loader hooks
now works with `require(esm)` - previously it tends to break
due to races.

In addition, when an asynchronous loader hook
returns a promise that never settles, the main thread no longer
silently exits with exit code 13, leaving the code below
any module loading calls silently ignored without being executed.
Instead, it now throws ERR_ASYNC_LOADER_REQUEST_NEVER_SETTLED
which can be caught and handled by the main thread. If the module
request comes from `import()`, the never-settling promise is
now relayed to the result returned by `import()`.

Drive-by: when annotating the error about importing undetectable
named exports from CommonJS, it now no longer reload the source
code of the CommonJS module, and instead reuses format information
cached when the module was loaded for linking.

PR-URL: https://github.com/nodejs/node/pull/60380
Fixes: https://github.com/nodejs/node/issues/59666
Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com>
Reviewed-By: Jacob Smith <jacob@frende.me>
2025-10-31 20:45:10 +00:00

945 lines
25 KiB
JavaScript

'use strict';
const {
ArrayPrototypePush,
ArrayPrototypeSlice,
Error,
FunctionPrototypeBind,
FunctionPrototypeCall,
ObjectDefineProperty,
ObjectGetOwnPropertyDescriptor,
ObjectGetPrototypeOf,
ObjectKeys,
Proxy,
ReflectApply,
ReflectConstruct,
ReflectGet,
SafeMap,
StringPrototypeSlice,
StringPrototypeStartsWith,
} = primordials;
const {
codes: {
ERR_INVALID_ARG_TYPE,
ERR_INVALID_ARG_VALUE,
ERR_INVALID_STATE,
},
} = require('internal/errors');
const esmLoader = require('internal/modules/esm/loader');
const { getOptionValue } = require('internal/options');
const {
fileURLToPath,
isURL,
pathToFileURL,
URL,
} = require('internal/url');
const {
emitExperimentalWarning,
getStructuredStack,
kEmptyObject,
} = require('internal/util');
let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => {
debug = fn;
});
const {
validateBoolean,
validateFunction,
validateInteger,
validateObject,
validateOneOf,
} = require('internal/validators');
const { MockTimers } = require('internal/test_runner/mock/mock_timers');
const { Module } = require('internal/modules/cjs/loader');
const { _load, _nodeModulePaths, _resolveFilename, isBuiltin } = Module;
function kDefaultFunction() {}
const enableModuleMocking = getOptionValue('--experimental-test-module-mocks');
const kSupportedFormats = [
'builtin',
'commonjs-typescript',
'commonjs',
'json',
'module-typescript',
'module',
];
let sharedModuleState;
const {
hooks: mockHooks,
mocks,
constants: { kBadExportsMessage, kMockSearchParam },
} = require('internal/test_runner/mock/loader');
class MockFunctionContext {
#calls;
#mocks;
#implementation;
#restore;
#times;
constructor(implementation, restore, times) {
this.#calls = [];
this.#mocks = new SafeMap();
this.#implementation = implementation;
this.#restore = restore;
this.#times = times;
}
/**
* Gets an array of recorded calls made to the mock function.
* @returns {Array} An array of recorded calls.
*/
get calls() {
return ArrayPrototypeSlice(this.#calls, 0);
}
/**
* Retrieves the number of times the mock function has been called.
* @returns {number} The call count.
*/
callCount() {
return this.#calls.length;
}
/**
* Sets a new implementation for the mock function.
* @param {Function} implementation - The new implementation for the mock function.
*/
mockImplementation(implementation) {
validateFunction(implementation, 'implementation');
this.#implementation = implementation;
}
/**
* Replaces the implementation of the function only once.
* @param {Function} implementation - The substitute function.
* @param {number} [onCall] - The call index to be replaced.
*/
mockImplementationOnce(implementation, onCall) {
validateFunction(implementation, 'implementation');
const nextCall = this.#calls.length;
const call = onCall ?? nextCall;
validateInteger(call, 'onCall', nextCall);
this.#mocks.set(call, implementation);
}
/**
* Restores the original function that was mocked.
*/
restore() {
const { descriptor, object, original, methodName } = this.#restore;
if (typeof methodName === 'string') {
// This is an object method spy.
ObjectDefineProperty(object, methodName, descriptor);
} else {
// This is a bare function spy. There isn't much to do here but make
// the mock call the original function.
this.#implementation = original;
}
}
/**
* Resets the recorded calls to the mock function
*/
resetCalls() {
this.#calls = [];
}
/**
* Tracks a call made to the mock function.
* @param {object} call - The call details.
*/
trackCall(call) {
ArrayPrototypePush(this.#calls, call);
}
/**
* Gets the next implementation to use for the mock function.
* @returns {Function} The next implementation.
*/
nextImpl() {
const nextCall = this.#calls.length;
const mock = this.#mocks.get(nextCall);
const impl = mock ?? this.#implementation;
if (nextCall + 1 === this.#times) {
this.restore();
}
this.#mocks.delete(nextCall);
return impl;
}
}
const {
nextImpl,
restore: restoreFn,
trackCall,
} = MockFunctionContext.prototype;
delete MockFunctionContext.prototype.trackCall;
delete MockFunctionContext.prototype.nextImpl;
class MockModuleContext {
#restore;
#sharedState;
constructor({
baseURL,
cache,
caller,
defaultExport,
format,
fullPath,
hasDefaultExport,
namedExports,
sharedState,
specifier,
}) {
const config = {
__proto__: null,
cache,
defaultExport,
hasDefaultExport,
namedExports,
caller,
};
sharedState.mockMap.set(baseURL, config);
sharedState.mockMap.set(fullPath, config);
this.#sharedState = sharedState;
this.#restore = {
__proto__: null,
baseURL,
cached: fullPath in Module._cache,
format,
fullPath,
value: Module._cache[fullPath],
};
const mock = mocks.get(baseURL);
if (mock?.active) {
debug('already mocking "%s"', baseURL);
throw new ERR_INVALID_STATE(
`Cannot mock '${specifier}'. The module is already mocked.`,
);
} else {
const localVersion = mock?.localVersion ?? 0;
debug('new mock version %d for "%s"', localVersion, baseURL);
mocks.set(baseURL, {
__proto__: null,
url: baseURL,
cache,
exportNames: ObjectKeys(namedExports),
hasDefaultExport,
format,
localVersion,
active: true,
});
}
delete Module._cache[fullPath];
sharedState.mockExports.set(baseURL, {
__proto__: null,
defaultExport,
namedExports,
});
}
restore() {
if (this.#restore === undefined) {
return;
}
// Delete the mock CJS cache entry. If the module was previously in the
// cache then restore the old value.
delete Module._cache[this.#restore.fullPath];
if (this.#restore.cached) {
Module._cache[this.#restore.fullPath] = this.#restore.value;
}
const mock = mocks.get(this.#restore.baseURL);
if (mock !== undefined) {
mock.active = false;
mock.localVersion++;
}
this.#sharedState.mockMap.delete(this.#restore.baseURL);
this.#sharedState.mockMap.delete(this.#restore.fullPath);
this.#restore = undefined;
}
}
const { restore: restoreModule } = MockModuleContext.prototype;
class MockPropertyContext {
#object;
#propertyName;
#value;
#originalValue;
#descriptor;
#accesses;
#onceValues;
constructor(object, propertyName, value) {
this.#onceValues = new SafeMap();
this.#accesses = [];
this.#object = object;
this.#propertyName = propertyName;
this.#originalValue = object[propertyName];
this.#value = arguments.length > 2 ? value : this.#originalValue;
this.#descriptor = ObjectGetOwnPropertyDescriptor(object, propertyName);
if (!this.#descriptor) {
throw new ERR_INVALID_ARG_VALUE(
'propertyName', propertyName, 'is not a property of the object',
);
}
const { configurable, enumerable } = this.#descriptor;
ObjectDefineProperty(object, propertyName, {
__proto__: null,
configurable,
enumerable,
get: () => {
const nextValue = this.#getAccessValue(this.#value);
const access = {
__proto__: null,
type: 'get',
value: nextValue,
// eslint-disable-next-line no-restricted-syntax
stack: new Error(),
};
ArrayPrototypePush(this.#accesses, access);
return nextValue;
},
set: this.mockImplementation.bind(this),
});
}
/**
* Gets an array of recorded accesses (get/set) to the property.
* @returns {Array} An array of access records.
*/
get accesses() {
return ArrayPrototypeSlice(this.#accesses, 0);
}
/**
* Retrieves the number of times the property was accessed (get or set).
* @returns {number} The total number of accesses.
*/
accessCount() {
return this.#accesses.length;
}
/**
* Sets a new value for the property.
* @param {any} value - The new value to be set.
* @throws {Error} If the property is not writable.
*/
mockImplementation(value) {
if (!this.#descriptor.writable) {
throw new ERR_INVALID_ARG_VALUE(
'propertyName', this.#propertyName, 'cannot be set',
);
}
const nextValue = this.#getAccessValue(value);
const access = {
__proto__: null,
type: 'set',
value: nextValue,
// eslint-disable-next-line no-restricted-syntax
stack: new Error(),
};
ArrayPrototypePush(this.#accesses, access);
this.#value = nextValue;
}
#getAccessValue(value) {
const accessIndex = this.#accesses.length;
let accessValue;
if (this.#onceValues.has(accessIndex)) {
accessValue = this.#onceValues.get(accessIndex);
this.#onceValues.delete(accessIndex);
} else {
accessValue = value;
}
return accessValue;
}
/**
* Sets a value to be used only for the next access (get or set), or a specific access index.
* @param {any} value - The value to be used once.
* @param {number} [onAccess] - The access index to be replaced.
*/
mockImplementationOnce(value, onAccess) {
const nextAccess = this.#accesses.length;
const accessIndex = onAccess ?? nextAccess;
validateInteger(accessIndex, 'onAccess', nextAccess);
this.#onceValues.set(accessIndex, value);
}
/**
* Resets the recorded accesses to the property.
*/
resetAccesses() {
this.#accesses = [];
}
/**
* Restores the original value of the property that was mocked.
*/
restore() {
ObjectDefineProperty(this.#object, this.#propertyName, {
__proto__: null,
...this.#descriptor,
value: this.#originalValue,
});
}
}
const { restore: restoreProperty } = MockPropertyContext.prototype;
class MockTracker {
#mocks = [];
#timers;
/**
* Returns the mock timers of this MockTracker instance.
* @returns {MockTimers} The mock timers instance.
*/
get timers() {
this.#timers ??= new MockTimers();
return this.#timers;
}
/**
* Creates a mock function tracker.
* @param {Function} [original] - The original function to be tracked.
* @param {Function} [implementation] - An optional replacement function for the original one.
* @param {object} [options] - Additional tracking options.
* @param {number} [options.times] - The maximum number of times the mock function can be called.
* @returns {ProxyConstructor} The mock function tracker.
*/
fn(
original = function() {},
implementation = original,
options = kEmptyObject,
) {
if (original !== null && typeof original === 'object') {
options = original;
original = function() {};
implementation = original;
} else if (implementation !== null && typeof implementation === 'object') {
options = implementation;
implementation = original;
}
validateFunction(original, 'original');
validateFunction(implementation, 'implementation');
validateObject(options, 'options');
const { times = Infinity } = options;
validateTimes(times, 'options.times');
const ctx = new MockFunctionContext(implementation, { __proto__: null, original }, times);
return this.#setupMock(ctx, original);
}
/**
* Creates a method tracker for a specified object or function.
* @param {(object | Function)} objectOrFunction - The object or function containing the method to be tracked.
* @param {string} methodName - The name of the method to be tracked.
* @param {Function} [implementation] - An optional replacement function for the original method.
* @param {object} [options] - Additional tracking options.
* @param {boolean} [options.getter] - Indicates whether this is a getter method.
* @param {boolean} [options.setter] - Indicates whether this is a setter method.
* @param {number} [options.times] - The maximum number of times the mock method can be called.
* @returns {ProxyConstructor} The mock method tracker.
*/
method(
objectOrFunction,
methodName,
implementation = kDefaultFunction,
options = kEmptyObject,
) {
validateStringOrSymbol(methodName, 'methodName');
if (typeof objectOrFunction !== 'function') {
validateObject(objectOrFunction, 'object');
}
if (implementation !== null && typeof implementation === 'object') {
options = implementation;
implementation = kDefaultFunction;
}
validateFunction(implementation, 'implementation');
validateObject(options, 'options');
const {
getter = false,
setter = false,
times = Infinity,
} = options;
validateBoolean(getter, 'options.getter');
validateBoolean(setter, 'options.setter');
validateTimes(times, 'options.times');
if (setter && getter) {
throw new ERR_INVALID_ARG_VALUE(
'options.setter', setter, "cannot be used with 'options.getter'",
);
}
const descriptor = findMethodOnPrototypeChain(objectOrFunction, methodName);
let original;
if (getter) {
original = descriptor?.get;
} else if (setter) {
original = descriptor?.set;
} else {
original = descriptor?.value;
}
if (typeof original !== 'function') {
throw new ERR_INVALID_ARG_VALUE(
'methodName', original, 'must be a method',
);
}
const restore = { __proto__: null, descriptor, object: objectOrFunction, methodName };
const impl = implementation === kDefaultFunction ?
original : implementation;
const ctx = new MockFunctionContext(impl, restore, times);
const mock = this.#setupMock(ctx, original);
const mockDescriptor = {
__proto__: null,
configurable: descriptor.configurable,
enumerable: descriptor.enumerable,
};
if (getter) {
mockDescriptor.get = mock;
mockDescriptor.set = descriptor.set;
} else if (setter) {
mockDescriptor.get = descriptor.get;
mockDescriptor.set = mock;
} else {
mockDescriptor.writable = descriptor.writable;
mockDescriptor.value = mock;
}
ObjectDefineProperty(objectOrFunction, methodName, mockDescriptor);
return mock;
}
/**
* Mocks a getter method of an object.
* This is a syntax sugar for the MockTracker.method with options.getter set to true
* @param {object} object - The target object.
* @param {string} methodName - The name of the getter method to be mocked.
* @param {Function} [implementation] - An optional replacement function for the targeted method.
* @param {object} [options] - Additional tracking options.
* @param {boolean} [options.getter] - Indicates whether this is a getter method.
* @param {boolean} [options.setter] - Indicates whether this is a setter method.
* @param {number} [options.times] - The maximum number of times the mock method can be called.
* @returns {ProxyConstructor} The mock method tracker.
*/
getter(
object,
methodName,
implementation = kDefaultFunction,
options = kEmptyObject,
) {
if (implementation !== null && typeof implementation === 'object') {
options = implementation;
implementation = kDefaultFunction;
} else {
validateObject(options, 'options');
}
const { getter = true } = options;
if (getter === false) {
throw new ERR_INVALID_ARG_VALUE(
'options.getter', getter, 'cannot be false',
);
}
return this.method(object, methodName, implementation, {
__proto__: null,
...options,
getter,
});
}
/**
* Mocks a setter method of an object.
* This function is a syntax sugar for MockTracker.method with options.setter set to true.
* @param {object} object - The target object.
* @param {string} methodName - The setter method to be mocked.
* @param {Function} [implementation] - An optional replacement function for the targeted method.
* @param {object} [options] - Additional tracking options.
* @param {boolean} [options.getter] - Indicates whether this is a getter method.
* @param {boolean} [options.setter] - Indicates whether this is a setter method.
* @param {number} [options.times] - The maximum number of times the mock method can be called.
* @returns {ProxyConstructor} The mock method tracker.
*/
setter(
object,
methodName,
implementation = kDefaultFunction,
options = kEmptyObject,
) {
if (implementation !== null && typeof implementation === 'object') {
options = implementation;
implementation = kDefaultFunction;
} else {
validateObject(options, 'options');
}
const { setter = true } = options;
if (setter === false) {
throw new ERR_INVALID_ARG_VALUE(
'options.setter', setter, 'cannot be false',
);
}
return this.method(object, methodName, implementation, {
__proto__: null,
...options,
setter,
});
}
module(specifier, options = kEmptyObject) {
emitExperimentalWarning('Module mocking');
if (typeof specifier !== 'string') {
if (!isURL(specifier))
throw new ERR_INVALID_ARG_TYPE('specifier', ['string', 'URL'], specifier);
specifier = `${specifier}`;
}
validateObject(options, 'options');
debug('module mock entry, specifier = "%s", options = %o', specifier, options);
const {
cache = false,
namedExports = kEmptyObject,
defaultExport,
} = options;
const hasDefaultExport = 'defaultExport' in options;
validateBoolean(cache, 'options.cache');
validateObject(namedExports, 'options.namedExports');
const sharedState = setupSharedModuleState();
const mockSpecifier = StringPrototypeStartsWith(specifier, 'node:') ?
StringPrototypeSlice(specifier, 5) : specifier;
// Get the file that called this function. We need four stack frames:
// vm context -> getStructuredStack() -> this function -> actual caller.
const filename = getStructuredStack()[3]?.getFileName();
// If the caller is already a file URL, use it as is. Otherwise, convert it.
const hasFileProtocol = StringPrototypeStartsWith(filename, 'file://');
const caller = hasFileProtocol ? filename : pathToFileURL(filename).href;
const request = { __proto__: null, specifier: mockSpecifier, attributes: kEmptyObject };
const { format, url } = sharedState.moduleLoader.resolveSync(caller, request);
debug('module mock, url = "%s", format = "%s", caller = "%s"', url, format, caller);
if (format) { // Format is not yet known for ambiguous files when detection is enabled.
validateOneOf(format, 'format', kSupportedFormats);
}
const baseURL = URL.parse(url);
if (!baseURL) {
throw new ERR_INVALID_ARG_VALUE(
'specifier', specifier, 'cannot compute URL',
);
}
if (baseURL.searchParams.has(kMockSearchParam)) {
throw new ERR_INVALID_STATE(
`Cannot mock '${specifier}'. The module is already mocked.`,
);
}
const fullPath = StringPrototypeStartsWith(url, 'file://') ?
fileURLToPath(url) : null;
const ctx = new MockModuleContext({
__proto__: null,
baseURL: baseURL.href,
cache,
caller,
defaultExport,
format,
fullPath,
hasDefaultExport,
namedExports,
sharedState,
specifier: mockSpecifier,
});
ArrayPrototypePush(this.#mocks, {
__proto__: null,
ctx,
restore: restoreModule,
});
return ctx;
}
/**
* Creates a property tracker for a specified object.
* @param {(object)} object - The object whose value is being tracked.
* @param {string} propertyName - The identifier of the property on object to be tracked.
* @param {any} value - An optional replacement value used as the mock value for object[valueName].
* @returns {ProxyConstructor} The mock property tracker.
*/
property(
object,
propertyName,
value,
) {
validateObject(object, 'object');
validateStringOrSymbol(propertyName, 'propertyName');
const ctx = arguments.length > 2 ?
new MockPropertyContext(object, propertyName, value) :
new MockPropertyContext(object, propertyName);
ArrayPrototypePush(this.#mocks, {
__proto__: null,
ctx,
restore: restoreProperty,
});
return new Proxy(object, {
__proto__: null,
get(target, property, receiver) {
if (property === 'mock') {
return ctx;
}
return ReflectGet(target, property, receiver);
},
});
}
/**
* Resets the mock tracker, restoring all mocks and clearing timers.
*/
reset() {
this.restoreAll();
this.#timers?.reset();
this.#mocks = [];
}
/**
* Restore all mocks created by this MockTracker instance.
*/
restoreAll() {
for (let i = 0; i < this.#mocks.length; i++) {
const { ctx, restore } = this.#mocks[i];
FunctionPrototypeCall(restore, ctx);
}
}
#setupMock(ctx, fnToMatch) {
const mock = new Proxy(fnToMatch, {
__proto__: null,
apply(_fn, thisArg, argList) {
const fn = FunctionPrototypeCall(nextImpl, ctx);
let result;
let error;
try {
result = ReflectApply(fn, thisArg, argList);
} catch (err) {
error = err;
throw err;
} finally {
FunctionPrototypeCall(trackCall, ctx, {
__proto__: null,
arguments: argList,
error,
result,
// eslint-disable-next-line no-restricted-syntax
stack: new Error(),
target: undefined,
this: thisArg,
});
}
return result;
},
construct(target, argList, newTarget) {
const realTarget = FunctionPrototypeCall(nextImpl, ctx);
let result;
let error;
try {
result = ReflectConstruct(realTarget, argList, newTarget);
} catch (err) {
error = err;
throw err;
} finally {
FunctionPrototypeCall(trackCall, ctx, {
__proto__: null,
arguments: argList,
error,
result,
// eslint-disable-next-line no-restricted-syntax
stack: new Error(),
target,
this: result,
});
}
return result;
},
get(target, property, receiver) {
if (property === 'mock') {
return ctx;
}
return ReflectGet(target, property, receiver);
},
});
ArrayPrototypePush(this.#mocks, {
__proto__: null,
ctx,
restore: restoreFn,
});
return mock;
}
}
function setupSharedModuleState() {
if (sharedModuleState === undefined) {
const { mock } = require('test');
const mockExports = new SafeMap();
const { registerHooks } = require('internal/modules/customization_hooks');
const moduleLoader = esmLoader.getOrInitializeCascadedLoader();
registerHooks(mockHooks);
sharedModuleState = {
__proto__: null,
mockExports,
mockMap: new SafeMap(),
moduleLoader,
};
mock._mockExports = mockExports;
Module._load = FunctionPrototypeBind(cjsMockModuleLoad, sharedModuleState);
}
return sharedModuleState;
}
function cjsMockModuleLoad(request, parent, isMain) {
let resolved;
if (isBuiltin(request)) {
resolved = ensureNodeScheme(request);
} else {
resolved = _resolveFilename(request, parent, isMain);
}
const config = this.mockMap.get(resolved);
if (config === undefined) {
return _load(request, parent, isMain);
}
const {
cache,
caller,
defaultExport,
hasDefaultExport,
namedExports,
} = config;
if (cache && Module._cache[resolved]) {
// The CJS cache entry is deleted when the mock is configured. If it has
// been repopulated, return the exports from that entry.
return Module._cache[resolved].exports;
}
// eslint-disable-next-line node-core/set-proto-to-null-in-object
const modExports = hasDefaultExport ? defaultExport : {};
const exportNames = ObjectKeys(namedExports);
if ((typeof modExports !== 'object' || modExports === null) &&
exportNames.length > 0) {
// eslint-disable-next-line no-restricted-syntax
throw new Error(kBadExportsMessage);
}
for (let i = 0; i < exportNames.length; ++i) {
const name = exportNames[i];
const descriptor = ObjectGetOwnPropertyDescriptor(namedExports, name);
ObjectDefineProperty(modExports, name, descriptor);
}
if (cache) {
const entry = new Module(resolved, caller);
entry.exports = modExports;
entry.filename = resolved;
entry.loaded = true;
entry.paths = _nodeModulePaths(entry.path);
Module._cache[resolved] = entry;
}
return modExports;
}
function validateStringOrSymbol(value, name) {
if (typeof value !== 'string' && typeof value !== 'symbol') {
throw new ERR_INVALID_ARG_TYPE(name, ['string', 'symbol'], value);
}
}
function validateTimes(value, name) {
if (value === Infinity) {
return;
}
validateInteger(value, name, 1);
}
function findMethodOnPrototypeChain(instance, methodName) {
let host = instance;
let descriptor;
while (host !== null) {
descriptor = ObjectGetOwnPropertyDescriptor(host, methodName);
if (descriptor) {
break;
}
host = ObjectGetPrototypeOf(host);
}
return descriptor;
}
function ensureNodeScheme(specifier) {
if (!StringPrototypeStartsWith(specifier, 'node:')) {
return `node:${specifier}`;
}
return specifier;
}
if (!enableModuleMocking) {
delete MockTracker.prototype.module;
}
module.exports = {
ensureNodeScheme,
MockTracker,
};