From b40f5ddb7ff40b38403a57dd881f9dbff9f9aa6b Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 2 Feb 2016 11:35:18 +0000 Subject: [PATCH 01/17] allow loggers/reporters to be reset Watcher support means tests are run multiple times. The mini reporter retains state that should be reset between runs. --- lib/logger.js | 8 ++++++++ lib/reporters/mini.js | 20 ++++++++++++-------- test/reporters/mini.js | 11 +++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) 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..0f00ee733 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; diff --git a/test/reporters/mini.js b/test/reporters/mini.js index e5dd54c3a..3fc512757 100644 --- a/test/reporters/mini.js +++ b/test/reporters/mini.js @@ -187,3 +187,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'); + t.end(); +}); From 5fb379f545f7d08a9370634aad0ad74009ee67cc Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 2 Feb 2016 11:38:14 +0000 Subject: [PATCH 02/17] prepare api for multiple runs Reset state between runs. Copy the files list before passing it to handlePaths(), since that function modifies the array. --- api.js | 26 ++++++++++++++++---------- test/api.js | 13 +++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/api.js b/api.js index 27d68b14d..01d0a7d24 100644 --- a/api.js +++ b/api.js @@ -27,6 +27,19 @@ function Api(files, options) { this.options = options || {}; this.options.require = (this.options.require || []).map(resolveCwd); + this.files = files || []; + + 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 +50,8 @@ 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; +}; Api.prototype._runFile = function (file) { var options = objectAssign({}, this.options, { @@ -144,7 +149,8 @@ Api.prototype._prefixTitle = function (file) { Api.prototype.run = function () { var self = this; - return handlePaths(this.files) + this._reset(); + return handlePaths(this.files.slice()) .map(function (file) { return path.resolve(file); }) diff --git a/test/api.js b/test/api.js index 029d21fab..d6c9d3c34 100644 --- a/test/api.js +++ b/test/api.js @@ -488,3 +488,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); + }); +}); From 2a5a48b46f73818fb896abe489477c03d84d8721 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 2 Feb 2016 11:41:05 +0000 Subject: [PATCH 03/17] naive watcher implementation --- cli.js | 35 +++++++++++++++++++++++++---------- lib/watcher.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 lib/watcher.js diff --git a/cli.js b/cli.js index c86bdff73..739ad8049 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,8 @@ var cli = meow([ ' --tap, -t Generate TAP output', ' --verbose, -v Enable verbose output', ' --no-cache Disable the transpiler cache', + // Leave --watch undocumented until it's stable enough + // ' --watch, -w Re-run tests when files change', '', 'Examples', ' ava', @@ -68,14 +71,16 @@ var cli = meow([ 'fail-fast', 'verbose', 'serial', - 'tap' + 'tap', + 'watch' ], default: conf, alias: { t: 'tap', v: 'verbose', r: 'require', - s: 'serial' + s: 'serial', + w: 'watch' } }); @@ -112,17 +117,27 @@ 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) { + watcher.start(logger, api, function (err) { if (err.name === 'AvaError') { console.log(' ' + colors.error(figures.cross) + ' ' + err.message); } else { console.error(colors.stack(err.stack)); } - - 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) { + if (err.name === 'AvaError') { + console.log(' ' + colors.error(figures.cross) + ' ' + err.message); + } else { + console.error(colors.stack(err.stack)); + } + + logger.exit(1); + }); +} diff --git a/lib/watcher.js b/lib/watcher.js new file mode 100644 index 000000000..fe1263ff9 --- /dev/null +++ b/lib/watcher.js @@ -0,0 +1,44 @@ +'use strict'; + +var chokidar = require('chokidar'); +var Promise = require('bluebird'); + +exports.start = function (logger, api, logError) { + // TODO(novemberborn) allow these patterns to be specified, or perhaps match + // anything (not just JS files). + var watcher = chokidar.watch(['package.json', '**/*.js'], { + // Copied from + // . + // TODO(novemberborn) extract into a package so a sensible set of patterns + // can be shared amongst projects. + // TODO(novemberborn) make configurable, perhaps similarly to how the + // include patterns are specified. + ignored: ['.git', 'node_modules', 'bower_components', '.sass-cache'], + ignoreInitial: true + }); + + var busy = api.run().then(function () { + logger.finish(); + }).catch(function (err) { + logError(err); + // Exit if an error occurs during the initial run. + logger.exit(1); + // Return a pending promise to avoid running new tests while exiting. + return new Promise(function () {}); + }); + + watcher.on('all', function (event) { + if (event === 'add' || event === 'change' || event === 'unlink') { + // TODO(novemberborn) debounce, rerun specific file depending on the + // change, or rerun all. + busy = busy.then(function () { + logger.reset(); + return api.run(); + }).catch(function (err) { + logError(err); + }).then(function () { + logger.finish(); + }); + } + }); +}; diff --git a/package.json b/package.json index 471eb87f3..cb5046ef8 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "bluebird": "^3.0.0", "caching-transform": "^1.0.0", "chalk": "^1.0.0", + "chokidar": "^1.4.2", "cli-cursor": "^1.0.2", "co-with-promise": "^4.6.0", "commondir": "^1.0.1", From 7e416952c6d46cec7813076ce0260339856e59b6 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 2 Feb 2016 12:13:46 +0000 Subject: [PATCH 04/17] debounce changes before rerunning tests Debounce for 10 milliseconds, repeat if another change event occurs in this window, until changes stabilize. --- lib/watcher.js | 46 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/lib/watcher.js b/lib/watcher.js index fe1263ff9..4ff63eaa5 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -29,16 +29,42 @@ exports.start = function (logger, api, logError) { watcher.on('all', function (event) { if (event === 'add' || event === 'change' || event === 'unlink') { - // TODO(novemberborn) debounce, rerun specific file depending on the - // change, or rerun all. - busy = busy.then(function () { - logger.reset(); - return api.run(); - }).catch(function (err) { - logError(err); - }).then(function () { - logger.finish(); - }); + debounce(); } }); + + var debouncing = false; + var debounceAgain = false; + function debounce() { + if (debouncing) { + debounceAgain = true; + return; + } + + debouncing = true; + setTimeout(function () { + busy.then(function () { + if (debounceAgain) { + debouncing = debounceAgain = false; + debounce(); + } else { + runAfterChanges(); + debouncing = debounceAgain = false; + } + }); + }, 10); + } + + function runAfterChanges() { + // TODO(novemberborn) rerun specific file depending on the change, or rerun + // all. + busy = new Promise(function (resolve) { + logger.reset(); + resolve(api.run()); + }).catch(function (err) { + logError(err); + }).then(function () { + logger.finish(); + }); + } }; From 7605f8e33fb4be6c3e0029012e48b533e13815fe Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 2 Feb 2016 17:11:06 +0000 Subject: [PATCH 05/17] (re)run specific tests when watching The watcher checks whether the changed files affect just tests. If so, it runs those tests again. Else it reruns the entire test suite. The API now resolves the default files patterns when it's constructed. It also exposes the default exclusion patterns. This allows the watcher to check if a changed file was indeed a test. --- api.js | 39 +++++++++++++------------ lib/watcher.js | 79 +++++++++++++++++++++++++++++++++++++++++--------- package.json | 1 + test/api.js | 36 +++++++++++++++++++++++ 4 files changed, 123 insertions(+), 32 deletions(-) diff --git a/api.js b/api.js index 01d0a7d24..b18e0d195 100644 --- a/api.js +++ b/api.js @@ -27,7 +27,22 @@ function Api(files, options) { this.options = options || {}; this.options.require = (this.options.require || []).map(resolveCwd); - this.files = files || []; + + 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); @@ -146,11 +161,11 @@ Api.prototype._prefixTitle = function (file) { return prefix; }; -Api.prototype.run = function () { +Api.prototype.run = function (files) { var self = this; this._reset(); - return handlePaths(this.files.slice()) + return handlePaths(files || this.files, this.excludePatterns) .map(function (file) { return path.resolve(file); }) @@ -216,26 +231,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/lib/watcher.js b/lib/watcher.js index 4ff63eaa5..a32a275ce 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -1,9 +1,13 @@ 'use strict'; var chokidar = require('chokidar'); +var multimatch = require('multimatch'); +var nodePath = require('path'); var Promise = require('bluebird'); exports.start = function (logger, api, logError) { + var isTest = makeTestMatcher(api.files, api.excludePatterns); + // TODO(novemberborn) allow these patterns to be specified, or perhaps match // anything (not just JS files). var watcher = chokidar.watch(['package.json', '**/*.js'], { @@ -27,8 +31,10 @@ exports.start = function (logger, api, logError) { return new Promise(function () {}); }); - watcher.on('all', function (event) { + var dirtyStates = {}; + watcher.on('all', function (event, path) { if (event === 'add' || event === 'change' || event === 'unlink') { + dirtyStates[path] = event; debounce(); } }); @@ -48,23 +54,68 @@ exports.start = function (logger, api, logError) { debouncing = debounceAgain = false; debounce(); } else { - runAfterChanges(); + busy = runAfterChanges(logger, api, logError, isTest, dirtyStates); + dirtyStates = {}; debouncing = debounceAgain = false; } }); }, 10); } +}; - function runAfterChanges() { - // TODO(novemberborn) rerun specific file depending on the change, or rerun - // all. - busy = new Promise(function (resolve) { - logger.reset(); - resolve(api.run()); - }).catch(function (err) { - logError(err); - }).then(function () { - logger.finish(); - }); +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 first directory component in the path matches a pattern. If + // so, generate a new pattern with **/*.js and see if the full path matches + // that. This mimicks the behavior in api.js + var firstDir = path.split(nodePath.sep)[0]; + if (!firstDir || multimatch(firstDir, initialPatterns).length === 0) { + return false; + } + var recursivePatterns = [].concat(nodePath.join(firstDir, '**', '*.js'), excludePatterns); + return multimatch(path, recursivePatterns).length === 1; + }; +} + +function runAfterChanges(logger, api, logError, 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 (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 (dirtyTests.length === dirtyPaths.length) { + resolve(api.run(addedOrChangedTests)); + } else { + resolve(api.run()); + } + }).catch(function (err) { + logError(err); + }).then(function () { + logger.finish(); + }); +} diff --git a/package.json b/package.json index cb5046ef8..329f6a8dc 100644 --- a/package.json +++ b/package.json @@ -105,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", diff --git a/test/api.js b/test/api.js index d6c9d3c34..b047fc689 100644 --- a/test/api.js +++ b/test/api.js @@ -381,6 +381,18 @@ test('test file in node_modules is ignored', function (t) { }); }); +test('test file in node_modules is ignored (explicit)', function (t) { + t.plan(2); + + var api = new Api(); + + api.run([path.join(__dirname, 'fixture/ignored-dirs/node_modules/test.js')]) + .catch(function (err) { + t.ok(err); + t.match(err.message, /Couldn't find any files to test/); + }); +}); + test('test file in fixtures is ignored', function (t) { t.plan(2); @@ -393,6 +405,18 @@ test('test file in fixtures is ignored', function (t) { }); }); +test('test file in fixtures is ignored (explicit)', function (t) { + t.plan(2); + + var api = new Api(); + + api.run([path.join(__dirname, 'fixture/ignored-dirs/fixtures/test.js')]) + .catch(function (err) { + t.ok(err); + t.match(err.message, /Couldn't find any files to test/); + }); +}); + test('test file in helpers is ignored', function (t) { t.plan(2); @@ -405,6 +429,18 @@ test('test file in helpers is ignored', function (t) { }); }); +test('test file in helpers is ignored (explicit)', function (t) { + t.plan(2); + + var api = new Api(); + + api.run([path.join(__dirname, 'fixture/ignored-dirs/helpers/test.js')]) + .catch(function (err) { + t.ok(err); + t.match(err.message, /Couldn't find any files to test/); + }); +}); + test('Node.js-style --require CLI argument', function (t) { t.plan(1); From 2ca853214d3cb4513dbde22c9f4ad8d4fa524f8d Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 2 Feb 2016 17:26:08 +0000 Subject: [PATCH 06/17] prefix title with file when running specific files Always prefix the test title when tests are run from specific files, even if only one file is run. This occurs when the watcher reruns tests. The log should disambiguate between not just the currently running tests but *all* tests that have run previously. --- api.js | 4 +++- test/api.js | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/api.js b/api.js index b18e0d195..3f4551aaa 100644 --- a/api.js +++ b/api.js @@ -66,6 +66,7 @@ Api.prototype._reset = function () { this.stats = []; this.tests = []; this.base = ''; + this.explicitTitles = false; }; Api.prototype._runFile = function (file) { @@ -139,7 +140,7 @@ Api.prototype._handleTest = function (test) { }; Api.prototype._prefixTitle = function (file) { - if (this.fileCount === 1) { + if (this.fileCount === 1 && !this.explicitTitles) { return ''; } @@ -165,6 +166,7 @@ Api.prototype.run = function (files) { var self = this; this._reset(); + this.explicitTitles = Boolean(files); return handlePaths(files || this.files, this.excludePatterns) .map(function (file) { return path.resolve(file); diff --git a/test/api.js b/test/api.js index b047fc689..88905c62a 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); From 615be3c9d167b4489f3c07029d488b81775826c3 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 2 Feb 2016 17:35:02 +0000 Subject: [PATCH 07/17] correct base when testing a single file commondir doesn't strip the basename if only a single path is presented. This is an issue now that test titles include the file path even if only a single file is being tested. --- api.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api.js b/api.js index 3f4551aaa..fdaa605b1 100644 --- a/api.js +++ b/api.js @@ -183,7 +183,11 @@ Api.prototype.run = function (files) { self.options.cacheDir = cacheDir; self.precompiler = new CachingPrecompiler(cacheDir); self.fileCount = files.length; - self.base = path.relative('.', commondir('.', files)) + path.sep; + if (self.fileCount === 1) { + self.base = path.relative('.', path.dirname(files[0])) + path.sep; + } else { + self.base = path.relative('.', commondir('.', files)) + path.sep; + } var tests = files.map(self._runFile); From bea1d84f5a93b5eb6249821dd2a71c1bcf3af621 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 2 Feb 2016 17:39:19 +0000 Subject: [PATCH 08/17] replace commondir by common-path-prefix I wrote common-path-prefix without knowing of commondir (npm search FTW). It explicitly excludes the base component so might be preferable over workarounds. --- api.js | 8 ++------ package.json | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/api.js b/api.js index fdaa605b1..ba081065a 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'); @@ -183,11 +183,7 @@ Api.prototype.run = function (files) { self.options.cacheDir = cacheDir; self.precompiler = new CachingPrecompiler(cacheDir); self.fileCount = files.length; - if (self.fileCount === 1) { - self.base = path.relative('.', path.dirname(files[0])) + path.sep; - } else { - self.base = path.relative('.', commondir('.', files)) + path.sep; - } + self.base = path.relative('.', commonPathPrefix(files)) + path.sep; var tests = files.map(self._runFile); diff --git a/package.json b/package.json index 329f6a8dc..d59357e17 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "chokidar": "^1.4.2", "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", From 936635de753b9c655a3b42b433ae2fa674688418 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 2 Feb 2016 17:45:16 +0000 Subject: [PATCH 09/17] make chokidar optional Like . --- cli.js | 17 ++++++++++++++--- lib/watcher.js | 12 ++++++++++-- package.json | 4 +++- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/cli.js b/cli.js index 739ad8049..3f5c10629 100755 --- a/cli.js +++ b/cli.js @@ -118,13 +118,24 @@ api.on('stdout', logger.stdout); api.on('stderr', logger.stderr); if (cli.flags.watch) { - watcher.start(logger, api, function (err) { + try { + watcher.start(logger, api, function (err) { + if (err.name === 'AvaError') { + console.log(' ' + colors.error(figures.cross) + ' ' + err.message); + } else { + console.error(colors.stack(err.stack)); + } + }); + } 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; } - }); + } } else { api.run() .then(function () { diff --git a/lib/watcher.js b/lib/watcher.js index a32a275ce..de05c85b5 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -1,16 +1,24 @@ 'use strict'; -var chokidar = require('chokidar'); +var AvaError = require('./ava-error'); 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.'); + } +} + exports.start = function (logger, api, logError) { var isTest = makeTestMatcher(api.files, api.excludePatterns); // TODO(novemberborn) allow these patterns to be specified, or perhaps match // anything (not just JS files). - var watcher = chokidar.watch(['package.json', '**/*.js'], { + var watcher = requireChokidar().watch(['package.json', '**/*.js'], { // Copied from // . // TODO(novemberborn) extract into a package so a sensible set of patterns diff --git a/package.json b/package.json index d59357e17..c6bb1afd2 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,6 @@ "bluebird": "^3.0.0", "caching-transform": "^1.0.0", "chalk": "^1.0.0", - "chokidar": "^1.4.2", "cli-cursor": "^1.0.2", "co-with-promise": "^4.6.0", "common-path-prefix": "^1.0.0", @@ -143,6 +142,9 @@ "xo": "*", "zen-observable": "^0.1.6" }, + "optionalDependencies": { + "chokidar": "^1.4.2" + }, "xo": { "ignore": [ "cli.js", From 9dd03d5d2b20de48e72cb59e29573dd412201b1b Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Wed, 3 Feb 2016 15:35:46 +0000 Subject: [PATCH 10/17] handle catastrophic test failures as exceptions Continue running other tests and let the reporters log the exceptions. The final results should indicate that failures occurred. It's quite useful when using --watch to see output from other tests, even if a particular test could not be run. AvaErrors don't have stack traces so modify reporters to log them appropriately. Rethrow any remaining errors coming out of Api#run() asynchronously so they become uncaught exceptions. They should be due to AVA bugs anyway so don't have to be pretty. --- api.js | 22 +++++++- cli.js | 20 ++----- lib/reporters/mini.js | 12 ++-- lib/reporters/tap.js | 15 +++-- lib/reporters/verbose.js | 4 ++ lib/watcher.js | 26 ++++----- test/api.js | 116 +++++++++++++++++++++----------------- test/reporters/mini.js | 14 +++-- test/reporters/tap.js | 18 ++++++ test/reporters/verbose.js | 14 +++++ 10 files changed, 166 insertions(+), 95 deletions(-) diff --git a/api.js b/api.js index ba081065a..31ce3f569 100644 --- a/api.js +++ b/api.js @@ -173,7 +173,12 @@ Api.prototype.run = function (files) { }) .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; @@ -205,7 +210,20 @@ Api.prototype.run = function (files) { 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: [] + }; + }); })); } } diff --git a/cli.js b/cli.js index 3f5c10629..4a00c20a0 100755 --- a/cli.js +++ b/cli.js @@ -119,13 +119,7 @@ api.on('stderr', logger.stderr); if (cli.flags.watch) { try { - watcher.start(logger, api, function (err) { - if (err.name === 'AvaError') { - console.log(' ' + colors.error(figures.cross) + ' ' + err.message); - } else { - console.error(colors.stack(err.stack)); - } - }); + watcher.start(logger, api); } catch (err) { if (err.name === 'AvaError') { // An AvaError may be thrown if chokidar is not installed. Log it nicely. @@ -143,12 +137,10 @@ if (cli.flags.watch) { logger.exit(api.failCount > 0 || api.rejectionCount > 0 || api.exceptionCount > 0 ? 1 : 0); }) .catch(function (err) { - if (err.name === 'AvaError') { - console.log(' ' + colors.error(figures.cross) + ' ' + err.message); - } else { - console.error(colors.stack(err.stack)); - } - - logger.exit(1); + // Don't swallow exceptions. Note that any expected error should already + // have been logged. + setImmediate(function () { + throw err; + }); }); } diff --git a/lib/reporters/mini.js b/lib/reporters/mini.js index 0f00ee733..186a02fe4 100644 --- a/lib/reporters/mini.js +++ b/lib/reporters/mini.js @@ -124,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 index de05c85b5..0b52cef64 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -13,7 +13,15 @@ function requireChokidar() { } } -exports.start = function (logger, api, logError) { +function rethrowAsync(err) { + // Don't swallow exceptions. Note that any expected error should already have + // been logged. + setImmediate(function () { + throw err; + }); +} + +exports.start = function (logger, api) { var isTest = makeTestMatcher(api.files, api.excludePatterns); // TODO(novemberborn) allow these patterns to be specified, or perhaps match @@ -31,13 +39,7 @@ exports.start = function (logger, api, logError) { var busy = api.run().then(function () { logger.finish(); - }).catch(function (err) { - logError(err); - // Exit if an error occurs during the initial run. - logger.exit(1); - // Return a pending promise to avoid running new tests while exiting. - return new Promise(function () {}); - }); + }).catch(rethrowAsync); var dirtyStates = {}; watcher.on('all', function (event, path) { @@ -62,7 +64,7 @@ exports.start = function (logger, api, logError) { debouncing = debounceAgain = false; debounce(); } else { - busy = runAfterChanges(logger, api, logError, isTest, dirtyStates); + busy = runAfterChanges(logger, api, isTest, dirtyStates); dirtyStates = {}; debouncing = debounceAgain = false; } @@ -96,7 +98,7 @@ function makeTestMatcher(files, excludePatterns) { }; } -function runAfterChanges(logger, api, logError, isTest, dirtyStates) { +function runAfterChanges(logger, api, isTest, dirtyStates) { var dirtyPaths = Object.keys(dirtyStates); var dirtyTests = dirtyPaths.filter(isTest); var addedOrChangedTests = dirtyTests.filter(function (path) { @@ -121,9 +123,7 @@ function runAfterChanges(logger, api, logError, isTest, dirtyStates) { } else { resolve(api.run()); } - }).catch(function (err) { - logError(err); }).then(function () { logger.finish(); - }); + }).catch(rethrowAsync); } diff --git a/test/api.js b/test/api.js index 88905c62a..3cc7867f1 100644 --- a/test/api.js +++ b/test/api.js @@ -383,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) { @@ -412,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) { @@ -436,11 +440,12 @@ 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) { @@ -448,11 +453,12 @@ test('test file in node_modules is ignored (explicit)', function (t) { var api = new Api(); - api.run([path.join(__dirname, 'fixture/ignored-dirs/node_modules/test.js')]) - .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([path.join(__dirname, 'fixture/ignored-dirs/node_modules/test.js')]); }); test('test file in fixtures is ignored', function (t) { @@ -460,11 +466,12 @@ 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) { @@ -472,11 +479,12 @@ test('test file in fixtures is ignored (explicit)', function (t) { var api = new Api(); - api.run([path.join(__dirname, 'fixture/ignored-dirs/fixtures/test.js')]) - .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([path.join(__dirname, 'fixture/ignored-dirs/fixtures/test.js')]); }); test('test file in helpers is ignored', function (t) { @@ -484,11 +492,12 @@ 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) { @@ -496,11 +505,12 @@ test('test file in helpers is ignored (explicit)', function (t) { var api = new Api(); - api.run([path.join(__dirname, 'fixture/ignored-dirs/helpers/test.js')]) - .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([path.join(__dirname, 'fixture/ignored-dirs/helpers/test.js')]); }); test('Node.js-style --require CLI argument', function (t) { diff --git a/test/reporters/mini.js b/test/reporters/mini.js index 3fc512757..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(); }); @@ -195,6 +201,6 @@ test('empty results after reset', function (t) { reporter.reset(); var output = reporter.finish(); - t.is(output, '\n'); + 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(); From 466d1a5b08c3038f175c34a00c95315a0e339d16 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 2 Feb 2016 18:04:10 +0000 Subject: [PATCH 11/17] rerun all tests when 'rs' is entered Inspired by nodemon, rerun all tests when 'rs' is entered on stdin. --- cli.js | 2 +- lib/watcher.js | 50 ++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/cli.js b/cli.js index 4a00c20a0..7e2b2df2a 100755 --- a/cli.js +++ b/cli.js @@ -119,7 +119,7 @@ api.on('stderr', logger.stderr); if (cli.flags.watch) { try { - watcher.start(logger, api); + watcher.start(logger, api, process.stdin); } catch (err) { if (err.name === 'AvaError') { // An AvaError may be thrown if chokidar is not installed. Log it nicely. diff --git a/lib/watcher.js b/lib/watcher.js index 0b52cef64..2d585b21b 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -21,7 +21,7 @@ function rethrowAsync(err) { }); } -exports.start = function (logger, api) { +exports.start = function (logger, api, stdin) { var isTest = makeTestMatcher(api.files, api.excludePatterns); // TODO(novemberborn) allow these patterns to be specified, or perhaps match @@ -49,7 +49,7 @@ exports.start = function (logger, api) { } }); - var debouncing = false; + var debouncing = null; var debounceAgain = false; function debounce() { if (debouncing) { @@ -57,20 +57,54 @@ exports.start = function (logger, api) { return; } - debouncing = true; - setTimeout(function () { + 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 = debounceAgain = false; + debouncing = null; + debounceAgain = false; debounce(); } else { busy = runAfterChanges(logger, api, isTest, dirtyStates); dirtyStates = {}; - debouncing = debounceAgain = false; + 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) { @@ -109,7 +143,7 @@ function runAfterChanges(logger, api, isTest, dirtyStates) { }); // No need to rerun tests if the only change is that tests were deleted. - if (unlinkedTests.length === dirtyPaths.length) { + if (dirtyPaths.length > 0 && unlinkedTests.length === dirtyPaths.length) { return Promise.resolve(); } @@ -118,7 +152,7 @@ function runAfterChanges(logger, api, isTest, dirtyStates) { // Run any new or changed tests, unless non-test files were changed too. // In that case rerun the entire test suite. - if (dirtyTests.length === dirtyPaths.length) { + if (dirtyPaths.length > 0 && dirtyTests.length === dirtyPaths.length) { resolve(api.run(addedOrChangedTests)); } else { resolve(api.run()); From dd1651041cfee4ed34b7d6f0da3ebc066dcf44c7 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Wed, 3 Feb 2016 16:39:23 +0000 Subject: [PATCH 12/17] fix typo in api test --- test/api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api.js b/test/api.js index 3cc7867f1..faa32e07e 100644 --- a/test/api.js +++ b/test/api.js @@ -358,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')]); From 2fff2e439bfcca8b462e80b394b68f6d339b9698 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 4 Feb 2016 18:51:17 +0000 Subject: [PATCH 13/17] test watcher Bump tap to get beforeEach and promise support. Use proxyquire to stub chokidar. Install lolex to get a next() method to advance the clock. Sinon 1.17 bundles an older version for its fake timers. These tests yield 100% code coverage for watcher.js! --- package.json | 4 +- test/watcher.js | 584 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 test/watcher.js diff --git a/package.json b/package.json index c6bb1afd2..7e50c8fb5 100644 --- a/package.json +++ b/package.json @@ -131,14 +131,16 @@ "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" }, diff --git a/test/watcher.js b/test/watcher.js new file mode 100644 index 000000000..e87586981 --- /dev/null +++ b/test/watcher.js @@ -0,0 +1,584 @@ +'use strict'; + +var Promise = require('bluebird'); +var EventEmitter = require('events').EventEmitter; +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 clock = lolex.install(0, ['setImmediate', 'setTimeout', 'clearTimeout']); + + var chokidar = { + watch: sinon.stub() + }; + + var logger = { + finish: sinon.spy(), + reset: sinon.spy() + }; + + var api = { + run: sinon.stub() + }; + + var subject = proxyquire.noCallThru().load('../lib/watcher', { + chokidar: chokidar + }); + + var emitter; + var stdin; + _t.beforeEach(function (done) { + emitter = new EventEmitter(); + chokidar.watch.reset(); + chokidar.watch.returns(emitter); + + 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 () { + subject.start(logger, api, 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 file changes', function (t) { + t.plan(2); + start(); + + t.ok(chokidar.watch.calledOnce); + t.same(chokidar.watch.firstCall.args, [ + ['package.json', '**/*.js'], + { + ignored: ['.git', 'node_modules', 'bower_components', '.sass-cache'], + 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}, + {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']; + api.run.returns(Promise.resolve()); + start(); + + add(path.join('dir', 'foo.js')); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[path.join('dir', 'foo.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); +}); From 1badcbb744e6ee3d1ec1065230929720a1d886fe Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 5 Feb 2016 16:49:27 +0000 Subject: [PATCH 14/17] debug-mode to log detected file changes Users may need to fine-tune the files and sources patterns for efficient watching. This enables an 'ava:watcher' debug mode which reports when files are added, changed or unlinked. --- lib/watcher.js | 2 ++ test/watcher.js | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/watcher.js b/lib/watcher.js index 2d585b21b..e88cfa614 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -1,6 +1,7 @@ 'use strict'; var AvaError = require('./ava-error'); +var debug = require('debug')('ava:watcher'); var multimatch = require('multimatch'); var nodePath = require('path'); var Promise = require('bluebird'); @@ -44,6 +45,7 @@ exports.start = function (logger, api, stdin) { 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(); } diff --git a/test/watcher.js b/test/watcher.js index e87586981..c1d71deb7 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -27,12 +27,12 @@ test('chokidar is not installed', function (t) { }); test('chokidar is installed', function (_t) { - var clock = lolex.install(0, ['setImmediate', 'setTimeout', 'clearTimeout']); - var chokidar = { watch: sinon.stub() }; + var debug = sinon.spy(); + var logger = { finish: sinon.spy(), reset: sinon.spy() @@ -43,16 +43,31 @@ test('chokidar is installed', function (_t) { }; var subject = proxyquire.noCallThru().load('../lib/watcher', { - chokidar: chokidar + 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(); @@ -146,6 +161,21 @@ test('chokidar is installed', function (_t) { }); }); + [ + {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}, From bb72d5a178707817cad9d18e72de96b089da0531 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 5 Feb 2016 17:00:46 +0000 Subject: [PATCH 15/17] implement --source --source provide match and ignore patterns for source files. The watcher will rerun tests when these source files change. --- cli.js | 10 ++++++---- lib/watcher.js | 37 +++++++++++++++++++++++++++++-------- test/watcher.js | 38 +++++++++++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/cli.js b/cli.js index 7e2b2df2a..449fea459 100755 --- a/cli.js +++ b/cli.js @@ -49,8 +49,9 @@ var cli = meow([ ' --tap, -t Generate TAP output', ' --verbose, -v Enable verbose output', ' --no-cache Disable the transpiler cache', - // Leave --watch undocumented until it's stable enough - // ' --watch, -w Re-run tests when files change', + // 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', @@ -65,7 +66,8 @@ var cli = meow([ ], { string: [ '_', - 'require' + 'require', + 'source' ], boolean: [ 'fail-fast', @@ -119,7 +121,7 @@ api.on('stderr', logger.stderr); if (cli.flags.watch) { try { - watcher.start(logger, api, process.stdin); + 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. diff --git a/lib/watcher.js b/lib/watcher.js index e88cfa614..69c67980d 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -22,19 +22,40 @@ function rethrowAsync(err) { }); } -exports.start = function (logger, api, stdin) { - var isTest = makeTestMatcher(api.files, api.excludePatterns); +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); - // TODO(novemberborn) allow these patterns to be specified, or perhaps match - // anything (not just JS files). - var watcher = requireChokidar().watch(['package.json', '**/*.js'], { + if (ignored.length === 0) { // Copied from // . // TODO(novemberborn) extract into a package so a sensible set of patterns // can be shared amongst projects. - // TODO(novemberborn) make configurable, perhaps similarly to how the - // include patterns are specified. - ignored: ['.git', 'node_modules', 'bower_components', '.sass-cache'], + ignored = ['.git', 'node_modules', 'bower_components', '.sass-cache']; + } + + 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 }); diff --git a/test/watcher.js b/test/watcher.js index c1d71deb7..ab7558cfe 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -19,7 +19,7 @@ test('chokidar is not installed', function (t) { }); try { - subject.start({}, {files: [], excludePatterns: []}); + 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.'); @@ -90,8 +90,8 @@ test('chokidar is installed', function (_t) { done(); }); - var start = function () { - subject.start(logger, api, stdin); + var start = function (sources) { + subject.start(logger, api, sources || [], stdin); }; var add = function (path) { @@ -127,13 +127,41 @@ test('chokidar is installed', function (_t) { pending.push(_t.test(name, fn)); }; - test('watches for file changes', function (t) { + 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'], + ['package.json', '**/*.js'].concat(api.files), + { + ignored: ['.git', 'node_modules', 'bower_components', '.sass-cache'], + 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: ['.git', 'node_modules', 'bower_components', '.sass-cache'], ignoreInitial: true From 12b47e20b33cc4d7fb22593caf3710ecb80f809c Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 5 Feb 2016 18:07:58 +0000 Subject: [PATCH 16/17] use ignore-by-default Watcher now uses an external package to control which directories it ignores by default. --- lib/watcher.js | 7 ++----- package.json | 1 + test/watcher.js | 5 +++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/watcher.js b/lib/watcher.js index 69c67980d..fe474c37b 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -2,6 +2,7 @@ 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'); @@ -40,11 +41,7 @@ function getChokidarPatterns(sources, initialFiles) { paths = paths.concat(initialFiles); if (ignored.length === 0) { - // Copied from - // . - // TODO(novemberborn) extract into a package so a sensible set of patterns - // can be shared amongst projects. - ignored = ['.git', 'node_modules', 'bower_components', '.sass-cache']; + ignored = defaultIgnore; } return {paths: paths, ignored: ignored}; diff --git a/package.json b/package.json index 7e50c8fb5..2179b8fd2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/watcher.js b/test/watcher.js index ab7558cfe..55925e0ee 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -2,6 +2,7 @@ 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'); @@ -135,7 +136,7 @@ test('chokidar is installed', function (_t) { t.same(chokidar.watch.firstCall.args, [ ['package.json', '**/*.js'].concat(api.files), { - ignored: ['.git', 'node_modules', 'bower_components', '.sass-cache'], + ignored: defaultIgnore, ignoreInitial: true } ]); @@ -163,7 +164,7 @@ test('chokidar is installed', function (_t) { t.same(chokidar.watch.firstCall.args, [ ['foo.js', 'baz.js'].concat(api.files), { - ignored: ['.git', 'node_modules', 'bower_components', '.sass-cache'], + ignored: defaultIgnore, ignoreInitial: true } ]); From dd63df92a6bc631c6a2d18eaa35bb843d08c8c64 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Mon, 8 Feb 2016 11:44:21 +0000 Subject: [PATCH 17/17] improve test matching in watcher Match any directory pattern, not just pattern for the first directory in the path. --- lib/watcher.js | 34 +++++++++++++++++++++++++++------- test/watcher.js | 5 +++-- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/lib/watcher.js b/lib/watcher.js index fe474c37b..2805d730b 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -140,15 +140,35 @@ function makeTestMatcher(files, excludePatterns) { return true; } - // Check if the first directory component in the path matches a pattern. If - // so, generate a new pattern with **/*.js and see if the full path matches - // that. This mimicks the behavior in api.js - var firstDir = path.split(nodePath.sep)[0]; - if (!firstDir || multimatch(firstDir, initialPatterns).length === 0) { + // Check if the path contains any directory components. + var dirname = nodePath.dirname(path); + if (dirname === '.') { return false; } - var recursivePatterns = [].concat(nodePath.join(firstDir, '**', '*.js'), excludePatterns); - return multimatch(path, recursivePatterns).length === 1; + + // 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; }; } diff --git a/test/watcher.js b/test/watcher.js index 55925e0ee..29d5880e6 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -460,14 +460,15 @@ test('chokidar is installed', function (_t) { test('files patterns may match directories', function (t) { t.plan(2); - api.files = ['dir']; + 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')]]); + t.same(api.run.secondCall.args, [[path.join('dir', 'foo.js'), path.join('dir2', 'foo', 'dir3', 'bar.js')]]); }); });