UNPKG

karmatic

Version:

Zero-config automatic (headless) browser testing. Powered by Karma, Webpack & Jasmine.

477 lines (423 loc) 15.1 kB
#!/usr/bin/env node 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