/* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-var-requires */ import { MaybeAsync } from '@/modules/shared/helpers/typeHelper' import { isArray, isFunction } from 'lodash' import mock from 'mock-require' import { ConditionalPick } from 'type-fest' export type MockedFunctionImplementation = (...args: any[]) => MaybeAsync /** * Mock a module's exported functions with the possibility to conditionally disable & change the mock * @param {string|string[]} modulePaths Absolute & relative paths to the module being mocked or if you sometimes require * with the '/index' suffix and sometimes don't you need to specify both options. Multiple options are required * because of a limitation of mock-require - it doesn't understand that all of these point to the same thing. * @param {string|string[]} dependencyPaths Paths to modules that use the mocked module and that you * want to re-require so that if they are already loaded in memory, they're re-required with the new mock. Basically, * if you've mocked a module, but it's not being used, debug the test and see if the mocked module is maybe required * by another module that you haven't specified in this list. */ export function mockRequireModule< MockType extends Record = Record >( modulePaths: string | string[], dependencyPaths: string | string[] = [], params: { preventDestroy?: boolean } = {} ) { type MockTypeFunctionsOnly = ConditionalPick type MockTypeFunctionProp = keyof MockTypeFunctionsOnly type MockedFunc = ( ...args: Parameters ) => ReturnType const { preventDestroy } = params modulePaths = isArray(modulePaths) ? modulePaths : [modulePaths] dependencyPaths = isArray(dependencyPaths) ? dependencyPaths : [dependencyPaths] let isDisabled = false let functionReplacements: Partial< Record> > = {} const originalModule = require(modulePaths[0]) as MockType const mockDefinition = new Proxy(originalModule, { get(target, prop) { const realProp = prop as keyof MockTypeFunctionsOnly const propVal = target[realProp] if (!isFunction(propVal)) return propVal return function (this: unknown, ...args: Parameters[]) { const potentialReplacement = functionReplacements[realProp] as typeof propVal if (isDisabled || !potentialReplacement || !isFunction(potentialReplacement)) { return propVal.apply(this, args) } return potentialReplacement.apply(this, args) } } }) // Initialize mock with all paths (relative path, absolute alias path - both need to be specified // cause of a limitation in mock-require) for (const modulePath of modulePaths) { mock(modulePath, mockDefinition) } /** * Re-requires the specified modules, in case they were required before the mock was set up * and thus don't have the mocked module */ const reRequireDependencies = () => { for (const dependencyPath of dependencyPaths) { mock.reRequire(dependencyPath) } } reRequireDependencies() const core = { /** * Set (or unset) a mocked implementation of a function */ mockFunction( functionName: F, implementation: MockedFunc ) { if (implementation) { functionReplacements[functionName] = implementation } else { delete functionReplacements[functionName] } }, /** * Remove all mocked function implementations */ resetMockedFunctions() { functionReplacements = {} }, /** * Remove a single function mock */ resetMockedFunction(functionName: MockTypeFunctionProp) { delete functionReplacements[functionName] }, /** * Temporarily disable the mock, sending all function calls to the real implementations */ disable() { isDisabled = true }, /** * Re-enable the mock, if it's been disabled before */ enable() { isDisabled = false }, /** * Unmock entirely * Note: All requires done before this point will still point to the mocks */ destroy(reRequireDeps = true) { if (preventDestroy) { isDisabled = true return } for (const modulePath of modulePaths) { mock.stop(modulePath) } if (reRequireDeps) reRequireDependencies() }, /** * Re-require specified dependencies */ reRequireDependencies } const helpers = { /** * Mock a function temporarily * * Set 'times' parameter to control how many times will the function be invoked * with the mocked implementation * * Use args & results arrays in result object to see the passed in arguments and function return values * that were collected */ hijackFunction( functionName: F, implementation: MockedFunc, params: { times: number } = { times: 1 } ) { let { times } = params if (!isFunction(implementation)) throw new Error('Implementation must be a function') const collectedReturns: Array>> = [] const collectedArgs: Array>> = [] core.enable() core.mockFunction( functionName, function (this: unknown, ...args: Parameters>) { const returnVal = implementation.apply(this, args) times-- if (times <= 0) { core.resetMockedFunction(functionName) } collectedArgs.push(args) collectedReturns.push(returnVal) return returnVal } ) return { /** * Arguments that were used to call the mocked function. Each entry in this array is an array of arguments, so use the first array dimension to choose * the invocation and the 2nd dimension to choose the specific argument. */ args: collectedArgs, /** * Return values that were returned from the mocked function. */ returns: collectedReturns, /** * Get the amount of invocations */ length: () => collectedArgs.length } } } return { ...core, ...helpers } } export type MockApiType = ReturnType /** * Create global mock. Essentially the same as mockRequireModule() but simplified * with safeguards so that you can't destroy it and break it in other tests * * Note: Global mocks should be registered in test/hooks.js before everything else! */ export function createGlobalMock>( modulePath: string ) { const globalMock = mockRequireModule([modulePath], [], { preventDestroy: true }) const { hijackFunction, resetMockedFunctions } = globalMock return { hijackFunction, resetMockedFunctions } }