1 | const path = require('path')
|
2 | const webpack = require('webpack')
|
3 | const debug = require('debug')('cypress:webpack')
|
4 |
|
5 | const createDeferred = require('./deferred')
|
6 | const stubbableRequire = require('./stubbable-require')
|
7 |
|
8 | let 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
|
12 | const 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 | //
|
40 | const 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
|
212 | Object.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
|
224 | preprocessor.__reset = () => {
|
225 | bundles = {}
|
226 | }
|
227 |
|
228 | function cleanseError (err) {
|
229 | return err.replace(/\n\s*at.*/g, '').replace(/From previous event:\n?/g, '')
|
230 | }
|
231 |
|
232 | module.exports = preprocessor
|