@cypress/webpack-preprocessor
Version:
Cypress preprocessor for bundling JavaScript via webpack
419 lines (418 loc) • 18.4 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var bluebird_1 = __importDefault(require("bluebird"));
var debug_1 = __importDefault(require("debug"));
var lodash_1 = __importDefault(require("lodash"));
var path = __importStar(require("path"));
var webpack_1 = __importDefault(require("webpack"));
var utils_1 = __importDefault(require("./lib/utils"));
var typescript_overrides_1 = require("./lib/typescript-overrides");
var getTsLoaderIfExists = function (rules) {
var tsLoaderRule;
rules.some(function (rule) {
if (!rule.use && !rule.loader)
return false;
if (Array.isArray(rule.use)) {
var foundRule = rule.use.find(function (use) {
return use.loader && use.loader.includes('ts-loader');
});
/**
* If the rule is found, it will look like this:
* rules: [
* {
* test: /\.tsx?$/,
* exclude: [/node_modules/],
* use: [{
* loader: 'ts-loader'
* }]
* }
* ]
*/
tsLoaderRule = foundRule;
return tsLoaderRule;
}
if (lodash_1.default.isObject(rule.use) && rule.use.loader && rule.use.loader.includes('ts-loader')) {
/**
* If the rule is found, it will look like this:
* rules: [
* {
* test: /\.tsx?$/,
* exclude: [/node_modules/],
* use: {
* loader: 'ts-loader'
* }
* }
* ]
*/
tsLoaderRule = rule.use;
return tsLoaderRule;
}
tsLoaderRule = rules.find(function (rule) {
/**
* If the rule is found, it will look like this:
* rules: [
* {
* test: /\.tsx?$/,
* exclude: [/node_modules/],
* loader: 'ts-loader'
* }
* ]
*/
return rule.loader && rule.loader.includes('ts-loader');
});
return tsLoaderRule;
});
return tsLoaderRule;
};
var debug = (0, debug_1.default)('cypress:webpack');
var debugStats = (0, debug_1.default)('cypress:webpack:stats');
// bundle promises from input spec filename to output bundled file paths
var bundles = {};
// we don't automatically load the rules, so that the babel dependencies are
// not required if a user passes in their own configuration
var getDefaultWebpackOptions = function () {
debug('load default options');
return {
mode: 'development',
module: {
rules: [
{
test: /\.jsx?$/,
exclude: [/node_modules/],
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
],
},
],
},
};
};
var replaceErrMessage = function (err, partToReplace, replaceWith) {
if (replaceWith === void 0) { replaceWith = ''; }
err.message = lodash_1.default.trim(err.message.replace(partToReplace, replaceWith));
if (err.stack) {
err.stack = lodash_1.default.trim(err.stack.replace(partToReplace, replaceWith));
}
return err;
};
var cleanModuleNotFoundError = function (err) {
var message = err.message;
if (!message.includes('Module not found'))
return err;
// Webpack 5 error messages are much less verbose. No need to clean.
if ('NormalModule' in webpack_1.default) {
return err;
}
var startIndex = message.lastIndexOf('resolve ');
var endIndex = message.lastIndexOf("doesn't exist") + "doesn't exist".length;
var partToReplace = message.substring(startIndex, endIndex);
var newMessagePart = "Looked for and couldn't find the file at the following paths:";
return replaceErrMessage(err, partToReplace, newMessagePart);
};
var cleanMultiNonsense = function (err) {
var message = err.message;
var startIndex = message.indexOf('@ multi');
if (startIndex < 0)
return err;
var partToReplace = message.substring(startIndex);
return replaceErrMessage(err, partToReplace);
};
var quietErrorMessage = function (err) {
if (!err || !err.message)
return err;
err = cleanModuleNotFoundError(err);
err = cleanMultiNonsense(err);
return err;
};
/**
* Webpack preprocessor configuration function. Takes configuration object
* and returns file preprocessor.
* @example
```
on('file:preprocessor', webpackPreprocessor(options))
```
*/
// @ts-ignore
var preprocessor = function (options) {
if (options === void 0) { options = {}; }
debug('user options: %o', options);
// we return function that accepts the arguments provided by
// the event 'file:preprocessor'
//
// this function will get called for the support file when a project is loaded
// (if the support file is not disabled)
// it will also get called for a spec file when that spec is requested by
// the Cypress runner
//
// when running in the GUI, it will likely get called multiple times
// with the same filePath, as the user could re-run the tests, causing
// the supported file and spec file to be requested again
return function (file) {
var filePath = file.filePath;
debug('get', filePath);
// since this function can get called multiple times with the same
// filePath, we return the cached bundle promise if we already have one
// since we don't want or need to re-initiate webpack for it
if (bundles[filePath]) {
debug("already have bundle for ".concat(filePath));
return bundles[filePath].promise;
}
var defaultWebpackOptions = getDefaultWebpackOptions();
// we're provided a default output path that lives alongside Cypress's
// app data files so we don't have to worry about where to put the bundled
// file on disk
var outputPath = path.extname(file.outputPath) === '.js'
? file.outputPath
: "".concat(file.outputPath, ".js");
var entry = [filePath].concat(options.additionalEntries || []);
var watchOptions = options.watchOptions || {};
// user can override the default options
var webpackOptions = lodash_1.default
.chain(options.webpackOptions)
.defaultTo(defaultWebpackOptions)
.defaults({
mode: defaultWebpackOptions.mode,
})
.assign({
// we need to set entry and output
entry: entry,
output: {
// disable automatic publicPath
publicPath: '',
path: path.dirname(outputPath),
filename: path.basename(outputPath),
},
})
.tap(function (opts) {
var _a, _b;
try {
var tsLoaderRule = getTsLoaderIfExists((_a = opts === null || opts === void 0 ? void 0 : opts.module) === null || _a === void 0 ? void 0 : _a.rules);
if (!tsLoaderRule) {
debug('ts-loader not detected');
return;
}
// FIXME: To prevent disruption, we are only passing in these 4 options to the ts-loader.
// We will be passing in the entire compilerOptions object from the tsconfig.json in Cypress 15.
// @see https://github.com/cypress-io/cypress/issues/29614#issuecomment-2722071332
// @see https://github.com/cypress-io/cypress/issues/31282
// Cypress ALWAYS wants sourceMap set to true, regardless of the user configuration.
// This is because we want to display a correct code frame in the test runner.
debug("ts-loader detected: overriding tsconfig to use sourceMap:true, inlineSourceMap:false, inlineSources:false, downlevelIteration:true");
tsLoaderRule.options = (tsLoaderRule === null || tsLoaderRule === void 0 ? void 0 : tsLoaderRule.options) || {};
tsLoaderRule.options.compilerOptions = ((_b = tsLoaderRule.options) === null || _b === void 0 ? void 0 : _b.compilerOptions) || {};
tsLoaderRule.options.compilerOptions.sourceMap = true;
tsLoaderRule.options.compilerOptions.inlineSourceMap = false;
tsLoaderRule.options.compilerOptions.inlineSources = false;
tsLoaderRule.options.compilerOptions.downlevelIteration = true;
}
catch (e) {
debug('ts-loader not detected', e);
return;
}
})
.tap(function (opts) {
if (opts.devtool === false) {
// disable any overrides if we've explicitly turned off sourcemaps
(0, typescript_overrides_1.overrideSourceMaps)(false, options.typescript);
return;
}
debug('setting devtool to inline-source-map');
opts.devtool = 'inline-source-map';
// override typescript to always generate proper source maps
(0, typescript_overrides_1.overrideSourceMaps)(true, options.typescript);
// To support dynamic imports, we have to disable any code splitting.
debug('Limiting number of chunks to 1');
opts.plugins = (opts.plugins || []).concat(new webpack_1.default.optimize.LimitChunkCountPlugin({ maxChunks: 1 }));
})
.value();
debug('webpackOptions: %o', webpackOptions);
debug('watchOptions: %o', watchOptions);
if (options.typescript)
debug('typescript: %s', options.typescript);
debug("input: ".concat(filePath));
debug("output: ".concat(outputPath));
var compiler = (0, webpack_1.default)(webpackOptions);
var firstBundle = utils_1.default.createDeferred();
// cache the bundle promise, so it can be returned if this function
// is invoked again with the same filePath
bundles[filePath] = {
promise: firstBundle.promise,
// we will resolve all reject everything in this array when a compile completes in the `handle` function
deferreds: [firstBundle],
initial: true,
};
var rejectWithErr = function (err) {
err = quietErrorMessage(err);
// @ts-ignore
err.filePath = filePath;
debug("errored bundling ".concat(outputPath), err.message);
var lastBundle = bundles[filePath].deferreds[bundles[filePath].deferreds.length - 1];
lastBundle.reject(err);
bundles[filePath].deferreds.length = 0;
};
// this function is called when bundling is finished, once at the start
// and, if watching, each time watching triggers a re-bundle
var handle = function (err, stats) {
if (err) {
debug('handle - had error', err.message);
return rejectWithErr(err);
}
var jsonStats = stats.toJson();
// these stats are really only useful for debugging
if (jsonStats.warnings.length > 0) {
debug("warnings for ".concat(outputPath, " %o"), jsonStats.warnings);
}
if (stats.hasErrors()) {
err = new Error('Webpack Compilation Error');
var errorsToAppend = jsonStats.errors
// remove stack trace lines since they're useless for debugging
.map(cleanseError)
// multiple errors separated by newline
.join('\n\n');
err.message += "\n".concat(errorsToAppend);
debug('stats had error(s) %o', jsonStats.errors);
return rejectWithErr(err);
}
debug('finished bundling', outputPath);
if (debugStats.enabled) {
/* eslint-disable-next-line no-console */
console.error(stats.toString({ colors: true }));
}
// seems to be a race condition where changing file before next tick
// does not cause build to rerun
bluebird_1.default.delay(0).then(function () {
if (!bundles[filePath]) {
return;
}
bundles[filePath].deferreds.forEach(function (deferred) {
// resolve with the outputPath so Cypress knows where to serve
// the file from
deferred.resolve(outputPath);
});
bundles[filePath].deferreds.length = 0;
});
};
var plugin = { name: 'CypressWebpackPreprocessor' };
// this event is triggered when watching and a file is saved
var onCompile = function () {
debug('compile', filePath);
/**
* Webpack 5 fix:
* If the bundle is the initial bundle, do not create the deferred promise
* as we already have one from above. Creating additional deferments on top of
* the first bundle causes reference issues with the first bundle returned, meaning
* the promise that is resolved/rejected is different from the one that is returned, which
* makes the preprocessor permanently hang
*/
if (!bundles[filePath].initial) {
var nextBundle = utils_1.default.createDeferred();
bundles[filePath].promise = nextBundle.promise;
bundles[filePath].deferreds.push(nextBundle);
}
bundles[filePath].promise.finally(function () {
debug('- compile finished for %s, initial? %s', filePath, bundles[filePath].initial);
// when the bundling is finished, emit 'rerun' to let Cypress
// know to rerun the spec, but NOT when it is the initial
// bundling of the file
if (!bundles[filePath].initial) {
file.emit('rerun');
}
bundles[filePath].initial = false;
})
// we suppress unhandled rejections so they don't bubble up to the
// unhandledRejection handler and crash the process. Cypress will
// eventually take care of the rejection when the file is requested.
// note that this does not work if attached to latestBundle.promise
// for some reason. it only works when attached after .finally ¯\_(ツ)_/¯
.suppressUnhandledRejections();
};
// when we should watch, we hook into the 'compile' hook so we know when
// to rerun the tests
if (file.shouldWatch) {
if (compiler.hooks) {
// TODO compile.tap takes "string | Tap"
// so seems we just need to pass plugin.name
// @ts-ignore
compiler.hooks.compile.tap(plugin, onCompile);
}
else if ('plugin' in compiler) {
// @ts-ignore
compiler.plugin('compile', onCompile);
}
}
var bundler = file.shouldWatch ? compiler.watch(watchOptions, handle) : compiler.run(handle);
// when the spec or project is closed, we need to clean up the cached
// bundle promise and stop the watcher via `bundler.close()`
file.on('close', function (cb) {
if (cb === void 0) { cb = function () { }; }
debug('close', filePath);
delete bundles[filePath];
if (file.shouldWatch) {
// in this case the bundler is webpack.Compiler.Watching
if (bundler && 'close' in bundler) {
bundler.close(cb);
}
}
});
// return the promise, which will resolve with the outputPath or reject
// with any error encountered
return bundles[filePath].promise;
};
};
// provide a clone of the default options
Object.defineProperty(preprocessor, 'defaultOptions', {
get: function () {
debug('get default options');
return {
webpackOptions: getDefaultWebpackOptions(),
watchOptions: {},
};
},
});
// for testing purposes, but do not add this to the typescript interface
// @ts-ignore
preprocessor.__reset = function () {
bundles = {};
};
// for testing purposes, but do not add this to the typescript interface
// @ts-ignore
preprocessor.__bundles = function () {
return bundles;
};
// @ts-ignore - webpack.StatsError is unique to webpack 5
// TODO: Remove this when we update to webpack 5.
function cleanseError(err) {
var msg = typeof err === 'string' ? err : err.message;
return msg.replace(/\n\s*at.*/g, '').replace(/From previous event:\n?/g, '');
}
module.exports = preprocessor;