From 766cfeeeed32aef4ad306f2363d17101761bddb9 Mon Sep 17 00:00:00 2001 From: vdemedes Date: Mon, 30 Nov 2015 13:47:17 +0700 Subject: [PATCH] Close #279 PR: Add programmatic API. Fixes #83, Fixes #282, Fixes #27 --- api.js | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++ cli.js | 208 ++++++++------------------------------------- index.js | 9 +- lib/babel.js | 2 +- lib/fork.js | 32 +++++++ lib/logger.js | 23 ++--- lib/runner.js | 6 -- test/fork.js | 5 ++ test/hooks.js | 2 + 9 files changed, 316 insertions(+), 201 deletions(-) create mode 100644 api.js diff --git a/api.js b/api.js new file mode 100644 index 000000000..ce509eb15 --- /dev/null +++ b/api.js @@ -0,0 +1,230 @@ +'use strict'; +var EventEmitter = require('events').EventEmitter; +var path = require('path'); +var util = require('util'); +var fs = require('fs'); +var flatten = require('arr-flatten'); +var Promise = require('bluebird'); +var figures = require('figures'); +var assign = require('object-assign'); +var globby = require('globby'); +var chalk = require('chalk'); +var fork = require('./lib/fork'); + +function Api(files, options) { + if (!(this instanceof Api)) { + return new Api(files, options); + } + + EventEmitter.call(this); + + assign(this, options); + + this.rejectionCount = 0; + this.exceptionCount = 0; + this.passCount = 0; + this.failCount = 0; + this.fileCount = 0; + this.testCount = 0; + this.errors = []; + this.stats = []; + this.tests = []; + this.files = files || []; + + Object.keys(Api.prototype).forEach(function (key) { + this[key] = this[key].bind(this); + }, this); +} + +util.inherits(Api, EventEmitter); +module.exports = Api; + +Api.prototype._runFile = function (file) { + var args = [file]; + + if (this.failFast) { + args.push('--fail-fast'); + } + + if (this.serial) { + args.push('--serial'); + } + + // Forward the `time-require` `--sorted` flag. + // Intended for internal optimization tests only. + if (this._sorted) { + args.push('--sorted'); + } + + return fork(args) + .on('stats', this._handleStats) + .on('test', this._handleTest) + .on('unhandledRejections', this._handleRejections) + .on('uncaughtException', this._handleExceptions); +}; + +Api.prototype._handleRejections = function (data) { + this.rejectionCount += data.rejections.length; + + data.rejections.forEach(function (err) { + err.type = 'rejection'; + err.file = data.file; + this.emit('error', err); + this.errors.push(err); + }, this); +}; + +Api.prototype._handleExceptions = function (err) { + this.exceptionCount++; + err.type = 'exception'; + this.emit('error', err); + this.errors.push(err); +}; + +Api.prototype._handleStats = function (stats) { + this.testCount += stats.testCount; +}; + +Api.prototype._handleTest = function (test) { + test.title = this._prefixTitle(test.file) + test.title; + + var isError = test.error.message; + + if (isError) { + this.errors.push(test); + } else { + test.error = null; + } + + this.emit('test', test); +}; + +Api.prototype._prefixTitle = function (file) { + if (this.fileCount === 1) { + return ''; + } + + var separator = ' ' + chalk.gray.dim(figures.pointerSmall) + ' '; + + var base = path.dirname(this.files[0]); + + if (base === '.') { + base = this.files[0] || 'test'; + } + + base += path.sep; + + var prefix = path.relative('.', file) + .replace(base, '') + .replace(/\.spec/, '') + .replace(/test\-/g, '') + .replace(/\.js$/, '') + .split(path.sep) + .join(separator); + + if (prefix.length > 0) { + prefix += separator; + } + + return prefix; +}; + +Api.prototype.run = function () { + var self = this; + + return handlePaths(this.files) + .map(function (file) { + return path.resolve(file); + }) + .then(function (files) { + if (files.length === 0) { + return Promise.reject(new Error('Couldn\'t find any files to test')); + } + + self.fileCount = files.length; + + var tests = files.map(self._runFile); + + // receive test count from all files and then run the tests + var statsCount = 0; + var deferred = Promise.pending(); + + tests.forEach(function (test) { + var counted = false; + + function tryRun() { + if (counted) { + return; + } + + if (++statsCount === self.fileCount) { + self.emit('ready'); + + var method = self.serial ? 'mapSeries' : 'map'; + + deferred.resolve(Promise[method](files, function (file, index) { + return tests[index].run(); + })); + } + } + + test.on('stats', tryRun); + test.catch(tryRun); + }); + + return deferred.promise; + }) + .then(function (results) { + // assemble stats from all tests + self.stats = results.map(function (result) { + return result.stats; + }); + + self.tests = results.map(function (result) { + return result.tests; + }); + + self.tests = flatten(self.tests); + + self.passCount = sum(self.stats, 'passCount'); + self.failCount = sum(self.stats, 'failCount'); + }); +}; + +function handlePaths(files) { + if (files.length === 0) { + files = [ + 'test.js', + 'test-*.js', + 'test/*.js' + ]; + } + + files.push('!**/node_modules/**'); + + // convert pinkie-promise to Bluebird promise + files = Promise.resolve(globby(files)); + + return files + .map(function (file) { + if (fs.statSync(file).isDirectory()) { + return handlePaths([path.join(file, '*.js')]); + } + + return file; + }) + .then(flatten) + .filter(function (file) { + return path.extname(file) === '.js' && path.basename(file)[0] !== '_'; + }); +} + +function sum(arr, key) { + var result = 0; + + arr.forEach(function (item) { + result += item[key]; + }); + + return result; +} diff --git a/cli.js b/cli.js index b9f5490aa..05f3a8d92 100755 --- a/cli.js +++ b/cli.js @@ -17,17 +17,12 @@ if (debug.enabled) { require('time-require'); } -var fs = require('fs'); -var path = require('path'); -var figures = require('figures'); -var flatten = require('arr-flatten'); -var globby = require('globby'); var meow = require('meow'); var updateNotifier = require('update-notifier'); var chalk = require('chalk'); var Promise = require('bluebird'); -var fork = require('./lib/fork'); var log = require('./lib/logger'); +var Api = require('./api'); // Bluebird specific Promise.longStackTraces(); @@ -58,133 +53,55 @@ var cli = meow([ ] }); -var rejectionCount = 0; -var exceptionCount = 0; -var testCount = 0; -var fileCount = 0; -var errors = []; - -function prefixTitle(file) { - var separator = ' ' + chalk.gray.dim(figures.pointerSmall) + ' '; - - var base = path.dirname(cli.input[0]); - - if (base === '.') { - base = cli.input[0] || 'test'; - } - - base += path.sep; - - var prefix = path.relative('.', file) - .replace(base, '') - .replace(/\.spec/, '') - .replace(/test\-/g, '') - .replace(/\.js$/, '') - .split(path.sep) - .join(separator); - - if (prefix.length > 0) { - prefix += separator; - } - - return prefix; -} +updateNotifier({pkg: cli.pkg}).notify(); -function stats(stats) { - testCount += stats.testCount; +if (cli.flags.init) { + require('ava-init')(); + return; } -function test(data) { - var isError = data.error.message; - - if (fileCount > 1) { - data.title = prefixTitle(data.file) + data.title; - } +log.write(); - if (isError) { - log.error(data.title, chalk.red(data.error.message)); +var api = new Api(cli.input, { + failFast: cli.flags.failFast, + serial: cli.flags.serial +}); - errors.push(data); +api.on('test', function (test) { + if (test.error) { + log.error(test.title, chalk.red(test.error.message)); } else { // don't log it if there's only one file and one anonymous test - if (fileCount === 1 && testCount === 1 && data.title === '[anonymous]') { + if (api.fileCount === 1 && api.testCount === 1 && test.title === '[anonymous]') { return; } - log.test(data); + log.test(test); } -} - -function run(file) { - var args = [file]; - - if (cli.flags.failFast) { - args.push('--fail-fast'); - } - - if (cli.flags.serial) { - args.push('--serial'); - } - - // Forward the `time-require` `--sorted` flag. - // Intended for internal optimization tests only. - if (cli.flags.sorted) { - args.push('--sorted'); - } - - return fork(args) - .on('stats', stats) - .on('test', test) - .on('unhandledRejections', handleRejections) - .on('uncaughtException', handleExceptions); -} - -function handleRejections(data) { - log.unhandledRejections(data.file, data.rejections); - rejectionCount += data.rejections.length; -} - -function handleExceptions(data) { - log.uncaughtException(data.file, data.exception); - exceptionCount++; -} - -function sum(arr, key) { - var result = 0; +}); - arr.forEach(function (item) { - result += item[key]; - }); +api.on('error', function (data) { + log.unhandledError(data.type, data.file, data); +}); - return result; -} +api.run() + .then(function () { + log.write(); + log.report(api.passCount, api.failCount, api.rejectionCount, api.exceptionCount); + log.write(); -function exit(results) { - // assemble stats from all tests - var stats = results.map(function (result) { - return result.stats; - }); + if (api.failCount > 0) { + log.errors(api.tests); + } - var tests = results.map(function (result) { - return result.tests; + process.stdout.write(''); + flushIoAndExit(api.failCount > 0 || api.rejectionCount > 0 || api.exceptionCount > 0 ? 1 : 0); + }) + .catch(function (err) { + log.error(err.message); + flushIoAndExit(1); }); - var passed = sum(stats, 'passCount'); - var failed = sum(stats, 'failCount'); - - log.write(); - log.report(passed, failed, rejectionCount, exceptionCount); - log.write(); - - if (failed > 0) { - log.errors(flatten(tests)); - } - - process.stdout.write(''); - - flushIoAndExit(failed > 0 || rejectionCount > 0 || exceptionCount > 0 ? 1 : 0); -} - function flushIoAndExit(code) { // TODO: figure out why this needs to be here to // correctly flush the output when multiple test files @@ -196,62 +113,3 @@ function flushIoAndExit(code) { process.exit(code); }, process.env.AVA_APPVEYOR ? 500 : 0); } - -function init(files) { - log.write(); - - return handlePaths(files) - .map(function (file) { - return path.resolve(file); - }) - .then(function (files) { - if (files.length === 0) { - log.error('Couldn\'t find any files to test\n'); - process.exit(1); - } - - fileCount = files.length; - - return cli.flags.serial ? Promise.mapSeries(files, run) - : Promise.all(files.map(run)); - }); -} - -function handlePaths(files) { - if (files.length === 0) { - files = [ - 'test.js', - 'test-*.js', - 'test/*.js' - ]; - } - - files.push('!**/node_modules/**'); - - // convert pinkie-promise to Bluebird promise - files = Promise.resolve(globby(files)); - - return files - .map(function (file) { - if (fs.statSync(file).isDirectory()) { - return handlePaths([path.join(file, '*.js')]); - } - - return file; - }) - .then(flatten) - .filter(function (file) { - return path.extname(file) === '.js' && path.basename(file)[0] !== '_'; - }); -} - -updateNotifier({pkg: cli.pkg}).notify(); - -if (cli.flags.init) { - require('ava-init')(); -} else { - init(cli.input).then(exit).catch(function (err) { - console.error(err.stack); - flushIoAndExit(1); - }); -} diff --git a/index.js b/index.js index 69b40b64e..dcf41bed6 100644 --- a/index.js +++ b/index.js @@ -66,8 +66,15 @@ function exit() { } globals.setImmediate(function () { + send('stats', { + testCount: runner.select({type: 'test'}).length + }); + runner.on('test', test); - runner.run().then(exit); + + process.on('ava-run', function () { + runner.run().then(exit); + }); }); module.exports = runner.test; diff --git a/lib/babel.js b/lib/babel.js index 2cc6359a5..334ba4883 100644 --- a/lib/babel.js +++ b/lib/babel.js @@ -83,7 +83,7 @@ requireFromString(transpiled.code, testPath, { // if ava was not required, show an error if (!exports.avaRequired) { - throw new Error('No tests found in ' + testPath + ', make sure to import "ava" at the top of your test file'); + send('no-tests'); } // parse and re-emit ava messages diff --git a/lib/fork.js b/lib/fork.js index 100da35a3..f195666a7 100644 --- a/lib/fork.js +++ b/lib/fork.js @@ -51,6 +51,12 @@ module.exports = function (args) { reject(new Error('Test results were not received from: ' + file)); } }); + + ps.on('no-tests', function () { + send(ps, 'teardown'); + + reject(new Error('No tests found in ' + path.relative('.', file) + ', make sure to import "ava" at the top of your test file')); + }); }); // emit `test` and `stats` events @@ -82,5 +88,31 @@ module.exports = function (args) { return promise; }; + promise.send = function (name, data) { + send(ps, name, data); + + return promise; + }; + + // send 'run' event only when fork is listening for it + var isReady = false; + + ps.on('stats', function () { + isReady = true; + }); + + promise.run = function () { + if (isReady) { + send(ps, 'run'); + return promise; + } + + ps.on('stats', function () { + send(ps, 'run'); + }); + + return promise; + }; + return promise; }; diff --git a/lib/logger.js b/lib/logger.js index 32532dec8..fd48fd021 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -84,26 +84,13 @@ x.report = function (passed, failed, unhandled, uncaught) { } }; -x.unhandledRejections = function (file, rejections) { - if (!(rejections && rejections.length)) { - return; - } - - rejections.forEach(function (rejection) { - log.write(chalk.red('Unhandled Rejection: ', file)); - - if (rejection.stack) { - log.writelpad(chalk.red(beautifyStack(rejection.stack))); - } else { - log.writelpad(chalk.red(JSON.stringify(rejection))); - } - - log.write(); - }); +var types = { + rejection: 'Unhandled Rejection', + exception: 'Uncaught Exception' }; -x.uncaughtException = function (file, error) { - log.write(chalk.red('Uncaught Exception: ', file)); +x.unhandledError = function (type, file, error) { + log.write(chalk.red(types[type] + ':', file)); if (error.stack) { log.writelpad(chalk.red(beautifyStack(error.stack))); diff --git a/lib/runner.js b/lib/runner.js index 3d1a4985a..78c6f8a48 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -5,7 +5,6 @@ var Promise = require('bluebird'); var hasFlag = require('has-flag'); var Test = require('./test'); var Hook = require('./hook'); -var send = require('./send'); var objectAssign = require('object-assign'); function noop() {} @@ -184,11 +183,6 @@ Runner.prototype.run = function () { testCount: serial.length + concurrent.length }; - // Runner is executed directly in tests, in that case `process.send() === undefined` - if (process.send) { - send('stats', stats); - } - return eachSeries(this.select({type: 'before', skipped: false}), this._runTest, this) .catch(noop) .then(function () { diff --git a/test/fork.js b/test/fork.js index 206d32dea..220fa2691 100644 --- a/test/fork.js +++ b/test/fork.js @@ -11,6 +11,7 @@ test('emits test event', function (t) { t.plan(1); fork(fixture('generators.js')) + .run() .on('test', function (tt) { t.is(tt.title, 'generator function'); t.end(); @@ -23,6 +24,7 @@ test('resolves promise with tests info', function (t) { var file = fixture('generators.js'); fork(file) + .run() .then(function (info) { t.is(info.stats.passCount, 1); t.is(info.tests.length, 1); @@ -35,6 +37,7 @@ test('rejects on error and streams output', function (t) { t.plan(2); fork(fixture('broken.js')) + .run() .on('uncaughtException', function (data) { t.true(/no such file or directory/.test(data.exception.message)); }) @@ -51,6 +54,7 @@ test('exit after tests are finished', function (t) { var cleanupCompleted = false; fork(fixture('long-running.js')) + .run() .on('exit', function () { t.true(Date.now() - start < 10000, 'test waited for a pending setTimeout'); t.true(cleanupCompleted, 'cleanup did not complete'); @@ -62,6 +66,7 @@ test('exit after tests are finished', function (t) { test('fake timers do not break duration', function (t) { fork(fixture('fake-timers.js')) + .run() .then(function (info) { var duration = info.tests[0].duration; t.true(duration < 1000, duration + ' < 1000'); diff --git a/test/hooks.js b/test/hooks.js index 2516fa6db..fc8dec7c9 100644 --- a/test/hooks.js +++ b/test/hooks.js @@ -298,6 +298,7 @@ test('don\'t display hook title if it did not fail', function (t) { t.plan(2); fork(path.join(__dirname, 'fixture', 'hooks-passing.js')) + .run() .on('test', function (test) { t.same(test.error, {}); t.is(test.title, 'pass'); @@ -311,6 +312,7 @@ test('display hook title if it failed', function (t) { t.plan(2); fork(path.join(__dirname, 'fixture', 'hooks-failing.js')) + .run() .on('test', function (test) { t.is(test.error.name, 'AssertionError'); t.is(test.title, 'beforeEach for "pass"');