karmatic
Version:
Zero-config automatic (headless) browser testing. Powered by Karma, Webpack & Jasmine.
477 lines (423 loc) • 15.1 kB
JavaScript
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var path = _interopDefault(require('path'));
var puppeteer = _interopDefault(require('puppeteer'));
var chalk = _interopDefault(require('chalk'));
var delve = _interopDefault(require('dlv'));
var fs = _interopDefault(require('fs'));
var simpleCodeFrame = require('simple-code-frame');
var errorstacks = require('errorstacks');
function fileExists(file) {
try {
return fs.statSync(file).isFile();
} catch (e) {}
return false;
}
function readFile(file) {
try {
return fs.readFileSync(file, 'utf8');
} catch (e) {}
}
function readDir(file) {
try {
return fs.readdirSync(file);
} catch (e) {}
}
function tryRequire(file) {
if (fileExists(file)) return require(file);
}
function dedupe(value, index, arr) {
return arr.indexOf(value) === index;
}
function indent(str, level) {
const space = ' '.repeat(level);
return str.split('\n').map(line => space + line).join('\n');
}
/**
* Colorize a pre-formatted code frame
* @param {string} str
*/
function highlightCodeFrame(str) {
return str.split('\n').map(line => {
if (/^>\s(.*)/.test(line)) {
return line.replace(/^>(.*)/, (_, content) => {
return chalk.bold.redBright('>') + chalk.white(content);
});
} else if (/^\s+\|\s+\^/.test(line)) {
return line.replace('|', chalk.dim('|')).replace('^', chalk.bold.redBright('^'));
}
return chalk.dim(line);
}).join('\n');
}
function cleanStack(str, cwd = process.cwd()) {
str = str.replace(/^[\s\S]+\n\n([A-Za-z]*Error: )/g, '$1');
let stack = str.replace(new RegExp(`( |\\()(https?:\\/\\/localhost:\\d+\\/base\\/|webpack:///|${cwd.replace(/([\\/[\]()*+$!^.,?])/g, '\\$1')}\\/*)?([^\\s():?]*?)(?:\\?[a-zA-Z0-9]+?)?(:\\d+(?::\\d+)?)`, 'g'), replacer);
let frames = errorstacks.parseStackTrace(stack); // Some frameworks mess with the stack. Use a simple heuristic
// to find the beginning of the proper stack.
let message = stack;
if (frames.length) {
let lines = stack.split('\n');
let stackStart = lines.indexOf(frames[0].raw);
if (stackStart > 0) {
message = lines.slice(0, stackStart).map(s => s.trim()).join('\n');
}
}
/**
* The nearest location where the user's code triggered the error.
* @type {import('errorstacks').StackFrame}
*/
let nearestFrame;
stack = frames.map(frame => {
// Only show frame for errors in the user's code
if (!nearestFrame && !/node_modules/.test(frame.fileName) && frame.type !== 'native') {
nearestFrame = frame;
} // Native traces don't have an error location
if (!frame.name || frame.type === 'native') {
return chalk.gray(frame.raw.trim());
}
const {
sourceFileName,
column,
fileName,
line,
name,
sourceColumn,
sourceLine
} = frame;
const loc = chalk.cyanBright(`${fileName}:${line}:${column}`);
const originalLoc = sourceFileName !== '' ? chalk.gray(' <- ') + chalk.gray(`${sourceFileName}:${sourceLine}:${sourceColumn}`) : '';
return chalk.gray(`at ${name} (${loc}${originalLoc})`);
}).join('\n');
let codeFrame = '';
if (nearestFrame) {
try {
const {
fileName,
line,
column
} = nearestFrame;
if (fileName) {
const content = fs.readFileSync(fileName, 'utf-8');
codeFrame = simpleCodeFrame.createCodeFrame(content, line - 1, column - 1, {
before: 2,
after: 2
});
codeFrame = highlightCodeFrame(codeFrame);
codeFrame = indent(codeFrame, 2) + '\n';
}
} catch (err) {
// eslint-disable-next-line no-console
console.log('INTERNAL WARNING: Failed to read stack frame code: ' + err);
}
}
message = indent(chalk.reset(message), 2);
return `\n${message}\n\n${codeFrame}${indent(stack, 4)}\n`;
}
function replacer(str, before, root, filename, position) {
return before + './' + filename + position;
}
function babelLoader(options) {
return {
test: /\.jsx?$/,
exclude: /node_modules/,
loader: require.resolve('babel-loader'),
query: {
presets: [[require.resolve('@babel/preset-env'), {
targets: {
browsers: ['last 2 Chrome versions', 'last 2 Firefox versions', (options.downlevel || options.browsers && String(options.browsers).match(/(\b|ms|microsoft)(ie|internet.explorer|edge)/gi)) && 'ie>=9'].filter(Boolean)
},
corejs: 2,
useBuiltIns: 'usage',
modules: false,
loose: true
}]],
plugins: [[require.resolve('@babel/plugin-proposal-object-rest-spread'), {
loose: true,
useBuiltIns: true
}], [require.resolve('@babel/plugin-transform-react-jsx'), {
pragma: options.pragma || 'h'
}]].concat(options.coverage ? [require.resolve('babel-plugin-istanbul')] : [])
}
};
}
function cssLoader(options) {
return {
test: /\.css$/,
loader: 'style-loader!css-loader'
};
}
const WEBPACK_VERSION = String(require('webpack').version || '3.0.0');
const WEBPACK_MAJOR = parseInt(WEBPACK_VERSION.split('.')[0], 10);
/**
* @param {Object} options
* @param {Array} options.files - Test files to run
* @param {Array} [options.browsers] - Custom list of browsers to run in
* @param {Boolean} [options.headless=false] - Run in Headless Chrome?
* @param {Boolean} [options.watch=false] - Start a continuous test server and retest when files change
* @param {Boolean} [options.coverage=false] - Instrument and collect code coverage statistics
* @param {Object} [options.webpackConfig] - Custom webpack configuration
* @param {Boolean} [options.downlevel=false] - Downlevel/transpile syntax to ES5
* @param {string} [options.chromeDataDir] - Use a custom Chrome profile directory
*/
function configure(options) {
let cwd = process.cwd(),
res = file => path.resolve(cwd, file);
let files = options.files.filter(Boolean);
if (!files.length) files = ['**/{*.test.js,*_test.js}'];
process.env.CHROME_BIN = puppeteer.executablePath();
let gitignore = (readFile(path.resolve(cwd, '.gitignore')) || '').replace(/(^\s*|\s*$|#.*$)/g, '').split('\n').filter(Boolean);
let repoRoot = (readDir(cwd) || []).filter(c => c[0] !== '.' && c !== 'node_modules' && gitignore.indexOf(c) === -1);
let rootFiles = '{' + repoRoot.join(',') + '}';
const PLUGINS = ['karma-chrome-launcher', 'karma-jasmine', 'karma-spec-reporter', 'karma-min-reporter', 'karma-sourcemap-loader', 'karma-webpack'].concat(options.coverage ? 'karma-coverage' : []);
const preprocessors = ['webpack', 'sourcemap', options.coverage && 'coverage'].filter(Boolean); // Custom launchers to be injected:
const launchers = {};
let useSauceLabs = false;
let browsers;
if (options.browsers) {
browsers = options.browsers.map(browser => {
if (/^chrome([ :-]?headless)?$/i.test(browser)) {
return `KarmaticChrome${/headless/i.test(browser) ? 'Headless' : ''}`;
}
if (/^firefox$/i.test(browser)) {
PLUGINS.push('karma-firefox-launcher');
return 'Firefox';
}
if (/^sauce-/.test(browser)) {
if (!useSauceLabs) {
useSauceLabs = true;
PLUGINS.push('karma-sauce-launcher');
}
const parts = browser.toLowerCase().split('-');
const name = parts.join('_');
launchers[name] = {
base: 'SauceLabs',
browserName: parts[1].replace(/^(msie|ie|internet ?explorer)$/i, 'Internet Explorer').replace(/^(ms|microsoft|)edge$/i, 'MicrosoftEdge'),
version: parts[2] || undefined,
platform: parts[3] ? parts[3].replace(/^win(dows)?[ -]+/gi, 'Windows ').replace(/^(macos|mac ?os ?x|os ?x)[ -]+/gi, 'OS X ') : undefined
};
return name;
}
return browser;
});
} else {
browsers = [options.headless === false ? 'KarmaticChrome' : 'KarmaticChromeHeadless'];
}
if (useSauceLabs) {
let missing = ['SAUCE_USERNAME', 'SAUCE_ACCESS_KEY'].filter(x => !process.env[x])[0];
if (missing) {
throw '\n' + chalk.bold.bgRed.white('Error:') + ' Missing SauceLabs auth configuration.' + '\n ' + chalk.white(`A SauceLabs browser was requested, but no ${chalk.magentaBright(missing)} environment variable provided.`) + '\n ' + chalk.white('Try prepending it to your test command:') + ' ' + chalk.greenBright(missing + '=... npm test') + '\n';
}
}
const WEBPACK_CONFIGS = ['webpack.config.babel.js', 'webpack.config.js'];
let webpackConfig = options.webpackConfig;
let pkg = tryRequire(res('package.json'));
if (pkg.scripts) {
for (let i in pkg.scripts) {
let script = pkg.scripts[i];
if (/\bwebpack\b[^&|]*(-c|--config)\b/.test(script)) {
let matches = script.match(/(?:-c|--config)\s+(?:([^\s])|(["'])(.*?)\2)/);
let configFile = matches && (matches[1] || matches[2]);
if (configFile) WEBPACK_CONFIGS.push(configFile);
}
}
}
if (!webpackConfig) {
for (let i = WEBPACK_CONFIGS.length; i--;) {
webpackConfig = tryRequire(res(WEBPACK_CONFIGS[i]));
if (webpackConfig) break;
}
}
if (typeof webpackConfig === 'function') {
webpackConfig = webpackConfig({
karmatic: true
}, {
mode: 'development',
karmatic: true
});
}
webpackConfig = webpackConfig || {};
let loaders = [].concat(delve(webpackConfig, 'module.loaders') || [], delve(webpackConfig, 'module.rules') || []);
function evaluateCondition(condition, filename, expected) {
if (typeof condition === 'function') {
return condition(filename) == expected;
} else if (condition instanceof RegExp) {
return condition.test(filename) == expected;
}
if (Array.isArray(condition)) {
for (let i = 0; i < condition.length; i++) {
if (evaluateCondition(condition[i], filename)) return expected;
}
}
return !expected;
}
function getLoader(predicate) {
if (typeof predicate === 'string') {
let filename = predicate;
predicate = loader => {
let {
test,
include,
exclude
} = loader;
if (exclude && evaluateCondition(exclude, filename, false)) return false;
if (include && !evaluateCondition(include, filename, true)) return false;
if (test && evaluateCondition(test, filename, true)) return true;
return false;
};
}
for (let i = 0; i < loaders.length; i++) {
if (predicate(loaders[i])) {
return {
index: i,
loader: loaders[i]
};
}
}
return false;
}
function webpackProp(name, value) {
let configured = delve(webpackConfig, name);
if (Array.isArray(value)) {
return value.concat(configured || []).filter(dedupe);
}
return Object.assign({}, configured || {}, value);
}
const chromeDataDir = options.chromeDataDir ? path.resolve(cwd, options.chromeDataDir) : null;
const flags = ['--no-sandbox'];
let generatedConfig = {
basePath: cwd,
plugins: PLUGINS.map(req => require.resolve(req)),
frameworks: ['jasmine'],
reporters: [options.watch ? 'min' : 'spec'].concat(options.coverage ? 'coverage' : [], useSauceLabs ? 'saucelabs' : []),
browsers,
sauceLabs: {
testName: pkg && pkg.name || undefined
},
customLaunchers: Object.assign({
KarmaticChrome: {
base: 'Chrome',
chromeDataDir,
flags
},
KarmaticChromeHeadless: {
base: 'ChromeHeadless',
chromeDataDir,
flags
}
}, launchers),
coverageReporter: {
reporters: [{
type: 'text-summary'
}, {
type: 'html'
}, {
type: 'lcovonly',
subdir: '.',
file: 'lcov.info'
}]
},
formatError(msg) {
try {
msg = JSON.parse(msg).message;
} catch (e) {}
return cleanStack(msg);
},
logLevel: 'ERROR',
loggers: [{
type: path.resolve(__dirname, 'appender.js')
}],
files: [// Inject Jest matchers:
{
pattern: path.resolve(__dirname, '../node_modules/expect/build-es5/index.js'),
watched: false,
included: true,
served: true
}].concat(...files.map(pattern => {
// Expand '**/xx' patterns but exempt node_modules and gitignored directories
let matches = pattern.match(/^\*\*\/(.+)$/);
if (!matches) return {
pattern,
watched: true,
served: true,
included: true
};
return [{
pattern: rootFiles + '/' + matches[0],
watched: true,
served: true,
included: true
}, {
pattern: matches[1],
watched: true,
served: true,
included: true
}];
})),
preprocessors: {
[rootFiles + '/**/*']: preprocessors,
[rootFiles]: preprocessors
},
webpack: {
devtool: 'inline-source-map',
// devtool: 'module-source-map',
mode: webpackConfig.mode || 'development',
module: {
// @TODO check webpack version and use loaders VS rules as the key here appropriately:
rules: loaders.concat(!getLoader(rule => `${rule.use},${rule.loader}`.match(/\bbabel-loader\b/)) ? babelLoader(options) : false
/*({
test: /\.[tj]sx?$/,
// include: files.map(f => minimatch.filter(f, { matchBase: true })),
exclude: /node_modules/,
enforce: 'pre',
loader: require.resolve('istanbul-instrumenter-loader')
})*/
, !getLoader('foo.css') && cssLoader()).filter(Boolean)
},
resolve: webpackProp('resolve', {
modules: webpackProp('resolve.modules', ['node_modules', path.resolve(__dirname, '../node_modules')]),
alias: webpackProp('resolve.alias', {
[pkg.name]: res('.'),
src: res('src')
})
}),
resolveLoader: webpackProp('resolveLoader', {
modules: webpackProp('resolveLoader.modules', ['node_modules', path.resolve(__dirname, '../node_modules')]),
alias: webpackProp('resolveLoader.alias', {
[pkg.name]: res('.'),
src: res('src')
})
}),
plugins: (webpackConfig.plugins || []).filter(plugin => {
let name = plugin && plugin.constructor.name;
return /^\s*(UglifyJS|HTML|ExtractText|BabelMinify)(.*Webpack)?Plugin\s*$/gi.test(name);
}),
node: webpackProp('node', {}),
performance: {
hints: false
}
},
webpackMiddleware: {
noInfo: true,
logLevel: 'error',
stats: 'errors-only'
},
colors: true,
client: {
captureConsole: true,
jasmine: {
random: false
}
}
};
if (WEBPACK_MAJOR < 4) {
delete generatedConfig.webpack.mode;
let {
rules
} = generatedConfig.webpack.module;
delete generatedConfig.webpack.module.rules;
generatedConfig.webpack.module.loaders = rules;
}
return generatedConfig;
}
module.exports = configure;
//# sourceMappingURL=configure.js.map