const fs = require('fs');
const path = require('path');
const { WebpackPluginInstance, Compiler, RuleSetCondition } = require('webpack');
/**
* Webpack plugin for automatically resolving the mock directory,
* including transpiling all files it contains as well as adding
* the entry file within it to the build/run output for the user.
*
* @extends WebpackPluginInstance
*/
class MockRequestsWebpackPlugin {
/**
* Activates mock-requests based on the provided parameters.
*
* @param {string} mocksDir - Directory of all mock files/logic, including the `mockEntryFile`.
* @param {string} mockEntryFile - Entry file that calls `MockRequests.configure()`.
* @param {boolean} activateMocks - If mocks determined by `mockEntryFile` should be activated or not.
* @param {Object} [options]
* @param {boolean} [options.pathsAreAbsolute=false] - If `mocksDir` and `mockEntryFile` are absolute paths instead of relative.
* @param {boolean} [options.transpileMocksDir=true] - If the files within `mocksDir` should be transpiled.
*/
constructor(
mocksDir,
mockEntryFile,
activateMocks,
{
pathsAreAbsolute = false,
transpileMocksDir = true
} = {}
) {
this.mocksDir = mocksDir;
this.mockEntryFile = mockEntryFile;
this.activateMocks = activateMocks;
this.pathsAreAbsolute = pathsAreAbsolute;
this.transpileMocksDir = transpileMocksDir;
}
get pluginName() {
return this.constructor.name;
}
getAbsPath(projectRootPath, includeEntryFile = false) {
let absPath = path.resolve(projectRootPath, this.mocksDir, includeEntryFile ? this.mockEntryFile : '');
if (this.pathsAreAbsolute) {
absPath = includeEntryFile ? this.mockEntryFile : this.mocksDir;
}
if (!fs.existsSync(absPath)) {
return null;
}
return absPath;
}
setAbsPaths(projectRootPath) {
this.mockDirAbsPath = this.getAbsPath(projectRootPath);
this.mockEntryAbsPath = this.getAbsPath(projectRootPath, true);
}
/**
* @param {RuleSetCondition} condition - User-defined condition for matching directories/files.
* @returns {boolean} - If the condition matches the mock directory/entry file.
* @private
*/
webpackConditionMatchesMockDir(condition) {
const { mockDirAbsPath, mockEntryAbsPath } = this;
if (condition instanceof RegExp) {
return condition.test(mockEntryAbsPath);
} else if (typeof condition === typeof '') {
return condition.includes(mockDirAbsPath);
} else if (typeof condition === typeof this.constructor) {
return condition(this.mocksDir) || condition(this.mockEntryFile) || condition(mockDirAbsPath) || condition(mockEntryAbsPath);
} else if (Array.isArray(condition)) {
return condition.some(this.webpackConditionMatchesMockDir);
} else { // is Object with and/or/not keys
const allAndConditionsMet = condition.and
? condition.and.reduce((matches, cond) => matches && this.webpackConditionMatchesMockDir(cond), true)
: true;
const anyOrConditionsMet = condition.or
? condition.or.some(this.webpackConditionMatchesMockDir)
: true;
const allNotConditionsMet = condition.not
? condition.not.reduce((matches, cond) => matches && !this.webpackConditionMatchesMockDir(cond), true)
: true;
return allAndConditionsMet && anyOrConditionsMet && allNotConditionsMet;
}
}
injectMocksIntoWebpackConfig(projectRootPath, moduleRules, entry) {
try {
const firstEntryName = Object.keys(entry)[0];
let firstEntryList = entry[firstEntryName].import;
if (!firstEntryList) {
// webpack@4 doesn't add the `import` field, so read the entry directly
if (Array.isArray(entry)) {
firstEntryList = entry;
} else if (Array.isArray(entry[firstEntryName])) {
firstEntryList = entry[firstEntryName];
}
if (typeof firstEntryList === typeof '' || !firstEntryList) {
throw new Error(
'webpack.config.js `entry` field cannot be a single string; it must be an array or an object containing an array.' +
'\n' +
'If running webpack-dev-server, this error can be ignored.' +
'\n' +
'If building final output with mocks, then please make the `entry` field an array instead of a string.'
);
}
}
const { mockDirAbsPath, mockEntryAbsPath } = this;
if (!mockDirAbsPath) {
throw new Error(`Could not find mock directory "${this.mocksDir}" from webpack context directory "${projectRootPath}"`);
}
if (!mockEntryAbsPath) {
throw new Error(`Could not find mock entry file "${this.mockEntryFile}" from webpack context directory "${projectRootPath}"`);
}
const addedNewEntry = this.addMockEntryFileToConfigEntry(firstEntryList);
if (addedNewEntry) {
this.addMockDirToModuleRule(moduleRules);
console.log('Network mocks activated by mock-requests.\n');
}
} catch (e) {
console.error(this.pluginName, 'Error:', e.message);
console.error('Note:', this.pluginName, 'has only been verified for webpack@>=5. Webpack runtime issues may be fixed by upgrading to v5.\n');
}
}
addMockEntryFileToConfigEntry(configEntryList) {
const { mockEntryAbsPath } = this;
if (configEntryList.includes(mockEntryAbsPath)) {
// Mock entry file has already been added to webpack config
// Don't add it again if a rebuild is triggered
return false;
}
// Make mock entry file the first in the `entry` list so it's loaded in the app first
configEntryList.splice(0, 0, mockEntryAbsPath);
return true;
}
addMockDirToModuleRule(moduleRules) {
if (!this.transpileMocksDir) {
return;
}
const { mockDirAbsPath } = this;
const matchingRuleForMockEntryFile = moduleRules.find(rule => this.webpackConditionMatchesMockDir(rule.test));
if (matchingRuleForMockEntryFile) {
const userWebpackRuleInclude = matchingRuleForMockEntryFile.include;
// Note: If `include` doesn't exist, then the matching rule applies to everything in the `compiler.context`
// so there's no need to add the mock directory since it will be handled automatically, even if the user
// applied a `rule.resource(Query)` (see: https://webpack.js.org/configuration/module/#ruleresource)
if (userWebpackRuleInclude instanceof RegExp) {
matchingRuleForMockEntryFile.include = [ userWebpackRuleInclude, mockDirAbsPath ];
} else if (Array.isArray(userWebpackRuleInclude)) {
userWebpackRuleInclude.push(mockDirAbsPath);
}
} else {
throw new Error(
`${this.pluginName}: Could not find a suitable \`module.rule.test\` for ${this.mockEntryFile}.`,
`Try using either a RegExp or RegExp[] as a value for \`test\` to ensure proper transpilation of ${this.mocksDir}.`
);
}
}
/**
* @param {Compiler} compiler
* @private
*/
apply(compiler) {
if (!this.activateMocks) {
return;
}
this.setAbsPaths(compiler.context);
const rules = compiler.options.module.rules;
compiler.hooks.entryOption.tap(this.pluginName, (context, entry) => {
if (typeof entry === typeof this.constructor) {
entry().then(resolvedEntry => this.injectMocksIntoWebpackConfig(context, rules, resolvedEntry));
} else {
this.injectMocksIntoWebpackConfig(context, rules, entry);
}
});
}
}
module.exports = MockRequestsWebpackPlugin;
module.exports.default = MockRequestsWebpackPlugin;