karma-browserify
Version:
A fast browserify integration for Karma that handles large projects with ease
427 lines (315 loc) • 10.4 kB
JavaScript
;
var browserify = require('browserify'),
watchify;
try {
watchify = require('watchify');
} catch (e) {
// watchify is an optional dependency
// we will fail as soon as a user tires to use autoWatch without
// watchify installed.
}
var convert = require('convert-source-map'),
minimatch = require('minimatch'),
escape = require('js-string-escape');
var path = require('path'),
fs = require('fs');
var reduce = require('lodash/reduce'),
find = require('lodash/find'),
some = require('lodash/some'),
forEach = require('lodash/forEach'),
assign = require('lodash/assign'),
omit = require('lodash/omit'),
debounce = require('lodash/debounce');
var BundleFile = require('./bundle-file');
/**
* The time to wait for additional file change nofifications
* before performing a rebundling operation.
*
* This value must be chosen with care. The smaller it is, the
* faster the rebundling + testing cycle is. At the same time
* the chance increases karma-browserify performs bundling steps
* twice because it triggers a rebundle before all file change
* triggers have been transmitted.
*/
var DEFAULT_BUNDLE_DELAY = 700;
var BUNDLE_ERROR_TPL = 'throw new Error("bundle error (see logs)");';
/**
* Extract the source map from the given bundle contents
*
* @param {String} source
* @return {SourceMap} if it could be parsed
*/
function extractSourceMap(bundleContents) {
var start = bundleContents.lastIndexOf('//# sourceMappingURL');
var sourceMapComment = start !== -1 ? bundleContents.substring(start) : '';
return sourceMapComment && convert.fromComment(sourceMapComment);
}
/**
* Creates an instance of karma-browserify that provides the
* neccessary framework and preprocessors.
*
* @param {BundleFile} [bundleFile]
*/
function Bro(bundleFile) {
var log;
/**
* Add bundle file to the list of files in the
* configuration, right before the first browserified
* test file and after everything else.
*
* That makes sure users can include non-commonJS files
* prior to the browserified bundle.
*
* @param {BundleFile} bundleFile the file containing the browserify bundle
* @param {Object} config the karma configuration to be updated
*/
function addBundleFile(bundleFile, config) {
var files = config.files,
preprocessors = config.preprocessors;
// list of patterns using our preprocessor
var patterns = reduce(preprocessors, function(matched, val, key) {
if (val.indexOf('browserify') !== -1) {
matched.push(key);
}
return matched;
}, []);
// first file being preprocessed
var file = find(files, function(f) {
return some(patterns, function(p) {
return minimatch(f.pattern, p);
});
});
var idx = 0;
if (file) {
idx = files.indexOf(file);
} else {
log.debug('no matching preprocessed file was found, defaulting to prepend');
}
log.debug('add bundle to config.files at position', idx);
// insert bundle on the correct spot
files.splice(idx, 0, {
pattern: bundleFile.location,
served: true,
included: true,
watched: true
});
}
/**
* The browserify instance that creates the
* minified bundle and gets added all test files to it.
*/
var b;
/**
* The browserify framework that creates the initial logger and bundle file
* as well as prepends the bundle file to the karma file configuration.
*/
function framework(emitter, config, logger) {
log = logger.create('framework.browserify');
if (!bundleFile) {
bundleFile = new BundleFile();
}
bundleFile.touch();
log.debug('created browserify bundle: %s', bundleFile.location);
b = createBundle(config);
// TODO(Nikku): hook into karma karmas file update facilities
// to remove files from the bundle once karma detects the deletion
// hook into exit for cleanup
emitter.on('exit', function(done) {
log.debug('cleaning up');
if (b.close) {
b.close();
}
bundleFile.remove();
done();
});
// add bundle file to the list of files defined in the
// configuration. be smart by doing so.
addBundleFile(bundleFile, config);
return b;
}
framework.$inject = [ 'emitter', 'config', 'logger' ];
/**
* Create the browserify bundle
*/
function createBundle(config) {
var bopts = config.browserify || {},
bundleDelay = bopts.bundleDelay || DEFAULT_BUNDLE_DELAY,
requireName = bopts.externalRequireName || 'require';
function warn(key) {
log.warn('Invalid config option: "' + key + 's" should be "' + key + '"');
}
forEach([ 'transform', 'plugin' ], function(key) {
if (bopts[key + 's']) {
warn(key);
}
});
var browserifyOptions = assign({
basedir: path.resolve(config.basePath),
// watchify.args
cache: {},
packageCache: {}
}, omit(bopts, [
'transform', 'plugin', 'configure', 'bundleDelay'
]));
if ('prebundle' in browserifyOptions) {
log.warn('The prebundle hook got removed in favor of configure');
}
if ('watchify' in browserifyOptions) {
log.warn('Configure watchify via config.watchify');
}
var w = browserify(browserifyOptions);
w.setMaxListeners(Infinity);
forEach(bopts.plugin, function(p) {
// ensure we can pass plugin options as
// the first parameter
if (!Array.isArray(p)) {
p = [ p ];
}
w.plugin.apply(w, p);
});
forEach(bopts.transform, function(t) {
// ensure we can pass transform options as
// the first parameter
if (!Array.isArray(t)) {
t = [ t ];
}
w.transform.apply(w, t);
});
// test if we have a configure function
if (bopts.configure && typeof bopts.configure === 'function') {
bopts.configure(w);
}
// register rebuild bundle on change
if (config.autoWatch) {
if (!watchify) {
log.error('watchify not found; install it via npm install --save-dev watchify');
log.error('cannot perform incremental rebuild');
throw new Error('watchify not found');
}
w = watchify(w, config.watchify);
log.info('registering rebuild (autoWatch=true)');
w.on('update', function(updated) {
// we perform an update, karma will trigger one, too
// because the bundling is deferred only one change will
// be triggered. Anything else is the result of a
// raise condition or a problem of watchify firing file
// changes to late
log.debug('files changed');
deferredBundle();
});
w.on('log', function(msg) {
log.info(msg);
});
// update bundle file
w.on('bundled', function(err, content) {
if (w._builtOnce) {
bundleFile.update(err ? BUNDLE_ERROR_TPL : content.toString('utf-8'));
log.info('bundle updated');
}
});
}
function deferredBundle(cb) {
if (cb) {
w.once('bundled', cb);
}
rebuild();
}
var rebuild = debounce(function rebuild() {
if (w._bundled) {
log.debug('resetting bundle');
var recorded = w._recorded;
w.reset();
recorded.forEach(function(e) {
// we remove missing files on the fly
// to cope with bundle internals missing
if (e.file && !fs.existsSync(path.resolve(config.basePath, e.file))) {
log.debug('removing missing file', e.file);
} else {
w.pipeline.write(e);
}
});
}
w.emit('prebundle', w);
log.debug('bundling');
w.bundle(function(err, content) {
if (err) {
log.error('bundle error');
log.error(String(err));
}
w.emit('bundled', err, content);
});
}, bundleDelay);
w.bundleFile = function(file, done) {
var absolutePath = path.resolve(file.path),
relativePath = path.relative(config.basePath, absolutePath);
// add file
log.debug('updating %s in bundle', relativePath);
// add the file during next prebundle step
w.once('prebundle', function() {
w.require('./' + relativePath, { expose: absolutePath });
});
deferredBundle(function(err) {
var stub = 'typeof ' + requireName + ' === "function" && ' + requireName + '("' + escape(absolutePath) + '");';
done(err, stub);
});
};
/**
* Wait for the bundle creation to have stabilized (no more additions) and invoke a callback.
*
* @param {Function} [callback] invoked with (err, content)
*/
w.deferredBundle = deferredBundle;
return w;
}
/**
* A processor that preprocesses commonjs test files which should be
* delivered via browserify.
*/
function testFilePreprocessor() {
return function(content, file, done) {
b.bundleFile(file, function(err, content) {
done(content && content.toString());
});
};
}
testFilePreprocessor.$inject = [ ];
/**
* A special preprocessor that builds the main browserify bundle once and
* passes the bundle contents through on all later preprocessing request.
*/
function bundlePreprocessor(config) {
var debug = config.browserify && config.browserify.debug;
function updateSourceMap(file, content) {
var map;
if (debug) {
map = extractSourceMap(content);
file.sourceMap = map && map.sourcemap;
}
}
return function(content, file, done) {
if (b._builtOnce) {
updateSourceMap(file, content);
return done(content);
}
log.debug('building bundle');
// wait for the initial bundle to be created
b.deferredBundle(function(err, content) {
b._builtOnce = config.autoWatch;
if (err) {
return done(BUNDLE_ERROR_TPL);
}
content = content.toString('utf-8');
updateSourceMap(file, content);
log.info('bundle built');
done(content);
});
};
}
bundlePreprocessor.$inject = [ 'config' ];
// API
this.framework = framework;
this.testFilePreprocessor = testFilePreprocessor;
this.bundlePreprocessor = bundlePreprocessor;
}
Bro.$inject = [];
module.exports = Bro;