Filipe Silva 1cd0a0811d feat(@angular/cli): improve ng test performance
This PR uses a new Karma plugin to enable vendor bundles in unit tests,
increasing rebuild performance.

On a medium size project rebuilds times were 15x smaller (16.5s to 0.9s).

Fix #5423
2017-05-08 16:51:40 +01:00

236 lines
7.8 KiB
TypeScript

import * as path from 'path';
import * as fs from 'fs';
import * as glob from 'glob';
import * as webpack from 'webpack';
const webpackDevMiddleware = require('webpack-dev-middleware');
import { Pattern } from './glob-copy-webpack-plugin';
import { WebpackTestConfig, WebpackTestOptions } from '../models/webpack-test-config';
import { KarmaWebpackThrowError } from './karma-webpack-throw-error';
const getAppFromConfig = require('../utilities/app-utils').getAppFromConfig;
let blocked: any[] = [];
let isBlocked = false;
function isDirectory(path: string) {
try {
return fs.statSync(path).isDirectory();
} catch (_) {
return false;
}
}
// Add files to the Karma files array.
function addKarmaFiles(files: any[], newFiles: any[], prepend = false) {
const defaults = {
included: true,
served: true,
watched: true
};
const processedFiles = newFiles
// Remove globs that do not match any files, otherwise Karma will show a warning for these.
.filter(file => glob.sync(file.pattern, { nodir: true }).length != 0)
// Fill in pattern properties with defaults.
.map(file => ({ ...defaults, ...file }));
// It's important to not replace the array, because
// karma already has a reference to the existing array.
if (prepend) {
files.unshift(...processedFiles);
} else {
files.push(...processedFiles);
}
}
const init: any = (config: any, emitter: any, customFileHandlers: any) => {
const appConfig = getAppFromConfig(config.angularCli.app);
const appRoot = path.join(config.basePath, appConfig.root);
const testConfig: WebpackTestOptions = Object.assign({
environment: 'dev',
codeCoverage: false,
sourcemaps: true,
progress: true,
}, config.angularCli);
// Add assets. This logic is mimics the one present in GlobCopyWebpackPlugin.
if (appConfig.assets) {
config.proxies = config.proxies || {};
appConfig.assets.forEach((pattern: Pattern) => {
// Convert all string patterns to Pattern type.
pattern = typeof pattern === 'string' ? { glob: pattern } : pattern;
// Add defaults.
// Input is always resolved relative to the appRoot.
pattern.input = path.resolve(appRoot, pattern.input || '');
pattern.output = pattern.output || '';
pattern.glob = pattern.glob || '';
// Build karma file pattern.
const assetPath = path.join(pattern.input, pattern.glob);
const filePattern = isDirectory(assetPath) ? assetPath + '/**' : assetPath;
addKarmaFiles(config.files, [{ pattern: filePattern, included: false }]);
// The `files` entry serves the file from `/base/{asset.input}/{asset.glob}`.
// We need to add a URL rewrite that exposes the asset as `/{asset.output}/{asset.glob}`.
let relativePath: string, proxyPath: string;
if (fs.existsSync(assetPath)) {
relativePath = path.relative(config.basePath, assetPath);
proxyPath = path.join(pattern.output, pattern.glob);
} else {
// For globs (paths that don't exist), proxy pattern.output to pattern.input.
relativePath = path.relative(config.basePath, pattern.input);
proxyPath = path.join(pattern.output);
}
// Proxy paths must have only forward slashes.
proxyPath = proxyPath.replace(/\\/g, '/');
config.proxies['/' + proxyPath] = '/base/' + relativePath;
});
}
// Add webpack config.
const webpackConfig = new WebpackTestConfig(testConfig, appConfig).buildConfig();
const webpackMiddlewareConfig = {
noInfo: true, // Hide webpack output because its noisy.
watchOptions: { poll: testConfig.poll },
publicPath: '/_karma_webpack_/',
stats: { // Also prevent chunk and module display output, cleaner look. Only emit errors.
assets: false,
colors: true,
version: false,
hash: false,
timings: false,
chunks: false,
chunkModules: false
}
};
// If Karma is being ran in single run mode, throw errors.
if (config.singleRun) {
webpackConfig.plugins.push(new KarmaWebpackThrowError());
}
// Use existing config if any.
config.webpack = Object.assign(webpackConfig, config.webpack);
config.webpackMiddleware = Object.assign(webpackMiddlewareConfig, config.webpackMiddleware);
// Remove the @angular/cli test file if present, for backwards compatibility.
const testFilePath = path.join(appRoot, appConfig.test);
config.files.forEach((file: any, index: number) => {
if (path.normalize(file.pattern) === testFilePath) {
config.files.splice(index, 1);
}
});
// When using code-coverage, auto-add coverage-istanbul.
config.reporters = config.reporters || [];
if (testConfig.codeCoverage && config.reporters.indexOf('coverage-istanbul') === -1) {
config.reporters.push('coverage-istanbul');
}
// Our custom context and debug files list the webpack bundles directly instead of using
// the karma files array.
config.customContextFile = `${__dirname}/karma-context.html`;
config.customDebugFile = `${__dirname}/karma-debug.html`;
// Add the request blocker.
config.beforeMiddleware = config.beforeMiddleware || [];
config.beforeMiddleware.push('angularCliBlocker');
// Delete global styles entry, we don't want to load them.
delete webpackConfig.entry.styles;
// The webpack tier owns the watch behavior so we want to force it in the config.
webpackConfig.watch = true;
// Files need to be served from a custom path for Karma.
webpackConfig.output.path = '/_karma_webpack_/';
webpackConfig.output.publicPath = '/_karma_webpack_/';
let compiler: any;
try {
compiler = webpack(webpackConfig);
} catch (e) {
console.error(e.stack || e);
if (e.details) {
console.error(e.details);
}
throw e;
}
['invalid', 'watch-run', 'run'].forEach(function (name) {
compiler.plugin(name, function (_: any, callback: () => void) {
isBlocked = true;
if (typeof callback === 'function') {
callback();
}
});
});
compiler.plugin('done', (stats: any) => {
// Don't refresh karma when there are webpack errors.
if (stats.compilation.errors.length === 0) {
emitter.refreshFiles();
isBlocked = false;
blocked.forEach((cb) => cb());
blocked = [];
}
});
const middleware = new webpackDevMiddleware(compiler, webpackMiddlewareConfig);
// Forward requests to webpack server.
customFileHandlers.push({
urlRegex: /^\/_karma_webpack_\/.*/,
handler: function handler(req: any, res: any) {
middleware(req, res, function () {
// Ensure script and style bundles are served.
// They are mentioned in the custom karma context page and we don't want them to 404.
const alwaysServe = [
'/_karma_webpack_/inline.bundle.js',
'/_karma_webpack_/polyfills.bundle.js',
'/_karma_webpack_/scripts.bundle.js',
'/_karma_webpack_/vendor.bundle.js',
];
if (alwaysServe.indexOf(req.url) != -1) {
res.statusCode = 200;
res.end();
} else {
res.statusCode = 404;
res.end('Not found');
}
});
}
});
emitter.on('exit', (done: any) => {
middleware.close();
done();
});
};
init.$inject = ['config', 'emitter', 'customFileHandlers'];
// Dummy preprocessor, just to keep karma from showing a warning.
const preprocessor: any = () => (content: any, _file: string, done: any) => done(null, content);
preprocessor.$inject = [];
// Block requests until the Webpack compilation is done.
function requestBlocker() {
return function (_request: any, _response: any, next: () => void) {
if (isBlocked) {
blocked.push(next);
} else {
next();
}
};
}
// Also export karma-webpack and karma-sourcemap-loader.
module.exports = Object.assign({
'framework:@angular/cli': ['factory', init],
'preprocessor:@angular/cli': ['factory', preprocessor],
'middleware:angularCliBlocker': ['factory', requestBlocker]
});