310 lines
9.6 KiB
JavaScript

'use strict'
const path = require('path')
const Promise = require('bluebird')
const fs = require('fs-extra')
const cloneDeep = require('lodash.clonedeep')
const browserify = require('browserify')
const watchify = require('watchify')
const debug = require('debug')('cypress:browserify')
const typescriptExtensionRegex = /\.tsx?$/
const errorTypes = {
TYPESCRIPT_AND_TSIFY: 'TYPESCRIPT_AND_TSIFY',
TYPESCRIPT_NONEXISTENT: 'TYPESCRIPT_NONEXISTENT',
TYPESCRIPT_NOT_CONFIGURED: 'TYPESCRIPT_NOT_CONFIGURED',
TYPESCRIPT_NOT_STRING: 'TYPESCRIPT_NOT_STRING',
}
const bundles = {}
// by default, we transform JavaScript (including some proposal features),
// JSX, & CoffeeScript
const defaultOptions = {
browserifyOptions: {
extensions: ['.js', '.jsx', '.coffee'],
transform: [
[
require.resolve('coffeeify'),
{},
],
[
require.resolve('babelify'),
{
ast: false,
babelrc: false,
plugins: [
...[
'babel-plugin-add-module-exports',
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-object-rest-spread',
].map(require.resolve),
[require.resolve('@babel/plugin-transform-runtime'), {
absoluteRuntime: path.dirname(require.resolve('@babel/runtime/package')),
}],
],
presets: [
'@babel/preset-env',
'@babel/preset-react',
].map(require.resolve),
},
],
],
plugin: [],
},
watchifyOptions: {
// ignore watching the following or the user's system can get bogged down
// by watchers
ignoreWatch: [
'**/.git/**',
'**/.nyc_output/**',
'**/.sass-cache/**',
'**/bower_components/**',
'**/coverage/**',
'**/node_modules/**',
],
},
}
const throwError = ({ message, type }) => {
const prefix = 'Error running @cypress/browserify-preprocessor:\n\n'
const err = new Error(`${prefix}${message}`)
if (type) err.type = type
throw err
}
const getBrowserifyOptions = async (entry, userBrowserifyOptions = {}, typescriptPath = null) => {
let browserifyOptions = cloneDeep(defaultOptions.browserifyOptions)
// allow user to override default options
browserifyOptions = Object.assign(browserifyOptions, userBrowserifyOptions, {
// these must always be new objects or 'update' events will not fire
cache: {},
packageCache: {},
})
// unless user has explicitly turned off source map support, always enable it
// so we can use it to point user to the source code
if (userBrowserifyOptions.debug !== false) {
browserifyOptions.debug = true
}
// we need to override and control entries
Object.assign(browserifyOptions, {
entries: [entry],
})
if (typescriptPath) {
if (typeof typescriptPath !== 'string') {
throwError({
type: errorTypes.TYPESCRIPT_NOT_STRING,
message: `The 'typescript' option must be a string. You passed: ${typescriptPath}`,
})
}
const pathExists = await fs.pathExists(typescriptPath)
if (!pathExists) {
throwError({
type: errorTypes.TYPESCRIPT_NONEXISTENT,
message: `The 'typescript' option must be a valid path to your TypeScript installation. We could not find anything at the following path: ${typescriptPath}`,
})
}
const transform = browserifyOptions.transform
const hasTsifyTransform = transform.some((stage) => Array.isArray(stage) && stage[0].includes('tsify'))
const hastsifyPlugin = browserifyOptions.plugin.includes('tsify')
if (hasTsifyTransform || hastsifyPlugin) {
const type = hasTsifyTransform ? 'transform' : 'plugin'
throwError({
type: errorTypes.TYPESCRIPT_AND_TSIFY,
message: `It looks like you passed the 'typescript' option and also specified a browserify ${type} for TypeScript. This may cause conflicts.
Please do one of the following:
1) Pass in the 'typescript' option and omit the browserify ${type} (Recommmended)
2) Omit the 'typescript' option and continue to use your own browserify ${type}`,
})
}
browserifyOptions.extensions.push('.ts', '.tsx')
// remove babelify setting
browserifyOptions.transform = transform.filter((stage) => !Array.isArray(stage) || !stage[0].includes('babelify'))
// add typescript compiler
browserifyOptions.transform.push([
path.join(__dirname, './lib/simple_tsify'), {
typescript: require(typescriptPath),
},
])
}
debug('browserifyOptions: %o', browserifyOptions)
return browserifyOptions
}
// export a function that returns another function, making it easy for users
// to configure like so:
//
// on('file:preprocessor', browserify(options))
//
const preprocessor = (options = {}) => {
debug('received 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 async (file) => {
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 browserify/watchify for it
if (bundles[filePath]) {
debug('already have bundle for:', filePath)
return bundles[filePath]
}
// 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 = file.outputPath
debug('input:', filePath)
debug('output:', outputPath)
const browserifyOptions = await getBrowserifyOptions(filePath, options.browserifyOptions, options.typescript)
const watchifyOptions = Object.assign({}, defaultOptions.watchifyOptions, options.watchifyOptions)
if (!options.typescript && typescriptExtensionRegex.test(filePath)) {
throwError({
type: errorTypes.TYPESCRIPT_NOT_CONFIGURED,
message: `You are attempting to preprocess a TypeScript file, but do not have TypeScript configured. Pass the 'typescript' option to enable TypeScript support.
The file: ${filePath}`,
})
}
const bundler = browserify(browserifyOptions)
if (file.shouldWatch) {
debug('watching')
bundler.plugin(watchify, watchifyOptions)
}
// yield the bundle if onBundle is specified so the user can modify it
// as need via `bundle.external()`, `bundle.plugin()`, etc
const onBundle = options.onBundle
if (typeof onBundle === 'function') {
onBundle(bundler)
}
// this kicks off the bundling and wraps it up in a promise. the promise
// is what is ultimately returned from this function
// it resolves with the outputPath so Cypress knows where to serve
// the file from
const bundle = () => {
return new Promise((resolve, reject) => {
debug(`making bundle ${outputPath}`)
const onError = (err) => {
err.filePath = filePath
// backup the original stack before its
// potentially modified from bluebird
err.originalStack = err.stack
debug(`errored bundling: ${outputPath}`, err)
reject(err)
}
const ws = fs.createWriteStream(outputPath)
ws.on('finish', () => {
debug('finished bundling:', outputPath)
resolve(outputPath)
})
ws.on('error', onError)
bundler
.bundle()
.on('error', onError)
.pipe(ws)
})
}
// when we're notified of an update via watchify, signal for Cypress to
// rerun the spec
bundler.on('update', () => {
debug('update:', filePath)
// we overwrite the cached bundle promise, so on subsequent invocations
// it gets the latest bundle
const bundlePromise = bundle().finally(() => {
debug('- update finished for:', filePath)
file.emit('rerun')
})
bundles[filePath] = bundlePromise
// we suppress unhandled rejections so they don't bubble up to the
// unhandledRejection handler and crash the app. Cypress will eventually
// take care of the rejection when the file is requested
bundlePromise.suppressUnhandledRejections()
})
const bundlePromise = fs
.ensureDir(path.dirname(outputPath))
.then(bundle)
// cache the bundle promise, so it can be returned if this function
// is invoked again with the same filePath
bundles[filePath] = bundlePromise
// 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', () => {
debug('close:', filePath)
delete bundles[filePath]
if (file.shouldWatch) {
bundler.close()
}
})
// return the promise, which will resolve with the outputPath or reject
// with any error encountered
return bundlePromise
}
}
// provide a clone of the default options
preprocessor.defaultOptions = JSON.parse(JSON.stringify(defaultOptions))
preprocessor.errorTypes = errorTypes
if (process.env.__TESTING__) {
preprocessor.reset = () => {
for (let filePath in bundles) {
delete bundles[filePath]
}
}
}
module.exports = preprocessor