UNPKG

7.23 kBJavaScriptView Raw
1const path = require('path')
2const webpack = require('webpack')
3const debug = require('debug')('cypress:webpack')
4
5const createDeferred = require('./deferred')
6const stubbableRequire = require('./stubbable-require')
7
8let bundles = {}
9
10// we don't automatically load the rules, so that the babel dependencies are
11// not required if a user passes in their own configuration
12const getDefaultWebpackOptions = () => {
13 debug('load default options')
14
15 return {
16 module: {
17 rules: [
18 {
19 test: /\.jsx?$/,
20 exclude: [/node_modules/],
21 use: [
22 {
23 loader: stubbableRequire.resolve('babel-loader'),
24 options: {
25 presets: [stubbableRequire.resolve('@babel/preset-env')],
26 },
27 },
28 ],
29 },
30 ],
31 },
32 }
33}
34
35// export a function that returns another function, making it easy for users
36// to configure like so:
37//
38// on('file:preprocessor', webpack(options))
39//
40const preprocessor = (options = {}) => {
41 debug('user options:', options)
42
43 // we return function that accepts the arguments provided by
44 // the event 'file:preprocessor'
45 //
46 // this function will get called for the support file when a project is loaded
47 // (if the support file is not disabled)
48 // it will also get called for a spec file when that spec is requested by
49 // the Cypress runner
50 //
51 // when running in the GUI, it will likely get called multiple times
52 // with the same filePath, as the user could re-run the tests, causing
53 // the supported file and spec file to be requested again
54 return (file) => {
55 const filePath = file.filePath
56
57 debug('get', filePath)
58
59 // since this function can get called multiple times with the same
60 // filePath, we return the cached bundle promise if we already have one
61 // since we don't want or need to re-initiate webpack for it
62 if (bundles[filePath]) {
63 debug(`already have bundle for ${filePath}`)
64
65 return bundles[filePath]
66 }
67
68 // user can override the default options
69 let webpackOptions = options.webpackOptions || getDefaultWebpackOptions()
70 const watchOptions = options.watchOptions || {}
71
72 debug('webpackOptions: %o', webpackOptions)
73 debug('watchOptions: %o', watchOptions)
74
75 const entry = [filePath].concat(options.additionalEntries || [])
76 // we're provided a default output path that lives alongside Cypress's
77 // app data files so we don't have to worry about where to put the bundled
78 // file on disk
79 const outputPath = file.outputPath
80
81 // we need to set entry and output
82 webpackOptions = Object.assign(webpackOptions, {
83 entry,
84 output: {
85 path: path.dirname(outputPath),
86 filename: path.basename(outputPath),
87 },
88 })
89
90 if (webpackOptions.devtool !== false) {
91 webpackOptions.devtool = 'inline-source-map'
92 }
93
94 debug(`input: ${filePath}`)
95 debug(`output: ${outputPath}`)
96
97 const compiler = webpack(webpackOptions)
98
99 // we keep a reference to the latest bundle in this scope
100 // it's a deferred object that will be resolved or rejected in
101 // the `handle` function below and its promise is what is ultimately
102 // returned from this function
103 let latestBundle = createDeferred()
104
105 // cache the bundle promise, so it can be returned if this function
106 // is invoked again with the same filePath
107 bundles[filePath] = latestBundle.promise
108
109 const rejectWithErr = (err) => {
110 err.filePath = filePath
111 debug(`errored bundling ${outputPath}`, err.message)
112
113 latestBundle.reject(err)
114 }
115
116 // this function is called when bundling is finished, once at the start
117 // and, if watching, each time watching triggers a re-bundle
118 const handle = (err, stats) => {
119 if (err) {
120 debug('handle - had error', err.message)
121
122 return rejectWithErr(err)
123 }
124
125 const jsonStats = stats.toJson()
126
127 if (stats.hasErrors()) {
128 err = new Error('Webpack Compilation Error')
129
130 const errorsToAppend = jsonStats.errors
131 // remove stack trace lines since they're useless for debugging
132 .map(cleanseError)
133 // multiple errors separated by newline
134 .join('\n\n')
135
136 err.message += `\n${errorsToAppend}`
137
138 debug('stats had error(s)')
139
140 return rejectWithErr(err)
141 }
142
143 // these stats are really only useful for debugging
144 if (jsonStats.warnings.length > 0) {
145 debug(`warnings for ${outputPath}`)
146 debug(jsonStats.warnings)
147 }
148
149 debug('finished bundling', outputPath)
150 // resolve with the outputPath so Cypress knows where to serve
151 // the file from
152 latestBundle.resolve(outputPath)
153 }
154
155 // this event is triggered when watching and a file is saved
156 const plugin = { name: 'CypressWebpackPreprocessor' }
157
158 const onCompile = () => {
159 debug('compile', filePath)
160 // we overwrite the latest bundle, so that a new call to this function
161 // returns a promise that resolves when the bundling is finished
162 latestBundle = createDeferred()
163 bundles[filePath] = latestBundle.promise
164
165 bundles[filePath].finally(() => {
166 debug('- compile finished for', filePath)
167 // when the bundling is finished, emit 'rerun' to let Cypress
168 // know to rerun the spec
169 file.emit('rerun')
170 })
171 // we suppress unhandled rejections so they don't bubble up to the
172 // unhandledRejection handler and crash the process. Cypress will
173 // eventually take care of the rejection when the file is requested.
174 // note that this does not work if attached to latestBundle.promise
175 // for some reason. it only works when attached after .tap ¯\_(ツ)_/¯
176 .suppressUnhandledRejections()
177 }
178
179 // when we should watch, we hook into the 'compile' hook so we know when
180 // to rerun the tests
181 if (file.shouldWatch) {
182 debug('watching')
183
184 if (compiler.hooks) {
185 compiler.hooks.compile.tap(plugin, onCompile)
186 } else {
187 compiler.plugin('compile', onCompile)
188 }
189 }
190
191 const bundler = file.shouldWatch ? compiler.watch(watchOptions, handle) : compiler.run(handle)
192
193 // when the spec or project is closed, we need to clean up the cached
194 // bundle promise and stop the watcher via `bundler.close()`
195 file.on('close', (cb = function () {}) => {
196 debug('close', filePath)
197 delete bundles[filePath]
198
199 if (file.shouldWatch) {
200 bundler.close(cb)
201 }
202 })
203
204 // return the promise, which will resolve with the outputPath or reject
205 // with any error encountered
206 return bundles[filePath]
207 }
208}
209
210// provide a clone of the default options, lazy-loading them
211// so they aren't required unless the user utilizes them
212Object.defineProperty(preprocessor, 'defaultOptions', {
213 get () {
214 debug('get default options')
215
216 return {
217 webpackOptions: getDefaultWebpackOptions(),
218 watchOptions: {},
219 }
220 },
221})
222
223// for testing purposes
224preprocessor.__reset = () => {
225 bundles = {}
226}
227
228function cleanseError (err) {
229 return err.replace(/\n\s*at.*/g, '').replace(/From previous event:\n?/g, '')
230}
231
232module.exports = preprocessor