UNPKG

nstdlib-nightly

Version:

Node.js standard library converted to runtime-agnostic ES modules.

787 lines (685 loc) 21.6 kB
// Source: https://github.com/nodejs/node/blob/65eff1eb/lib/internal/test_runner/mock/mock.js import { codes as __codes__ } from "nstdlib/lib/internal/errors"; import * as esmLoader from "nstdlib/lib/internal/modules/esm/loader"; import { getOptionValue } from "nstdlib/lib/internal/options"; import { fileURLToPath, toPathIfFileURL, URL } from "nstdlib/lib/internal/url"; import { emitExperimentalWarning, getStructuredStack, kEmptyObject, } from "nstdlib/lib/internal/util"; import { validateBoolean, validateFunction, validateInteger, validateObject, validateOneOf, validateString, } from "nstdlib/lib/internal/validators"; import { MockTimers } from "nstdlib/lib/internal/test_runner/mock/mock_timers"; import { strictEqual, notStrictEqual } from "nstdlib/lib/assert"; import { Module } from "nstdlib/lib/internal/modules/cjs/loader"; import { MessageChannel } from "nstdlib/lib/worker_threads"; import * as __hoisted_test__ from "nstdlib/lib/test"; const { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_INVALID_STATE } = __codes__; const { _load, _nodeModulePaths, _resolveFilename, isBuiltin } = Module; function kDefaultFunction() {} const enableModuleMocking = getOptionValue("--experimental-test-module-mocks"); const kMockSearchParam = "node-test-mock"; const kMockSuccess = 1; const kMockExists = 2; const kMockUnknownMessage = 3; const kWaitTimeout = 5_000; const kBadExportsMessage = "Cannot create mock because named exports " + "cannot be applied to the provided default export."; const kSupportedFormats = ["builtin", "commonjs", "module"]; let sharedModuleState; class MockFunctionContext { #calls; #mocks; #implementation; #restore; #times; constructor(implementation, restore, times) { this.#calls = []; this.#mocks = new Map(); this.#implementation = implementation; this.#restore = restore; this.#times = times; } /** * Gets an array of recorded calls made to the mock function. * @returns {Array} An array of recorded calls. */ get calls() { return Array.prototype.slice.call(this.#calls, 0); } /** * Retrieves the number of times the mock function has been called. * @returns {number} The call count. */ callCount() { return this.#calls.length; } /** * Sets a new implementation for the mock function. * @param {Function} implementation - The new implementation for the mock function. */ mockImplementation(implementation) { validateFunction(implementation, "implementation"); this.#implementation = implementation; } /** * Replaces the implementation of the function only once. * @param {Function} implementation - The substitute function. * @param {number} [onCall] - The call index to be replaced. */ mockImplementationOnce(implementation, onCall) { validateFunction(implementation, "implementation"); const nextCall = this.#calls.length; const call = onCall ?? nextCall; validateInteger(call, "onCall", nextCall); this.#mocks.set(call, implementation); } /** * Restores the original function that was mocked. */ restore() { const { descriptor, object, original, methodName } = this.#restore; if (typeof methodName === "string") { // This is an object method spy. Object.defineProperty(object, methodName, descriptor); } else { // This is a bare function spy. There isn't much to do here but make // the mock call the original function. this.#implementation = original; } } /** * Resets the recorded calls to the mock function */ resetCalls() { this.#calls = []; } /** * Tracks a call made to the mock function. * @param {object} call - The call details. */ trackCall(call) { Array.prototype.push.call(this.#calls, call); } /** * Gets the next implementation to use for the mock function. * @returns {Function} The next implementation. */ nextImpl() { const nextCall = this.#calls.length; const mock = this.#mocks.get(nextCall); const impl = mock ?? this.#implementation; if (nextCall + 1 === this.#times) { this.restore(); } this.#mocks.delete(nextCall); return impl; } } const { nextImpl, restore: restoreFn, trackCall, } = MockFunctionContext.prototype; delete MockFunctionContext.prototype.trackCall; delete MockFunctionContext.prototype.nextImpl; class MockModuleContext { #restore; #sharedState; constructor({ baseURL, cache, caller, defaultExport, format, fullPath, hasDefaultExport, namedExports, sharedState, }) { const ack = new Int32Array(new SharedArrayBuffer(4)); const config = { __proto__: null, cache, defaultExport, hasDefaultExport, namedExports, caller: toPathIfFileURL(caller), }; sharedState.mockMap.set(baseURL, config); sharedState.mockMap.set(fullPath, config); this.#sharedState = sharedState; this.#restore = { __proto__: null, ack, baseURL, cached: fullPath in Module._cache, format, fullPath, value: Module._cache[fullPath], }; sharedState.loaderPort.postMessage({ __proto__: null, type: "node:test:register", payload: { __proto__: null, ack, baseURL, cache, exportNames: Object.keys(namedExports), hasDefaultExport, format, }, }); waitForAck(ack); delete Module._cache[fullPath]; sharedState.mockExports.set(baseURL, { __proto__: null, defaultExport, namedExports, }); } restore() { if (this.#restore === undefined) { 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.cached) { Module._cache[this.#restore.fullPath] = this.#restore.value; } AtomicsStore(this.#restore.ack, 0, 0); this.#sharedState.loaderPort.postMessage({ __proto__: null, type: "node:test:unregister", payload: { __proto__: null, ack: this.#restore.ack, baseURL: this.#restore.baseURL, }, }); waitForAck(this.#restore.ack); this.#sharedState.mockMap.delete(this.#restore.baseURL); this.#sharedState.mockMap.delete(this.#restore.fullPath); this.#restore = undefined; } } const { restore: restoreModule } = MockModuleContext.prototype; class MockTracker { #mocks = []; #timers; /** * Returns the mock timers of this MockTracker instance. * @returns {MockTimers} The mock timers instance. */ get timers() { this.#timers ??= new MockTimers(); return this.#timers; } /** * Creates a mock function tracker. * @param {Function} [original] - The original function to be tracked. * @param {Function} [implementation] - An optional replacement function for the original one. * @param {object} [options] - Additional tracking options. * @param {number} [options.times=Infinity] - The maximum number of times the mock function can be called. * @returns {ProxyConstructor} The mock function tracker. */ fn( original = function () {}, implementation = original, options = kEmptyObject, ) { if (original !== null && typeof original === "object") { options = original; original = function () {}; implementation = original; } else if (implementation !== null && typeof implementation === "object") { options = implementation; implementation = original; } validateFunction(original, "original"); validateFunction(implementation, "implementation"); validateObject(options, "options"); const { times = Infinity } = options; validateTimes(times, "options.times"); const ctx = new MockFunctionContext( implementation, { __proto__: null, original }, times, ); return this.#setupMock(ctx, original); } /** * Creates a method tracker for a specified object or function. * @param {(object | Function)} objectOrFunction - The object or function containing the method to be tracked. * @param {string} methodName - The name of the method to be tracked. * @param {Function} [implementation] - An optional replacement function for the original method. * @param {object} [options] - Additional tracking options. * @param {boolean} [options.getter=false] - Indicates whether this is a getter method. * @param {boolean} [options.setter=false] - Indicates whether this is a setter method. * @param {number} [options.times=Infinity] - The maximum number of times the mock method can be called. * @returns {ProxyConstructor} The mock method tracker. */ method( objectOrFunction, methodName, implementation = kDefaultFunction, options = kEmptyObject, ) { validateStringOrSymbol(methodName, "methodName"); if (typeof objectOrFunction !== "function") { validateObject(objectOrFunction, "object"); } if (implementation !== null && typeof implementation === "object") { options = implementation; implementation = kDefaultFunction; } validateFunction(implementation, "implementation"); validateObject(options, "options"); const { getter = false, setter = false, times = Infinity } = options; validateBoolean(getter, "options.getter"); validateBoolean(setter, "options.setter"); validateTimes(times, "options.times"); if (setter && getter) { throw new ERR_INVALID_ARG_VALUE( "options.setter", setter, "cannot be used with 'options.getter'", ); } const descriptor = findMethodOnPrototypeChain(objectOrFunction, methodName); let original; if (getter) { original = descriptor?.get; } else if (setter) { original = descriptor?.set; } else { original = descriptor?.value; } if (typeof original !== "function") { throw new ERR_INVALID_ARG_VALUE( "methodName", original, "must be a method", ); } const restore = { __proto__: null, descriptor, object: objectOrFunction, methodName, }; const impl = implementation === kDefaultFunction ? original : implementation; const ctx = new MockFunctionContext(impl, restore, times); const mock = this.#setupMock(ctx, original); const mockDescriptor = { __proto__: null, configurable: descriptor.configurable, enumerable: descriptor.enumerable, }; if (getter) { mockDescriptor.get = mock; mockDescriptor.set = descriptor.set; } else if (setter) { mockDescriptor.get = descriptor.get; mockDescriptor.set = mock; } else { mockDescriptor.writable = descriptor.writable; mockDescriptor.value = mock; } Object.defineProperty(objectOrFunction, methodName, mockDescriptor); return mock; } /** * Mocks a getter method of an object. * This is a syntax sugar for the MockTracker.method with options.getter set to true * @param {object} object - The target object. * @param {string} methodName - The name of the getter method to be mocked. * @param {Function} [implementation] - An optional replacement function for the targeted method. * @param {object} [options] - Additional tracking options. * @param {boolean} [options.getter=true] - Indicates whether this is a getter method. * @param {boolean} [options.setter=false] - Indicates whether this is a setter method. * @param {number} [options.times=Infinity] - The maximum number of times the mock method can be called. * @returns {ProxyConstructor} The mock method tracker. */ getter( object, methodName, implementation = kDefaultFunction, options = kEmptyObject, ) { if (implementation !== null && typeof implementation === "object") { options = implementation; implementation = kDefaultFunction; } else { validateObject(options, "options"); } const { getter = true } = options; if (getter === false) { throw new ERR_INVALID_ARG_VALUE( "options.getter", getter, "cannot be false", ); } return this.method(object, methodName, implementation, { __proto__: null, ...options, getter, }); } /** * Mocks a setter method of an object. * This function is a syntax sugar for MockTracker.method with options.setter set to true. * @param {object} object - The target object. * @param {string} methodName - The setter method to be mocked. * @param {Function} [implementation] - An optional replacement function for the targeted method. * @param {object} [options] - Additional tracking options. * @param {boolean} [options.getter=false] - Indicates whether this is a getter method. * @param {boolean} [options.setter=true] - Indicates whether this is a setter method. * @param {number} [options.times=Infinity] - The maximum number of times the mock method can be called. * @returns {ProxyConstructor} The mock method tracker. */ setter( object, methodName, implementation = kDefaultFunction, options = kEmptyObject, ) { if (implementation !== null && typeof implementation === "object") { options = implementation; implementation = kDefaultFunction; } else { validateObject(options, "options"); } const { setter = true } = options; if (setter === false) { throw new ERR_INVALID_ARG_VALUE( "options.setter", setter, "cannot be false", ); } return this.method(object, methodName, implementation, { __proto__: null, ...options, setter, }); } module(specifier, options = kEmptyObject) { emitExperimentalWarning("Module mocking"); validateString(specifier, "specifier"); validateObject(options, "options"); { /* debug */ } const { cache = false, namedExports = kEmptyObject, defaultExport, } = options; const hasDefaultExport = "defaultExport" in options; validateBoolean(cache, "options.cache"); validateObject(namedExports, "options.namedExports"); const sharedState = setupSharedModuleState(); const mockSpecifier = String.prototype.startsWith.call(specifier, "node:") ? String.prototype.slice.call(specifier, 5) : specifier; // Get the file that called this function. We need four stack frames: // vm context -> getStructuredStack() -> this function -> actual caller. const caller = getStructuredStack()[3]?.getFileName(); const { format, url } = sharedState.moduleLoader.resolveSync( mockSpecifier, caller, null, ); { /* debug */ } if (format) { // Format is not yet known for ambiguous files when detection is enabled. validateOneOf(format, "format", kSupportedFormats); } const baseURL = URL.parse(url); if (!baseURL) { throw new ERR_INVALID_ARG_VALUE( "specifier", specifier, "cannot compute URL", ); } if (baseURL.searchParams.has(kMockSearchParam)) { throw new ERR_INVALID_STATE( `Cannot mock '${specifier}.' The module is already mocked.`, ); } const fullPath = String.prototype.startsWith.call(url, "file://") ? fileURLToPath(url) : null; const ctx = new MockModuleContext({ __proto__: null, baseURL: baseURL.href, cache, caller, defaultExport, format, fullPath, hasDefaultExport, namedExports, sharedState, specifier: mockSpecifier, }); Array.prototype.push.call(this.#mocks, { __proto__: null, ctx, restore: restoreModule, }); return ctx; } /** * Resets the mock tracker, restoring all mocks and clearing timers. */ reset() { this.restoreAll(); this.#timers?.reset(); this.#mocks = []; } /** * Restore all mocks created by this MockTracker instance. */ restoreAll() { for (let i = 0; i < this.#mocks.length; i++) { const { ctx, restore } = this.#mocks[i]; Function.prototype.call.call(restore, ctx); } } #setupMock(ctx, fnToMatch) { const mock = new Proxy(fnToMatch, { __proto__: null, apply(_fn, thisArg, argList) { const fn = Function.prototype.call.call(nextImpl, ctx); let result; let error; try { result = ReflectApply(fn, thisArg, argList); } catch (err) { error = err; throw err; } finally { Function.prototype.call.call(trackCall, ctx, { __proto__: null, arguments: argList, error, result, // eslint-disable-next-line no-restricted-syntax stack: new Error(), target: undefined, this: thisArg, }); } return result; }, construct(target, argList, newTarget) { const realTarget = Function.prototype.call.call(nextImpl, ctx); let result; let error; try { result = Reflect.construct(realTarget, argList, newTarget); } catch (err) { error = err; throw err; } finally { Function.prototype.call.call(trackCall, ctx, { __proto__: null, arguments: argList, error, result, // eslint-disable-next-line no-restricted-syntax stack: new Error(), target, this: result, }); } return result; }, get(target, property, receiver) { if (property === "mock") { return ctx; } return ReflectGet(target, property, receiver); }, }); Array.prototype.push.call(this.#mocks, { __proto__: null, ctx, restore: restoreFn, }); return mock; } } function setupSharedModuleState() { if (sharedModuleState === undefined) { const { mock } = __hoisted_test__; const mockExports = new Map(); const { port1, port2 } = new MessageChannel(); const moduleLoader = esmLoader.getOrInitializeCascadedLoader(); moduleLoader.register( "internal/test_runner/mock/loader", "node:", { __proto__: null, port: port2 }, [port2], true, ); sharedModuleState = { __proto__: null, loaderPort: port1, mockExports, mockMap: new Map(), moduleLoader, }; mock._mockExports = mockExports; Module._load = Function.prototype.bind.call( cjsMockModuleLoad, sharedModuleState, ); } return sharedModuleState; } function cjsMockModuleLoad(request, parent, isMain) { let resolved; if (isBuiltin(request)) { resolved = ensureNodeScheme(request); } else { resolved = _resolveFilename(request, parent, isMain); } const config = this.mockMap.get(resolved); if (config === undefined) { return _load(request, parent, isMain); } const { cache, caller, defaultExport, hasDefaultExport, namedExports } = config; if (cache && Module._cache[resolved]) { // The CJS cache entry is deleted when the mock is configured. If it has // been repopulated, return the exports from that entry. return Module._cache[resolved].exports; } // eslint-disable-next-line node-core/set-proto-to-null-in-object const modExports = hasDefaultExport ? defaultExport : {}; const exportNames = Object.keys(namedExports); if ( (typeof modExports !== "object" || modExports === null) && exportNames.length > 0 ) { // eslint-disable-next-line no-restricted-syntax throw new Error(kBadExportsMessage); } for (let i = 0; i < exportNames.length; ++i) { const name = exportNames[i]; const descriptor = Object.getOwnPropertyDescriptor(namedExports, name); Object.defineProperty(modExports, name, descriptor); } if (cache) { const entry = new Module(resolved, caller); entry.exports = modExports; entry.filename = resolved; entry.loaded = true; entry.paths = _nodeModulePaths(entry.path); Module._cache[resolved] = entry; } return modExports; } function validateStringOrSymbol(value, name) { if (typeof value !== "string" && typeof value !== "symbol") { throw new ERR_INVALID_ARG_TYPE(name, ["string", "symbol"], value); } } function validateTimes(value, name) { if (value === Infinity) { return; } validateInteger(value, name, 1); } function findMethodOnPrototypeChain(instance, methodName) { let host = instance; let descriptor; while (host !== null) { descriptor = Object.getOwnPropertyDescriptor(host, methodName); if (descriptor) { break; } host = Object.getPrototypeOf(host); } return descriptor; } function waitForAck(buf) { const result = AtomicsWait(buf, 0, 0, kWaitTimeout); notStrictEqual(result, "timed-out", "test mocking synchronization failed"); strictEqual(buf[0], kMockSuccess); } function ensureNodeScheme(specifier) { if (!String.prototype.startsWith.call(specifier, "node:")) { return `node:${specifier}`; } return specifier; } if (!enableModuleMocking) { delete MockTracker.prototype.module; } export { ensureNodeScheme }; export { kBadExportsMessage }; export { kMockSearchParam }; export { kMockSuccess }; export { kMockExists }; export { kMockUnknownMessage }; export { MockTracker };