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
62 changes: 44 additions & 18 deletions lib/internal/test_runner/mock/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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) {
Expand All @@ -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 = {
Expand Down
86 changes: 72 additions & 14 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 {
SafeMap,
StringPrototypeSlice,
StringPrototypeStartsWith,
encodeURIComponent,
} = primordials;
const {
codes: {
Expand Down Expand Up @@ -192,6 +193,7 @@ class MockModuleContext {
namedExports,
sharedState,
specifier,
virtual,
}) {
const config = {
__proto__: null,
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -680,6 +727,7 @@ class MockTracker {
namedExports,
sharedState,
specifier: mockSpecifier,
virtual,
});

ArrayPrototypePush(this.#mocks, {
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions test/fixtures/test-runner/mock-virtual-paths.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading