Skip to content
Open
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
88 changes: 88 additions & 0 deletions lib/internal/test_runner/mock/mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const {
ReflectConstruct,
ReflectGet,
SafeMap,
StringPrototypeIncludes,
StringPrototypeSlice,
StringPrototypeStartsWith,
} = primordials;
Expand Down Expand Up @@ -196,6 +197,7 @@ class MockModuleContext {
baseURL,
cache,
caller,
cjsPath,
format,
fullPath,
moduleExports,
Expand All @@ -211,12 +213,25 @@ class MockModuleContext {

sharedState.mockMap.set(baseURL, config);
sharedState.mockMap.set(fullPath, config);
// For dual packages (e.g., a package with a "exports" field that exposes
// both ESM and CJS entry points), the file selected by the ESM resolver
// (used to compute fullPath) may differ from the one selected by CJS
// require(). Register the CJS-resolved path so that require() also picks
// up the mock. See https://github.com/nodejs/node/issues/58231.
if (cjsPath !== null && cjsPath !== fullPath) {
sharedState.mockMap.set(cjsPath, config);
}

this.#sharedState = sharedState;
this.#restore = {
__proto__: null,
baseURL,
cached: fullPath in Module._cache,
cjsPath,
cjsCached: cjsPath !== null && cjsPath !== fullPath &&
cjsPath in Module._cache,
cjsValue: cjsPath !== null && cjsPath !== fullPath ?
Module._cache[cjsPath] : undefined,
format,
fullPath,
value: Module._cache[fullPath],
Expand Down Expand Up @@ -246,6 +261,9 @@ class MockModuleContext {
}

delete Module._cache[fullPath];
if (cjsPath !== null && cjsPath !== fullPath) {
delete Module._cache[cjsPath];
}
sharedState.mockExports.set(baseURL, {
__proto__: null,
moduleExports,
Expand All @@ -265,6 +283,14 @@ class MockModuleContext {
Module._cache[this.#restore.fullPath] = this.#restore.value;
}

if (this.#restore.cjsPath !== null &&
this.#restore.cjsPath !== this.#restore.fullPath) {
delete Module._cache[this.#restore.cjsPath];
if (this.#restore.cjsCached) {
Module._cache[this.#restore.cjsPath] = this.#restore.cjsValue;
}
}

const mock = mocks.get(this.#restore.baseURL);

if (mock !== undefined) {
Expand All @@ -274,6 +300,10 @@ class MockModuleContext {

this.#sharedState.mockMap.delete(this.#restore.baseURL);
this.#sharedState.mockMap.delete(this.#restore.fullPath);
if (this.#restore.cjsPath !== null &&
this.#restore.cjsPath !== this.#restore.fullPath) {
this.#sharedState.mockMap.delete(this.#restore.cjsPath);
}
this.#restore = undefined;
}
}
Expand Down Expand Up @@ -669,11 +699,19 @@ class MockTracker {

const fullPath = StringPrototypeStartsWith(url, 'file://') ?
fileURLToPath(url) : null;
// 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 ctx = new MockModuleContext({
__proto__: null,
baseURL: baseURL.href,
cache,
caller,
cjsPath,
format,
fullPath,
moduleExports,
Expand Down Expand Up @@ -960,6 +998,56 @@ function cjsMockModuleLoad(request, parent, isMain) {
return modExports;
}

// 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().
// Returns an absolute file path on success, or null when the specifier cannot
// be resolved as CJS (for example, when the package is ESM-only or when it is
// a non-file URL such as data:, http:, or node:).
function resolveAsCJS(specifier, callerURL, esmFullPath) {
if (isBuiltin(specifier) ||
StringPrototypeStartsWith(specifier, 'node:') ||
StringPrototypeStartsWith(specifier, 'data:') ||
StringPrototypeStartsWith(specifier, 'http:') ||
StringPrototypeStartsWith(specifier, 'https:')) {
return null;
}

let parentPath;
if (StringPrototypeStartsWith(callerURL, 'file://')) {
try {
parentPath = fileURLToPath(callerURL);
} catch {
return null;
}
} else {
return null;
}

try {
const tmpModule = new Module(parentPath, null);
tmpModule.paths = _nodeModulePaths(parentPath);
const resolved = _resolveFilename(specifier, tmpModule, false);
if (typeof resolved !== 'string') {
return null;
}
// If the resolution matches what the ESM resolver picked, there is
// nothing additional to register.
if (resolved === esmFullPath) {
return esmFullPath;
}
// If the resolution returned something that is not a filesystem path
// (e.g., a builtin id without a slash or backslash), ignore it.
if (!StringPrototypeIncludes(resolved, '/') &&
!StringPrototypeIncludes(resolved, '\\')) {
return null;
}
return resolved;
} catch {
return null;
}
}

function validateStringOrSymbol(value, name) {
if (typeof value !== 'string' && typeof value !== 'symbol') {
throw new ERR_INVALID_ARG_TYPE(name, ['string', 'symbol'], value);
Expand Down
32 changes: 32 additions & 0 deletions test/fixtures/test-runner/mock-nm-dual-pkg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict';
const assert = require('node:assert');
const { test } = require('node:test');
const fixture = 'dual-pkg-with-exports';

test('mock node_modules dual package with conditional exports', async (t) => {
const mock = t.mock.module(fixture, {
namedExports: { add(x, y) { return 1 + x + y; }, flavor: 'mocked' },
});

// CJS require should pick up the mock even though the package's "exports"
// field maps the "require" condition to a different file than "import".
const cjsImpl = require(fixture);
assert.strictEqual(cjsImpl.add(4, 5), 10);
assert.strictEqual(cjsImpl.flavor, 'mocked');

// ESM dynamic import should also pick up the mock.
const esmImpl = await import(fixture);
assert.strictEqual(esmImpl.add(4, 5), 10);
assert.strictEqual(esmImpl.flavor, 'mocked');

mock.restore();

// After restore, both module systems should see the original exports.
const restoredCjs = require(fixture);
assert.strictEqual(restoredCjs.add(4, 5), 9);
assert.strictEqual(restoredCjs.flavor, 'cjs');

const restoredEsm = await import(fixture);
assert.strictEqual(restoredEsm.add(4, 5), 9);
assert.strictEqual(restoredEsm.flavor, 'esm');
});

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions test/parallel/test-runner-mock-dual-package.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use strict';
const common = require('../common');
const { isMainThread } = require('worker_threads');

if (!isMainThread) {
common.skip('registering customization hooks in Workers does not work');
}

const fixtures = require('../common/fixtures');
const assert = require('node:assert');
const { test } = require('node:test');

// Regression test for https://github.com/nodejs/node/issues/58231
// When a dual package exposes both ESM and CJS entry points via the
// "exports" field with "import"/"require" conditions, the ESM resolver
// picks one file (e.g. index.js) and CJS require() picks another
// (e.g. index.cjs). mock.module() must intercept both so that require()
// of the mocked module does not return the original CJS file.
test('mock.module intercepts dual package require with conditional exports',
async () => {
const cwd = fixtures.path('test-runner');
const fixture = fixtures.path('test-runner', 'mock-nm-dual-pkg.js');
const args = ['--experimental-test-module-mocks', fixture];
const {
code,
stdout,
signal,
} = await common.spawnPromisified(process.execPath, args, { cwd });

assert.strictEqual(signal, null);
assert.strictEqual(code, 0,
'child process exited with non-zero status\n' +
`stdout:\n${stdout}`);
assert.match(stdout, /pass 1/);
assert.match(stdout, /fail 0/);
});
Loading