From 8c4a8e4842cb5c7c30f4769428e9089f7e092b22 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Sat, 25 Apr 2026 12:49:32 +0530 Subject: [PATCH] test_runner: mock dual-package with conditional exports When `mock.module()` targets a package whose `exports` field maps `import` and `require` to different files, the ESM resolver and the CJS resolver disagree on the resolved path. Only the ESM path was registered in `mockMap`, so `require()` of the mocked specifier bypassed the mock and loaded the real CJS module. Resolve the specifier through `Module._resolveFilename` from the caller's directory in addition to the existing ESM resolution. When the two paths differ, register the CJS path as a second key in `mockMap` and invalidate `Module._cache[cjsPath]`, restoring it on `restore()`. Single-resolution packages keep their existing behavior. Fixes: https://github.com/nodejs/node/issues/58231 Signed-off-by: Maruthan G --- lib/internal/test_runner/mock/mock.js | 88 +++++++++++++++++++ test/fixtures/test-runner/mock-nm-dual-pkg.js | 32 +++++++ .../dual-pkg-with-exports/index.cjs | 5 ++ .../dual-pkg-with-exports/index.js | 2 + .../dual-pkg-with-exports/package.json | 12 +++ .../parallel/test-runner-mock-dual-package.js | 36 ++++++++ 6 files changed, 175 insertions(+) create mode 100644 test/fixtures/test-runner/mock-nm-dual-pkg.js create mode 100644 test/fixtures/test-runner/node_modules/dual-pkg-with-exports/index.cjs create mode 100644 test/fixtures/test-runner/node_modules/dual-pkg-with-exports/index.js create mode 100644 test/fixtures/test-runner/node_modules/dual-pkg-with-exports/package.json create mode 100644 test/parallel/test-runner-mock-dual-package.js diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js index fb1ed322b414fc..b2fe60ff735d9a 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -16,6 +16,7 @@ const { ReflectConstruct, ReflectGet, SafeMap, + StringPrototypeIncludes, StringPrototypeSlice, StringPrototypeStartsWith, } = primordials; @@ -196,6 +197,7 @@ class MockModuleContext { baseURL, cache, caller, + cjsPath, format, fullPath, moduleExports, @@ -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], @@ -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, @@ -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) { @@ -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; } } @@ -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, @@ -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); diff --git a/test/fixtures/test-runner/mock-nm-dual-pkg.js b/test/fixtures/test-runner/mock-nm-dual-pkg.js new file mode 100644 index 00000000000000..3686373c60325d --- /dev/null +++ b/test/fixtures/test-runner/mock-nm-dual-pkg.js @@ -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'); +}); diff --git a/test/fixtures/test-runner/node_modules/dual-pkg-with-exports/index.cjs b/test/fixtures/test-runner/node_modules/dual-pkg-with-exports/index.cjs new file mode 100644 index 00000000000000..a8085de75d6013 --- /dev/null +++ b/test/fixtures/test-runner/node_modules/dual-pkg-with-exports/index.cjs @@ -0,0 +1,5 @@ +'use strict'; +const add = (x, y) => x + y; +const flavor = 'cjs'; + +module.exports = { add, flavor }; diff --git a/test/fixtures/test-runner/node_modules/dual-pkg-with-exports/index.js b/test/fixtures/test-runner/node_modules/dual-pkg-with-exports/index.js new file mode 100644 index 00000000000000..f9e72d7f62fb7c --- /dev/null +++ b/test/fixtures/test-runner/node_modules/dual-pkg-with-exports/index.js @@ -0,0 +1,2 @@ +export const add = (x, y) => x + y; +export const flavor = 'esm'; diff --git a/test/fixtures/test-runner/node_modules/dual-pkg-with-exports/package.json b/test/fixtures/test-runner/node_modules/dual-pkg-with-exports/package.json new file mode 100644 index 00000000000000..e225302a770ea4 --- /dev/null +++ b/test/fixtures/test-runner/node_modules/dual-pkg-with-exports/package.json @@ -0,0 +1,12 @@ +{ + "name": "dual-pkg-with-exports", + "type": "module", + "main": "index.js", + "exports": { + ".": { + "import": "./index.js", + "require": "./index.cjs" + } + }, + "private": true +} diff --git a/test/parallel/test-runner-mock-dual-package.js b/test/parallel/test-runner-mock-dual-package.js new file mode 100644 index 00000000000000..f96837d2cebf68 --- /dev/null +++ b/test/parallel/test-runner-mock-dual-package.js @@ -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/); + });