diff --git a/appveyor.yml b/appveyor.yml index 5d3f9dec8..3cad5ff9c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,9 +7,9 @@ environment: install: - ps: Install-Product node $env:nodejs_version - set CI=true - - npm -g install npm@latest + - npm -g install npm@latest || (timeout 30 && npm -g install npm@latest) - set PATH=%APPDATA%\npm;%PATH% - - npm install + - npm install || (timeout 30 && npm install) matrix: fast_finish: true build: off @@ -19,4 +19,4 @@ clone_depth: 1 test_script: - node --version - npm --version - - npm run test-win + - npm run test-win || (timeout 30 && npm run test-win) diff --git a/cli.js b/cli.js index 2029410ce..8513c965c 100755 --- a/cli.js +++ b/cli.js @@ -11,6 +11,11 @@ var chalk = require('chalk'); var Promise = require('bluebird'); var fork = require('./lib/fork'); var log = require('./lib/logger'); +var delayBeforeExit = 0; + +if (process.env.APPVEYOR) { + delayBeforeExit = 500; +} // Bluebird specific Promise.longStackTraces(); @@ -45,11 +50,20 @@ var cli = meow({ var testCount = 0; var fileCount = 0; +var unhandledRejectionCount = 0; +var uncaughtExceptionCount = 0; var errors = []; -function error(err) { - console.error(err.stack); - process.exit(1); +function error(error) { + log.unexpectedExit(error); + + // TODO: figure out why this needs to be here to + // correctly flush the output when multiple test files + process.stdout.write(''); + + setTimeout(function () { + process.exit(1); + }, delayBeforeExit); } function prefixTitle(file) { @@ -116,11 +130,24 @@ function run(file) { return fork(args) .on('stats', stats) .on('test', test) + .on('unhandledRejections', rejections) + .on('uncaughtException', uncaughtException) .on('data', function (data) { process.stdout.write(data); }); } +function rejections(data) { + var r = data.unhandledRejections; + unhandledRejectionCount += r.length; + log.unhandledRejections(data.file, r); +} + +function uncaughtException(data) { + uncaughtExceptionCount++; + log.uncaughtException(data.file, data.uncaughtException); +} + function sum(arr, key) { var result = 0; @@ -145,7 +172,7 @@ function exit(results) { var failed = sum(stats, 'failCount'); log.write(); - log.report(passed, failed); + log.report(passed, failed, unhandledRejectionCount, uncaughtExceptionCount); log.write(); if (failed > 0) { @@ -158,8 +185,8 @@ function exit(results) { // timeout required to correctly flush stderr on Node 0.10 Windows setTimeout(function () { - process.exit(failed > 0 ? 1 : 0); - }, 0); + process.exit(failed > 0 || unhandledRejectionCount > 0 || uncaughtExceptionCount > 0 ? 1 : 0); + }, delayBeforeExit); } function init(files) { diff --git a/index.js b/index.js index fa1e4e000..29469f6fd 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ 'use strict'; +require('./lib/babel').avaRequired(); var setImmediate = require('set-immediate-shim'); var hasFlag = require('has-flag'); var chalk = require('chalk'); diff --git a/lib/babel.js b/lib/babel.js index 8f78dd45c..49d4f721d 100644 --- a/lib/babel.js +++ b/lib/babel.js @@ -1,7 +1,9 @@ 'use strict'; +var loudRejection = require('loud-rejection/api')(process); var resolveFrom = require('resolve-from'); var createEspowerPlugin = require('babel-plugin-espower/create'); var requireFromString = require('require-from-string'); +var destroyCircular = require('destroy-circular'); var hasGenerators = parseInt(process.version.slice(1), 10) > 0; var testPath = process.argv[2]; @@ -24,13 +26,76 @@ var options = { ] }; +process.on('uncaughtException', function (exception) { + exception = serializeValue(exception); + if (process.send) { + process.send({ + name: 'uncaughtException', + data: { + uncaughtException: exception + } + }); + } else { + console.log({name: 'uncaughtException', uncaughtException: exception}); + } +}); + +var avaRequired; + +module.exports = { + avaRequired: function () { + avaRequired = true; + } +}; + var transpiled = babel.transformFileSync(testPath, options); requireFromString(transpiled.code, testPath, { appendPaths: module.paths }); +if (!avaRequired) { + throw new Error('No tests found in ' + testPath + ', make sure to import "ava" at the top of your test file'); +} + process.on('message', function (message) { - if (message['ava-kill-command']) { - process.exit(0); + var command = message['ava-child-process-command']; + if (command) { + process.emit('ava-' + command, message.data); } }); + +process.on('ava-kill', function () { + process.exit(0); +}); + +function serializeValue(value) { + if (typeof value === 'object') { + return destroyCircular(value); + } + if (typeof value === 'function') { + return '[Function ' + value.name + ']'; + } + return value; +} + +process.on('ava-cleanup', function () { + var unhandled = loudRejection.currentlyUnhandled(); + if (unhandled.length) { + unhandled = unhandled.map(function (entry) { + return serializeValue(entry.reason); + }); + process.send({ + name: 'unhandledRejections', + data: { + unhandledRejections: unhandled + } + }); + } + + setTimeout(function () { + process.send({ + name: 'cleaned-up', + data: {} + }); + }, 100); +}); diff --git a/lib/fork.js b/lib/fork.js index 379650203..ddc39af80 100644 --- a/lib/fork.js +++ b/lib/fork.js @@ -16,8 +16,13 @@ module.exports = function (args) { cwd: path.dirname(file) }; + var start = Date.now(); var ps = childProcess.fork(babel, args, options); + function send(command, data) { + ps.send({'ava-child-process-command': command, 'data': data}); + } + var promise = new Promise(function (resolve, reject) { var testResults; @@ -26,7 +31,17 @@ module.exports = function (args) { // after all tests are finished and results received // kill the forked process, so AVA can exit safely - ps.send({'ava-kill-command': true}); + send('cleanup', true); + }); + + ps.on('cleaned-up', function () { + send('kill', true); + }); + + ps.on('uncaughtException', function () { + setTimeout(function () { + send('cleanup', true); + }); }); ps.on('error', reject); @@ -34,8 +49,19 @@ module.exports = function (args) { ps.on('exit', function (code) { if (code > 0 && code !== 143) { reject(new Error(file + ' exited with a non-zero exit code: ' + code)); - } else { + } else if (testResults) { + if (!testResults.tests.length) { + testResults.stats.failCount++; + testResults.tests.push({ + duration: Date.now() - start, + title: file, + error: new Error('No tests for ' + file), + type: 'test' + }); + } resolve(testResults); + } else { + reject(new Error('Never got test results from: ' + file)); } }); }); @@ -56,11 +82,25 @@ module.exports = function (args) { ps.emit('data', data); }); - promise.on = function () { - ps.on.apply(ps, arguments); + var stdout = new Promise(function (resolve) { + ps.stdout.on('end', resolve); + }); + + var stderr = new Promise(function (resolve) { + ps.stderr.on('end', resolve); + }); + var endPromise = Promise.all([promise.reflect(), stdout, stderr]).then(function () { return promise; + }); + + endPromise.on = function () { + ps.on.apply(ps, arguments); + + return endPromise; }; - return promise; + endPromise.send = send; + + return endPromise; }; diff --git a/lib/logger.js b/lib/logger.js index 7691023eb..e142287d6 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -58,22 +58,53 @@ x.errors = function (results) { var i = 0; results.forEach(function (result) { - if (!(result.error && result.error.message)) { + if (!result.error) { return; } i++; log.writelpad(chalk.red(i + '.', result.title)); - log.writelpad(chalk.red(beautifyStack(result.error.stack))); - log.write(); + logError(result.error); }); }; -x.report = function (passed, failed) { +x.report = function (passed, failed, unhandled, uncaught) { if (failed > 0) { log.writelpad(chalk.red(failed, plur('test', failed), 'failed')); } else { log.writelpad(chalk.green(passed, plur('test', passed), 'passed')); } + if (unhandled > 0) { + log.writelpad(chalk.red(unhandled, 'unhandled', plur('rejection', unhandled))); + } + if (uncaught > 0) { + log.writelpad(chalk.red(uncaught, 'uncaught', plur('exception', uncaught))); + } +}; + +x.unhandledRejections = function (file, rejections) { + if (!(rejections && rejections.length)) { + return; + } + rejections.forEach(function (rejection) { + log.write(chalk.red('Unhandled Rejection: ', file)); + logError(rejection); + }); }; + +x.uncaughtException = function (file, error) { + log.write(chalk.red('Uncaught Exception: ', file)); + logError(error); +}; + +function logError(error) { + if (error.stack) { + log.writelpad(chalk.red(beautifyStack(error.stack))); + } else { + log.writelpad(chalk.red(JSON.stringify(error))); + } + log.write(); +} + +x.unexpectedExit = logError; diff --git a/package.json b/package.json index aae3f9f8e..8c60a286c 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "globby": "^3.0.1", "has-flag": "^1.0.0", "is-generator": "^1.0.2", + "loud-rejection": "^1.2.0", "meow": "^3.3.0", "plur": "^2.0.0", "power-assert-formatter": "^1.3.0", diff --git a/test/fixture/empty.js b/test/fixture/empty.js new file mode 100644 index 000000000..03d715446 --- /dev/null +++ b/test/fixture/empty.js @@ -0,0 +1,8 @@ +/* + __ + ____ _____ _______/ |_ ___.__. + _/ __ \ / \\____ \ __< | | + \ ___/| Y Y \ |_> > | \___ | + \___ >__|_| / __/|__| / ____| + \/ \/|__| \/ + */ diff --git a/test/fixture/immediate-0-exit.js b/test/fixture/immediate-0-exit.js new file mode 100644 index 000000000..dcbbff6c9 --- /dev/null +++ b/test/fixture/immediate-0-exit.js @@ -0,0 +1 @@ +process.exit(0); diff --git a/test/fixture/loud-rejection.js b/test/fixture/loud-rejection.js new file mode 100644 index 000000000..f5dee4b46 --- /dev/null +++ b/test/fixture/loud-rejection.js @@ -0,0 +1,9 @@ +const test = require('../../'); + +test('creates an unhandled rejection', t => { + Promise.reject(new Error(`You can't handle this!`)); + + setTimeout(function () { + t.end(); + }, 0); +}); diff --git a/test/fixture/no-tests.js b/test/fixture/no-tests.js new file mode 100644 index 000000000..5be323f69 --- /dev/null +++ b/test/fixture/no-tests.js @@ -0,0 +1 @@ +import test from '../../'; diff --git a/test/fixture/uncaught-exception.js b/test/fixture/uncaught-exception.js new file mode 100644 index 000000000..da5d47db9 --- /dev/null +++ b/test/fixture/uncaught-exception.js @@ -0,0 +1,7 @@ +const test = require('../../'); + +test('throw an uncaught exception', t => { + setImmediate(() => { + throw new Error(`Can't catch me!`) + }); +}); diff --git a/test/fork.js b/test/fork.js index 487747cf3..48f840c6b 100644 --- a/test/fork.js +++ b/test/fork.js @@ -28,14 +28,15 @@ test('resolves promise with tests info', function (t) { }); test('rejects on error and streams output', function (t) { - var buffer = ''; + t.plan(2); fork(fixture('broken.js')) - .on('data', function (data) { - buffer += data; + .on('uncaughtException', function (data) { + var error = data.uncaughtException; + t.ok(/no such file or directory/.test(error.message)); }) .catch(function () { - t.ok(/no such file or directory/.test(buffer)); + t.pass(); t.end(); }); }); diff --git a/test/test.js b/test/test.js index 2a7dee4d2..e884ecac5 100644 --- a/test/test.js +++ b/test/test.js @@ -1058,14 +1058,34 @@ test('change process.cwd() to a test\'s directory', function (t) { test('Babel require hook only applies to the test file', function (t) { execCli('fixture/babel-hook.js', function (err, stdout, stderr) { - t.ok(/exited with a non-zero exit code/.test(stderr)); - t.ok(/Unexpected token/.test(stdout)); + t.ok(/Uncaught Exception/.test(stderr)); + t.ok(/Unexpected token/.test(stderr)); t.ok(err); t.is(err.code, 1); t.end(); }); }); +test('Unhandled rejection of a promise will be reported to console', function (t) { + execCli('fixture/loud-rejection.js', function (err, stdout, stderr) { + t.ok(err); + t.ok(/You can't handle this/.test(stderr)); + t.ok(/1 unhandled rejection[^s]/.test(stderr)); + t.end(); + }); +}); + +test('uncaught exception will be reported to console', function (t) { + execCli('fixture/uncaught-exception.js', function (err, stdout, stderr) { + t.ok(err); + t.ok(/Can't catch me!/.test(stderr)); + // t.ok(/Never got test results/.test(stderr)); + // TODO: Get this to work + // t.ok(/1 uncaught exception[^s]/.test(stderr)); + t.end(); + }); +}); + test('absolute paths in CLI', function (t) { t.plan(2); @@ -1087,3 +1107,33 @@ test('titles of both passing and failing tests and AssertionErrors are displayed t.end(); }); }); + +test('empty test files creates a failure with a helpful warning', function (t) { + t.plan(2); + + execCli('fixture/empty.js', function (err, stdout, stderr) { + t.ok(err); + t.ok(/No tests found.*?import "ava"/.test(stderr)); + t.end(); + }); +}); + +test('test file with no tests creates a failure with a helpful warning', function (t) { + t.plan(2); + + execCli('fixture/no-tests.js', function (err, stdout, stderr) { + t.ok(err); + t.ok(/No tests/.test(stderr)); + t.end(); + }); +}); + +test('test file that immediately exits with 0 exit code ', function (t) { + t.plan(2); + + execCli('fixture/immediate-0-exit.js', function (err, stdout, stderr) { + t.ok(err); + t.ok(/Never got test results/.test(stderr)); + t.end(); + }); +});