Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -2775,6 +2775,12 @@ changes:
**This option cannot be used with `options.exports`.**
This option is deprecated and will be removed in a later version.
Prefer `options.exports`.
* `virtual` {boolean} If `true`, `specifier` is allowed to refer to a module
that cannot be resolved, such as a package that is not installed or a file
that does not exist. The mock is then created as an ECMAScript module that
can be loaded with both `import` and `require()`. If the module can be
resolved, this option has no effect and the module is mocked as usual.
**Default:** `false`.
* Returns: {MockModuleContext} An object that can be used to manipulate the mock.

This function is used to mock the exports of ECMAScript modules, CommonJS modules, JSON modules, and
Expand Down
56 changes: 54 additions & 2 deletions lib/internal/test_runner/mock/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
const {
JSONStringify,
SafeMap,
StringPrototypeStartsWith,
encodeURIComponent,
} = primordials;

const kMockSearchParam = 'node-test-mock';
const kVirtualMockProtocol = 'node-test-mock:';
const kBadExportsMessage = 'Cannot create mock because named exports ' +
'cannot be applied to the provided default export.';

Expand All @@ -15,10 +18,46 @@ let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => {

const mocks = new SafeMap();

/**
* Computes the URL used to identify a virtual mock, i.e. a mock created with
* `virtual: true` for a module that cannot be resolved. Relative and absolute
* specifiers map to the file: URL they would resolve to, so that the same file
* imported through different relative paths maps to the same mock. Bare
* specifiers map to a synthetic URL, as there is no file to point to.
* @param {string} specifier
* @param {string} [parentURL]
* @returns {string}
*/
function getVirtualMockURL(specifier, parentURL) {
if (StringPrototypeStartsWith(specifier, 'file:') ||
StringPrototypeStartsWith(specifier, './') ||
StringPrototypeStartsWith(specifier, '../') ||
StringPrototypeStartsWith(specifier, '/')) {
return new URL(specifier, parentURL).href;
}

return `${kVirtualMockProtocol}///${encodeURIComponent(specifier)}`;
}

function resolve(specifier, context, nextResolve) {
debug('resolve hook entry, specifier = "%s", context = %o', specifier, context);

const nextResolveResult = nextResolve(specifier, context);
let nextResolveResult;
try {
nextResolveResult = nextResolve(specifier, context);
} catch (err) {
// The module does not exist. If a virtual mock was created for it, resolve
// to the mock instead of failing.
if (err?.code !== 'ERR_MODULE_NOT_FOUND') {
throw err;
}
const virtualURL = getVirtualMockURL(specifier, context.parentURL);
const virtualMock = mocks.get(virtualURL);
if (virtualMock?.active !== true || virtualMock.virtual !== true) {
throw err;
}
nextResolveResult = { __proto__: null, url: virtualURL, format: 'module' };
}
const mockSpecifier = nextResolveResult.url;

const mock = mocks.get(mockSpecifier);
Expand Down Expand Up @@ -51,9 +90,21 @@ function load(url, context, nextLoad) {

const baseURL = parsedURL ? parsedURL.href : url;
const mock = mocks.get(baseURL);
debug('load hook, mock = %o', mock);

if (mock?.active === true && mock.virtual === true) {
// There is no module to load for a virtual mock, so do not call nextLoad().
const result = {
__proto__: null,
format: 'module',
shortCircuit: true,
source: createSourceFromMock(mock, 'module'),
};
debug('load hook finished (virtual mock), result = %o', result);
return result;
}

const original = nextLoad(url, context);
debug('load hook, mock = %o', mock);
if (mock?.active !== true) {
return original;
}
Expand Down Expand Up @@ -144,6 +195,7 @@ if (module.exports === null || typeof module.exports !== 'object') {
}

module.exports = {
getVirtualMockURL,
hooks: { __proto__: null, load, resolve },
mocks,
constants: {
Expand Down
74 changes: 68 additions & 6 deletions lib/internal/test_runner/mock/mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
ReflectApply,
ReflectConstruct,
ReflectGet,
RegExpPrototypeExec,
SafeMap,
StringPrototypeIncludes,
StringPrototypeSlice,
Expand Down Expand Up @@ -65,6 +66,8 @@ const {
_resolveFilename,
isBuiltin,
} = Module;
const { dirname, isAbsolute, resolve: resolvePath } = require('path');
const kRelativePathRegExp = /^\.\.?(?:[\\/]|$)/;
function kDefaultFunction() {}
const enableModuleMocking = getOptionValue('--experimental-test-module-mocks');
const kSupportedFormats = [
Expand All @@ -85,6 +88,7 @@ const deprecateDefaultExport = deprecateProperty(
'mock.module(): options.defaultExport is deprecated. Use options.exports.default instead.',
);
const {
getVirtualMockURL,
hooks: mockHooks,
mocks,
constants: { kBadExportsMessage, kMockSearchParam },
Expand Down Expand Up @@ -214,12 +218,14 @@ class MockModuleContext {
moduleExports,
sharedState,
specifier,
virtual,
}) {
const config = {
__proto__: null,
cache,
moduleExports,
caller,
virtual,
};

sharedState.mockMap.set(baseURL, config);
Expand Down Expand Up @@ -268,6 +274,7 @@ class MockModuleContext {
format,
localVersion,
active: true,
virtual,
});
}

Expand Down Expand Up @@ -676,6 +683,7 @@ class MockTracker {
const {
cache,
moduleExports,
virtual,
} = normalizeModuleMockOptions(options);

const sharedState = setupSharedModuleState();
Expand All @@ -689,8 +697,25 @@ class MockTracker {
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);
let format;
let url;
let isVirtual = false;
try {
({ format, url } = sharedState.moduleLoader.resolveSync(caller, request));
} catch (err) {
// With `virtual: true`, a module that does not exist can be mocked. If
// the module does exist, it is mocked like any other module so that every
// path that resolves to it receives the mock.
if (!virtual || err?.code !== 'ERR_MODULE_NOT_FOUND' ||
StringPrototypeStartsWith(specifier, 'node:')) {
throw err;
}
url = getVirtualMockURL(mockSpecifier, caller);
format = 'module';
isVirtual = true;
}
debug('module mock, url = "%s", format = "%s", caller = "%s", virtual = %s',
url, format, caller, isVirtual);
if (format) { // Format is not yet known for ambiguous files when detection is enabled.
validateOneOf(format, 'format', kSupportedFormats);
}
Expand All @@ -708,15 +733,20 @@ class MockTracker {
);
}

const fullPath = StringPrototypeStartsWith(url, 'file://') ?
let fullPath = StringPrototypeStartsWith(url, 'file://') ?
fileURLToPath(url) : null;
if (isVirtual && fullPath === null) {
// Bare specifier virtual mocks have no file path. Use the synthetic URL
// as their CommonJS cache key.
fullPath = url;
}
// For dual packages, the ESM resolver may return a different file than
// CJS require() would for the same specifier (e.g., when a package's
// "exports" field points to different files for the "import" and
// "require" conditions). Compute the CJS-resolved path so that
// require() of a mocked module also picks up the mock.
// See https://github.com/nodejs/node/issues/58231.
const cjsPath = resolveAsCJS(mockSpecifier, caller, fullPath);
const cjsPath = isVirtual ? null : resolveAsCJS(mockSpecifier, caller, fullPath);
const ctx = new MockModuleContext({
__proto__: null,
baseURL: baseURL.href,
Expand All @@ -728,6 +758,7 @@ class MockTracker {
moduleExports,
sharedState,
specifier: mockSpecifier,
virtual: isVirtual,
});

ArrayPrototypePush(this.#mocks, {
Expand Down Expand Up @@ -865,8 +896,9 @@ class MockTracker {
}

function normalizeModuleMockOptions(options) {
const { cache = false } = options;
const { cache = false, virtual = false } = options;
validateBoolean(cache, 'options.cache');
validateBoolean(virtual, 'options.virtual');

const hasExports = 'exports' in options;
const hasNamedExports = 'namedExports' in options;
Expand Down Expand Up @@ -917,6 +949,7 @@ function normalizeModuleMockOptions(options) {
__proto__: null,
cache,
moduleExports,
virtual,
};
}

Expand Down Expand Up @@ -975,7 +1008,11 @@ function cjsMockModuleLoad(request, parent, isMain) {
if (isBuiltin(request)) {
resolved = ensureNodeScheme(request);
} else {
resolved = _resolveFilename(request, parent, isMain);
try {
resolved = _resolveFilename(request, parent, isMain);
} catch (err) {
resolved = resolveVirtualMock(this.mockMap, request, parent, err);
}
}

const config = this.mockMap.get(resolved);
Expand Down Expand Up @@ -1025,6 +1062,31 @@ function cjsMockModuleLoad(request, parent, isMain) {
return modExports;
}

// Called when CommonJS resolution of `request` failed. If a virtual mock exists
// for `request`, return its CommonJS cache key. Otherwise, rethrow `err`.
function resolveVirtualMock(mockMap, request, parent, err) {
if (err?.code !== 'MODULE_NOT_FOUND' && err?.code !== 'ERR_MODULE_NOT_FOUND') {
throw err;
}

const parentDir = parent?.filename ? dirname(parent.filename) : process.cwd();
let key;
if (isAbsolute(request) ||
RegExpPrototypeExec(kRelativePathRegExp, request) !== null) {
// Use path semantics so that Windows paths are handled as well.
key = resolvePath(parentDir, request);
} else {
const url = getVirtualMockURL(request, pathToFileURL(`${parentDir}/`).href);
key = StringPrototypeStartsWith(url, 'file://') ? fileURLToPath(url) : url;
}

if (mockMap.get(key)?.virtual !== true) {
throw err;
}

return key;
}

// Resolve `specifier` using CJS resolution rules so that mocks for dual
// packages (e.g., a package whose "exports" field points to different files
// for the "import" and "require" conditions) also intercept require().
Expand Down
Loading
Loading