diff --git a/lib/internal/test_runner/mock/loader.js b/lib/internal/test_runner/mock/loader.js index a7a22539be3093..25c50874f0b1f4 100644 --- a/lib/internal/test_runner/mock/loader.js +++ b/lib/internal/test_runner/mock/loader.js @@ -18,9 +18,28 @@ const mocks = new SafeMap(); function resolve(specifier, context, nextResolve) { debug('resolve hook entry, specifier = "%s", context = %o', specifier, context); - const nextResolveResult = nextResolve(specifier, context); - const mockSpecifier = nextResolveResult.url; + // Try normal resolution first. If it fails, check for a virtual mock + // of a non-existent module. + let nextResolveResult; + try { + nextResolveResult = nextResolve(specifier, context); + } catch (resolveError) { + // Resolution failed - check if there's a virtual mock for this specifier. + const virtualMock = mocks.get(specifier); + if (virtualMock?.active === true && virtualMock?.virtual) { + const url = new URL(virtualMock.url); + url.searchParams.set(kMockSearchParam, virtualMock.localVersion); + if (!virtualMock.cache) { + virtualMock.localVersion++; + } + const { href } = url; + debug('resolve hook finished (virtual), url = "%s"', href); + return { __proto__: null, url: href, format: virtualMock.format, shortCircuit: true }; + } + throw resolveError; + } + const mockSpecifier = nextResolveResult.url; const mock = mocks.get(mockSpecifier); debug('resolve hook, specifier = "%s", mock = %o', specifier, mock); @@ -39,7 +58,7 @@ function resolve(specifier, context, nextResolve) { const { href } = url; debug('resolve hook finished, url = "%s"', href); - return { __proto__: null, url: href, format: nextResolveResult.format }; + return { __proto__: null, url: href, format: mock.format || nextResolveResult.format }; } function load(url, context, nextLoad) { @@ -52,25 +71,32 @@ function load(url, context, nextLoad) { const baseURL = parsedURL ? parsedURL.href : url; const mock = mocks.get(baseURL); - const original = nextLoad(url, context); - debug('load hook, mock = %o', mock); if (mock?.active !== true) { + const original = nextLoad(url, context); + debug('load hook, mock = %o', mock); return original; } - // Treat builtins as commonjs because customization hooks do not allow a - // core module to be replaced. - // Also collapse 'commonjs-sync' and 'require-commonjs' to 'commonjs'. - let format = original.format; - switch (original.format) { - case 'builtin': // Deliberate fallthrough - case 'commonjs-sync': // Deliberate fallthrough - case 'require-commonjs': - format = 'commonjs'; - break; - case 'json': - format = 'module'; - break; + let format; + if (mock.virtual) { + // Virtual mock - no real module to load from disk. + format = mock.format; + } else { + const original = nextLoad(url, context); + // Treat builtins as commonjs because customization hooks do not allow a + // core module to be replaced. + // Also collapse 'commonjs-sync' and 'require-commonjs' to 'commonjs'. + format = original.format; + switch (original.format) { + case 'builtin': // Deliberate fallthrough + case 'commonjs-sync': // Deliberate fallthrough + case 'require-commonjs': + format = 'commonjs'; + break; + case 'json': + format = 'module'; + break; + } } const result = { diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js index 1af24c77a10731..39d02abf71946a 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -16,6 +16,7 @@ const { SafeMap, StringPrototypeSlice, StringPrototypeStartsWith, + encodeURIComponent, } = primordials; const { codes: { @@ -192,6 +193,7 @@ class MockModuleContext { namedExports, sharedState, specifier, + virtual, }) { const config = { __proto__: null, @@ -200,19 +202,27 @@ class MockModuleContext { hasDefaultExport, namedExports, caller, + virtual, }; sharedState.mockMap.set(baseURL, config); - sharedState.mockMap.set(fullPath, config); + if (fullPath) { + sharedState.mockMap.set(fullPath, config); + } else if (virtual) { + // Virtual mock for a non-existent module - store under raw specifier + // so CJS resolution fallback can find it. + sharedState.mockMap.set(specifier, config); + } this.#sharedState = sharedState; this.#restore = { __proto__: null, baseURL, - cached: fullPath in Module._cache, + cached: fullPath ? fullPath in Module._cache : false, format, fullPath, - value: Module._cache[fullPath], + specifier: virtual ? specifier : undefined, + value: fullPath ? Module._cache[fullPath] : undefined, }; const mock = mocks.get(baseURL); @@ -226,7 +236,7 @@ class MockModuleContext { const localVersion = mock?.localVersion ?? 0; debug('new mock version %d for "%s"', localVersion, baseURL); - mocks.set(baseURL, { + const mockEntry = { __proto__: null, url: baseURL, cache, @@ -235,10 +245,17 @@ class MockModuleContext { format, localVersion, active: true, - }); + virtual, + }; + mocks.set(baseURL, mockEntry); + if (virtual) { + mocks.set(specifier, mockEntry); + } } - delete Module._cache[fullPath]; + if (fullPath) { + delete Module._cache[fullPath]; + } sharedState.mockExports.set(baseURL, { __proto__: null, defaultExport, @@ -251,12 +268,19 @@ class MockModuleContext { 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.fullPath) { + // 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; + } - if (this.#restore.cached) { - Module._cache[this.#restore.fullPath] = this.#restore.value; + this.#sharedState.mockMap.delete(this.#restore.fullPath); + } else if (this.#restore.specifier !== undefined) { + // Virtual mock for non-existent module - clean up specifier key. + this.#sharedState.mockMap.delete(this.#restore.specifier); } const mock = mocks.get(this.#restore.baseURL); @@ -267,7 +291,9 @@ class MockModuleContext { } this.#sharedState.mockMap.delete(this.#restore.baseURL); - this.#sharedState.mockMap.delete(this.#restore.fullPath); + if (this.#restore.specifier !== undefined) { + mocks.delete(this.#restore.specifier); + } this.#restore = undefined; } } @@ -630,10 +656,12 @@ class MockTracker { cache = false, namedExports = kEmptyObject, defaultExport, + virtual = false, } = options; const hasDefaultExport = 'defaultExport' in options; validateBoolean(cache, 'options.cache'); + validateBoolean(virtual, 'options.virtual'); validateObject(namedExports, 'options.namedExports'); const sharedState = setupSharedModuleState(); @@ -647,7 +675,26 @@ 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); + + // Try to resolve the specifier. For virtual mocks, if the module exists + // on disk, use its canonical URL so that all resolution paths to that + // module are properly intercepted. Only fall back to a synthetic URL + // if the module truly doesn't exist. + let format, url; + if (virtual) { + try { + ({ url } = sharedState.moduleLoader.resolveSync(caller, request)); + } catch { + // Module doesn't exist - use a synthetic URL. + url = `mock:///${encodeURIComponent(mockSpecifier)}`; + } + // Virtual mocks always use 'module' format since the generated source + // is ESM regardless of the original module's format. + format = 'module'; + } else { + ({ 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); @@ -680,6 +727,7 @@ class MockTracker { namedExports, sharedState, specifier: mockSpecifier, + virtual, }); ArrayPrototypePush(this.#mocks, { @@ -844,7 +892,17 @@ function cjsMockModuleLoad(request, parent, isMain) { if (isBuiltin(request)) { resolved = ensureNodeScheme(request); } else { - resolved = _resolveFilename(request, parent, isMain); + try { + resolved = _resolveFilename(request, parent, isMain); + } catch (resolveError) { + // Resolution failed - check if there's a virtual mock for this specifier. + const virtualConfig = this.mockMap.get(request); + if (virtualConfig?.virtual) { + resolved = request; + } else { + throw resolveError; + } + } } const config = this.mockMap.get(resolved); diff --git a/test/fixtures/test-runner/mock-virtual-paths.js b/test/fixtures/test-runner/mock-virtual-paths.js new file mode 100644 index 00000000000000..a2c454f5ce88ee --- /dev/null +++ b/test/fixtures/test-runner/mock-virtual-paths.js @@ -0,0 +1,27 @@ +'use strict'; +const assert = require('node:assert'); +const { test } = require('node:test'); + +// This test verifies that a virtual mock of an existing package intercepts +// ALL resolution paths to that module, not just the exact specifier used +// to create the mock. This is critical to avoid multiple sources of truth. + +test('virtual mock intercepts all resolution paths to the same module', async (t) => { + t.mock.module('reporter-cjs', { + virtual: true, + namedExports: { fn() { return 'mocked'; } }, + }); + + // Access via package name (the specifier used to create the mock). + const byName = require('reporter-cjs'); + assert.strictEqual(byName.fn(), 'mocked'); + + // Access via relative path to the actual file on disk. This resolves to + // the same canonical file:// URL, so it must also return the mock. + const byRelativePath = require('./node_modules/reporter-cjs/index.js'); + assert.strictEqual(byRelativePath.fn(), 'mocked'); + + // Access via import() by package name. + const byImport = await import('reporter-cjs'); + assert.strictEqual(byImport.fn(), 'mocked'); +}); diff --git a/test/parallel/test-runner-module-mocking.js b/test/parallel/test-runner-module-mocking.js index dcb6f84597fe71..8d63b9da6de6ca 100644 --- a/test/parallel/test-runner-module-mocking.js +++ b/test/parallel/test-runner-module-mocking.js @@ -679,3 +679,114 @@ test('wrong import syntax should throw error after module mocking', async () => assert.match(stderr, /Error \[ERR_MODULE_NOT_FOUND\]: Cannot find module/); assert.strictEqual(code, 1); }); + +test('virtual mock of nonexistent module with ESM', async (t) => { + await t.test('mock with namedExports', async (t) => { + t.mock.module('nonexistent-esm-pkg', { + virtual: true, + namedExports: { hello() { return 'mocked'; } }, + }); + + const mod = await import('nonexistent-esm-pkg'); + assert.strictEqual(mod.hello(), 'mocked'); + }); + + await t.test('mock with defaultExport', async (t) => { + const defaultValue = { key: 'value' }; + t.mock.module('nonexistent-esm-default', { + virtual: true, + defaultExport: defaultValue, + }); + + const mod = await import('nonexistent-esm-default'); + assert.deepStrictEqual(mod.default, defaultValue); + }); + + await t.test('mock with both namedExports and defaultExport', async (t) => { + t.mock.module('nonexistent-esm-both', { + virtual: true, + defaultExport: 'the default', + namedExports: { foo: 42 }, + }); + + const mod = await import('nonexistent-esm-both'); + assert.strictEqual(mod.default, 'the default'); + assert.strictEqual(mod.foo, 42); + }); +}); + +test('virtual mock restore works', async (t) => { + const ctx = t.mock.module('nonexistent-restore-pkg', { + virtual: true, + namedExports: { value: 1 }, + }); + + const mod = await import('nonexistent-restore-pkg'); + assert.strictEqual(mod.value, 1); + + ctx.restore(); + + await assert.rejects( + import('nonexistent-restore-pkg'), + { code: 'ERR_MODULE_NOT_FOUND' }, + ); +}); + +test('virtual mock of nonexistent module with CJS', async (t) => { + t.mock.module('nonexistent-cjs-pkg', { + virtual: true, + namedExports: { greet() { return 'hi'; } }, + }); + + const mod = require('nonexistent-cjs-pkg'); + assert.strictEqual(mod.greet(), 'hi'); +}); + +test('nonexistent module without virtual flag still throws', async (t) => { + assert.throws(() => { + t.mock.module('totally-nonexistent-pkg-12345', { + namedExports: { foo: 'bar' }, + }); + }, { code: 'ERR_MODULE_NOT_FOUND' }); +}); + +test('virtual mock overrides an existing module', async (t) => { + const original = require('readline'); + assert.strictEqual(typeof original.cursorTo, 'function'); + + t.mock.module('readline', { + virtual: true, + namedExports: { custom() { return 'virtual'; } }, + }); + + // Both 'readline' and 'node:readline' should resolve to the mock + // because the specifier resolves to the same canonical URL. + const mocked = await import('readline'); + assert.strictEqual(mocked.custom(), 'virtual'); + assert.strictEqual(mocked.cursorTo, undefined); + + const mockedWithPrefix = await import('node:readline'); + assert.strictEqual(mockedWithPrefix.custom(), 'virtual'); + assert.strictEqual(mockedWithPrefix.cursorTo, undefined); +}); + +test('virtual mock intercepts all resolution paths to the same module', async (t) => { + const cwd = fixtures.path('test-runner'); + const fixture = fixtures.path('test-runner', 'mock-virtual-paths.js'); + const args = ['--experimental-test-module-mocks', fixture]; + const { + code, + stdout, + signal, + } = await common.spawnPromisified(process.execPath, args, { cwd }); + + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + assert.match(stdout, /pass 1/); +}); + +test('input validation for virtual option', async (t) => { + assert.throws(() => { + t.mock.module('some-pkg', { virtual: 'yes' }); + }, { code: 'ERR_INVALID_ARG_TYPE' }); +});