diff --git a/api.js b/api.js index 27d68b14d..31ce3f569 100644 --- a/api.js +++ b/api.js @@ -9,7 +9,7 @@ var figures = require('figures'); var globby = require('globby'); var chalk = require('chalk'); var objectAssign = require('object-assign'); -var commondir = require('commondir'); +var commonPathPrefix = require('common-path-prefix'); var resolveCwd = require('resolve-cwd'); var uniqueTempDir = require('unique-temp-dir'); var findCacheDir = require('find-cache-dir'); @@ -27,6 +27,34 @@ function Api(files, options) { this.options = options || {}; this.options.require = (this.options.require || []).map(resolveCwd); + + if (!files || files.length === 0) { + this.files = [ + 'test.js', + 'test-*.js', + 'test' + ]; + } else { + this.files = files; + } + + this.excludePatterns = [ + '!**/node_modules/**', + '!**/fixtures/**', + '!**/helpers/**' + ]; + + Object.keys(Api.prototype).forEach(function (key) { + this[key] = this[key].bind(this); + }, this); + + this._reset(); +} + +util.inherits(Api, EventEmitter); +module.exports = Api; + +Api.prototype._reset = function () { this.rejectionCount = 0; this.exceptionCount = 0; this.passCount = 0; @@ -37,16 +65,9 @@ function Api(files, options) { this.errors = []; this.stats = []; this.tests = []; - this.files = files || []; this.base = ''; - - Object.keys(Api.prototype).forEach(function (key) { - this[key] = this[key].bind(this); - }, this); -} - -util.inherits(Api, EventEmitter); -module.exports = Api; + this.explicitTitles = false; +}; Api.prototype._runFile = function (file) { var options = objectAssign({}, this.options, { @@ -119,7 +140,7 @@ Api.prototype._handleTest = function (test) { }; Api.prototype._prefixTitle = function (file) { - if (this.fileCount === 1) { + if (this.fileCount === 1 && !this.explicitTitles) { return ''; } @@ -141,16 +162,23 @@ Api.prototype._prefixTitle = function (file) { return prefix; }; -Api.prototype.run = function () { +Api.prototype.run = function (files) { var self = this; - return handlePaths(this.files) + this._reset(); + this.explicitTitles = Boolean(files); + return handlePaths(files || this.files, this.excludePatterns) .map(function (file) { return path.resolve(file); }) .then(function (files) { if (files.length === 0) { - return Promise.reject(new AvaError('Couldn\'t find any files to test')); + self._handleExceptions({ + exception: new AvaError('Couldn\'t find any files to test'), + file: undefined + }); + + return []; } var cacheEnabled = self.options.cacheEnabled !== false; @@ -160,7 +188,7 @@ Api.prototype.run = function () { self.options.cacheDir = cacheDir; self.precompiler = new CachingPrecompiler(cacheDir); self.fileCount = files.length; - self.base = path.relative('.', commondir('.', files)) + path.sep; + self.base = path.relative('.', commonPathPrefix(files)) + path.sep; var tests = files.map(self._runFile); @@ -182,7 +210,20 @@ Api.prototype.run = function () { var method = self.options.serial ? 'mapSeries' : 'map'; resolve(Promise[method](files, function (file, index) { - return tests[index].run(); + return tests[index].run().catch(function (err) { + // The test failed catastrophically. Flag it up as an + // exception, then return an empty result. Other tests may + // continue to run. + self._handleExceptions({ + exception: err, + file: file + }); + + return { + stats: {passCount: 0, skipCount: 0, failCount: 0}, + tests: [] + }; + }); })); } } @@ -210,26 +251,14 @@ Api.prototype.run = function () { }); }; -function handlePaths(files) { - if (files.length === 0) { - files = [ - 'test.js', - 'test-*.js', - 'test' - ]; - } - - files.push('!**/node_modules/**'); - files.push('!**/fixtures/**'); - files.push('!**/helpers/**'); - +function handlePaths(files, excludePatterns) { // convert pinkie-promise to Bluebird promise - files = Promise.resolve(globby(files)); + files = Promise.resolve(globby(files.concat(excludePatterns))); return files .map(function (file) { if (fs.statSync(file).isDirectory()) { - return handlePaths([path.join(file, '**', '*.js')]); + return handlePaths([path.join(file, '**', '*.js')], excludePatterns); } return file; diff --git a/cli.js b/cli.js index c86bdff73..449fea459 100755 --- a/cli.js +++ b/cli.js @@ -29,6 +29,7 @@ var verboseReporter = require('./lib/reporters/verbose'); var miniReporter = require('./lib/reporters/mini'); var tapReporter = require('./lib/reporters/tap'); var Logger = require('./lib/logger'); +var watcher = require('./lib/watcher'); var Api = require('./api'); // Bluebird specific @@ -48,6 +49,9 @@ var cli = meow([ ' --tap, -t Generate TAP output', ' --verbose, -v Enable verbose output', ' --no-cache Disable the transpiler cache', + // Leave --watch and --sources undocumented until they're stable enough + // ' --watch, -w Re-run tests when tests and source files change', + // ' --source Pattern to match source files so tests can be re-run (Can be repeated)', '', 'Examples', ' ava', @@ -62,20 +66,23 @@ var cli = meow([ ], { string: [ '_', - 'require' + 'require', + 'source' ], boolean: [ 'fail-fast', 'verbose', 'serial', - 'tap' + 'tap', + 'watch' ], default: conf, alias: { t: 'tap', v: 'verbose', r: 'require', - s: 'serial' + s: 'serial', + w: 'watch' } }); @@ -112,17 +119,30 @@ api.on('error', logger.unhandledError); api.on('stdout', logger.stdout); api.on('stderr', logger.stderr); -api.run() - .then(function () { - logger.finish(); - logger.exit(api.failCount > 0 || api.rejectionCount > 0 || api.exceptionCount > 0 ? 1 : 0); - }) - .catch(function (err) { +if (cli.flags.watch) { + try { + watcher.start(logger, api, arrify(cli.flags.source), process.stdin); + } catch (err) { if (err.name === 'AvaError') { + // An AvaError may be thrown if chokidar is not installed. Log it nicely. console.log(' ' + colors.error(figures.cross) + ' ' + err.message); + logger.exit(1); } else { - console.error(colors.stack(err.stack)); + // Rethrow so it becomes an uncaught exception. + throw err; } - - logger.exit(1); - }); + } +} else { + api.run() + .then(function () { + logger.finish(); + logger.exit(api.failCount > 0 || api.rejectionCount > 0 || api.exceptionCount > 0 ? 1 : 0); + }) + .catch(function (err) { + // Don't swallow exceptions. Note that any expected error should already + // have been logged. + setImmediate(function () { + throw err; + }); + }); +} diff --git a/lib/logger.js b/lib/logger.js index efee271c8..251308124 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -25,6 +25,14 @@ Logger.prototype.start = function () { this.write(this.reporter.start()); }; +Logger.prototype.reset = function () { + if (!this.reporter.reset) { + return; + } + + this.write(this.reporter.reset()); +}; + Logger.prototype.test = function (test) { this.write(this.reporter.test(test)); }; diff --git a/lib/reporters/mini.js b/lib/reporters/mini.js index cb1bf6205..186a02fe4 100644 --- a/lib/reporters/mini.js +++ b/lib/reporters/mini.js @@ -10,14 +10,7 @@ function MiniReporter() { return new MiniReporter(); } - this.passCount = 0; - this.failCount = 0; - this.skipCount = 0; - this.rejectionCount = 0; - this.exceptionCount = 0; - this.currentStatus = ''; - this.statusLineCount = 0; - this.lastLineTracker = lastLineTracker(); + this.reset(); this.stream = process.stderr; this.stringDecoder = new StringDecoder(); } @@ -28,6 +21,17 @@ MiniReporter.prototype.start = function () { return ''; }; +MiniReporter.prototype.reset = function () { + this.passCount = 0; + this.failCount = 0; + this.skipCount = 0; + this.rejectionCount = 0; + this.exceptionCount = 0; + this.currentStatus = ''; + this.statusLineCount = 0; + this.lastLineTracker = lastLineTracker(); +}; + MiniReporter.prototype.test = function (test) { var status = ''; var title; @@ -120,11 +124,15 @@ MiniReporter.prototype.finish = function () { i++; - var title = err.type === 'rejection' ? 'Unhandled Rejection' : 'Uncaught Exception'; - var description = err.stack ? err.stack : JSON.stringify(err); + if (err.type === 'exception' && err.name === 'AvaError') { + status += '\n\n ' + colors.error(i + '. ' + err.message) + '\n'; + } else { + var title = err.type === 'rejection' ? 'Unhandled Rejection' : 'Uncaught Exception'; + var description = err.stack ? err.stack : JSON.stringify(err); - status += '\n\n ' + colors.error(i + '.', title) + '\n'; - status += ' ' + colors.stack(description); + status += '\n\n ' + colors.error(i + '.', title) + '\n'; + status += ' ' + colors.stack(description); + } }); } diff --git a/lib/reporters/tap.js b/lib/reporters/tap.js index 45d384bd7..af5a79d75 100644 --- a/lib/reporters/tap.js +++ b/lib/reporters/tap.js @@ -51,12 +51,17 @@ TapReporter.prototype.test = function (test) { TapReporter.prototype.unhandledError = function (err) { var output = [ '# ' + err.message, - format('not ok %d - %s', ++this.i, err.message), - ' ---', - ' name: ' + err.name, - ' at: ' + getSourceFromStack(err.stack, 1), - ' ...' + format('not ok %d - %s', ++this.i, err.message) ]; + // AvaErrors don't have stack traces. + if (err.type !== 'exception' || err.name !== 'AvaError') { + output.push( + ' ---', + ' name: ' + err.name, + ' at: ' + getSourceFromStack(err.stack, 1), + ' ...' + ); + } return output.join('\n'); }; diff --git a/lib/reporters/verbose.js b/lib/reporters/verbose.js index ec566ad7f..902017868 100644 --- a/lib/reporters/verbose.js +++ b/lib/reporters/verbose.js @@ -37,6 +37,10 @@ VerboseReporter.prototype.test = function (test) { }; VerboseReporter.prototype.unhandledError = function (err) { + if (err.type === 'exception' && err.name === 'AvaError') { + return ' ' + colors.error(figures.cross) + ' ' + err.message; + } + var types = { rejection: 'Unhandled Rejection', exception: 'Uncaught Exception' diff --git a/lib/watcher.js b/lib/watcher.js new file mode 100644 index 000000000..2805d730b --- /dev/null +++ b/lib/watcher.js @@ -0,0 +1,203 @@ +'use strict'; + +var AvaError = require('./ava-error'); +var debug = require('debug')('ava:watcher'); +var defaultIgnore = require('ignore-by-default').directories(); +var multimatch = require('multimatch'); +var nodePath = require('path'); +var Promise = require('bluebird'); + +function requireChokidar() { + try { + return require('chokidar'); + } catch (err) { + throw new AvaError('The optional dependency chokidar failed to install and is required for --watch. Chokidar is likely not supported on your platform.'); + } +} + +function rethrowAsync(err) { + // Don't swallow exceptions. Note that any expected error should already have + // been logged. + setImmediate(function () { + throw err; + }); +} + +function getChokidarPatterns(sources, initialFiles) { + var paths = []; + var ignored = []; + + sources.forEach(function (pattern) { + if (pattern[0] === '!') { + ignored.push(pattern.slice(1)); + } else { + paths.push(pattern); + } + }); + + if (paths.length === 0) { + paths = ['package.json', '**/*.js']; + } + paths = paths.concat(initialFiles); + + if (ignored.length === 0) { + ignored = defaultIgnore; + } + + return {paths: paths, ignored: ignored}; +} + +exports.start = function (logger, api, sources, stdin) { + var isTest = makeTestMatcher(api.files, api.excludePatterns); + + var patterns = getChokidarPatterns(sources, api.files); + var watcher = requireChokidar().watch(patterns.paths, { + ignored: patterns.ignored, + ignoreInitial: true + }); + + var busy = api.run().then(function () { + logger.finish(); + }).catch(rethrowAsync); + + var dirtyStates = {}; + watcher.on('all', function (event, path) { + if (event === 'add' || event === 'change' || event === 'unlink') { + debug('Detected %s of %s', event, path); + dirtyStates[path] = event; + debounce(); + } + }); + + var debouncing = null; + var debounceAgain = false; + function debounce() { + if (debouncing) { + debounceAgain = true; + return; + } + + var timer = debouncing = setTimeout(function () { + busy.then(function () { + // Do nothing if debouncing was canceled while waiting for the busy + // promise to fulfil. + if (debouncing !== timer) { + return; + } + + if (debounceAgain) { + debouncing = null; + debounceAgain = false; + debounce(); + } else { + busy = runAfterChanges(logger, api, isTest, dirtyStates); + dirtyStates = {}; + debouncing = null; + debounceAgain = false; + } + }); + }, 10); + } + + function cancelDebounce() { + if (debouncing) { + clearTimeout(debouncing); + debouncing = null; + debounceAgain = false; + } + } + + stdin.resume(); + stdin.setEncoding('utf8'); + stdin.on('data', function (data) { + data = data.trim().toLowerCase(); + if (data !== 'rs') { + return; + } + + // Cancel the debouncer, it might rerun specific tests whereas *all* tests + // need to be rerun. + cancelDebounce(); + busy.then(function () { + // Cancel the debouncer again, it might have restarted while waiting for + // the busy promise to fulfil. + cancelDebounce(); + busy = runAfterChanges(logger, api, isTest, {}); + }); + }); +}; + +function makeTestMatcher(files, excludePatterns) { + var initialPatterns = files.concat(excludePatterns); + return function (path) { + // Like in api.js, tests must be .js files and not start with _ + if (nodePath.extname(path) !== '.js' || nodePath.basename(path)[0] === '_') { + return false; + } + + // Check if the entire path matches a pattern. + if (multimatch(path, initialPatterns).length === 1) { + return true; + } + + // Check if the path contains any directory components. + var dirname = nodePath.dirname(path); + if (dirname === '.') { + return false; + } + + // Compute all possible subpaths. Note that the dirname is assumed to be + // relative to the working directory, without a leading `./`. + var subpaths = dirname.split(nodePath.sep).reduce(function (subpaths, component) { + var parent = subpaths[subpaths.length - 1]; + if (parent) { + subpaths.push(nodePath.join(parent, component)); + } else { + subpaths.push(component); + } + return subpaths; + }, []); + + // Check if any of the possible subpaths match a pattern. If so, generate a + // new pattern with **/*.js. + var recursivePatterns = subpaths.filter(function (subpath) { + return multimatch(subpath, initialPatterns).length === 1; + }).map(function (subpath) { + return nodePath.join(subpath, '**', '*.js'); + }); + + // See if the entire path matches any of the subpaths patterns, taking the + // excludePatterns into account. This mimicks the behavior in api.js + return multimatch(path, recursivePatterns.concat(excludePatterns)).length === 1; + }; +} + +function runAfterChanges(logger, api, isTest, dirtyStates) { + var dirtyPaths = Object.keys(dirtyStates); + var dirtyTests = dirtyPaths.filter(isTest); + var addedOrChangedTests = dirtyTests.filter(function (path) { + return dirtyStates[path] !== 'unlink'; + }); + var unlinkedTests = dirtyTests.filter(function (path) { + return dirtyStates[path] === 'unlink'; + }); + + // No need to rerun tests if the only change is that tests were deleted. + if (dirtyPaths.length > 0 && unlinkedTests.length === dirtyPaths.length) { + return Promise.resolve(); + } + + return new Promise(function (resolve) { + logger.reset(); + + // Run any new or changed tests, unless non-test files were changed too. + // In that case rerun the entire test suite. + if (dirtyPaths.length > 0 && dirtyTests.length === dirtyPaths.length) { + resolve(api.run(addedOrChangedTests)); + } else { + resolve(api.run()); + } + }).then(function () { + logger.finish(); + }).catch(rethrowAsync); +} diff --git a/package.json b/package.json index 471eb87f3..2179b8fd2 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "chalk": "^1.0.0", "cli-cursor": "^1.0.2", "co-with-promise": "^4.6.0", - "commondir": "^1.0.1", + "common-path-prefix": "^1.0.0", "convert-source-map": "^1.1.2", "core-assert": "^0.1.0", "debug": "^2.2.0", @@ -95,6 +95,7 @@ "find-cache-dir": "^0.1.1", "fn-name": "^2.0.0", "globby": "^4.0.0", + "ignore-by-default": "^1.0.0", "is-ci": "^1.0.7", "is-generator-fn": "^1.0.0", "is-observable": "^0.1.0", @@ -104,6 +105,7 @@ "max-timeout": "^1.0.0", "md5-hex": "^1.2.0", "meow": "^3.7.0", + "multimatch": "^2.1.0", "object-assign": "^4.0.1", "observable-to-promise": "^0.3.0", "option-chain": "^0.1.0", @@ -130,17 +132,22 @@ "get-stream": "^1.1.0", "git-branch": "^0.3.0", "inquirer": "^0.11.1", + "lolex": "^1.4.0", "mkdirp": "^0.5.1", "nyc": "^5.1.0", "pify": "^2.3.0", + "proxyquire": "^1.7.4", "rimraf": "^2.5.0", "signal-exit": "^2.1.2", "sinon": "^1.17.2", "source-map-fixtures": "^1.0.0", - "tap": "^5.0.1", + "tap": "^5.4.2", "xo": "*", "zen-observable": "^0.1.6" }, + "optionalDependencies": { + "chokidar": "^1.4.2" + }, "xo": { "ignore": [ "cli.js", diff --git a/test/api.js b/test/api.js index 029d21fab..faa32e07e 100644 --- a/test/api.js +++ b/test/api.js @@ -39,7 +39,7 @@ test('async/await support', function (t) { }); }); -test('test title prefixes', function (t) { +test('test title prefixes — multiple files', function (t) { t.plan(6); var separator = ' ' + figures.pointerSmall + ' '; @@ -77,6 +77,68 @@ test('test title prefixes', function (t) { }); }); +test('test title prefixes — single file', function (t) { + t.plan(2); + + var separator = ' ' + figures.pointerSmall + ' '; + var files = [ + path.join(__dirname, 'fixture/generators.js') + ]; + var expected = [ + ['generator function'].join(separator) + ]; + var index; + + var api = new Api(files); + + api.run() + .then(function () { + // if all lines were removed from expected output + // actual output matches expected output + t.is(expected.length, 0); + }); + + api.on('test', function (a) { + index = expected.indexOf(a.title); + + t.true(index >= 0); + + // remove line from expected output + expected.splice(index, 1); + }); +}); + +test('test title prefixes — single file (explicit)', function (t) { + t.plan(2); + + var separator = ' ' + figures.pointerSmall + ' '; + var files = [ + path.join(__dirname, 'fixture/generators.js') + ]; + var expected = [ + ['generators', 'generator function'].join(separator) + ]; + var index; + + var api = new Api(); + + api.run(files) + .then(function () { + // if all lines were removed from expected output + // actual output matches expected output + t.is(expected.length, 0); + }); + + api.on('test', function (a) { + index = expected.indexOf(a.title); + + t.true(index >= 0); + + // remove line from expected output + expected.splice(index, 1); + }); +}); + test('display filename prefixes for failed test stack traces', function (t) { t.plan(3); @@ -296,7 +358,7 @@ test('absolute paths', function (t) { }); }); -test('search directories recursivly for files', function (t) { +test('search directories recursively for files', function (t) { t.plan(2); var api = new Api([path.join(__dirname, 'fixture/subdir')]); @@ -321,28 +383,30 @@ test('titles of both passing and failing tests and AssertionErrors are returned' }); }); -test('empty test files creates a failure with a helpful warning', function (t) { +test('empty test files cause an AvaError to be emitted', function (t) { t.plan(2); var api = new Api([path.join(__dirname, 'fixture/empty.js')]); - api.run() - .catch(function (err) { - t.ok(err); - t.match(err.message, /No tests found.*?import "ava"/); - }); + api.on('error', function (err) { + t.is(err.name, 'AvaError'); + t.match(err.message, /No tests found.*?import "ava"/); + }); + + return api.run(); }); -test('test file with no tests creates a failure with a helpful warning', function (t) { +test('test file with no tests causes an AvaError to be emitted', function (t) { t.plan(2); var api = new Api([path.join(__dirname, 'fixture/no-tests.js')]); - api.run() - .catch(function (err) { - t.ok(err); - t.match(err.message, /No tests/); - }); + api.on('error', function (err) { + t.is(err.name, 'AvaError'); + t.match(err.message, /No tests/); + }); + + return api.run(); }); test('test file that immediately exits with 0 exit code ', function (t) { @@ -350,23 +414,25 @@ test('test file that immediately exits with 0 exit code ', function (t) { var api = new Api([path.join(__dirname, 'fixture/immediate-0-exit.js')]); - api.run() - .catch(function (err) { - t.ok(err); - t.match(err.message, /Test results were not received from/); - }); + api.on('error', function (err) { + t.is(err.name, 'AvaError'); + t.match(err.message, /Test results were not received from/); + }); + + return api.run(); }); -test('testing nonexistent files rejects', function (t) { +test('testing nonexistent files causes an AvaError to be emitted', function (t) { t.plan(2); var api = new Api([path.join(__dirname, 'fixture/broken.js')]); - api.run() - .catch(function (err) { - t.ok(err); - t.match(err.message, /Couldn't find any files to test/); - }); + api.on('error', function (err) { + t.is(err.name, 'AvaError'); + t.match(err.message, /Couldn't find any files to test/); + }); + + return api.run(); }); test('test file in node_modules is ignored', function (t) { @@ -374,11 +440,25 @@ test('test file in node_modules is ignored', function (t) { var api = new Api([path.join(__dirname, 'fixture/ignored-dirs/node_modules/test.js')]); - api.run() - .catch(function (err) { - t.ok(err); - t.match(err.message, /Couldn't find any files to test/); - }); + api.on('error', function (err) { + t.is(err.name, 'AvaError'); + t.match(err.message, /Couldn't find any files to test/); + }); + + return api.run(); +}); + +test('test file in node_modules is ignored (explicit)', function (t) { + t.plan(2); + + var api = new Api(); + + api.on('error', function (err) { + t.is(err.name, 'AvaError'); + t.match(err.message, /Couldn't find any files to test/); + }); + + return api.run([path.join(__dirname, 'fixture/ignored-dirs/node_modules/test.js')]); }); test('test file in fixtures is ignored', function (t) { @@ -386,11 +466,25 @@ test('test file in fixtures is ignored', function (t) { var api = new Api([path.join(__dirname, 'fixture/ignored-dirs/fixtures/test.js')]); - api.run() - .catch(function (err) { - t.ok(err); - t.match(err.message, /Couldn't find any files to test/); - }); + api.on('error', function (err) { + t.is(err.name, 'AvaError'); + t.match(err.message, /Couldn't find any files to test/); + }); + + return api.run(); +}); + +test('test file in fixtures is ignored (explicit)', function (t) { + t.plan(2); + + var api = new Api(); + + api.on('error', function (err) { + t.is(err.name, 'AvaError'); + t.match(err.message, /Couldn't find any files to test/); + }); + + return api.run([path.join(__dirname, 'fixture/ignored-dirs/fixtures/test.js')]); }); test('test file in helpers is ignored', function (t) { @@ -398,11 +492,25 @@ test('test file in helpers is ignored', function (t) { var api = new Api([path.join(__dirname, 'fixture/ignored-dirs/helpers/test.js')]); - api.run() - .catch(function (err) { - t.ok(err); - t.match(err.message, /Couldn't find any files to test/); - }); + api.on('error', function (err) { + t.is(err.name, 'AvaError'); + t.match(err.message, /Couldn't find any files to test/); + }); + + return api.run(); +}); + +test('test file in helpers is ignored (explicit)', function (t) { + t.plan(2); + + var api = new Api(); + + api.on('error', function (err) { + t.is(err.name, 'AvaError'); + t.match(err.message, /Couldn't find any files to test/); + }); + + return api.run([path.join(__dirname, 'fixture/ignored-dirs/helpers/test.js')]); }); test('Node.js-style --require CLI argument', function (t) { @@ -488,3 +596,16 @@ test('test file with only skipped tests does not create a failure', function (t) t.true(api.tests[0].skip); }); }); + +test('resets state before running', function (t) { + t.plan(2); + + var api = new Api([path.resolve('test/fixture/es2015.js')]); + + api.run().then(function () { + t.is(api.passCount, 1); + return api.run(); + }).then(function () { + t.is(api.passCount, 1); + }); +}); diff --git a/test/reporters/mini.js b/test/reporters/mini.js index e5dd54c3a..40a6e08af 100644 --- a/test/reporters/mini.js +++ b/test/reporters/mini.js @@ -1,6 +1,7 @@ 'use strict'; var chalk = require('chalk'); var test = require('tap').test; +var AvaError = require('../../lib/ava-error'); var miniReporter = require('../../lib/reporters/mini'); var beautifyStack = require('../../lib/beautify-stack'); @@ -140,25 +141,30 @@ test('results with passing tests and rejections', function (t) { test('results with passing tests and exceptions', function (t) { var reporter = miniReporter(); reporter.passCount = 1; - reporter.exceptionCount = 1; + reporter.exceptionCount = 2; var err = new Error('failure'); err.type = 'exception'; err.stack = beautifyStack(err.stack); + var avaErr = new AvaError('A futuristic test runner'); + avaErr.type = 'exception'; + reporter.api = { - errors: [err] + errors: [err, avaErr] }; var output = reporter.finish().split('\n'); t.is(output[0], ''); t.is(output[1], ' ' + chalk.green('1 passed')); - t.is(output[2], ' ' + chalk.red('1 exception')); + t.is(output[2], ' ' + chalk.red('2 exceptions')); t.is(output[3], ''); t.is(output[4], ' ' + chalk.red('1. Uncaught Exception')); t.match(output[5], /Error: failure/); t.match(output[6], /test\/reporters\/mini\.js/); + var next = 6 + output.slice(6).indexOf('') + 1; + t.is(output[next], ' ' + chalk.red('2. A futuristic test runner')); t.end(); }); @@ -187,3 +193,14 @@ test('results with errors', function (t) { t.match(output[6], /test\/reporters\/mini\.js/); t.end(); }); + +test('empty results after reset', function (t) { + var reporter = miniReporter(); + + reporter.failCount = 1; + reporter.reset(); + + var output = reporter.finish(); + t.is(output, '\n\n'); + t.end(); +}); diff --git a/test/reporters/tap.js b/test/reporters/tap.js index 1716899b9..8d7f5712d 100644 --- a/test/reporters/tap.js +++ b/test/reporters/tap.js @@ -76,6 +76,24 @@ test('unhandled error', function (t) { t.end(); }); +test('ava error', function (t) { + var reporter = tapReporter(); + + var actualOutput = reporter.unhandledError({ + type: 'exception', + name: 'AvaError', + message: 'A futuristic test runner' + }); + + var expectedOutput = [ + '# A futuristic test runner', + 'not ok 1 - A futuristic test runner' + ].join('\n'); + + t.is(actualOutput, expectedOutput); + t.end(); +}); + test('results', function (t) { var reporter = tapReporter(); var api = { diff --git a/test/reporters/verbose.js b/test/reporters/verbose.js index 9cae3e654..1b189bc14 100644 --- a/test/reporters/verbose.js +++ b/test/reporters/verbose.js @@ -121,6 +121,20 @@ test('uncaught exception', function (t) { t.end(); }); +test('ava error', function (t) { + var reporter = createReporter(); + + var output = reporter.unhandledError({ + type: 'exception', + file: 'test.js', + name: 'AvaError', + message: 'A futuristic test runner' + }).split('\n'); + + t.is(output[0], chalk.red(' ' + figures.cross + ' A futuristic test runner')); + t.end(); +}); + test('unhandled rejection', function (t) { var reporter = createReporter(); diff --git a/test/watcher.js b/test/watcher.js new file mode 100644 index 000000000..29d5880e6 --- /dev/null +++ b/test/watcher.js @@ -0,0 +1,644 @@ +'use strict'; + +var Promise = require('bluebird'); +var EventEmitter = require('events').EventEmitter; +var defaultIgnore = require('ignore-by-default').directories(); +var lolex = require('lolex'); +var path = require('path'); +var proxyquire = require('proxyquire'); +var sinon = require('sinon'); +var PassThrough = require('stream').PassThrough; +var test = require('tap').test; + +var setImmediate = require('../lib/globals').setImmediate; + +test('chokidar is not installed', function (t) { + t.plan(2); + + var subject = proxyquire.noCallThru().load('../lib/watcher', { + chokidar: null + }); + + try { + subject.start({}, {files: [], excludePatterns: []}, []); + } catch (err) { + t.is(err.name, 'AvaError'); + t.is(err.message, 'The optional dependency chokidar failed to install and is required for --watch. Chokidar is likely not supported on your platform.'); + } +}); + +test('chokidar is installed', function (_t) { + var chokidar = { + watch: sinon.stub() + }; + + var debug = sinon.spy(); + + var logger = { + finish: sinon.spy(), + reset: sinon.spy() + }; + + var api = { + run: sinon.stub() + }; + + var subject = proxyquire.noCallThru().load('../lib/watcher', { + chokidar: chokidar, + debug: function (name) { + return function () { + var args = [name]; + args.push.apply(args, arguments); + debug.apply(null, args); + }; + } + }); + + var clock; + var emitter; + var stdin; + _t.beforeEach(function (done) { + if (clock) { + clock.uninstall(); + } + clock = lolex.install(0, ['setImmediate', 'setTimeout', 'clearTimeout']); + + emitter = new EventEmitter(); + chokidar.watch.reset(); + chokidar.watch.returns(emitter); + + debug.reset(); + + logger.finish.reset(); + logger.reset.reset(); + + api.run.reset(); + api.run.returns(new Promise(function () {})); + api.files = [ + 'test.js', + 'test-*.js', + 'test' + ]; + api.excludePatterns = [ + '!**/node_modules/**', + '!**/fixtures/**', + '!**/helpers/**' + ]; + + stdin = new PassThrough(); + stdin.pause(); + + done(); + }); + + var start = function (sources) { + subject.start(logger, api, sources || [], stdin); + }; + + var add = function (path) { + emitter.emit('all', 'add', path || 'source.js'); + }; + var change = function (path) { + emitter.emit('all', 'change', path || 'source.js'); + }; + var unlink = function (path) { + emitter.emit('all', 'unlink', path || 'source.js'); + }; + + var delay = function () { + return new Promise(function (now) { + setImmediate(now); + }); + }; + + // Advance the clock to get past the debounce timeout, then wait for a promise + // to be resolved to get past the busy.then() delay. + var debounce = function (times) { + times = times >= 0 ? times : 1; + clock.next(); + return delay().then(function () { + if (times > 1) { + return debounce(times - 1); + } + }); + }; + + var pending = []; + var test = function (name, fn) { + pending.push(_t.test(name, fn)); + }; + + test('watches for default source file changes, as well as test files', function (t) { + t.plan(2); + start(); + + t.ok(chokidar.watch.calledOnce); + t.same(chokidar.watch.firstCall.args, [ + ['package.json', '**/*.js'].concat(api.files), + { + ignored: defaultIgnore, + ignoreInitial: true + } + ]); + }); + + test('watched source files are configurable', function (t) { + t.plan(2); + start(['foo.js', '!bar.js', 'baz.js', '!qux.js']); + + t.ok(chokidar.watch.calledOnce); + t.same(chokidar.watch.firstCall.args, [ + ['foo.js', 'baz.js'].concat(api.files), + { + ignored: ['bar.js', 'qux.js'], + ignoreInitial: true + } + ]); + }); + + test('default set of ignored files if configured sources does not contain exclusion patterns', function (t) { + t.plan(2); + start(['foo.js', 'baz.js']); + + t.ok(chokidar.watch.calledOnce); + t.same(chokidar.watch.firstCall.args, [ + ['foo.js', 'baz.js'].concat(api.files), + { + ignored: defaultIgnore, + ignoreInitial: true + } + ]); + }); + + test('starts running the initial tests', function (t) { + t.plan(4); + + var done; + api.run.returns(new Promise(function (resolve) { + done = resolve; + })); + + start(); + t.ok(api.run.calledOnce); + t.same(api.run.firstCall.args, []); + + // finish is only called after the run promise fulfils. + t.ok(logger.finish.notCalled); + done(); + return delay().then(function () { + t.ok(logger.finish.calledOnce); + }); + }); + + [ + {label: 'is added', fire: add, event: 'add'}, + {label: 'changes', fire: change, event: 'change'}, + {label: 'is removed', fire: unlink, event: 'unlink'} + ].forEach(function (variant) { + test('logs a debug message when a file is ' + variant.label, function (t) { + t.plan(2); + start(); + + variant.fire('file.js'); + t.ok(debug.calledOnce); + t.same(debug.firstCall.args, ['ava:watcher', 'Detected %s of %s', variant.event, 'file.js']); + }); + }); + + [ + {label: 'is added', fire: add}, + {label: 'changes', fire: change}, + {label: 'is removed', fire: unlink} + ].forEach(function (variant) { + test('reruns initial tests when a source file ' + variant.label, function (t) { + t.plan(6); + api.run.returns(Promise.resolve()); + start(); + + var done; + api.run.returns(new Promise(function (resolve) { + done = resolve; + })); + + // reset isn't called in the initial run. + t.ok(logger.reset.notCalled); + + variant.fire(); + return debounce().then(function () { + t.ok(api.run.calledTwice); + // reset is called before the second run. + t.ok(logger.reset.calledBefore(api.run.secondCall)); + // no explicit files are provided. + t.same(api.run.secondCall.args, []); + + // finish is only called after the run promise fulfils. + t.ok(logger.finish.calledOnce); + done(); + return delay(); + }).then(function () { + t.ok(logger.finish.calledTwice); + }); + }); + }); + + test('debounces by 10ms', function (t) { + t.plan(1); + api.run.returns(Promise.resolve()); + start(); + + change(); + var before = clock.now; + return debounce().then(function () { + t.is(clock.now - before, 10); + }); + }); + + test('debounces again if changes occur in the interval', function (t) { + t.plan(2); + api.run.returns(Promise.resolve()); + start(); + + change(); + change(); + + var before = clock.now; + return debounce(2).then(function () { + t.is(clock.now - before, 2 * 10); + change(); + return debounce(); + }).then(function () { + t.is(clock.now - before, 3 * 10); + }); + }); + + test('only reruns tests once the initial run has finished', function (t) { + t.plan(2); + + var done; + api.run.returns(new Promise(function (resolve) { + done = resolve; + })); + start(); + + change(); + clock.next(); + return delay().then(function () { + t.ok(api.run.calledOnce); + + done(); + return delay(); + }).then(function () { + t.ok(api.run.calledTwice); + }); + }); + + test('only reruns tests once the previous run has finished', function (t) { + t.plan(3); + api.run.returns(Promise.resolve()); + start(); + + var done; + api.run.returns(new Promise(function (resolve) { + done = resolve; + })); + + change(); + return debounce().then(function () { + t.ok(api.run.calledTwice); + + change(); + clock.next(); + return delay(); + }).then(function () { + t.ok(api.run.calledTwice); + + done(); + return delay(); + }).then(function () { + t.ok(api.run.calledThrice); + }); + }); + + [ + {label: 'is added', fire: add}, + {label: 'changes', fire: change} + ].forEach(function (variant) { + test('(re)runs a test file when it ' + variant.label, function (t) { + t.plan(6); + api.run.returns(Promise.resolve()); + start(); + + var done; + api.run.returns(new Promise(function (resolve) { + done = resolve; + })); + + // reset isn't called in the initial run. + t.ok(logger.reset.notCalled); + + variant.fire('test.js'); + return debounce().then(function () { + t.ok(api.run.calledTwice); + // reset is called before the second run. + t.ok(logger.reset.calledBefore(api.run.secondCall)); + // the test.js file is provided + t.same(api.run.secondCall.args, [['test.js']]); + + // finish is only called after the run promise fulfils. + t.ok(logger.finish.calledOnce); + done(); + return delay(); + }).then(function () { + t.ok(logger.finish.calledTwice); + }); + }); + }); + + test('(re)runs several test files when they are added or changed', function (t) { + t.plan(2); + api.run.returns(Promise.resolve()); + start(); + + add('test-one.js'); + change('test-two.js'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + // the test files are provided + t.same(api.run.secondCall.args, [['test-one.js', 'test-two.js']]); + }); + }); + + test('reruns initial tests if both source and test files are added or changed', function (t) { + t.plan(2); + api.run.returns(Promise.resolve()); + start(); + + add('test.js'); + unlink('source.js'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + // no explicit files are provided. + t.same(api.run.secondCall.args, []); + }); + }); + + test('does nothing if tests are deleted', function (t) { + t.plan(2); + api.run.returns(Promise.resolve()); + start(); + + unlink('test.js'); + return debounce().then(function () { + t.ok(logger.reset.notCalled); + t.ok(api.run.calledOnce); + }); + }); + + test('determines whether changed files are tests based on the initial files patterns', function (t) { + t.plan(2); + + api.files = ['foo-{bar,baz}.js']; + api.run.returns(Promise.resolve()); + start(); + + add('foo-bar.js'); + add('foo-baz.js'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [['foo-bar.js', 'foo-baz.js']]); + }); + }); + + test('initial exclude patterns override whether something is a test file', function (t) { + t.plan(2); + + api.files = ['foo-{bar,baz}.js']; + api.excludePatterns = ['!*bar*']; + api.run.returns(Promise.resolve()); + start(); + + add('foo-bar.js'); + add('foo-baz.js'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + // foo-bar.js is excluded from being a test file, thus the initial tests + // are run. + t.same(api.run.secondCall.args, []); + }); + }); + + test('test files must end in .js', function (t) { + t.plan(2); + + api.files = ['foo.bar']; + api.run.returns(Promise.resolve()); + start(); + + add('foo.bar'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + // foo.bar cannot be a test file, thus the initial tests are run. + t.same(api.run.secondCall.args, []); + }); + }); + + test('test files must not start with an underscore', function (t) { + t.plan(2); + + api.files = ['_foo.bar']; + api.run.returns(Promise.resolve()); + start(); + + add('_foo.bar'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + // _foo.bar cannot be a test file, thus the initial tests are run. + t.same(api.run.secondCall.args, []); + }); + }); + + test('files patterns may match directories', function (t) { + t.plan(2); + + api.files = ['dir', 'dir2/*/dir3']; + api.run.returns(Promise.resolve()); + start(); + + add(path.join('dir', 'foo.js')); + add(path.join('dir2', 'foo', 'dir3', 'bar.js')); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[path.join('dir', 'foo.js'), path.join('dir2', 'foo', 'dir3', 'bar.js')]]); + }); + }); + + test('exclude patterns override directory matches', function (t) { + t.plan(2); + + api.files = ['dir']; + api.excludePatterns = ['!**/exclude/**']; + api.run.returns(Promise.resolve()); + start(); + + add(path.join('dir', 'exclude', 'foo.js')); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + // dir/exclude/foo.js is excluded from being a test file, thus the initial + // tests are run. + t.same(api.run.secondCall.args, []); + }); + }); + + test('reruns initial tests when "rs" is entered on stdin', function (t) { + t.plan(2); + api.run.returns(Promise.resolve()); + start(); + + stdin.write('rs\n'); + return delay().then(function () { + t.ok(api.run.calledTwice); + + stdin.write('\trs \n'); + return delay(); + }).then(function () { + t.ok(api.run.calledThrice); + }); + }); + + test('entering "rs" on stdin cancels any debouncing', function (t) { + t.plan(7); + api.run.returns(Promise.resolve()); + start(); + + var before = clock.now; + var done; + api.run.returns(new Promise(function (resolve) { + done = resolve; + })); + + add(); + stdin.write('rs\n'); + return delay().then(function () { + // Processing "rs" caused a new run. + t.ok(api.run.calledTwice); + + // Try to advance the clock. This is *after* "rs" was processed. The + // debounce timeout should have been canceled, so the clock can't have + // advanced. + clock.next(); + t.is(before, clock.now); + + add(); + // Advance clock *before* "rs" is received. Note that the previous run + // hasn't finished yet. + clock.next(); + stdin.write('rs\n'); + + return delay(); + }).then(function () { + // No new runs yet. + t.ok(api.run.calledTwice); + // Though the clock has advanced. + t.is(clock.now - before, 10); + before = clock.now; + + var previous = done; + api.run.returns(new Promise(function (resolve) { + done = resolve; + })); + + // Finish the previous run. + previous(); + + return delay(); + }).then(function () { + // There's only one new run. + t.ok(api.run.calledThrice); + + stdin.write('rs\n'); + return delay(); + }).then(function () { + add(); + + // Finish the previous run. This should cause a new run due to the "rs" + // input. + done(); + + return delay(); + }).then(function () { + // Again there's only one new run. + t.is(api.run.callCount, 4); + + // Try to advance the clock. This is *after* "rs" was processed. The + // debounce timeout should have been canceled, so the clock can't have + // advanced. + clock.next(); + t.is(before, clock.now); + }); + }); + + test('does nothing if anything other than "rs" is entered on stdin', function (t) { + t.plan(2); + api.run.returns(Promise.resolve()); + start(); + + stdin.write('foo\n'); + return debounce().then(function () { + t.ok(logger.reset.notCalled); + t.ok(api.run.calledOnce); + }); + }); + + test('ignores unexpected events from chokidar', function (t) { + t.plan(2); + api.run.returns(Promise.resolve()); + start(); + + emitter.emit('all', 'foo'); + return debounce().then(function () { + t.ok(logger.reset.notCalled); + t.ok(api.run.calledOnce); + }); + }); + + test('initial run rejects', function (t) { + t.plan(1); + var expected = new Error(); + api.run.returns(Promise.reject(expected)); + start(); + + return delay().then(function () { + // The error is rethrown asynchronously, using setImmediate. The clock has + // faked setTimeout, so if we call clock.next() it'll invoke and rethrow + // the error, which can then be caught here. + try { + clock.next(); + } catch (err) { + t.is(err, expected); + } + }); + }); + + test('subsequent run rejects', function (t) { + t.plan(1); + api.run.returns(Promise.resolve()); + start(); + + var expected = new Error(); + api.run.returns(Promise.reject(expected)); + + add(); + return debounce().then(function () { + // The error is rethrown asynchronously, using setImmediate. The clock has + // faked setTimeout, so if we call clock.next() it'll invoke and rethrow + // the error, which can then be caught here. + try { + clock.next(); + } catch (err) { + t.is(err, expected); + } + }); + }); + + return Promise.all(pending); +});