1 | const cloneDeep = require('lodash.clonedeep')
|
2 | const path = require('path')
|
3 | const webpack = require('webpack')
|
4 | const log = require('debug')('cypress:webpack')
|
5 |
|
6 | const createDeferred = require('./deferred')
|
7 |
|
8 | const bundles = {}
|
9 |
|
10 | // by default, we transform JavaScript (up to anything at stage-4) and JSX
|
11 | const defaultOptions = {
|
12 | webpackOptions: {
|
13 | module: {
|
14 | rules: [
|
15 | {
|
16 | test: /\.jsx?$/,
|
17 | exclude: [/node_modules/],
|
18 | use: [
|
19 | {
|
20 | loader: require.resolve('babel-loader'),
|
21 | options: {
|
22 | presets: ['babel-preset-env', 'babel-preset-react'].map(require.resolve),
|
23 | },
|
24 | },
|
25 | ],
|
26 | },
|
27 | ],
|
28 | },
|
29 | },
|
30 | watchOptions: {},
|
31 | }
|
32 |
|
33 | // export a function that returns another function, making it easy for users
|
34 | // to configure like so:
|
35 | //
|
36 | // on('file:preprocessor', webpack(options))
|
37 | //
|
38 | const preprocessor = (options = {}) => {
|
39 | log('user options:', options)
|
40 |
|
41 | // we return function that accepts the arguments provided by
|
42 | // the event 'file:preprocessor'
|
43 | //
|
44 | // this function will get called for the support file when a project is loaded
|
45 | // (if the support file is not disabled)
|
46 | // it will also get called for a spec file when that spec is requested by
|
47 | // the Cypress runner
|
48 | //
|
49 | // when running in the GUI, it will likely get called multiple times
|
50 | // with the same filePath, as the user could re-run the tests, causing
|
51 | // the supported file and spec file to be requested again
|
52 | return (file) => {
|
53 | const filePath = file.filePath
|
54 | log('get', filePath)
|
55 |
|
56 | // since this function can get called multiple times with the same
|
57 | // filePath, we return the cached bundle promise if we already have one
|
58 | // since we don't want or need to re-initiate webpack for it
|
59 | if (bundles[filePath]) {
|
60 | log(`already have bundle for ${filePath}`)
|
61 | return bundles[filePath]
|
62 | }
|
63 |
|
64 | // user can override the default options
|
65 | let webpackOptions = Object.assign({}, defaultOptions.webpackOptions, options.webpackOptions)
|
66 | let watchOptions = Object.assign({}, defaultOptions.watchOptions, options.watchOptions)
|
67 |
|
68 | // we're provided a default output path that lives alongside Cypress's
|
69 | // app data files so we don't have to worry about where to put the bundled
|
70 | // file on disk
|
71 | const outputPath = file.outputPath
|
72 |
|
73 | // we need to set entry and output
|
74 | webpackOptions = Object.assign(webpackOptions, {
|
75 | entry: filePath,
|
76 | output: {
|
77 | path: path.dirname(outputPath),
|
78 | filename: path.basename(outputPath),
|
79 | },
|
80 | })
|
81 |
|
82 | log(`input: ${filePath}`)
|
83 | log(`output: ${outputPath}`)
|
84 |
|
85 | const compiler = webpack(webpackOptions)
|
86 |
|
87 | // we keep a reference to the latest bundle in this scope
|
88 | // it's a deferred object that will be resolved or rejected in
|
89 | // the `handle` function below and its promise is what is ultimately
|
90 | // returned from this function
|
91 | let latestBundle = createDeferred()
|
92 | // cache the bundle promise, so it can be returned if this function
|
93 | // is invoked again with the same filePath
|
94 | bundles[filePath] = latestBundle.promise
|
95 |
|
96 | const rejectWithErr = (err) => {
|
97 | err.filePath = filePath
|
98 | // backup the original stack before it's potentially modified by bluebird
|
99 | err.originalStack = err.stack
|
100 | log(`errored bundling ${outputPath}`, err)
|
101 | latestBundle.reject(err)
|
102 | }
|
103 |
|
104 | // this function is called when bundling is finished, once at the start
|
105 | // and, if watching, each time watching triggers a re-bundle
|
106 | const handle = (err, stats) => {
|
107 | if (err) {
|
108 | return rejectWithErr(err)
|
109 | }
|
110 |
|
111 | const jsonStats = stats.toJson()
|
112 |
|
113 | if (stats.hasErrors()) {
|
114 | err = new Error('Webpack Compilation Error')
|
115 | err.stack = jsonStats.errors.join('\n\n')
|
116 | return rejectWithErr(err)
|
117 | }
|
118 |
|
119 | // these stats are really only useful for debugging
|
120 | if (jsonStats.warnings.length > 0) {
|
121 | log(`warnings for ${outputPath}`)
|
122 | log(jsonStats.warnings)
|
123 | }
|
124 |
|
125 | log('finished bundling', outputPath)
|
126 | // resolve with the outputPath so Cypress knows where to serve
|
127 | // the file from
|
128 | latestBundle.resolve(outputPath)
|
129 | }
|
130 |
|
131 | // this event is triggered when watching and a file is saved
|
132 | compiler.plugin('compile', () => {
|
133 | log('compile', filePath)
|
134 | // we overwrite the latest bundle, so that a new call to this function
|
135 | // returns a promise that resolves when the bundling is finished
|
136 | latestBundle = createDeferred()
|
137 | bundles[filePath] = latestBundle.promise.tap(() => {
|
138 | log('- compile finished for', filePath)
|
139 | // when the bundling is finished, we call `util.fileUpdated`
|
140 | // to let Cypress know to re-run the spec
|
141 | file.emit('rerun')
|
142 | })
|
143 | })
|
144 |
|
145 | if (file.shouldWatch) {
|
146 | log('watching')
|
147 | }
|
148 |
|
149 | const bundler = file.shouldWatch
|
150 | ? compiler.watch(watchOptions, handle)
|
151 | : compiler.run(handle)
|
152 |
|
153 | // when the spec or project is closed, we need to clean up the cached
|
154 | // bundle promise and stop the watcher via `bundler.close()`
|
155 | file.on('close', () => {
|
156 | log('close', filePath)
|
157 | delete bundles[filePath]
|
158 |
|
159 | if (file.shouldWatch) {
|
160 | bundler.close()
|
161 | }
|
162 | })
|
163 |
|
164 | // return the promise, which will resolve with the outputPath or reject
|
165 | // with any error encountered
|
166 | return bundles[filePath]
|
167 | }
|
168 | }
|
169 |
|
170 | // provide a clone of the default options
|
171 | preprocessor.defaultOptions = cloneDeep(defaultOptions)
|
172 |
|
173 | module.exports = preprocessor
|