node/lib/test/mock_loader.js
Colin Ihrig a619789ef0
test_runner: support module mocking
This commit adds experimental module mocking to the test runner.

PR-URL: https://github.com/nodejs/node/pull/52848
Fixes: https://github.com/nodejs/node/issues/51164
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com>
2024-05-19 05:09:09 +00:00

227 lines
6.5 KiB
JavaScript

'use strict';
const {
JSONStringify,
SafeMap,
globalThis: {
Atomics: {
notify: AtomicsNotify,
store: AtomicsStore,
},
},
} = primordials;
const {
ensureNodeScheme,
kBadExportsMessage,
kMockSearchParam,
kMockSuccess,
kMockExists,
kMockUnknownMessage,
} = require('internal/test_runner/mock/mock');
const { pathToFileURL, URL } = require('internal/url');
const { normalizeReferrerURL } = require('internal/modules/helpers');
let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => {
debug = fn;
});
const { createRequire, isBuiltin } = require('module');
// TODO(cjihrig): This file should not be exposed publicly, but register() does
// not handle internal loaders. Before marking this API as stable, one of the
// following issues needs to be implemented:
// https://github.com/nodejs/node/issues/49473
// or https://github.com/nodejs/node/issues/52219
// TODO(cjihrig): The mocks need to be thread aware because the exports are
// evaluated on the thread that creates the mock. Before marking this API as
// stable, one of the following issues needs to be implemented:
// https://github.com/nodejs/node/issues/49472
// or https://github.com/nodejs/node/issues/52219
// TODO(cjihrig): Network imports should be supported. There are two current
// hurdles:
// - The module format returned by the load() hook is not known. This could be
// implemented as an option, or default to 'module' for network imports.
// - The generated mock module imports 'node:test', which is not allowed by
// checkIfDisallowedImport() in the ESM code.
const mocks = new SafeMap();
async function initialize(data) {
data?.port.on('message', ({ type, payload }) => {
debug('mock loader received message type "%s" with payload %o', type, payload);
if (type === 'node:test:register') {
const { baseURL } = payload;
const mock = mocks.get(baseURL);
if (mock?.active) {
debug('already mocking "%s"', baseURL);
sendAck(payload.ack, kMockExists);
return;
}
const localVersion = mock?.localVersion ?? 0;
debug('new mock version %d for "%s"', localVersion, baseURL);
mocks.set(baseURL, {
__proto__: null,
active: true,
cache: payload.cache,
exportNames: payload.exportNames,
format: payload.format,
hasDefaultExport: payload.hasDefaultExport,
localVersion,
url: baseURL,
});
sendAck(payload.ack);
} else if (type === 'node:test:unregister') {
const mock = mocks.get(payload.baseURL);
if (mock !== undefined) {
mock.active = false;
mock.localVersion++;
}
sendAck(payload.ack);
} else {
sendAck(payload.ack, kMockUnknownMessage);
}
});
}
async function resolve(specifier, context, nextResolve) {
debug('resolve hook entry, specifier = "%s", context = %o', specifier, context);
let mockSpecifier;
if (isBuiltin(specifier)) {
mockSpecifier = ensureNodeScheme(specifier);
} else {
// TODO(cjihrig): This try...catch should be replaced by defaultResolve(),
// but there are some edge cases that caused the tests to fail on Windows.
try {
const req = createRequire(context.parentURL);
specifier = pathToFileURL(req.resolve(specifier)).href;
} catch {
const parentURL = normalizeReferrerURL(context.parentURL);
const parsedURL = URL.parse(specifier, parentURL)?.href;
if (parsedURL) {
specifier = parsedURL;
}
}
mockSpecifier = specifier;
}
const mock = mocks.get(mockSpecifier);
debug('resolve hook, specifier = "%s", mock = %o', specifier, mock);
if (mock?.active !== true) {
return nextResolve(specifier, context);
}
const url = new URL(mockSpecifier);
url.searchParams.set(kMockSearchParam, mock.localVersion);
if (!mock.cache) {
// With ESM, we can't remove modules from the cache. Bump the module's
// version instead so that the next import will be uncached.
mock.localVersion++;
}
debug('resolve hook finished, url = "%s"', url.href);
return nextResolve(url.href, context);
}
async function load(url, context, nextLoad) {
debug('load hook entry, url = "%s", context = %o', url, context);
const parsedURL = URL.parse(url);
if (parsedURL) {
parsedURL.searchParams.delete(kMockSearchParam);
}
const baseURL = parsedURL ? parsedURL.href : url;
const mock = mocks.get(baseURL);
debug('load hook, mock = %o', mock);
if (mock?.active !== true) {
return nextLoad(url);
}
// Treat builtins as commonjs because customization hooks do not allow a
// core module to be replaced.
const format = mock.format === 'builtin' ? 'commonjs' : mock.format;
return {
__proto__: null,
format,
shortCircuit: true,
source: await createSourceFromMock(mock),
};
}
async function createSourceFromMock(mock) {
// Create mock implementation from provided exports.
const { exportNames, format, hasDefaultExport, url } = mock;
const useESM = format === 'module';
const source = `${testImportSource(useESM)}
if (!$__test.mock._mockExports.has('${url}')) {
throw new Error(${JSONStringify(`mock exports not found for "${url}"`)});
}
const $__exports = $__test.mock._mockExports.get(${JSONStringify(url)});
${defaultExportSource(useESM, hasDefaultExport)}
${namedExportsSource(useESM, exportNames)}
`;
return source;
}
function testImportSource(useESM) {
if (useESM) {
return "import $__test from 'node:test';";
}
return "const $__test = require('node:test');";
}
function defaultExportSource(useESM, hasDefaultExport) {
if (!hasDefaultExport) {
return '';
} else if (useESM) {
return 'export default $__exports.defaultExport;';
}
return 'module.exports = $__exports.defaultExport;';
}
function namedExportsSource(useESM, exportNames) {
let source = '';
if (!useESM && exportNames.length > 0) {
source += `
if (module.exports === null || typeof module.exports !== 'object') {
throw new Error('${JSONStringify(kBadExportsMessage)}');
}
`;
}
for (let i = 0; i < exportNames.length; ++i) {
const name = exportNames[i];
if (useESM) {
source += `export let ${name} = $__exports.namedExports[${JSONStringify(name)}];\n`;
} else {
source += `module.exports[${JSONStringify(name)}] = $__exports.namedExports[${JSONStringify(name)}];\n`;
}
}
return source;
}
function sendAck(buf, status = kMockSuccess) {
AtomicsStore(buf, 0, status);
AtomicsNotify(buf, 0);
}
module.exports = { initialize, load, resolve };