mirror of
https://github.com/cypress-io/cypress.git
synced 2026-01-30 02:54:27 -06:00
* fix: change default options for sourceMaps inside WBIP * add better error handling to @cypress/webpack-preprocessor when using typescript 5 * chore: update snapshots for protocol spec * move options downstream into @cypress/webpack-preprocessor and see what fails * address comments from code review
527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
import Bluebird from 'bluebird'
|
|
import Debug from 'debug'
|
|
import _ from 'lodash'
|
|
import * as events from 'events'
|
|
import * as path from 'path'
|
|
import webpack from 'webpack'
|
|
import utils from './lib/utils'
|
|
import { overrideSourceMaps } from './lib/typescript-overrides'
|
|
|
|
const getTsLoaderIfExists = (rules) => {
|
|
let tsLoaderRule
|
|
|
|
rules.some((rule) => {
|
|
if (!rule.use && !rule.loader) return false
|
|
|
|
if (Array.isArray(rule.use)) {
|
|
const foundRule = rule.use.find((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 (_.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((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
|
|
}
|
|
|
|
const debug = Debug('cypress:webpack')
|
|
const debugStats = Debug('cypress:webpack:stats')
|
|
|
|
type FilePath = string
|
|
interface BundleObject {
|
|
promise: Bluebird<FilePath>
|
|
deferreds: Array<{ resolve: (filePath: string) => void, reject: (error: Error) => void, promise: Bluebird<string> }>
|
|
initial: boolean
|
|
}
|
|
|
|
// bundle promises from input spec filename to output bundled file paths
|
|
let bundles: {[key: string]: BundleObject} = {}
|
|
|
|
// we don't automatically load the rules, so that the babel dependencies are
|
|
// not required if a user passes in their own configuration
|
|
const getDefaultWebpackOptions = (): webpack.Configuration => {
|
|
debug('load default options')
|
|
|
|
return {
|
|
mode: 'development',
|
|
module: {
|
|
rules: [
|
|
{
|
|
test: /\.jsx?$/,
|
|
exclude: [/node_modules/],
|
|
use: [
|
|
{
|
|
loader: 'babel-loader',
|
|
options: {
|
|
presets: ['@babel/preset-env'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}
|
|
}
|
|
|
|
const replaceErrMessage = (err: Error, partToReplace: string, replaceWith = '') => {
|
|
err.message = _.trim(err.message.replace(partToReplace, replaceWith))
|
|
|
|
if (err.stack) {
|
|
err.stack = _.trim(err.stack.replace(partToReplace, replaceWith))
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
const cleanModuleNotFoundError = (err: Error) => {
|
|
const 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) {
|
|
return err
|
|
}
|
|
|
|
const startIndex = message.lastIndexOf('resolve ')
|
|
const endIndex = message.lastIndexOf(`doesn't exist`) + `doesn't exist`.length
|
|
const partToReplace = message.substring(startIndex, endIndex)
|
|
const newMessagePart = `Looked for and couldn't find the file at the following paths:`
|
|
|
|
return replaceErrMessage(err, partToReplace, newMessagePart)
|
|
}
|
|
|
|
const cleanMultiNonsense = (err: Error) => {
|
|
const message = err.message
|
|
const startIndex = message.indexOf('@ multi')
|
|
|
|
if (startIndex < 0) return err
|
|
|
|
const partToReplace = message.substring(startIndex)
|
|
|
|
return replaceErrMessage(err, partToReplace)
|
|
}
|
|
|
|
const quietErrorMessage = (err: Error) => {
|
|
if (!err || !err.message) return err
|
|
|
|
err = cleanModuleNotFoundError(err)
|
|
err = cleanMultiNonsense(err)
|
|
|
|
return err
|
|
}
|
|
|
|
/**
|
|
* Configuration object for this Webpack preprocessor
|
|
*/
|
|
interface PreprocessorOptions {
|
|
webpackOptions?: webpack.Configuration
|
|
watchOptions?: Object
|
|
typescript?: string
|
|
additionalEntries?: string[]
|
|
}
|
|
|
|
interface FileEvent extends events.EventEmitter {
|
|
filePath: FilePath
|
|
outputPath: string
|
|
shouldWatch: boolean
|
|
}
|
|
|
|
/**
|
|
* Cypress asks file preprocessor to bundle the given file
|
|
* and return the full path to produced bundle.
|
|
*/
|
|
type FilePreprocessor = (file: FileEvent) => Bluebird<FilePath>
|
|
|
|
type WebpackPreprocessorFn = (options: PreprocessorOptions) => FilePreprocessor
|
|
|
|
/**
|
|
* Cypress file preprocessor that can bundle specs
|
|
* using Webpack.
|
|
*/
|
|
interface WebpackPreprocessor extends WebpackPreprocessorFn {
|
|
/**
|
|
* Default options for Cypress Webpack preprocessor.
|
|
* You can modify these options then pass to the preprocessor.
|
|
* @example
|
|
```
|
|
const defaults = webpackPreprocessor.defaultOptions
|
|
module.exports = (on) => {
|
|
delete defaults.webpackOptions.module.rules[0].use[0].options.presets
|
|
on('file:preprocessor', webpackPreprocessor(defaults))
|
|
}
|
|
```
|
|
*
|
|
* @type {Omit<PreprocessorOptions, 'additionalEntries'>}
|
|
* @memberof WebpackPreprocessor
|
|
*/
|
|
defaultOptions: Omit<PreprocessorOptions, 'additionalEntries'>
|
|
}
|
|
|
|
/**
|
|
* Webpack preprocessor configuration function. Takes configuration object
|
|
* and returns file preprocessor.
|
|
* @example
|
|
```
|
|
on('file:preprocessor', webpackPreprocessor(options))
|
|
```
|
|
*/
|
|
// @ts-ignore
|
|
const preprocessor: WebpackPreprocessor = (options: PreprocessorOptions = {}): FilePreprocessor => {
|
|
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 (file: FileEvent) => {
|
|
const 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 ${filePath}`)
|
|
|
|
return bundles[filePath].promise
|
|
}
|
|
|
|
const 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
|
|
const outputPath = path.extname(file.outputPath) === '.js'
|
|
? file.outputPath
|
|
: `${file.outputPath}.js`
|
|
|
|
const entry = [filePath].concat(options.additionalEntries || [])
|
|
|
|
const watchOptions = options.watchOptions || {}
|
|
|
|
// user can override the default options
|
|
const webpackOptions: webpack.Configuration = _
|
|
.chain(options.webpackOptions)
|
|
.defaultTo(defaultWebpackOptions)
|
|
.defaults({
|
|
mode: defaultWebpackOptions.mode,
|
|
})
|
|
.assign({
|
|
// we need to set entry and output
|
|
entry,
|
|
output: {
|
|
// disable automatic publicPath
|
|
publicPath: '',
|
|
path: path.dirname(outputPath),
|
|
filename: path.basename(outputPath),
|
|
},
|
|
})
|
|
.tap((opts) => {
|
|
try {
|
|
const tsLoaderRule = getTsLoaderIfExists(opts?.module?.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?.options || {}
|
|
tsLoaderRule.options.compilerOptions = tsLoaderRule.options?.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((opts) => {
|
|
if (opts.devtool === false) {
|
|
// disable any overrides if we've explicitly turned off sourcemaps
|
|
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
|
|
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.optimize.LimitChunkCountPlugin({ maxChunks: 1 }))
|
|
})
|
|
.value() as any
|
|
|
|
debug('webpackOptions: %o', webpackOptions)
|
|
debug('watchOptions: %o', watchOptions)
|
|
if (options.typescript) debug('typescript: %s', options.typescript)
|
|
|
|
debug(`input: ${filePath}`)
|
|
debug(`output: ${outputPath}`)
|
|
|
|
const compiler = webpack(webpackOptions)
|
|
|
|
let firstBundle = utils.createDeferred<string>()
|
|
|
|
// 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,
|
|
}
|
|
|
|
const rejectWithErr = (err: Error) => {
|
|
err = quietErrorMessage(err)
|
|
|
|
// @ts-ignore
|
|
err.filePath = filePath
|
|
|
|
debug(`errored bundling ${outputPath}`, err.message)
|
|
|
|
const 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
|
|
const handle = (err: Error, stats: webpack.Stats) => {
|
|
if (err) {
|
|
debug('handle - had error', err.message)
|
|
|
|
return rejectWithErr(err)
|
|
}
|
|
|
|
const jsonStats = stats.toJson()
|
|
|
|
// these stats are really only useful for debugging
|
|
if (jsonStats.warnings.length > 0) {
|
|
debug(`warnings for ${outputPath} %o`, jsonStats.warnings)
|
|
}
|
|
|
|
if (stats.hasErrors()) {
|
|
err = new Error('Webpack Compilation Error')
|
|
|
|
const 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${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.delay(0).then(() => {
|
|
if (!bundles[filePath]) {
|
|
return
|
|
}
|
|
|
|
bundles[filePath].deferreds.forEach((deferred) => {
|
|
// resolve with the outputPath so Cypress knows where to serve
|
|
// the file from
|
|
deferred.resolve(outputPath)
|
|
})
|
|
|
|
bundles[filePath].deferreds.length = 0
|
|
})
|
|
}
|
|
|
|
const plugin = { name: 'CypressWebpackPreprocessor' }
|
|
|
|
// this event is triggered when watching and a file is saved
|
|
const onCompile = () => {
|
|
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) {
|
|
const nextBundle = utils.createDeferred<string>()
|
|
|
|
bundles[filePath].promise = nextBundle.promise
|
|
bundles[filePath].deferreds.push(nextBundle)
|
|
}
|
|
|
|
bundles[filePath].promise.finally(() => {
|
|
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)
|
|
}
|
|
}
|
|
|
|
const 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', (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 () {
|
|
debug('get default options')
|
|
|
|
return {
|
|
webpackOptions: getDefaultWebpackOptions(),
|
|
watchOptions: {},
|
|
}
|
|
},
|
|
})
|
|
|
|
// for testing purposes, but do not add this to the typescript interface
|
|
// @ts-ignore
|
|
preprocessor.__reset = () => {
|
|
bundles = {}
|
|
}
|
|
|
|
// for testing purposes, but do not add this to the typescript interface
|
|
// @ts-ignore
|
|
preprocessor.__bundles = () => {
|
|
return bundles
|
|
}
|
|
|
|
// @ts-ignore - webpack.StatsError is unique to webpack 5
|
|
// TODO: Remove this when we update to webpack 5.
|
|
function cleanseError (err: string | webpack.StatsError) {
|
|
let msg = typeof err === 'string' ? err : err.message
|
|
|
|
return msg.replace(/\n\s*at.*/g, '').replace(/From previous event:\n?/g, '')
|
|
}
|
|
|
|
export = preprocessor
|